diff --git a/backlog/tasks/task-192 - Fix-stale-anime-cover-art-after-AniList-reassignment.md b/backlog/tasks/task-192 - Fix-stale-anime-cover-art-after-AniList-reassignment.md new file mode 100644 index 0000000..81a5adc --- /dev/null +++ b/backlog/tasks/task-192 - Fix-stale-anime-cover-art-after-AniList-reassignment.md @@ -0,0 +1,67 @@ +--- +id: TASK-192 +title: Fix stale anime cover art after AniList reassignment +status: Done +assignee: + - codex +created_date: '2026-03-20 00:12' +updated_date: '2026-03-20 00:14' +labels: + - stats + - immersion-tracker + - anilist +milestone: m-1 +dependencies: [] +references: + - src/core/services/immersion-tracker-service.ts + - src/core/services/immersion-tracker/query.ts + - src/core/services/immersion-tracker-service.test.ts +priority: medium +--- + +## Description + + +Fix the stats anime-detail cover image path so reassigning an anime to a different AniList entry replaces the stored cover art bytes instead of keeping the previous image blob under updated metadata. + + +## Acceptance Criteria + +- [x] #1 Reassigning an anime to a different AniList entry stores the new cover art bytes for that anime's videos +- [x] #2 Shared blob deduplication still works when multiple videos in the anime use the same new cover image +- [x] #3 Focused regression coverage proves stale cover blobs are replaced on reassignment + + +## Implementation Plan + + +1. Add a failing regression test that reassigns an anime twice with different downloaded cover bytes and asserts the resolved cover updates. +2. Update cover-art upsert logic so new blob bytes generate a new shared hash instead of reusing an existing hash for the row. +3. Run the focused immersion tracker service test file and record the result. + + +## Implementation Notes + + +2026-03-20: Created during live debugging of a user-reported stale anime profile picture after changing the AniList entry from the stats UI. +2026-03-20: Root cause was in `upsertCoverArt(...)`. When a row already had `cover_blob_hash`, a later AniList reassignment with a freshly downloaded cover reused the existing hash instead of hashing the new bytes, so the blob store kept serving the old image while metadata changed. +2026-03-20: Added a regression in `src/core/services/immersion-tracker-service.test.ts` that reassigns the same anime twice with different fetched image bytes and asserts the resolved anime cover changes to the second blob while both videos still deduplicate to one shared hash. +2026-03-20: Fixed `src/core/services/immersion-tracker/query.ts` so incoming cover blob bytes compute a fresh hash before falling back to an existing row hash. Existing hashes are now reused only when no new bytes were fetched. +2026-03-20: Verification commands run: + - `bun test src/core/services/immersion-tracker-service.test.ts` + - `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts` + - `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts` +2026-03-20: Verification results: + - focused service test: passed + - verifier lane selection: `core` + - verifier result: passed (`bun run typecheck`, `bun run test:fast`) + - verifier artifacts: `.tmp/skill-verification/subminer-verify-20260320-001433-IZLFqs/` + + +## Final Summary + + +Fixed stale anime cover art after AniList reassignment by correcting cover-blob hash replacement in the immersion tracker storage layer. Reassignments now store the new fetched image bytes instead of reusing the previous blob hash from the row, while still deduplicating the updated image across videos in the same anime. + +Added focused regression coverage that reproduces the exact failure mode: same anime reassigned twice with different cover downloads, with the second image expected to replace the first. Verified with the touched service test file plus the SubMiner `core` verification lane. + diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 741025f..a974621 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -2128,6 +2128,129 @@ test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains comp } }); +test('reassignAnimeAnilist replaces stale cover blobs when the AniList cover changes', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + const originalFetch = globalThis.fetch; + const initialCoverBlob = Buffer.from([1, 2, 3, 4]); + const replacementCoverBlob = Buffer.from([9, 8, 7, 6]); + let fetchCallCount = 0; + + try { + globalThis.fetch = async () => { + fetchCallCount += 1; + const blob = fetchCallCount === 1 ? initialCoverBlob : replacementCoverBlob; + return new Response(new Uint8Array(blob), { + status: 200, + headers: { 'Content-Type': 'image/jpeg' }, + }); + }; + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const privateApi = tracker as unknown as { db: DatabaseSync }; + + privateApi.db.exec(` + INSERT INTO imm_anime ( + anime_id, + normalized_title_key, + canonical_title, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES ( + 1, + 'little witch academia', + 'Little Witch Academia', + 1000, + 1000 + ); + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + source_type, + duration_ms, + anime_id, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 'local:/tmp/lwa-1.mkv', + 'Little Witch Academia S01E01', + 1, + 0, + 1, + 1000, + 1000 + ), + ( + 2, + 'local:/tmp/lwa-2.mkv', + 'Little Witch Academia S01E02', + 1, + 0, + 1, + 1000, + 1000 + ); + `); + + await tracker.reassignAnimeAnilist(1, { + anilistId: 33489, + titleRomaji: 'Little Witch Academia', + coverUrl: 'https://example.com/lwa-old.jpg', + }); + + await tracker.reassignAnimeAnilist(1, { + anilistId: 100526, + titleRomaji: 'Otome Game Sekai wa Mob ni Kibishii Sekai desu', + coverUrl: 'https://example.com/mobseka-new.jpg', + }); + + const mediaRows = privateApi.db + .prepare( + ` + SELECT + video_id AS videoId, + anilist_id AS anilistId, + cover_url AS coverUrl, + cover_blob_hash AS coverBlobHash + FROM imm_media_art + ORDER BY video_id ASC + `, + ) + .all() as Array<{ + videoId: number; + anilistId: number | null; + coverUrl: string | null; + coverBlobHash: string | null; + }>; + const blobRows = privateApi.db + .prepare('SELECT blob_hash AS blobHash, cover_blob AS coverBlob FROM imm_cover_art_blobs') + .all() as Array<{ blobHash: string; coverBlob: Buffer }>; + const resolvedCover = await tracker.getAnimeCoverArt(1); + + assert.equal(fetchCallCount, 2); + assert.equal(mediaRows.length, 2); + assert.equal(mediaRows[0]?.anilistId, 100526); + assert.equal(mediaRows[0]?.coverUrl, 'https://example.com/mobseka-new.jpg'); + assert.equal(mediaRows[0]?.coverBlobHash, mediaRows[1]?.coverBlobHash); + assert.equal(blobRows.length, 1); + assert.deepEqual( + new Uint8Array(blobRows[0]?.coverBlob ?? Buffer.alloc(0)), + new Uint8Array(replacementCoverBlob), + ); + assert.deepEqual( + new Uint8Array(resolvedCover?.coverBlob ?? Buffer.alloc(0)), + new Uint8Array(replacementCoverBlob), + ); + } finally { + globalThis.fetch = originalFetch; + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('reassignAnimeAnilist preserves existing description when description is omitted', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts index 2a8965d..7eb30de 100644 --- a/src/core/services/immersion-tracker/query.ts +++ b/src/core/services/immersion-tracker/query.ts @@ -2289,10 +2289,13 @@ export function upsertCoverArt( const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl); const nowMs = Date.now(); const coverBlob = normalizeCoverBlobBytes(art.coverBlob); - let coverBlobHash = sharedCoverBlobHash ?? existing?.coverBlobHash ?? null; + let coverBlobHash = sharedCoverBlobHash ?? null; if (!coverBlobHash && coverBlob && coverBlob.length > 0) { coverBlobHash = createHash('sha256').update(coverBlob).digest('hex'); } + if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) { + coverBlobHash = existing?.coverBlobHash ?? null; + } if (coverBlobHash && coverBlob && coverBlob.length > 0 && !sharedCoverBlobHash) { db.prepare(