import assert from 'node:assert/strict'; import test from 'node:test'; import { Database, type DatabaseSync } from './sqlite'; import { ensureSchema, getOrCreateAnimeRecord, getOrCreateVideoRecord, linkYoutubeVideoToAnimeRecord, } from './storage'; import { getAnimeDetail, getAnimeLibrary } from './query-library'; import { updateAnimeAnilistInfo } from './query-maintenance'; import { repairLegacySeasonlessAnimeRows, resolveAnimeAnilistConflict, } from './anime-season-repair'; import { getAnimeMergeRecommendations, recordAnimeMergeRecommendation, } from './anime-merge-recommendations'; import { createCoverArtFetcher } from '../anilist/cover-art-fetcher'; import { SCHEMA_VERSION, SOURCE_TYPE_REMOTE, type YoutubeVideoMetadata } from './types'; const metadata: YoutubeVideoMetadata = { youtubeVideoId: 'video1', videoUrl: 'https://www.youtube.com/watch?v=video1', videoTitle: 'Video title', videoThumbnailUrl: null, channelId: 'UC123', channelName: 'Channel name', channelUrl: 'https://www.youtube.com/channel/UC123', channelThumbnailUrl: null, uploaderId: null, uploaderUrl: null, description: null, metadataJson: null, }; function createVideo(db: DatabaseSync, key: string): number { return getOrCreateVideoRecord(db, key, { canonicalTitle: 'Video title', sourcePath: null, sourceUrl: metadata.videoUrl, sourceType: SOURCE_TYPE_REMOTE, }); } function createAnime( db: DatabaseSync, parsedTitle: string, metadataJson: string | null = null, ): number { return getOrCreateAnimeRecord(db, { parsedTitle, canonicalTitle: parsedTitle, metadataJson, anilistId: null, titleRomaji: null, titleEnglish: null, titleNative: null, }); } test('schema 23 channel migration preserves history and manual assignments and is idempotent', () => { const db = new Database(':memory:'); try { ensureSchema(db); const ids = [ createAnime(db, 'youtube-channel:UC123'), createAnime(db, 'youtube-channel-url:https://www.youtube.com/@creator'), createAnime(db, 'youtube-channel-name:Creator'), createAnime(db, 'Renamed channel', '{ "source": "youtube-channel" }'), ]; const animeId = createAnime(db, 'Anime title', 'legacy non-JSON metadata'); const videoId = createVideo(db, 'manual'); db.prepare( 'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?', ).run(animeId, videoId); db.prepare( 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)', ).run(animeId); const history = getAnimeLibrary(db); // Reproduce the previous schema, including its lack of a media kind column. db.exec( 'DROP INDEX idx_anime_namespace_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)', ); ensureSchema(db); ensureSchema(db); for (const id of ids) { const row = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(id); assert.ok(row && typeof row === 'object' && 'media_kind' in row); assert.equal(row.media_kind, 'youtube'); } assert.deepEqual(getAnimeLibrary(db), history); assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), animeId); const version = db .prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version') .get(); assert.ok(version && typeof version === 'object' && 'version' in version); assert.equal(version.version, SCHEMA_VERSION); } finally { db.close(); } }); test('channel creation and repeated linking expose youtube in library and detail without losing totals', async () => { const db = new Database(':memory:'); try { ensureSchema(db); const videoId = createVideo(db, 'first'); const channelId = linkYoutubeVideoToAnimeRecord(db, videoId, metadata); assert.ok(channelId); const secondVideoId = createVideo(db, 'second'); assert.equal(linkYoutubeVideoToAnimeRecord(db, secondVideoId, metadata), channelId); db.prepare( 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123456, 7)', ).run(channelId); assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'youtube'); const detail = getAnimeDetail(db, channelId); assert.equal(detail?.mediaKind, 'youtube'); assert.equal(detail?.episodeCount, 2); assert.equal(detail?.totalActiveMs, 123456); assert.equal(detail?.totalCards, 7); // Even parsed season numbers and a matching anime name must not trigger repairs. db.prepare('UPDATE imm_videos SET parsed_season = video_id').run(); assert.equal(repairLegacySeasonlessAnimeRows(db).repaired, 0); const animeId = createAnime(db, 'Channel name'); for (const matchConfidence of ['exact', 'weak', 'manual'] as const) { assert.equal( resolveAnimeAnilistConflict(db, channelId, 123, { matchConfidence }) .anilistAssignmentBlocked, true, ); } updateAnimeAnilistInfo(db, videoId, { anilistId: 123, titleRomaji: 'Wrong title', titleEnglish: null, titleNative: null, episodesTotal: 12, }); recordAnimeMergeRecommendation(db, channelId, animeId, 123); assert.deepEqual(getAnimeMergeRecommendations(db), []); assert.equal(getAnimeDetail(db, channelId)?.anilistId, null); const fetcher = createCoverArtFetcher( { acquire: async () => { assert.fail('YouTube must not query AniList'); }, recordResponse: () => {}, }, console, ); assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Channel name'), false); } finally { db.close(); } }); test('startup reclassifies channels created by an older build after the schema upgrade', () => { const db = new Database(':memory:'); try { ensureSchema(db); // An old build omits media_kind when creating a channel in the upgraded DB. const channelId = createAnime(db, 'youtube-channel:UCnew'); db.prepare('UPDATE imm_anime SET anilist_id = 321 WHERE anime_id = ?').run(channelId); const animeId = createAnime(db, 'Regular anime'); const videoId = createVideo(db, 'older-build'); db.prepare( 'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?', ).run(channelId, videoId); db.prepare( 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 120000, 5)', ).run(channelId); assert.equal(getAnimeLibrary(db)[0]?.mediaKind, 'anime'); ensureSchema(db); const channel = getAnimeLibrary(db)[0]; assert.equal(channel?.animeId, channelId); assert.equal(channel?.mediaKind, 'youtube'); assert.equal( ( db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as { anilist_id: number | null; } ).anilist_id, null, ); assert.equal(channel?.totalActiveMs, 120000); assert.equal(channel?.totalCards, 5); assert.equal(linkYoutubeVideoToAnimeRecord(db, videoId, metadata), channelId); const anime = db.prepare('SELECT media_kind FROM imm_anime WHERE anime_id = ?').get(animeId); assert.ok(anime && typeof anime === 'object' && 'media_kind' in anime); assert.equal(anime.media_kind, 'anime'); } finally { db.close(); } }); test('title identity and aliases never cross media kinds', () => { const db = new Database(':memory:'); try { ensureSchema(db); const animeId = createAnime(db, 'Shared title'); const input = { mediaKind: 'youtube' as const, parsedTitle: 'Shared title', canonicalTitle: 'Shared title', anilistId: null, titleRomaji: null, titleEnglish: null, titleNative: null, metadataJson: null, }; const channelId = getOrCreateAnimeRecord(db, input); assert.notEqual(channelId, animeId); db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = ?').run(channelId); assert.equal(getOrCreateAnimeRecord(db, input), channelId); assert.equal( ( db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(channelId) as { anilist_id: number | null; } ).anilist_id, null, ); assert.equal(createAnime(db, 'Shared title'), animeId); db.prepare( 'INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id) VALUES (?, ?)', ).run('alias title', animeId); assert.notEqual(getOrCreateAnimeRecord(db, { ...input, parsedTitle: 'Alias title' }), animeId); assert.throws(() => db .prepare( "INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'duplicate', 'youtube')", ) .run(), ); } finally { db.close(); } }); test('anime title lookups land on a same-named live-action entry but never on a channel', () => { const db = new Database(':memory:'); try { ensureSchema(db); const dramaId = createAnime(db, 'Hanzawa Naoki'); db.prepare( "UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?", ).run(dramaId); // A later season folder parses to the same title with the default anime // kind and must join the TMDB-linked entry rather than duplicate it. assert.equal( getOrCreateAnimeRecord(db, { parsedTitle: 'Hanzawa Naoki', canonicalTitle: 'Hanzawa Naoki', anilistId: 99, titleRomaji: null, titleEnglish: null, titleNative: null, metadataJson: null, }), dramaId, ); const row = db .prepare('SELECT media_kind, anilist_id, tmdb_id FROM imm_anime WHERE anime_id = ?') .get(dramaId) as { media_kind: string; anilist_id: number | null; tmdb_id: number }; assert.equal(row.media_kind, 'live_action'); assert.equal(row.anilist_id, null); assert.equal(row.tmdb_id, 61222); assert.notEqual( getOrCreateAnimeRecord(db, { mediaKind: 'youtube', parsedTitle: 'Hanzawa Naoki', canonicalTitle: 'Hanzawa Naoki', anilistId: null, titleRomaji: null, titleEnglish: null, titleNative: null, metadataJson: null, }), dramaId, ); } finally { db.close(); } }); test('schema 24 title constraint migration preserves referenced data', () => { const db = new Database(':memory:'); try { // Reproduce the original table-level uniqueness constraint. db.exec(`CREATE TABLE imm_anime( anime_id INTEGER PRIMARY KEY AUTOINCREMENT, normalized_title_key TEXT NOT NULL UNIQUE, canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE, title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER, description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT, media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')) )`); ensureSchema(db); const animeId = createAnime(db, 'Shared title'); const videoId = createVideo(db, 'migration-video'); db.prepare( 'UPDATE imm_videos SET anime_id = ?, anime_assignment_locked = 1 WHERE video_id = ?', ).run(animeId, videoId); db.prepare( 'INSERT INTO imm_lifetime_anime(anime_id, total_active_ms, total_cards) VALUES (?, 123, 4)', ).run(animeId); // Restore the old constraint while leaving child rows populated. db.exec(`PRAGMA foreign_keys = OFF; CREATE TABLE old_anime AS SELECT * FROM imm_anime; DROP TABLE imm_anime; CREATE TABLE imm_anime( anime_id INTEGER PRIMARY KEY AUTOINCREMENT, normalized_title_key TEXT NOT NULL UNIQUE, canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE, title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER, description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT, media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))); INSERT INTO imm_anime SELECT anime_id, normalized_title_key, canonical_title, anilist_id, title_romaji, title_english, title_native, episodes_total, description, metadata_json, CREATED_DATE, LAST_UPDATE_DATE, media_kind FROM old_anime; DROP TABLE old_anime; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (24, 0); PRAGMA foreign_keys = ON;`); ensureSchema(db); ensureSchema(db); assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []); // The v0.19.6 CHECK only allowed anime and youtube; live-action must fit now. db.prepare( "INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind, tmdb_id, tmdb_type) VALUES ('drama', 'Drama', 'live_action', 1, 'tv')", ).run(); assert.equal( (db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys, 1, ); assert.equal( ( db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as { anime_id: number; } ).anime_id, animeId, ); assert.equal( ( db .prepare('SELECT total_active_ms FROM imm_lifetime_anime WHERE anime_id = ?') .get(animeId) as { total_active_ms: number } ).total_active_ms, 123, ); db.prepare( "INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind) VALUES ('shared title', 'Channel', 'youtube')", ).run(); } finally { db.close(); } });