diff --git a/changes/youtube-library-kind-docs.md b/changes/youtube-library-kind-docs.md new file mode 100644 index 00000000..bdaa80d1 --- /dev/null +++ b/changes/youtube-library-kind-docs.md @@ -0,0 +1,4 @@ +type: docs +area: stats + +- Document YouTube channel filtering and video statistics in the Library. diff --git a/changes/youtube-library-kind.md b/changes/youtube-library-kind.md new file mode 100644 index 00000000..ea0ef58c --- /dev/null +++ b/changes/youtube-library-kind.md @@ -0,0 +1,7 @@ +type: added +area: stats + +- Store YouTube channels as a separate media kind and migrate existing channel entries without changing viewing history or manual video assignments, including channels created after temporarily returning to an older build. +- Add All Titles, Anime, and YouTube Library filters, identify channel pages, and keep channels out of AniList matching, season repair, and duplicate recommendations. +- Keep same-title anime and YouTube records separate in storage and sync while repairing legacy channel classification. +- Refuse merges and video moves that would mix an anime entry with a YouTube channel; the move picker and merge selection only combine entries of the same kind. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 2202c887..230d57a9 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -55,12 +55,12 @@ When older stats already grouped multiple seasons under one series entry, SubMin 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. +When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use **All Titles**, **Anime**, or **YouTube** above the grid to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations. A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available: -- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card. -- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid. +- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. Anime entries and YouTube channels cannot be merged into each other. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card. +- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry of the same kind, so a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid. Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested. @@ -327,14 +327,14 @@ LIMIT ?; - Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups. - Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`. -### Schema (v23) +### Schema (v24) The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table. Core tables: - `imm_videos` - video key/title/source metadata -- `imm_anime` - anime/series metadata referenced by videos and lifetime tables +- `imm_anime` - anime/series or YouTube channel metadata (`media_kind`) referenced by videos and lifetime tables - `imm_anime_title_aliases` - alternate titles that resolve to the same anime row - `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard - `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index d29eaa2f..a9c48df6 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -149,6 +149,10 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI - **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently. - **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface. +## Viewing stats + +The stats Library groups tracked YouTube videos by channel when channel metadata is available. Select **YouTube** in the Library filter to see those channels separately from anime. Each channel page lists its videos, watch time, vocabulary, and mined cards. See [Immersion Tracking](/immersion-tracking#library). + ## Related pages - [Usage - YouTube Playback](/usage#youtube-playback) diff --git a/launcher/test-support/immersion-db-schema.test.ts b/launcher/test-support/immersion-db-schema.test.ts index 0320a7c9..68632178 100644 --- a/launcher/test-support/immersion-db-schema.test.ts +++ b/launcher/test-support/immersion-db-schema.test.ts @@ -31,6 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [ 'imm_lifetime_applied_sessions', 'imm_stats_excluded_words', 'idx_anime_normalized_title', + 'idx_anime_kind_title', 'idx_anime_anilist_id', 'idx_videos_anime_id', 'idx_sessions_video_started', diff --git a/launcher/test-support/immersion-db-schema.ts b/launcher/test-support/immersion-db-schema.ts index c99e2cf4..32d68730 100644 --- a/launcher/test-support/immersion-db-schema.ts +++ b/launcher/test-support/immersion-db-schema.ts @@ -12,7 +12,7 @@ export const IMMERSION_DB_FIXTURE_DDL = ` ); CREATE TABLE imm_anime( anime_id INTEGER PRIMARY KEY AUTOINCREMENT, - normalized_title_key TEXT NOT NULL UNIQUE, + normalized_title_key TEXT NOT NULL, canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE, title_romaji TEXT, @@ -22,8 +22,10 @@ export const IMMERSION_DB_FIXTURE_DDL = ` description TEXT, metadata_json TEXT, CREATED_DATE TEXT, - LAST_UPDATE_DATE TEXT + LAST_UPDATE_DATE TEXT, + media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')) ); + CREATE UNIQUE INDEX idx_anime_kind_title ON imm_anime(media_kind, normalized_title_key); CREATE TABLE imm_videos( video_id INTEGER PRIMARY KEY AUTOINCREMENT, video_key TEXT NOT NULL UNIQUE, diff --git a/src/core/services/anilist/cover-art-fetcher.ts b/src/core/services/anilist/cover-art-fetcher.ts index ac7be780..13b85c64 100644 --- a/src/core/services/anilist/cover-art-fetcher.ts +++ b/src/core/services/anilist/cover-art-fetcher.ts @@ -192,6 +192,16 @@ export function createCoverArtFetcher( return { async fetchIfMissing(db, videoId, canonicalTitle): Promise { + const channel = db + .prepare( + ` + SELECT 1 FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + WHERE v.video_id = ? AND a.media_kind != 'anime' + `, + ) + .get(videoId); + if (channel) return false; const existing = getCoverArt(db, videoId); if (existing?.coverBlob) { return true; diff --git a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts index 96250e0b..1dbdac83 100644 --- a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts +++ b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts @@ -13,7 +13,11 @@ import { getOrCreateAnimeRecord, linkVideoToAnimeRecord, } from '../storage.js'; -import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js'; +import { + MEDIA_KIND_MISMATCH_MESSAGE, + mergeAnimeRecords, + moveVideoToAnime, +} from '../anime-merge.js'; import { dismissAnimeMergeRecommendation, getAnimeMergeRecommendations, @@ -940,3 +944,36 @@ test('automatic AniList update onto an entry that already links elsewhere does n ); }); }); + +test('merge and move refuse to mix anime entries with YouTube channels', () => { + withDb((db) => { + insertAnime(db, { animeId: 1, key: 'some anime', title: 'Some Anime', anilistId: 555 }); + insertAnime(db, { animeId: 2, key: 'youtube channel uc123', title: 'Channel' }); + db.prepare("UPDATE imm_anime SET media_kind = 'youtube' WHERE anime_id = 2").run(); + insertEpisode(db, { videoId: 10, animeId: 1 }); + insertEpisode(db, { videoId: 20, animeId: 2 }); + + assert.throws(() => mergeAnimeRecords(db, 1, [2]), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + assert.throws(() => mergeAnimeRecords(db, 2, [1]), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + assert.throws(() => moveVideoToAnime(db, 20, 1), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + assert.throws(() => moveVideoToAnime(db, 10, 2), { message: MEDIA_KIND_MISMATCH_MESSAGE }); + + const rows = db + .prepare( + 'SELECT anime_id AS animeId, media_kind AS mediaKind FROM imm_anime ORDER BY anime_id', + ) + .all() as Array<{ animeId: number; mediaKind: string }>; + assert.deepEqual(rows, [ + { animeId: 1, mediaKind: 'anime' }, + { animeId: 2, mediaKind: 'youtube' }, + ]); + assert.equal( + ( + db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 20').get() as { + animeId: number; + } + ).animeId, + 2, + ); + }); +}); diff --git a/src/core/services/immersion-tracker/anime-merge-recommendations.ts b/src/core/services/immersion-tracker/anime-merge-recommendations.ts index 6ac0c096..1ad96e97 100644 --- a/src/core/services/immersion-tracker/anime-merge-recommendations.ts +++ b/src/core/services/immersion-tracker/anime-merge-recommendations.ts @@ -27,7 +27,7 @@ function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null .prepare( `SELECT canonical_title, title_romaji, title_english, title_native FROM imm_anime - WHERE anime_id = ?`, + WHERE anime_id = ? AND media_kind = 'anime'`, ) .get(animeId) as AnimeTitleRow | null; } @@ -77,6 +77,7 @@ export function shouldRecommendAnilistConflict( conflictAnimeId: number, options: AnimeConflictRecommendationOptions, ): boolean { + if (!getAnimeTitles(db, targetAnimeId) || !getAnimeTitles(db, conflictAnimeId)) return false; if (options.survivor === 'target' || options.matchConfidence === 'manual') return false; if ( !animeSeasonsAreMergeCompatible( @@ -99,6 +100,8 @@ export function recordAnimeMergeRecommendation( secondCandidateAnimeId: number, anilistId: number, ): void { + if (!getAnimeTitles(db, firstCandidateAnimeId) || !getAnimeTitles(db, secondCandidateAnimeId)) + return; const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId); const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId); const timestamp = toDbTimestamp(nowMs()); @@ -141,6 +144,8 @@ export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecomm second_anime_id AS secondAnimeId FROM imm_anime_merge_recommendations WHERE status = 'pending' + AND first_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime') + AND second_anime_id IN (SELECT anime_id FROM imm_anime WHERE media_kind = 'anime') ORDER BY recommendation_id ASC`, ) .all() as Array<{ diff --git a/src/core/services/immersion-tracker/anime-merge.ts b/src/core/services/immersion-tracker/anime-merge.ts index 7c347297..d85da0ef 100644 --- a/src/core/services/immersion-tracker/anime-merge.ts +++ b/src/core/services/immersion-tracker/anime-merge.ts @@ -1,3 +1,4 @@ +import type { MediaKind } from '../../../shared/media-kind'; import type { DatabaseSync } from './sqlite'; import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime'; import { toDbTimestamp } from './query-shared'; @@ -5,6 +6,8 @@ import { nowMs } from './time'; /** Thrown when a move names an episode or destination entry that is not there. */ export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry'; +/** Thrown when a merge or move would mix an anime entry with a YouTube channel. */ +export const MEDIA_KIND_MISMATCH_MESSAGE = 'Anime and YouTube channel entries cannot be combined'; export interface AnimeMergeSummary { /** Library entry that owns every moved episode once the merge finishes. */ @@ -60,8 +63,11 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow .get(animeId) ?? null) as AnimeMetadataRow | null; } -function animeExists(db: DatabaseSync, animeId: number): boolean { - return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId)); +function readMediaKind(db: DatabaseSync, animeId: number): MediaKind | null { + const row = db + .prepare('SELECT media_kind AS mediaKind FROM imm_anime WHERE anime_id = ?') + .get(animeId) as { mediaKind: MediaKind } | undefined; + return row?.mediaKind ?? null; } function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean { @@ -161,7 +167,8 @@ export function mergeAnimeRecordsInTransaction( sourceAnimeIds: number[], ): AnimeMergeSummary { const summary = emptyMergeSummary(targetAnimeId); - if (!animeExists(db, targetAnimeId)) { + const targetKind = readMediaKind(db, targetAnimeId); + if (targetKind === null) { return summary; } @@ -195,8 +202,13 @@ export function mergeAnimeRecordsInTransaction( const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?'); for (const sourceAnimeId of new Set(sourceAnimeIds)) { - if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) { - continue; + if (sourceAnimeId === targetAnimeId) continue; + const sourceKind = readMediaKind(db, sourceAnimeId); + if (sourceKind === null) continue; + // A channel folded into an anime would only be recreated on the next + // watch, because title lookups never cross kinds; refuse instead. + if (sourceKind !== targetKind) { + throw new Error(MEDIA_KIND_MISMATCH_MESSAGE); } const sourceMetadata = readAnimeMetadata(db, sourceAnimeId); @@ -257,11 +269,15 @@ export function moveVideoToAnime( const videoRow = db .prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?') .get(videoId) as { animeId: number | null } | null; - if (!videoRow || !animeExists(db, targetAnimeId)) { + const targetKind = readMediaKind(db, targetAnimeId); + if (!videoRow || targetKind === null) { throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE); } const previousAnimeId = videoRow.animeId; + if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) { + throw new Error(MEDIA_KIND_MISMATCH_MESSAGE); + } if (previousAnimeId === targetAnimeId) { db.prepare( 'UPDATE imm_videos SET anime_assignment_locked = 1, LAST_UPDATE_DATE = ? WHERE video_id = ?', diff --git a/src/core/services/immersion-tracker/anime-season-repair.ts b/src/core/services/immersion-tracker/anime-season-repair.ts index ced3ed18..797575c4 100644 --- a/src/core/services/immersion-tracker/anime-season-repair.ts +++ b/src/core/services/immersion-tracker/anime-season-repair.ts @@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null { episodes_total, description FROM imm_anime - WHERE anime_id = ? + WHERE anime_id = ? AND media_kind = 'anime' `, ) .get(animeId) as AnimeRow | null; @@ -335,7 +335,8 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe 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 + WHERE a.media_kind = 'anime' + AND v.parsed_title IS NOT NULL AND TRIM(v.parsed_title) != '' AND v.parsed_season IS NOT NULL AND v.parsed_season > 0 @@ -372,6 +373,11 @@ export function resolveAnimeAnilistConflict( anilistId: number, options: AnimeAnilistConflictOptions = {}, ): AnimeSeasonRepairSummary { + if (!getAnimeRow(db, targetAnimeId)) { + const summary = emptySummary(); + summary.anilistAssignmentBlocked = true; + return summary; + } const conflict = db .prepare( ` @@ -387,6 +393,11 @@ export function resolveAnimeAnilistConflict( return emptySummary(); } + if (!getAnimeRow(db, conflict.animeId)) { + const summary = emptySummary(); + summary.anilistAssignmentBlocked = true; + return summary; + } return runInTransaction(db, () => { const targetRow = getAnimeRow(db, targetAnimeId); if ( diff --git a/src/core/services/immersion-tracker/query-library.ts b/src/core/services/immersion-tracker/query-library.ts index 69240462..cb8f5736 100644 --- a/src/core/services/immersion-tracker/query-library.ts +++ b/src/core/services/immersion-tracker/query-library.ts @@ -33,6 +33,7 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { SELECT a.anime_id AS animeId, a.canonical_title AS canonicalTitle, + a.media_kind AS mediaKind, a.anilist_id AS anilistId, COALESCE(lm.total_sessions, 0) AS totalSessions, COALESCE(lm.total_active_ms, 0) AS totalActiveMs, @@ -63,6 +64,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo SELECT a.anime_id AS animeId, a.canonical_title AS canonicalTitle, + a.media_kind AS mediaKind, a.anilist_id AS anilistId, a.title_romaji AS titleRomaji, a.title_english AS titleEnglish, diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index d04b3cbd..87e6ac1c 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -1,3 +1,4 @@ +import type { MediaKind } from '../../../shared/media-kind'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { parseMediaInfo } from '../../../jimaku/utils'; @@ -24,6 +25,7 @@ export interface TrackerPreparedStatements { } export interface AnimeRecordInput { + mediaKind?: MediaKind; parsedTitle: string; canonicalTitle: string; seasonScope?: number | null; @@ -569,6 +571,8 @@ function ensureSubtitleLineEventIndex(db: DatabaseSync): void { } export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number { + const mediaKind = input.mediaKind ?? 'anime'; + const anilistId = mediaKind === 'anime' ? input.anilistId : null; const seasonScope = normalizeSeasonScope(input.seasonScope); const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope); const canonicalTitle = @@ -580,17 +584,23 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput } const byAnilistId = - input.anilistId !== null - ? (db.prepare('SELECT anime_id FROM imm_anime WHERE anilist_id = ?').get(input.anilistId) as { + anilistId !== null + ? (db + .prepare("SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'") + .get(anilistId) as { anime_id: number; } | null) : null; const byNormalizedTitle = db - .prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?') - .get(normalizedTitleKey) as { anime_id: number } | null; + .prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?') + .get(normalizedTitleKey, mediaKind) as { anime_id: number } | null; const byTitleAlias = db - .prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?') - .get(normalizedTitleKey) as { anime_id: number } | null; + .prepare( + `SELECT a.anime_id FROM imm_anime_title_aliases AS alias + JOIN imm_anime AS a ON a.anime_id = alias.anime_id + WHERE alias.normalized_title_key = ? AND a.media_kind = ?`, + ) + .get(normalizedTitleKey, mediaKind) as { anime_id: number } | null; const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias; if (existing?.anime_id) { // An alias remembers an intentionally merged-away spelling. Reusing it @@ -601,7 +611,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput UPDATE imm_anime SET canonical_title = COALESCE(NULLIF(?, ''), canonical_title), - anilist_id = COALESCE(?, anilist_id), + anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END, title_romaji = COALESCE(?, title_romaji), title_english = COALESCE(?, title_english), title_native = COALESCE(?, title_native), @@ -611,7 +621,8 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput `, ).run( canonicalTitleUpdate, - input.anilistId, + mediaKind, + anilistId, input.titleRomaji, input.titleEnglish, input.titleNative, @@ -627,6 +638,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput .prepare( ` INSERT INTO imm_anime( + media_kind, normalized_title_key, canonical_title, anilist_id, @@ -636,13 +648,14 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput metadata_json, CREATED_DATE, LAST_UPDATE_DATE - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( + input.mediaKind ?? 'anime', normalizedTitleKey, canonicalTitle, - input.anilistId, + anilistId, input.titleRomaji, input.titleEnglish, input.titleNative, @@ -803,6 +816,7 @@ export function linkYoutubeVideoToAnimeRecord( } const animeId = getOrCreateAnimeRecord(db, { + mediaKind: 'youtube', parsedTitle: identity.parsedTitle, canonicalTitle: identity.canonicalTitle, anilistId: null, @@ -875,6 +889,70 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void { } } +// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact +// and foreign keys disabled so dependent history and manual assignments survive. +function migrateAnimeTitleUniqueness(db: DatabaseSync): void { + const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as { + sql: string; + }; + if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) { + const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }; + const sequence = db + .prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'") + .get() as { seq: number } | null; + db.exec('PRAGMA foreign_keys = OFF'); + try { + db.exec('BEGIN IMMEDIATE'); + db.exec( + schema.sql + .replace( + /CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i, + 'CREATE TABLE imm_anime_new', + ) + .replace( + /normalized_title_key TEXT NOT NULL UNIQUE/i, + 'normalized_title_key TEXT NOT NULL', + ), + ); + db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime; + DROP TABLE imm_anime; + ALTER TABLE imm_anime_new RENAME TO imm_anime;`); + if (sequence) { + db.prepare("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'imm_anime'").run( + sequence.seq, + ); + } + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } finally { + db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`); + } + } + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title + ON imm_anime(media_kind, normalized_title_key)`); +} + +// Older builds can create channel rows with the default anime kind even after +// the schema upgrade. Repair classification on every startup without moving videos. +function classifyYoutubeChannels(db: DatabaseSync): void { + db.exec(` + UPDATE imm_anime + SET media_kind = 'youtube', anilist_id = NULL + WHERE media_kind = 'anime' + AND NOT EXISTS (SELECT 1 FROM imm_anime AS channel + WHERE channel.media_kind = 'youtube' + AND channel.normalized_title_key = imm_anime.normalized_title_key) + AND ( + normalized_title_key LIKE 'youtube channel %' + OR CASE WHEN json_valid(metadata_json) + THEN json_extract(metadata_json, '$.source') = 'youtube-channel' + ELSE 0 END + ) + `); +} + export function ensureSchema(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_schema_version ( @@ -897,6 +975,7 @@ export function ensureSchema(db: DatabaseSync): void { .prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1') .get() as { schema_version: number } | null; if (currentVersion?.schema_version === SCHEMA_VERSION) { + classifyYoutubeChannels(db); ensureLexicalDailyRollupTables(db); ensureLifetimeSummaryTables(db); ensureStatsExcludedWordsTable(db); @@ -908,7 +987,7 @@ export function ensureSchema(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_anime( anime_id INTEGER PRIMARY KEY AUTOINCREMENT, - normalized_title_key TEXT NOT NULL UNIQUE, + normalized_title_key TEXT NOT NULL, canonical_title TEXT NOT NULL, anilist_id INTEGER UNIQUE, title_romaji TEXT, @@ -921,6 +1000,12 @@ export function ensureSchema(db: DatabaseSync): void { LAST_UPDATE_DATE TEXT ); `); + addColumnIfMissing( + db, + 'imm_anime', + 'media_kind', + "TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))", + ); db.exec(` CREATE TABLE IF NOT EXISTS imm_videos( video_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1464,6 +1549,8 @@ export function ensureSchema(db: DatabaseSync): void { ); } + migrateAnimeTitleUniqueness(db); + classifyYoutubeChannels(db); migrateSessionEventTimestampsToText(db); ensureLexicalDailyRollupTables(db); diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index 18d3d59a..4cab88c8 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -1,4 +1,6 @@ -export const SCHEMA_VERSION = 23; +import type { MediaKind } from '../../../shared/media-kind'; + +export const SCHEMA_VERSION = 25; export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_FLUSH_INTERVAL_MS = 500; @@ -518,6 +520,7 @@ export interface YoutubeVideoMetadata { } export interface AnimeLibraryRow { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; @@ -531,6 +534,7 @@ export interface AnimeLibraryRow { } export interface AnimeDetailRow { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; diff --git a/src/core/services/immersion-tracker/youtube-kind.test.ts b/src/core/services/immersion-tracker/youtube-kind.test.ts new file mode 100644 index 00000000..8da915f1 --- /dev/null +++ b/src/core/services/immersion-tracker/youtube-kind.test.ts @@ -0,0 +1,310 @@ +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_kind_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('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 * 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(), []); + 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(); + } +}); diff --git a/src/core/services/stats-server/library-routes.ts b/src/core/services/stats-server/library-routes.ts index f705f1ba..dd3e678a 100644 --- a/src/core/services/stats-server/library-routes.ts +++ b/src/core/services/stats-server/library-routes.ts @@ -1,6 +1,9 @@ import type { Hono } from 'hono'; import { statsJson } from '../../../types/stats-http-contract.js'; -import { UNKNOWN_MOVE_TARGET_MESSAGE } from '../immersion-tracker/anime-merge.js'; +import { + MEDIA_KIND_MISMATCH_MESSAGE, + UNKNOWN_MOVE_TARGET_MESSAGE, +} from '../immersion-tracker/anime-merge.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import { buildSentenceSearchOptions, @@ -245,7 +248,15 @@ export function registerStatsLibraryRoutes( const body = await c.req.json().catch(() => null); const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId); if (sourceAnimeIds.length === 0) return c.body(null, 400); - const summary = await tracker.mergeAnime(animeId, sourceAnimeIds); + let summary: Awaited>; + try { + summary = await tracker.mergeAnime(animeId, sourceAnimeIds); + } catch (error) { + if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) { + return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409); + } + throw error; + } // Nothing folded means the target or every source was already gone, so the // caller should not be told the merge succeeded. if (summary.mergedAnimeIds.length === 0) return c.body(null, 404); @@ -281,6 +292,9 @@ export function registerStatsLibraryRoutes( if (error instanceof Error && error.message === UNKNOWN_MOVE_TARGET_MESSAGE) { return c.body(null, 404); } + if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) { + return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409); + } throw error; } }); diff --git a/src/core/services/stats-sync/merge-catalog.ts b/src/core/services/stats-sync/merge-catalog.ts index a58b8d62..692e0c52 100644 --- a/src/core/services/stats-sync/merge-catalog.ts +++ b/src/core/services/stats-sync/merge-catalog.ts @@ -2,6 +2,7 @@ import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver' import { insertRow, tableExists, type SyncMergeSummary } from './shared'; const ANIME_COPY_COLUMNS = [ + 'media_kind', 'normalized_title_key', 'canonical_title', 'anilist_id', @@ -98,11 +99,28 @@ export function mergeAnime( summary: SyncMergeSummary, ): Map { const map = new Map(); - const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?'); - const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?'); + const byAnilist = local.query( + "SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'", + ); + const byTitleKey = local.query( + 'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?', + ); + // A pre-classification channel can be repaired, but a genuine anime sharing + // its title must remain a separate entry. + const legacyChannel = local.query(`SELECT anime_id FROM imm_anime + WHERE normalized_title_key = ? AND media_kind = 'anime' AND ( + normalized_title_key LIKE 'youtube channel %' + OR CASE WHEN json_valid(metadata_json) + THEN json_extract(metadata_json, '$.source') = 'youtube-channel' ELSE 0 END + )`); + const releaseChannelAnilistId = local.query( + "UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?", + ); const fillMissing = local.query( `UPDATE imm_anime SET + media_kind = ?, + anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE anilist_id END, title_romaji = COALESCE(title_romaji, ?), title_english = COALESCE(title_english, ?), title_native = COALESCE(title_native, ?), @@ -116,12 +134,24 @@ export function mergeAnime( `SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`, )) { const remoteId = Number(row.anime_id); - const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ?? - byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined; + if (row.media_kind === 'anime' && row.anilist_id !== null) { + // AniList identifiers belong to anime, including when an older peer + // incorrectly attached one to a channel. + releaseChannelAnilistId.run(row.anilist_id); + } + const existing = ((row.media_kind === 'anime' && row.anilist_id !== null + ? byAnilist.get(row.anilist_id) + : undefined) ?? + byTitleKey.get(row.normalized_title_key, row.media_kind) ?? + (row.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as + | SqlRow + | undefined; if (existing) { const localId = Number(existing.anime_id); map.set(remoteId, localId); fillMissing.run( + row.media_kind, + row.media_kind, row.title_romaji, row.title_english, row.title_native, @@ -133,7 +163,9 @@ export function mergeAnime( } // No local row matched by anilist_id (checked first in `existing` above) // or title key, so the remote anilist_id — if any — is free to insert as-is. - const values = ANIME_COPY_COLUMNS.map((column) => row[column]); + const values = ANIME_COPY_COLUMNS.map((column) => + column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column], + ); map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values)); summary.animeAdded += 1; } diff --git a/src/core/services/stats-sync/merge-occurrences.test.ts b/src/core/services/stats-sync/merge-occurrences.test.ts index a8dbaffe..efc23760 100644 --- a/src/core/services/stats-sync/merge-occurrences.test.ts +++ b/src/core/services/stats-sync/merge-occurrences.test.ts @@ -98,3 +98,122 @@ for (const legacyOccurrences of [false, true]) { } }); } + +test('sync preserves the YouTube media kind when adding a channel', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-youtube-')); + try { + const localPath = buildDb(dir, 'local.sqlite', { + word: '猫', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + const remotePath = buildDb(dir, 'remote.sqlite', { + word: '犬', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + const remote = new Database(remotePath); + remote.exec("UPDATE imm_anime SET media_kind = 'youtube'"); + remote.close(); + mergeSnapshotIntoDb(localPath, remotePath); + const local = new Database(localPath); + try { + const row = local + .prepare( + "SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'key-remote.sqlite'", + ) + .get(); + assert.ok(row && typeof row === 'object' && 'media_kind' in row); + assert.equal(row.media_kind, 'youtube'); + } finally { + local.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('sync separates same-title kinds and repairs only identifiable legacy channels', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-kinds-')); + const localPath = buildDb(dir, 'local', { + word: 'local', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + const remotePath = buildDb(dir, 'remote', { + word: 'remote', + seenMs: BASE_MS, + legacyOccurrences: false, + }); + try { + const local = new Database(localPath); + local.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', anilist_id = 42; + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, metadata_json, title_english, LAST_UPDATE_DATE) + VALUES (2, 'legacy', 'Local channel', '{"source":"youtube-channel"}', 'Keep local', '9999999999999'); + UPDATE imm_anime SET anilist_id = 99 WHERE anime_id = 2; + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id) + VALUES (3, 'other shared', 'Channel with bad ID', 'youtube', 77);`); + local.close(); + const remote = new Database(remotePath); + remote.exec(`UPDATE imm_anime SET normalized_title_key = 'shared', media_kind = 'youtube', anilist_id = 42; + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, title_english, description, LAST_UPDATE_DATE) + VALUES (2, 'legacy', 'Remote channel', 'youtube', 'Remote title', 'Fill missing', '1'); + INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, media_kind, anilist_id) + VALUES (3, 'other shared', 'Real anime', 'anime', 77);`); + remote.close(); + mergeSnapshotIntoDb(localPath, remotePath); + mergeSnapshotIntoDb(localPath, remotePath); + const db = new Database(localPath); + try { + const rows = db + .prepare( + 'SELECT media_kind FROM imm_anime WHERE normalized_title_key = ? ORDER BY media_kind', + ) + .all('shared'); + assert.deepEqual(rows, [{ media_kind: 'anime' }, { media_kind: 'youtube' }]); + assert.deepEqual( + db + .prepare( + "SELECT media_kind FROM imm_anime WHERE normalized_title_key = 'other shared' ORDER BY media_kind", + ) + .all(), + [{ media_kind: 'anime' }, { media_kind: 'youtube' }], + ); + assert.equal( + ( + db.prepare('SELECT media_kind FROM imm_anime WHERE anilist_id = 77').get() as { + media_kind: string; + } + ).media_kind, + 'anime', + ); + assert.deepEqual( + db + .prepare( + 'SELECT media_kind, anilist_id, title_english, description FROM imm_anime WHERE anime_id = 2', + ) + .all()[0], + { + media_kind: 'youtube', + anilist_id: null, + title_english: 'Keep local', + description: 'Fill missing', + }, + ); + assert.equal( + ( + db + .prepare( + "SELECT a.media_kind FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id WHERE v.video_key = 'video-remote'", + ) + .get() as { media_kind: string } + ).media_kind, + 'youtube', + ); + } finally { + db.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/shared/media-kind.ts b/src/shared/media-kind.ts new file mode 100644 index 00000000..5899bb58 --- /dev/null +++ b/src/shared/media-kind.ts @@ -0,0 +1,3 @@ +export const MEDIA_KINDS = ['anime', 'youtube'] as const; + +export type MediaKind = (typeof MEDIA_KINDS)[number]; diff --git a/src/types/stats-wire.ts b/src/types/stats-wire.ts index 40ff0865..c65e1d9f 100644 --- a/src/types/stats-wire.ts +++ b/src/types/stats-wire.ts @@ -1,3 +1,4 @@ +import type { MediaKind } from '../shared/media-kind'; export interface SessionSummary { sessionId: number; canonicalTitle: string | null; @@ -240,6 +241,7 @@ export const EventType = { export type EventType = (typeof EventType)[keyof typeof EventType]; export interface AnimeLibraryItem { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; @@ -261,6 +263,7 @@ export interface AnilistEntry { export interface AnimeDetailData { detail: { + mediaKind: MediaKind; animeId: number; canonicalTitle: string; anilistId: number | null; diff --git a/stats/src/components/anime/AnimeCard.test.tsx b/stats/src/components/anime/AnimeCard.test.tsx index bcbd3c00..7d087b31 100644 --- a/stats/src/components/anime/AnimeCard.test.tsx +++ b/stats/src/components/anime/AnimeCard.test.tsx @@ -7,6 +7,7 @@ test('AnimeCard includes linked AniList id in cover URLs to avoid stale library const markup = renderToStaticMarkup(
{anime.canonicalTitle}
- {anime.episodeCount} episode{anime.episodeCount !== 1 ? 's' : ''} + {anime.episodeCount} {anime.mediaKind === 'youtube' ? 'video' : 'episode'} + {anime.episodeCount !== 1 ? 's' : ''}
{formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards diff --git a/stats/src/components/anime/AnimeCoverImage.test.tsx b/stats/src/components/anime/AnimeCoverImage.test.tsx index dfaf927f..bed2148c 100644 --- a/stats/src/components/anime/AnimeCoverImage.test.tsx +++ b/stats/src/components/anime/AnimeCoverImage.test.tsx @@ -16,6 +16,7 @@ test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', ( const markup = renderToStaticMarkup( Loading...
; if (error) return
Error: {error}
; - if (!data?.detail) return
Anime not found
; + if (!data?.detail) return
Library entry not found
; const { detail, episodes, anilistEntries } = data; @@ -226,6 +226,7 @@ export function AnimeDetailView({ { onEpisodeMoved?.(); @@ -237,7 +238,7 @@ export function AnimeDetailView({ /> - {showAnilistSelector && ( + {detail.mediaKind === 'anime' && showAnilistSelector && ( void { function libraryItem(animeId: number, title: string): AnimeLibraryItem { return { + mediaKind: 'anime', animeId, canonicalTitle: title, anilistId: null, diff --git a/stats/src/components/anime/AnimeHeader.test.tsx b/stats/src/components/anime/AnimeHeader.test.tsx index 9f542a37..3d19d0ca 100644 --- a/stats/src/components/anime/AnimeHeader.test.tsx +++ b/stats/src/components/anime/AnimeHeader.test.tsx @@ -6,6 +6,7 @@ import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete- import type { AnimeDetailData } from '../../types/stats'; const DETAIL: AnimeDetailData['detail'] = { + mediaKind: 'anime', animeId: 3, canonicalTitle: 'Project Radio Noise Season 2', anilistId: 20661, @@ -69,3 +70,16 @@ test('confirmAnimeDelete spells out how much data the entry deletion removes', a assert.match(seen[1] ?? '', /3 episodes/); assert.match(seen[0] ?? '', /every session and stat/); }); + +test('YouTube channel headers show videos and omit all AniList controls', () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + assert.match(markup, /YouTube channel/); + assert.match(markup, /video/); + assert.doesNotMatch(markup, /AniList|anilist\.co|episode/); +}); diff --git a/stats/src/components/anime/AnimeHeader.tsx b/stats/src/components/anime/AnimeHeader.tsx index a6871cc3..bbedb268 100644 --- a/stats/src/components/anime/AnimeHeader.tsx +++ b/stats/src/components/anime/AnimeHeader.tsx @@ -37,6 +37,7 @@ export function AnimeHeader({ onDeleteAnime, isDeletingAnime = false, }: AnimeHeaderProps) { + const isYoutube = detail.mediaKind === 'youtube'; const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter( (t): t is string => t != null && t !== detail.canonicalTitle, ); @@ -61,33 +62,36 @@ export function AnimeHeader({ )}
- {detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''} + {isYoutube ? 'YouTube channel · ' : ''} + {detail.episodeCount} {isYoutube ? 'video' : 'episode'} + {detail.episodeCount !== 1 ? 's' : ''}
- {anilistEntries.length > 0 ? ( - hasMultipleEntries ? ( - anilistEntries.map((entry) => ) - ) : ( + {!isYoutube && + (anilistEntries.length > 0 ? ( + hasMultipleEntries ? ( + anilistEntries.map((entry) => ) + ) : ( + + View on AniList {'\u2197'} + + ) + ) : detail.anilistId ? ( View on AniList {'\u2197'} - ) - ) : detail.anilistId ? ( - - View on AniList {'\u2197'} - - ) : null} - {onChangeAnilist && ( + ) : null)} + {!isYoutube && onChangeAnilist && ( + ))} +
{SORT_OPTIONS.map((opt) => ( ))} @@ -202,7 +235,15 @@ export function AnimeTab({ {selectionMode ? 'Cancel' : 'Select'}
- {filtered.length} titles · {formatDuration(totalMs)} + {filtered.length}{' '} + {mediaKind === 'youtube' + ? filtered.length === 1 + ? 'channel' + : 'channels' + : filtered.length === 1 + ? 'title' + : 'titles'}{' '} + · {formatDuration(totalMs)}
@@ -227,11 +268,13 @@ export function AnimeTab({
{checkedEntries.length === 0 ? 'Pick the duplicate entries to combine' - : `${checkedEntries.length} selected`} + : mixedKindsChecked + ? 'Anime and YouTube channels cannot be combined' + : `${checkedEntries.length} selected`}
@@ -218,8 +223,8 @@ export function EpisodeList({ void handleDeleteEpisode(ep.videoId, ep.canonicalTitle); }} className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red focus-visible:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`} - title="Delete episode" - aria-label="Delete episode" + title={isYoutube ? 'Delete video' : 'Delete episode'} + aria-label={isYoutube ? 'Delete video' : 'Delete episode'} > {'\u2715'} @@ -243,6 +248,7 @@ export function EpisodeList({ void handleMoveEpisode(movingEpisode.videoId, entry.animeId)} diff --git a/stats/src/components/anime/EpisodeMove.test.tsx b/stats/src/components/anime/EpisodeMove.test.tsx index 57a40966..acebee95 100644 --- a/stats/src/components/anime/EpisodeMove.test.tsx +++ b/stats/src/components/anime/EpisodeMove.test.tsx @@ -63,6 +63,7 @@ function episode(videoId: number, title: string): AnimeEpisode { function libraryItem(animeId: number, title: string): AnimeLibraryItem { return { + mediaKind: 'anime', animeId, canonicalTitle: title, anilistId: null, diff --git a/stats/src/components/anime/LibraryEntryPicker.tsx b/stats/src/components/anime/LibraryEntryPicker.tsx index 222d9519..c4c27aea 100644 --- a/stats/src/components/anime/LibraryEntryPicker.tsx +++ b/stats/src/components/anime/LibraryEntryPicker.tsx @@ -4,11 +4,14 @@ import { formatDuration } from '../../lib/formatters'; import { useModalFocus } from '../../hooks/useModalFocus'; import { AnimeCoverImage } from './AnimeCoverImage'; import type { AnimeLibraryItem } from '../../types/stats'; +import type { MediaKind } from '../../../../src/shared/media-kind'; interface LibraryEntryPickerProps { heading: string; /** Entries that cannot be picked, typically the one being moved away from. */ excludeAnimeIds?: number[]; + /** When set, only entries of this kind are offered. */ + mediaKind?: MediaKind; initialQuery?: string; busyAnimeId?: number | null; error?: string | null; @@ -19,6 +22,7 @@ interface LibraryEntryPickerProps { export function LibraryEntryPicker({ heading, excludeAnimeIds = [], + mediaKind, initialQuery = '', busyAnimeId = null, error = null, @@ -69,9 +73,10 @@ export function LibraryEntryPicker({ const term = query.trim().toLowerCase(); return (entries ?? []) .filter((entry) => !excluded.has(entry.animeId)) + .filter((entry) => mediaKind === undefined || entry.mediaKind === mediaKind) .filter((entry) => !term || entry.canonicalTitle.toLowerCase().includes(term)) .sort((a, b) => b.lastWatchedMs - a.lastWatchedMs); - }, [entries, excluded, query]); + }, [entries, excluded, mediaKind, query]); return (
{entry.canonicalTitle}
- {entry.episodeCount} episode{entry.episodeCount !== 1 ? 's' : ''} ·{' '} - {formatDuration(entry.totalActiveMs)} + {entry.episodeCount} {entry.mediaKind === 'youtube' ? 'video' : 'episode'} + {entry.episodeCount !== 1 ? 's' : ''} · {formatDuration(entry.totalActiveMs)}
{busyAnimeId === entry.animeId ? ( diff --git a/stats/src/lib/yomitan-lookup.test.tsx b/stats/src/lib/yomitan-lookup.test.tsx index 145f39d2..f364becc 100644 --- a/stats/src/lib/yomitan-lookup.test.tsx +++ b/stats/src/lib/yomitan-lookup.test.tsx @@ -116,6 +116,7 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => { detail={{ animeId: 1, canonicalTitle: 'Anime', + mediaKind: 'anime', anilistId: null, titleRomaji: null, titleEnglish: null,