From d5bfdcae7bab6dd9e0a85ab98bee275493d4a122 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 9 Jun 2026 12:41:07 -0700 Subject: [PATCH] fix(stats): repair legacy combined-season anime rows on startup (#116) --- changes/season-scoped-library.md | 1 + docs-site/immersion-tracking.md | 2 + .../immersion-tracker-service.test.ts | 480 ++++++++++++++++++ .../services/immersion-tracker-service.ts | 15 + .../__tests__/query-split-modules.test.ts | 110 ++++ .../immersion-tracker/anime-season-repair.ts | 330 ++++++++++++ .../immersion-tracker/query-maintenance.ts | 16 +- 7 files changed, 952 insertions(+), 2 deletions(-) create mode 100644 src/core/services/immersion-tracker/anime-season-repair.ts diff --git a/changes/season-scoped-library.md b/changes/season-scoped-library.md index 561d3237..e7e4bfd3 100644 --- a/changes/season-scoped-library.md +++ b/changes/season-scoped-library.md @@ -2,4 +2,5 @@ type: changed area: stats - Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback. +- Repaired older combined-series stats rows by moving parsed episodes into season-specific library entries, rebuilding summaries, and deleting now-empty legacy rows. - Refresh anime detail and library cover art immediately after manually changing an AniList entry. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index ea19d751..0227afd3 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -50,6 +50,8 @@ Cover-art library with search and sorting, per-series progress, episode drill-do Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card. +When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries. + Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard. When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section. diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 97689d0f..b08bdf11 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -1676,6 +1676,276 @@ test('handleMediaChange splits matching parsed titles across distinct seasons', } }); +test('startup redistributes legacy combined anime rows across parsed seasons', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + 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, + anilist_id, + title_romaji, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES ( + 1, + 'frieren', + 'Frieren', + 154587, + 'Sousou no Frieren', + 1000, + 1000 + ); + + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + anime_id, + source_type, + source_path, + parsed_basename, + parsed_title, + parsed_season, + parsed_episode, + parser_source, + parser_confidence, + watched, + duration_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 'local:/tmp/Frieren S01E01.mkv', + 'Frieren S01E01', + 1, + 1, + '/tmp/Frieren S01E01.mkv', + 'Frieren S01E01.mkv', + 'Frieren', + 1, + 1, + 'fallback', + 0.9, + 1, + 0, + 1000, + 1000 + ), + ( + 2, + 'local:/tmp/Frieren S02E01.mkv', + 'Frieren S02E01', + 1, + 1, + '/tmp/Frieren S02E01.mkv', + 'Frieren S02E01.mkv', + 'Frieren', + 2, + 1, + 'fallback', + 0.9, + 1, + 0, + 1000, + 1000 + ); + + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + ended_at_ms, + status, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 'season-repair-session-1', 1, 1000, 2000, 2, 1000, 2000), + (2, 'season-repair-session-2', 2, 3000, 4000, 2, 3000, 4000); + + INSERT INTO imm_session_telemetry ( + session_id, + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + pause_count, + pause_ms, + seek_forward_count, + seek_backward_count, + media_buffer_events + ) VALUES + (1, 2000, 1000, 1000, 1, 10, 1, 0, 0, 0, 0, 0, 0, 0), + (2, 4000, 2000, 2000, 2, 20, 2, 0, 0, 0, 0, 0, 0, 0); + `); + + tracker.destroy(); + tracker = new Ctor({ dbPath }); + const repairedApi = tracker as unknown as { db: DatabaseSync }; + + const rows = repairedApi.db + .prepare( + ` + SELECT + a.canonical_title AS canonicalTitle, + a.normalized_title_key AS normalizedTitleKey, + a.anilist_id AS anilistId, + COUNT(v.video_id) AS videoCount, + COALESCE(lm.total_active_ms, 0) AS totalActiveMs + FROM imm_anime a + LEFT JOIN imm_videos v ON v.anime_id = a.anime_id + LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id + GROUP BY a.anime_id + ORDER BY a.canonical_title ASC + `, + ) + .all() as Array<{ + canonicalTitle: string; + normalizedTitleKey: string; + anilistId: number | null; + videoCount: number; + totalActiveMs: number; + }>; + + assert.deepEqual(rows, [ + { + canonicalTitle: 'Frieren Season 1', + normalizedTitleKey: 'frieren season 1', + anilistId: 154587, + videoCount: 1, + totalActiveMs: 1000, + }, + { + canonicalTitle: 'Frieren Season 2', + normalizedTitleKey: 'frieren season 2', + anilistId: null, + videoCount: 1, + totalActiveMs: 2000, + }, + ]); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('startup skips single-season anime rows during legacy season repair', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + 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, + anilist_id, + title_romaji, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES ( + 1, + 'frieren', + 'Frieren', + 154587, + 'Sousou no Frieren', + 1000, + 1000 + ); + + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + anime_id, + source_type, + source_path, + parsed_basename, + parsed_title, + parsed_season, + parsed_episode, + parser_source, + parser_confidence, + watched, + duration_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES ( + 1, + 'local:/tmp/Frieren S01E01.mkv', + 'Frieren S01E01', + 1, + 1, + '/tmp/Frieren S01E01.mkv', + 'Frieren S01E01.mkv', + 'Frieren', + 1, + 1, + 'fallback', + 0.9, + 1, + 0, + 1000, + 1000 + ); + `); + + tracker.destroy(); + tracker = new Ctor({ dbPath }); + const repairedApi = tracker as unknown as { db: DatabaseSync }; + + const rows = repairedApi.db + .prepare( + ` + SELECT + a.canonical_title AS canonicalTitle, + a.normalized_title_key AS normalizedTitleKey, + a.anilist_id AS anilistId, + COUNT(v.video_id) AS videoCount + FROM imm_anime a + LEFT JOIN imm_videos v ON v.anime_id = a.anime_id + GROUP BY a.anime_id + ORDER BY a.anime_id ASC + `, + ) + .all() as Array<{ + canonicalTitle: string; + normalizedTitleKey: string; + anilistId: number | null; + videoCount: number; + }>; + + assert.deepEqual(rows, [ + { + canonicalTitle: 'Frieren', + normalizedTitleKey: 'frieren', + anilistId: 154587, + videoCount: 1, + }, + ]); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('Jellyfin playback metadata links stream videos to existing series title', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -2847,6 +3117,216 @@ test('reassignAnimeAnilist preserves existing description when description is om } }); +test('reassignAnimeAnilist redistributes conflicting legacy combined row before assigning AniList id', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + 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, + anilist_id, + title_romaji, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 'konosuba', + 'KonoSuba', + 21202, + 'Kono Subarashii Sekai ni Shukufuku wo!', + 1000, + 1000 + ), + ( + 2, + 'konosuba season 1', + 'KonoSuba Season 1', + NULL, + NULL, + 1000, + 1000 + ); + + INSERT INTO imm_videos ( + video_id, + video_key, + canonical_title, + anime_id, + source_type, + source_path, + parsed_basename, + parsed_title, + parsed_season, + parsed_episode, + parser_source, + parser_confidence, + watched, + duration_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 'local:/tmp/KonoSuba S01E01.mkv', + 'KonoSuba S01E01', + 1, + 1, + '/tmp/KonoSuba S01E01.mkv', + 'KonoSuba S01E01.mkv', + 'KonoSuba', + 1, + 1, + 'fallback', + 0.9, + 1, + 0, + 1000, + 1000 + ), + ( + 2, + 'local:/tmp/KonoSuba S02E01.mkv', + 'KonoSuba S02E01', + 1, + 1, + '/tmp/KonoSuba S02E01.mkv', + 'KonoSuba S02E01.mkv', + 'KonoSuba', + 2, + 1, + 'fallback', + 0.9, + 1, + 0, + 1000, + 1000 + ), + ( + 3, + 'local:/tmp/KonoSuba S01E02.mkv', + 'KonoSuba S01E02', + 2, + 1, + '/tmp/KonoSuba S01E02.mkv', + 'KonoSuba S01E02.mkv', + 'KonoSuba', + 1, + 2, + 'fallback', + 0.9, + 1, + 0, + 1000, + 1000 + ); + + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + ended_at_ms, + status, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 'anilist-conflict-session-1', 1, 1000, 2000, 2, 1000, 2000), + (2, 'anilist-conflict-session-2', 2, 3000, 4000, 2, 3000, 4000), + (3, 'anilist-conflict-session-3', 3, 5000, 6000, 2, 5000, 6000); + + INSERT INTO imm_subtitle_lines ( + session_id, + video_id, + anime_id, + line_index, + text, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 1, 1, 0, 'season one legacy line', 1000, 1000), + (2, 2, 1, 0, 'season two legacy line', 1000, 1000); + + INSERT INTO imm_session_telemetry ( + session_id, + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + pause_count, + pause_ms, + seek_forward_count, + seek_backward_count, + media_buffer_events + ) VALUES + (1, 2000, 1000, 1000, 1, 10, 0, 0, 0, 0, 0, 0, 0, 0), + (2, 4000, 2000, 2000, 2, 20, 0, 0, 0, 0, 0, 0, 0, 0), + (3, 6000, 3000, 3000, 3, 30, 0, 0, 0, 0, 0, 0, 0, 0); + `); + + await tracker.reassignAnimeAnilist(2, { + anilistId: 21202, + titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!', + }); + + const rows = privateApi.db + .prepare( + ` + SELECT + a.canonical_title AS canonicalTitle, + a.anilist_id AS anilistId, + COUNT(DISTINCT v.video_id) AS videoCount, + COUNT(DISTINCT sl.line_id) AS subtitleLineCount, + COALESCE(lm.total_active_ms, 0) AS totalActiveMs + FROM imm_anime a + LEFT JOIN imm_videos v ON v.anime_id = a.anime_id + LEFT JOIN imm_subtitle_lines sl ON sl.anime_id = a.anime_id + LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id + GROUP BY a.anime_id + ORDER BY a.canonical_title ASC + `, + ) + .all() as Array<{ + canonicalTitle: string; + anilistId: number | null; + videoCount: number; + subtitleLineCount: number; + totalActiveMs: number; + }>; + + assert.deepEqual(rows, [ + { + canonicalTitle: 'KonoSuba Season 1', + anilistId: 21202, + videoCount: 2, + subtitleLineCount: 1, + totalActiveMs: 4000, + }, + { + canonicalTitle: 'KonoSuba Season 2', + anilistId: null, + videoCount: 1, + subtitleLineCount: 1, + totalActiveMs: 2000, + }, + ]); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('handleMediaChange stores youtube metadata for new youtube sessions', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 07c0432e..ee233edc 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -91,6 +91,10 @@ import { upsertCoverArt, } from './immersion-tracker/query-maintenance'; import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair'; +import { + repairLegacySeasonlessAnimeRows, + resolveAnimeAnilistConflict, +} from './immersion-tracker/anime-season-repair'; import { buildVideoKey, deriveCanonicalTitle, @@ -475,6 +479,13 @@ export class ImmersionTrackerService { `Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`, ); } + const seasonRepair = repairLegacySeasonlessAnimeRows(this.db); + if (seasonRepair.movedVideos > 0 || seasonRepair.deletedAnimeRows > 0) { + this.logger.info( + `Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`, + ); + rebuildLifetimeSummaryTables(this.db); + } if (shouldBackfillLifetimeSummaries(this.db)) { const result = rebuildLifetimeSummaryTables(this.db); if (result.appliedSessions > 0) { @@ -733,6 +744,7 @@ export class ImmersionTrackerService { coverUrl?: string | null; }, ): Promise { + const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId); this.db .prepare( ` @@ -758,6 +770,9 @@ export class ImmersionTrackerService { nowMs(), animeId, ); + if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) { + rebuildLifetimeSummaryTables(this.db); + } // Update cover art for all videos in this anime if (info.coverUrl) { diff --git a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts index 2dad5990..6c530f73 100644 --- a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts @@ -680,6 +680,116 @@ test('split maintenance helpers update anime metadata and watched state', () => } }); +test('updateAnimeAnilistInfo redistributes legacy combined row before assigning duplicate AniList id', () => { + const { db, dbPath } = createDb(); + + try { + const legacyAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'KonoSuba', + canonicalTitle: 'KonoSuba', + anilistId: 21202, + titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!', + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + const seasonAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'KonoSuba', + canonicalTitle: 'KonoSuba', + seasonScope: 1, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + const legacySeasonOneVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e01.mkv', { + canonicalTitle: 'KonoSuba S01E01', + sourcePath: '/tmp/konosuba-s01e01.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const legacySeasonTwoVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s02e01.mkv', { + canonicalTitle: 'KonoSuba S02E01', + sourcePath: '/tmp/konosuba-s02e01.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + const targetVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e02.mkv', { + canonicalTitle: 'KonoSuba S01E02', + sourcePath: '/tmp/konosuba-s01e02.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + + linkVideoToAnimeRecord(db, legacySeasonOneVideoId, { + animeId: legacyAnimeId, + parsedBasename: 'konosuba-s01e01.mkv', + parsedTitle: 'KonoSuba', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + linkVideoToAnimeRecord(db, legacySeasonTwoVideoId, { + animeId: legacyAnimeId, + parsedBasename: 'konosuba-s02e01.mkv', + parsedTitle: 'KonoSuba', + parsedSeason: 2, + parsedEpisode: 1, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + linkVideoToAnimeRecord(db, targetVideoId, { + animeId: seasonAnimeId, + parsedBasename: 'konosuba-s01e02.mkv', + parsedTitle: 'KonoSuba', + parsedSeason: 1, + parsedEpisode: 2, + parserSource: 'test', + parserConfidence: 1, + parseMetadataJson: null, + }); + + updateAnimeAnilistInfo(db, targetVideoId, { + anilistId: 21202, + titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!', + titleEnglish: null, + titleNative: null, + episodesTotal: 10, + }); + + const rows = db + .prepare( + ` + SELECT + a.canonical_title AS canonicalTitle, + a.anilist_id AS anilistId, + COUNT(v.video_id) AS videoCount + FROM imm_anime a + LEFT JOIN imm_videos v ON v.anime_id = a.anime_id + GROUP BY a.anime_id + ORDER BY a.canonical_title ASC + `, + ) + .all() as Array<{ + canonicalTitle: string; + anilistId: number | null; + videoCount: number; + }>; + + assert.deepEqual(rows, [ + { canonicalTitle: 'KonoSuba Season 1', anilistId: 21202, videoCount: 2 }, + { canonicalTitle: 'KonoSuba Season 2', anilistId: null, videoCount: 1 }, + ]); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('deleteSessions refreshes only rollups affected by deleted sessions', () => { const { db, dbPath } = createDb(); diff --git a/src/core/services/immersion-tracker/anime-season-repair.ts b/src/core/services/immersion-tracker/anime-season-repair.ts new file mode 100644 index 00000000..31cc4996 --- /dev/null +++ b/src/core/services/immersion-tracker/anime-season-repair.ts @@ -0,0 +1,330 @@ +import type { DatabaseSync } from './sqlite'; +import { getOrCreateAnimeRecord } from './storage'; +import { toDbTimestamp } from './query-shared'; +import { nowMs } from './time'; + +export interface AnimeSeasonRepairSummary { + scanned: number; + repaired: number; + movedVideos: number; + deletedAnimeRows: number; +} + +interface AnimeRow { + anime_id: number; + anilist_id: number | null; + title_romaji: string | null; + title_english: string | null; + title_native: string | null; + episodes_total: number | null; + description: string | null; +} + +interface ParsedVideoRow { + video_id: number; + parsed_title: string | null; + parsed_season: number | null; +} + +interface RedistributeOptions { + transferAnilistToAnimeId?: number | null; + transferLegacyAnilist?: boolean; + overwriteTargetAnilist?: boolean; +} + +function emptySummary(scanned = 0): AnimeSeasonRepairSummary { + return { + scanned, + repaired: 0, + movedVideos: 0, + deletedAnimeRows: 0, + }; +} + +function mergeSummary( + target: AnimeSeasonRepairSummary, + source: AnimeSeasonRepairSummary, +): AnimeSeasonRepairSummary { + target.scanned += source.scanned; + target.repaired += source.repaired; + target.movedVideos += source.movedVideos; + target.deletedAnimeRows += source.deletedAnimeRows; + return target; +} + +function runInTransaction(db: DatabaseSync, work: () => T): T { + db.exec('BEGIN'); + try { + const result = work(); + db.exec('COMMIT'); + return result; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +function normalizeSeason(value: number | null): number | null { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + return null; + } + return value; +} + +function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null { + return db + .prepare( + ` + SELECT + anime_id, + anilist_id, + title_romaji, + title_english, + title_native, + episodes_total, + description + FROM imm_anime + WHERE anime_id = ? + `, + ) + .get(animeId) as AnimeRow | null; +} + +function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] { + return db + .prepare( + ` + SELECT video_id, parsed_title, parsed_season + FROM imm_videos + WHERE anime_id = ? + ORDER BY video_id ASC + `, + ) + .all(animeId) as ParsedVideoRow[]; +} + +function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean { + const row = db + .prepare( + ` + SELECT 1 AS found + WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?) + OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?) + `, + ) + .get(animeId, animeId) as { found: number } | null; + return Boolean(row); +} + +function assignAnilistToTarget( + db: DatabaseSync, + source: AnimeRow, + targetAnimeId: number, + overwriteTarget: boolean, + updatedAt: string, +): boolean { + if (source.anilist_id === null || targetAnimeId === source.anime_id) { + return false; + } + + const target = getAnimeRow(db, targetAnimeId); + if (!target) { + return false; + } + if (!overwriteTarget && target.anilist_id !== null && target.anilist_id !== source.anilist_id) { + return false; + } + + db.prepare( + ` + UPDATE imm_anime + SET anilist_id = NULL, + LAST_UPDATE_DATE = ? + WHERE anime_id = ? + `, + ).run(updatedAt, source.anime_id); + + const updated = db + .prepare( + ` + UPDATE imm_anime + SET + anilist_id = ?, + title_romaji = COALESCE(?, title_romaji), + title_english = COALESCE(?, title_english), + title_native = COALESCE(?, title_native), + episodes_total = COALESCE(?, episodes_total), + description = COALESCE(?, description), + LAST_UPDATE_DATE = ? + WHERE anime_id = ? + `, + ) + .run( + source.anilist_id, + source.title_romaji, + source.title_english, + source.title_native, + source.episodes_total, + source.description, + updatedAt, + targetAnimeId, + ) as { changes: number }; + return updated.changes > 0; +} + +function redistributeAnimeRowByParsedSeasonsInTransaction( + db: DatabaseSync, + animeId: number, + options: RedistributeOptions = {}, +): AnimeSeasonRepairSummary { + const source = getAnimeRow(db, animeId); + if (!source) { + return emptySummary(1); + } + + const videos = getParsedVideos(db, animeId); + const summary = emptySummary(1); + const updatedAt = toDbTimestamp(nowMs()); + const targetBySeason = new Map(); + + for (const video of videos) { + const parsedTitle = video.parsed_title?.trim(); + const season = normalizeSeason(video.parsed_season); + if (!parsedTitle || season === null) { + continue; + } + + const targetAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle, + canonicalTitle: parsedTitle, + seasonScope: season, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + targetBySeason.set(season, targetAnimeId); + + if (targetAnimeId === animeId) { + continue; + } + + const videoUpdate = db + .prepare( + ` + UPDATE imm_videos + SET anime_id = ?, + LAST_UPDATE_DATE = ? + WHERE video_id = ? + `, + ) + .run(targetAnimeId, updatedAt, video.video_id) as { changes: number }; + const lineUpdate = db + .prepare( + ` + UPDATE imm_subtitle_lines + SET anime_id = ?, + LAST_UPDATE_DATE = ? + WHERE video_id = ? + `, + ) + .run(targetAnimeId, updatedAt, video.video_id) as { changes: number }; + + if (videoUpdate.changes > 0 || lineUpdate.changes > 0) { + summary.movedVideos += 1; + } + } + + const transferTarget = + options.transferAnilistToAnimeId ?? + (options.transferLegacyAnilist + ? (targetBySeason.get(1) ?? + (targetBySeason.size === 1 ? [...targetBySeason.values()][0] : null)) + : null); + if (transferTarget) { + const transferred = assignAnilistToTarget( + db, + source, + transferTarget, + options.overwriteTargetAnilist ?? false, + updatedAt, + ); + if (transferred) { + summary.repaired += 1; + } + } + + if (!hasAnimeReferences(db, animeId)) { + const deleted = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId) as { + changes: number; + }; + if (deleted.changes > 0) { + summary.deletedAnimeRows += 1; + } + } + + if (summary.movedVideos > 0 || summary.deletedAnimeRows > 0) { + summary.repaired += 1; + } + return summary; +} + +export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRepairSummary { + return runInTransaction(db, () => { + const candidates = db + .prepare( + ` + SELECT a.anime_id AS animeId + FROM imm_anime a + JOIN imm_videos v ON v.anime_id = a.anime_id + WHERE v.parsed_title IS NOT NULL + AND TRIM(v.parsed_title) != '' + AND v.parsed_season IS NOT NULL + AND v.parsed_season > 0 + GROUP BY a.anime_id + HAVING COUNT(DISTINCT v.parsed_season) > 1 + ORDER BY a.anime_id ASC + `, + ) + .all() as Array<{ animeId: number }>; + const summary = emptySummary(); + for (const candidate of candidates) { + mergeSummary( + summary, + redistributeAnimeRowByParsedSeasonsInTransaction(db, candidate.animeId, { + transferLegacyAnilist: true, + }), + ); + } + return summary; + }); +} + +export function resolveAnimeAnilistConflict( + db: DatabaseSync, + targetAnimeId: number, + anilistId: number, +): AnimeSeasonRepairSummary { + const conflict = db + .prepare( + ` + SELECT anime_id AS animeId + FROM imm_anime + WHERE anilist_id = ? + AND anime_id != ? + LIMIT 1 + `, + ) + .get(anilistId, targetAnimeId) as { animeId: number } | null; + if (!conflict) { + return emptySummary(); + } + + return runInTransaction(db, () => + redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, { + transferAnilistToAnimeId: targetAnimeId, + overwriteTargetAnilist: true, + }), + ); +} diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts index 56152a1e..91c83af4 100644 --- a/src/core/services/immersion-tracker/query-maintenance.ts +++ b/src/core/services/immersion-tracker/query-maintenance.ts @@ -1,9 +1,10 @@ import { createHash } from 'node:crypto'; import type { DatabaseSync } from './sqlite'; import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage'; -import { rebuildLifetimeSummariesInTransaction } from './lifetime'; +import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime'; import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance'; import { nowMs } from './time'; +import { resolveAnimeAnilistConflict } from './anime-season-repair'; import { PartOfSpeech, type MergedToken } from '../../../types'; import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech'; @@ -425,6 +426,14 @@ export function updateAnimeAnilistInfo( } | null; if (!row?.anime_id) return; + const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId); + const targetRow = db + .prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?') + .get(videoId) as { + anime_id: number | null; + } | null; + if (!targetRow?.anime_id) return; + db.prepare( ` UPDATE imm_anime @@ -444,8 +453,11 @@ export function updateAnimeAnilistInfo( info.titleNative, info.episodesTotal, toDbTimestamp(nowMs()), - row.anime_id, + targetRow.anime_id, ); + if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) { + rebuildLifetimeSummaries(db); + } } export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {