diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt index 2a21715d..46ea2e8b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageService.kt @@ -94,7 +94,11 @@ class ImageService( // Always try to get existing image first to avoid detached entity issues val existingImage = imageRepository.findByOriginalUrl(image.originalUrl) - if (existingImage != null && existingImage.contentId != null) { + // Check if the existing image has valid content + val existingImageHasValidContent = (existingImage != null && imageHasValidContent(existingImage)) + + // If the existing image has valid content we can just associate it instead of downloading again + if (existingImageHasValidContent && existingImage.contentId != null) { // If we have an existing image with content, associate it with the current image imageContentStore.associate(image, existingImage.contentId) // Update the current image's content metadata @@ -104,7 +108,7 @@ class ImageService( return } - // If no existing image or existing image has no content, download it + // If no existing image or existing image has no valid content, download it TikaInputStream.get { URI.create(image.originalUrl).toURL().openStream() }.use { input -> image.mimeType = tika.detect(input) imageContentStore.setContent(image, input) @@ -135,8 +139,8 @@ class ImageService( val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId) if (!isImageStillInUse) { - imageContentStore.unsetContent(image) imageRepository.delete(image) + imageContentStore.unsetContent(image) } } @@ -145,4 +149,9 @@ class ImageService( imageRepository.save(image) return imageContentStore.setContent(image, content) } + + private fun imageHasValidContent(image: Image): Boolean { + val imageContent = imageContentStore.getContent(image) + return imageContent != null && image.contentLength != null && image.contentLength!! > 0 + } } \ No newline at end of file diff --git a/app/src/main/resources/db/migration/V2.1.2.1__Fix_game_images_uniqueness.sql b/app/src/main/resources/db/migration/V2.1.2.1__Fix_game_images_uniqueness.sql new file mode 100644 index 00000000..0d8f577d --- /dev/null +++ b/app/src/main/resources/db/migration/V2.1.2.1__Fix_game_images_uniqueness.sql @@ -0,0 +1,75 @@ +-- Flyway Migration: V2.1.2 +-- Purpose: Remove unintended single-column uniqueness on GAME_IMAGES.IMAGES_ID +-- (leftover unique constraint / index from initial schema) and +-- replace it with a proper composite uniqueness over (GAME_ID, IMAGES_ID) +-- allowing the same image to be linked to multiple games while +-- preventing duplicate pairs. +-- +-- Context Recap: +-- * Initial table GAME_IMAGES had: IMAGES_ID UNIQUE (constraint UKBDE7M3TKHIEEYBINM2ED0B6X1) +-- * V2.1.0.1 only renamed that constraint (to UQ_GAME_IMAGES_IMAGE_ID if present) – did not drop it. +-- * Attempting to drop unique index now shows: "Index ... belongs to constraint FK_GAME_IMAGES_IMAGE". +-- This means H2 re-used (or bound) the existing unique index for the foreign key, so we must drop the FK first. +-- * Prior partial execution of an earlier draft of this migration might already have created +-- composite index UX_GAME_IMAGES_GAME_IMAGE. Script is idempotent. +-- Strategy (idempotent): +-- 1. Drop foreign key FK_GAME_IMAGES_IMAGE (and legacy hashed name) to free the index. +-- 2. Drop the old unique constraint names (hashed and friendly) if they still exist. +-- 3. Drop lingering unique indexes (hashed + variants, including the one ending with _INDEX_C). +-- 4. Create a NON-UNIQUE index on IMAGES_ID. +-- 5. Create (or ensure) composite UNIQUE index (GAME_ID, IMAGES_ID). +-- 6. Recreate foreign key FK_GAME_IMAGES_IMAGE. +-- 7. (Optional) Verification queries shown in comments. + +/****************************************************************************************** + * 1. Drop foreign key so bound unique index can be removed + ******************************************************************************************/ +ALTER TABLE GAME_IMAGES + DROP CONSTRAINT IF EXISTS FK_GAME_IMAGES_IMAGE; +-- Legacy hashed name (in case rename migration not applied yet) +ALTER TABLE GAME_IMAGES + DROP CONSTRAINT IF EXISTS FK5YWV1DMXCM2VSQUEB7RHQ3JK9; + +/****************************************************************************************** + * 2. Drop legacy/friendly unique constraints (if still defined) + ******************************************************************************************/ +ALTER TABLE GAME_IMAGES + DROP CONSTRAINT IF EXISTS UKBDE7M3TKHIEEYBINM2ED0B6X1; -- original hashed name +ALTER TABLE GAME_IMAGES + DROP CONSTRAINT IF EXISTS UQ_GAME_IMAGES_IMAGE_ID; +-- friendly name + +/****************************************************************************************** + * 3. Drop lingering unique indexes that may remain after constraint drop + * (H2 auto-named variants; include conservative list). Safe if absent. + ******************************************************************************************/ +DROP INDEX IF EXISTS UKBDE7M3TKHIEEYBINM2ED0B6X1_INDEX_C; + +/****************************************************************************************** + * 4. Create supporting NON-UNIQUE index for IMAGES_ID (only if missing) + ******************************************************************************************/ +CREATE INDEX IF NOT EXISTS IDX_GAME_IMAGES_IMAGE ON GAME_IMAGES (IMAGES_ID); + +/****************************************************************************************** + * 5. Create / ensure composite uniqueness (prevents duplicate pairs, allows reuse of images) + ******************************************************************************************/ +CREATE UNIQUE INDEX IF NOT EXISTS UX_GAME_IMAGES_GAME_IMAGE ON GAME_IMAGES (GAME_ID, IMAGES_ID); + +/****************************************************************************************** + * 6. Recreate foreign key (H2 will use existing non-unique index or create one silently) + ******************************************************************************************/ +ALTER TABLE GAME_IMAGES + ADD CONSTRAINT FK_GAME_IMAGES_IMAGE FOREIGN KEY (IMAGES_ID) REFERENCES IMAGE (ID); +-- (FK to GAME side should already exist; keep idempotent recreation separate if ever needed.) + +/****************************************************************************************** + * 7. (Optional verification after migration) + * -- SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE FROM INFORMATION_SCHEMA.CONSTRAINTS WHERE TABLE_NAME='GAME_IMAGES'; + * -- SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME FROM INFORMATION_SCHEMA.INDEXES WHERE TABLE_NAME='GAME_IMAGES'; + * Expected: + * Constraint FK_GAME_IMAGES_IMAGE present (TYPE='REFERENTIAL'). + * Composite unique index UX_GAME_IMAGES_GAME_IMAGE (NON_UNIQUE=FALSE, columns GAME_ID, IMAGES_ID). + * Non-unique index IDX_GAME_IMAGES_IMAGE (NON_UNIQUE=TRUE, column IMAGES_ID). + * No remaining single-column unique index enforcing uniqueness of IMAGES_ID alone. + ******************************************************************************************/ +-- End of migration. diff --git a/app/src/main/resources/db/migration/V2.1.2.2__Cleanup_unreferenced_images.sql b/app/src/main/resources/db/migration/V2.1.2.2__Cleanup_unreferenced_images.sql new file mode 100644 index 00000000..1ea4b01a --- /dev/null +++ b/app/src/main/resources/db/migration/V2.1.2.2__Cleanup_unreferenced_images.sql @@ -0,0 +1,33 @@ +-- Flyway Migration: V2.1.2.2 +-- Purpose: Remove orphan (unreferenced) IMAGE rows that are no longer linked to any +-- GAME (cover/header), GAME_IMAGES (many-to-many screenshots), or USERS (avatar). +-- +-- Rationale: +-- A previous bug deleted content (files) before deleting the DB row, allowing the +-- IMAGE entity to remain referenced or resurrected. After fixing logic order, we +-- now perform a one-time cleanup of rows that have no remaining foreign key references. +-- +-- Safety: +-- The DELETE only targets rows for which no referencing rows exist; it will not +-- violate FK constraints. Uses NOT EXISTS predicates (safer than NOT IN when NULLs present). +-- +-- Idempotency: +-- Running this migration again (e.g., in replayed environments) is harmless because +-- once removed, those rows no longer exist. +-- +-- Verification (optional; run manually): +-- SELECT COUNT(*) FROM IMAGE i +-- WHERE NOT EXISTS (SELECT 1 FROM GAME g WHERE g.COVER_IMAGE_ID = i.ID) +-- AND NOT EXISTS (SELECT 1 FROM GAME g2 WHERE g2.HEADER_IMAGE_ID = i.ID) +-- AND NOT EXISTS (SELECT 1 FROM GAME_IMAGES gi WHERE gi.IMAGES_ID = i.ID) +-- AND NOT EXISTS (SELECT 1 FROM USERS u WHERE u.AVATAR_ID = i.ID); +-- -- Expect 0 after delete. + +DELETE FROM IMAGE i +WHERE NOT EXISTS (SELECT 1 FROM GAME g WHERE g.COVER_IMAGE_ID = i.ID) + AND NOT EXISTS (SELECT 1 FROM GAME g2 WHERE g2.HEADER_IMAGE_ID = i.ID) + AND NOT EXISTS (SELECT 1 FROM GAME_IMAGES gi WHERE gi.IMAGES_ID = i.ID) + AND NOT EXISTS (SELECT 1 FROM USERS u WHERE u.AVATAR_ID = i.ID); + +-- End of migration. + diff --git a/build.gradle.kts b/build.gradle.kts index 4c49a620..87a86f50 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import java.nio.file.Files group = "org.gameyfin" -version = "2.1.1" +version = "2.1.2" allprojects { repositories {