diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index dac9c3b4..93fa0736 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -2978,6 +2978,66 @@ test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan } }); +test('Jellyfin link repair clears stale subtitle assignments when the repaired video is unassigned', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const db = (tracker as unknown as { db: DatabaseSync }).db; + const timestamp = toDbTimestamp(trackerNowMs()); + const legacyUrl = + 'http://jellyfin.local/Videos/item-null/stream?static=true&api_key=secret-token'; + const stableUrl = 'jellyfin://jellyfin.local/item/item-null'; + db.prepare( + `INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (1, 'stale show', 'Stale Show', ?, ?)`, + ).run(timestamp, timestamp); + db.prepare( + `INSERT INTO imm_videos ( + video_id, video_key, anime_id, canonical_title, source_type, source_url, + duration_ms, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES + (1, ?, 1, 'Legacy Stream', 2, ?, 0, ?, ?), + (2, ?, NULL, 'Canonical Stream', 2, ?, 0, ?, ?)`, + ).run( + `remote:${legacyUrl}`, + legacyUrl, + timestamp, + timestamp, + `remote:${stableUrl}`, + stableUrl, + timestamp, + timestamp, + ); + db.prepare( + `INSERT INTO imm_sessions ( + session_id, session_uuid, video_id, started_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (1, 'jellyfin-null-assignment', 1, ?, 2, ?, ?)`, + ).run(timestamp, timestamp, timestamp); + db.prepare( + `INSERT INTO imm_subtitle_lines ( + session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (1, 1, 1, 1, 'stale line', ?, ?)`, + ).run(timestamp, timestamp); + + repairJellyfinStreamVideoLinks(db); + + const video = db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = 1').get() as { + anime_id: number | null; + }; + const line = db.prepare('SELECT anime_id FROM imm_subtitle_lines WHERE video_id = 1').get() as { + anime_id: number | null; + }; + assert.equal(video.anime_id, null); + assert.equal(line.anime_id, null); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('applies configurable queue, flush, and retention policy', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -4232,6 +4292,23 @@ printf '%s\n' '${ytDlpOutput}' nowMs, nowMs, ); + privateApi.db + .prepare( + `INSERT INTO imm_anime ( + anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (1, 'manual backfill collection', 'Manual Backfill Collection', ?, ?)`, + ) + .run(nowMs, nowMs); + privateApi.db + .prepare( + `UPDATE imm_videos + SET anime_id = 1, + anime_assignment_locked = 1, + parsed_title = 'Manual Backfill Collection', + parser_source = 'manual-test' + WHERE video_id = 1`, + ) + .run(); privateApi.db .prepare( ` @@ -4286,6 +4363,15 @@ printf '%s\n' '${ytDlpOutput}' after[0]?.channelThumbnailUrl, 'https://yt3.googleusercontent.com/backfill-avatar=s88', ); + const lockedVideo = privateApi.db + .prepare( + `SELECT anime_id AS animeId, parsed_title AS parsedTitle + FROM imm_videos + WHERE video_id = 1`, + ) + .get() as { animeId: number | null; parsedTitle: string | null }; + assert.equal(lockedVideo.animeId, 1); + assert.equal(lockedVideo.parsedTitle, 'Manual Backfill Collection'); } finally { process.env.PATH = originalPath; tracker?.destroy(); @@ -4296,7 +4382,7 @@ printf '%s\n' '${ytDlpOutput}' } }); -test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async () => { +test('getAnimeLibrary lazily relinks unlocked youtube rows without moving manual assignments', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -4320,6 +4406,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async ( INSERT INTO imm_videos ( video_id, anime_id, + anime_assignment_locked, video_key, canonical_title, parsed_title, @@ -4345,6 +4432,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async ( ( 1, 1, + 0, 'remote:https://www.youtube.com/watch?v=first', 'watch?v first', 'watch?v first', @@ -4370,6 +4458,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async ( ( 2, 2, + 1, 'remote:https://www.youtube.com/watch?v=second', 'watch?v second', 'watch?v second', @@ -4541,7 +4630,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async ( const sharedRows = rows.filter((row) => row.canonicalTitle === 'Shared Channel'); assert.equal(sharedRows.length, 1); - assert.equal(sharedRows[0]?.episodeCount, 2); + assert.equal(sharedRows[0]?.episodeCount, 1); const relinked = privateApi.db .prepare( @@ -4555,8 +4644,17 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async ( ) .all() as Array<{ canonicalTitle: string; total: number }>; - assert.equal(relinked[0]?.canonicalTitle, 'Shared Channel'); - assert.equal(relinked[0]?.total, 2); + assert.equal(relinked.find((row) => row.canonicalTitle === 'Shared Channel')?.total, 1); + assert.equal(relinked.find((row) => row.canonicalTitle === 'watch?v second')?.total, 1); + const lockedVideo = privateApi.db + .prepare( + `SELECT anime_id AS animeId, parsed_title AS parsedTitle + FROM imm_videos + WHERE video_id = 2`, + ) + .get() as { animeId: number | null; parsedTitle: string | null }; + assert.equal(lockedVideo.animeId, 2); + assert.equal(lockedVideo.parsedTitle, 'watch?v second'); } finally { tracker?.destroy(); cleanupDbPath(dbPath); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index eee50a32..841f73c9 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -1318,6 +1318,7 @@ export class ImmersionTrackerService { LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id WHERE v.source_type = ? + AND v.anime_assignment_locked = 0 AND v.source_url IS NOT NULL AND ( LOWER(v.source_url) LIKE 'https://www.youtube.com/%' diff --git a/src/core/services/immersion-tracker/jellyfin-link-repair.ts b/src/core/services/immersion-tracker/jellyfin-link-repair.ts index 866213dc..2f3c3942 100644 --- a/src/core/services/immersion-tracker/jellyfin-link-repair.ts +++ b/src/core/services/immersion-tracker/jellyfin-link-repair.ts @@ -406,15 +406,13 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe currentTimestamp, candidate.video_id, ); - if (assignmentAnimeId !== null) { - db.prepare( - ` - UPDATE imm_subtitle_lines - SET anime_id = ?, LAST_UPDATE_DATE = ? - WHERE video_id = ? - `, - ).run(assignmentAnimeId, currentTimestamp, candidate.video_id); - } + db.prepare( + ` + UPDATE imm_subtitle_lines + SET anime_id = ?, LAST_UPDATE_DATE = ? + WHERE video_id = ? + `, + ).run(assignmentAnimeId, currentTimestamp, candidate.video_id); summary.repaired += 1; } summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp); diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index b11cef1e..46d87114 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -1366,6 +1366,70 @@ test('youtube videos can be regrouped under a shared channel anime identity', () } }); +test('youtube channel relinking preserves a locked manual assignment', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=locked', { + canonicalTitle: 'Locked Video', + sourcePath: null, + sourceUrl: 'https://www.youtube.com/watch?v=locked', + sourceType: SOURCE_TYPE_REMOTE, + }); + const manualAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Manual Collection', + canonicalTitle: 'Manual Collection', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId: manualAnimeId, + parsedBasename: null, + parsedTitle: 'Manual Collection', + parsedSeason: null, + parsedEpisode: null, + parserSource: 'manual-test', + parserConfidence: 1, + parseMetadataJson: null, + }); + db.prepare('UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_id = ?').run(videoId); + + const linkedAnimeId = linkYoutubeVideoToAnimeRecord(db, videoId, { + youtubeVideoId: 'locked', + videoUrl: 'https://www.youtube.com/watch?v=locked', + videoTitle: 'Locked Video', + videoThumbnailUrl: null, + channelId: 'UC-locked', + channelName: 'Automatic Channel', + channelUrl: null, + channelThumbnailUrl: null, + uploaderId: null, + uploaderUrl: null, + description: null, + metadataJson: null, + }); + + assert.equal(linkedAnimeId, manualAnimeId); + const video = db + .prepare('SELECT anime_id, parsed_title FROM imm_videos WHERE video_id = ?') + .get(videoId) as { anime_id: number | null; parsed_title: string | null }; + assert.equal(video.anime_id, manualAnimeId); + assert.equal(video.parsed_title, 'Manual Collection'); + const automaticAnime = db + .prepare(`SELECT anime_id FROM imm_anime WHERE canonical_title = 'Automatic Channel'`) + .get(); + assert.equal(automaticAnime, undefined); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('start/finalize session updates ended_at and status', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 24727277..e494a94b 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -677,8 +677,11 @@ export function linkVideoToAnimeRecord( ); } -export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): number | null { - const row = db +function getLockedAnimeAssignment( + db: DatabaseSync, + videoId: number, +): { animeId: number | null } | null { + return db .prepare( ` SELECT anime_id AS animeId @@ -688,7 +691,10 @@ export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): num `, ) .get(videoId) as { animeId: number | null } | null; - return row?.animeId ?? null; +} + +export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): number | null { + return getLockedAnimeAssignment(db, videoId)?.animeId ?? null; } /** @@ -743,6 +749,11 @@ export function linkYoutubeVideoToAnimeRecord( videoId: number, metadata: YoutubeVideoMetadata, ): number | null { + const lockedAssignment = getLockedAnimeAssignment(db, videoId); + if (lockedAssignment) { + return lockedAssignment.animeId; + } + const identity = buildYoutubeChannelAnimeIdentity(metadata); if (!identity) { return null;