diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index ae81895..81840a3 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -31,11 +31,12 @@ function checkDependencies(args: Args): void { if (!commandExists('mpv')) missing.push('mpv'); - if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) { + const isYoutubeUrl = args.targetKind === 'url' && isYoutubeTarget(args.target); + if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('yt-dlp')) { missing.push('yt-dlp'); } - if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('ffmpeg')) { + if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('ffmpeg')) { missing.push('ffmpeg'); } diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index d336f4e..0fe0b5b 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -1284,6 +1284,40 @@ test('flushTelemetry checkpoints latest playback position on the active session } }); +test('recordSubtitleLine advances session checkpoint progress when playback position is unavailable', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('https://stream.example.com/subtitle-progress.m3u8', 'Subtitle Progress'); + tracker.recordSubtitleLine('line one', 170, 185, [], null); + + const privateApi = tracker as unknown as { + db: DatabaseSync; + sessionState: { sessionId: number } | null; + flushTelemetry: (force?: boolean) => void; + flushNow: () => void; + }; + const sessionId = privateApi.sessionState?.sessionId; + assert.ok(sessionId); + + privateApi.flushTelemetry(true); + privateApi.flushNow(); + + const row = privateApi.db + .prepare('SELECT ended_media_ms FROM imm_sessions WHERE session_id = ?') + .get(sessionId) as { ended_media_ms: number | null } | null; + + assert.equal(row?.ended_media_ms, 185_000); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('deleteSession ignores the currently active session and keeps new writes flushable', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -2412,6 +2446,23 @@ printf '%s\n' '${ytDlpOutput}' `, ) .get() as { canonicalTitle: string } | null; + const animeRow = privateApi.db + .prepare( + ` + SELECT + a.canonical_title AS canonicalTitle, + v.parsed_title AS parsedTitle, + v.parser_source AS parserSource + FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + WHERE v.video_id = 1 + `, + ) + .get() as { + canonicalTitle: string; + parsedTitle: string | null; + parserSource: string | null; + } | null; assert.ok(row); assert.ok(videoRow); @@ -2427,6 +2478,9 @@ printf '%s\n' '${ytDlpOutput}' assert.equal(row.uploaderUrl, 'https://www.youtube.com/@creator'); assert.equal(row.description, 'Video description'); assert.equal(videoRow.canonicalTitle, 'Video Name'); + assert.equal(animeRow?.canonicalTitle, 'Creator Name'); + assert.equal(animeRow?.parsedTitle, 'Creator Name'); + assert.equal(animeRow?.parserSource, 'youtube'); } finally { process.env.PATH = originalPath; globalThis.fetch = originalFetch; @@ -2438,6 +2492,419 @@ printf '%s\n' '${ytDlpOutput}' } }); +test('getMediaLibrary lazily backfills missing youtube metadata for existing rows', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + const originalPath = process.env.PATH; + let fakeBinDir: string | null = null; + + try { + fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-')); + const ytDlpOutput = + '{"id":"backfill123","title":"Backfilled Video Title","webpage_url":"https://www.youtube.com/watch?v=backfill123","thumbnail":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg","channel_id":"UCbackfill123","channel":"Backfill Creator","channel_url":"https://www.youtube.com/channel/UCbackfill123","uploader_id":"@backfill","uploader_url":"https://www.youtube.com/@backfill","description":"Backfilled description","thumbnails":[{"url":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/backfill-avatar=s88"}]}'; + if (process.platform === 'win32') { + const outputPath = path.join(fakeBinDir, 'output.json'); + fs.writeFileSync(outputPath, ytDlpOutput, 'utf8'); + fs.writeFileSync( + path.join(fakeBinDir, 'yt-dlp.cmd'), + '@echo off\r\ntype "%~dp0output.json"\r\n', + 'utf8', + ); + } else { + const scriptPath = path.join(fakeBinDir, 'yt-dlp'); + fs.writeFileSync( + scriptPath, + `#!/bin/sh +printf '%s\n' '${ytDlpOutput}' +`, + { mode: 0o755 }, + ); + } + process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`; + + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const privateApi = tracker as unknown as { db: DatabaseSync }; + const nowMs = Date.now(); + + privateApi.db + .prepare( + ` + INSERT INTO imm_videos ( + video_key, + canonical_title, + source_type, + source_path, + source_url, + duration_ms, + file_size_bytes, + codec_id, + container_id, + width_px, + height_px, + fps_x100, + bitrate_kbps, + audio_codec_id, + hash_sha256, + screenshot_path, + metadata_json, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'remote:https://www.youtube.com/watch?v=backfill123', + 'watch?v=backfill123', + 2, + null, + 'https://www.youtube.com/watch?v=backfill123', + 0, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + nowMs, + nowMs, + ); + privateApi.db + .prepare( + ` + INSERT INTO imm_lifetime_media ( + video_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run(1, 1, 5_000, 0, 0, 50, 0, nowMs, nowMs, nowMs, nowMs); + + const before = await tracker.getMediaLibrary(); + assert.equal(before[0]?.channelName ?? null, null); + + await waitForCondition(() => { + const row = privateApi.db + .prepare( + ` + SELECT + video_title AS videoTitle, + channel_name AS channelName, + channel_thumbnail_url AS channelThumbnailUrl + FROM imm_youtube_videos + WHERE video_id = 1 + `, + ) + .get() as { + videoTitle: string | null; + channelName: string | null; + channelThumbnailUrl: string | null; + } | null; + return ( + row?.videoTitle === 'Backfilled Video Title' && + row.channelName === 'Backfill Creator' && + row.channelThumbnailUrl === 'https://yt3.googleusercontent.com/backfill-avatar=s88' + ); + }, 5_000); + + const after = await tracker.getMediaLibrary(); + assert.equal(after[0]?.videoTitle, 'Backfilled Video Title'); + assert.equal(after[0]?.channelName, 'Backfill Creator'); + assert.equal( + after[0]?.channelThumbnailUrl, + 'https://yt3.googleusercontent.com/backfill-avatar=s88', + ); + } finally { + process.env.PATH = originalPath; + tracker?.destroy(); + cleanupDbPath(dbPath); + if (fakeBinDir) { + fs.rmSync(fakeBinDir, { recursive: true, force: true }); + } + } +}); + +test('getAnimeLibrary lazily relinks youtube rows to channel groupings', 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 }; + const nowMs = Date.now(); + + privateApi.db.exec(` + INSERT INTO imm_anime ( + anime_id, + normalized_title_key, + canonical_title, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 'watch v first', 'watch?v first', ${nowMs}, ${nowMs}), + (2, 'watch v second', 'watch?v second', ${nowMs}, ${nowMs}); + + INSERT INTO imm_videos ( + video_id, + anime_id, + video_key, + canonical_title, + parsed_title, + parser_source, + source_type, + source_path, + source_url, + duration_ms, + file_size_bytes, + codec_id, + container_id, + width_px, + height_px, + fps_x100, + bitrate_kbps, + audio_codec_id, + hash_sha256, + screenshot_path, + metadata_json, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 1, + 'remote:https://www.youtube.com/watch?v=first', + 'watch?v first', + 'watch?v first', + 'fallback', + 2, + NULL, + 'https://www.youtube.com/watch?v=first', + 0, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + ${nowMs}, + ${nowMs} + ), + ( + 2, + 2, + 'remote:https://www.youtube.com/watch?v=second', + 'watch?v second', + 'watch?v second', + 'fallback', + 2, + NULL, + 'https://www.youtube.com/watch?v=second', + 0, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + ${nowMs}, + ${nowMs} + ); + + INSERT INTO imm_youtube_videos ( + video_id, + youtube_video_id, + video_url, + video_title, + video_thumbnail_url, + channel_id, + channel_name, + channel_url, + channel_thumbnail_url, + uploader_id, + uploader_url, + description, + metadata_json, + fetched_at_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 'first', + 'https://www.youtube.com/watch?v=first', + 'First Video', + 'https://i.ytimg.com/vi/first/hqdefault.jpg', + 'UCchannel1', + 'Shared Channel', + 'https://www.youtube.com/channel/UCchannel1', + 'https://yt3.googleusercontent.com/shared=s88', + '@shared', + 'https://www.youtube.com/@shared', + NULL, + '{}', + ${nowMs}, + ${nowMs}, + ${nowMs} + ), + ( + 2, + 'second', + 'https://www.youtube.com/watch?v=second', + 'Second Video', + 'https://i.ytimg.com/vi/second/hqdefault.jpg', + 'UCchannel1', + 'Shared Channel', + 'https://www.youtube.com/channel/UCchannel1', + 'https://yt3.googleusercontent.com/shared=s88', + '@shared', + 'https://www.youtube.com/@shared', + NULL, + '{}', + ${nowMs}, + ${nowMs}, + ${nowMs} + ); + + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + ended_at_ms, + status, + total_watched_ms, + active_watched_ms, + lines_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + yomitan_lookup_count, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 'session-youtube-1', + 1, + ${nowMs - 70000}, + ${nowMs - 10000}, + 2, + 65000, + 60000, + 0, + 100, + 0, + 0, + 0, + 0, + ${nowMs}, + ${nowMs} + ), + ( + 2, + 'session-youtube-2', + 2, + ${nowMs - 50000}, + ${nowMs - 5000}, + 2, + 35000, + 30000, + 0, + 50, + 0, + 0, + 0, + 0, + ${nowMs}, + ${nowMs} + ); + + INSERT INTO imm_lifetime_anime ( + anime_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + episodes_started, + episodes_completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 1, 60000, 0, 0, 100, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}), + (2, 1, 30000, 0, 0, 50, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}); + + INSERT INTO imm_lifetime_media ( + video_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 1, 60000, 0, 0, 100, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}), + (2, 1, 30000, 0, 0, 50, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}); + `); + + const rows = await tracker.getAnimeLibrary(); + const sharedRows = rows.filter((row) => row.canonicalTitle === 'Shared Channel'); + + assert.equal(sharedRows.length, 1); + assert.equal(sharedRows[0]?.episodeCount, 2); + + const relinked = privateApi.db + .prepare( + ` + SELECT a.canonical_title AS canonicalTitle, COUNT(*) AS total + FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + GROUP BY a.anime_id, a.canonical_title + ORDER BY total DESC, a.anime_id ASC + `, + ) + .all() as Array<{ canonicalTitle: string; total: number }>; + + assert.equal(relinked[0]?.canonicalTitle, 'Shared Channel'); + assert.equal(relinked[0]?.total, 2); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('reassignAnimeAnilist clears description when description is explicitly null', 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 2382e30..cbd28f6 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -20,6 +20,7 @@ import { getOrCreateAnimeRecord, getOrCreateVideoRecord, linkVideoToAnimeRecord, + linkYoutubeVideoToAnimeRecord, type TrackerPreparedStatements, updateVideoMetadataRecord, updateVideoTitleRecord, @@ -161,6 +162,7 @@ const YOUTUBE_COVER_RETRY_MS = 5 * 60 * 1000; const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120; const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed'; const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/; +const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000; function isValidYouTubeVideoId(value: string | null): boolean { return Boolean(value && YOUTUBE_ID_PATTERN.test(value)); @@ -535,11 +537,15 @@ export class ImmersionTrackerService { } async getMediaLibrary(): Promise { - return getMediaLibrary(this.db); + const rows = getMediaLibrary(this.db); + this.backfillYoutubeMetadataForLibrary(); + return rows; } async getMediaDetail(videoId: number): Promise { - return getMediaDetail(this.db, videoId); + const detail = getMediaDetail(this.db, videoId); + this.backfillYoutubeMetadataForVideo(videoId); + return detail; } async getMediaSessions(videoId: number, limit = 100): Promise { @@ -555,10 +561,12 @@ export class ImmersionTrackerService { } async getAnimeLibrary(): Promise { + this.relinkYoutubeAnimeLibrary(); return getAnimeLibrary(this.db); } async getAnimeDetail(animeId: number): Promise { + this.relinkYoutubeAnimeLibrary(); return getAnimeDetail(this.db, animeId); } @@ -909,6 +917,7 @@ export class ImmersionTrackerService { return; } upsertYoutubeVideoMetadata(this.db, videoId, metadata); + linkYoutubeVideoToAnimeRecord(this.db, videoId, metadata); if (metadata.videoTitle?.trim()) { updateVideoTitleRecord(this.db, videoId, metadata.videoTitle.trim()); } @@ -927,6 +936,174 @@ export class ImmersionTrackerService { }); } + private backfillYoutubeMetadataForLibrary(): void { + const candidate = this.db + .prepare( + ` + SELECT + v.video_id AS videoId, + v.source_url AS sourceUrl + FROM imm_videos v + JOIN imm_lifetime_media lm ON lm.video_id = v.video_id + LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id + WHERE + v.source_type = ? + AND v.source_url IS NOT NULL + AND ( + LOWER(v.source_url) LIKE 'https://www.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtu.be/%' + ) + AND ( + yv.video_id IS NULL + OR yv.video_title IS NULL + OR yv.channel_name IS NULL + OR yv.channel_thumbnail_url IS NULL + ) + AND ( + yv.fetched_at_ms IS NULL + OR yv.fetched_at_ms <= ? + ) + ORDER BY lm.last_watched_ms DESC, v.video_id DESC + LIMIT 1 + `, + ) + .get( + SOURCE_TYPE_REMOTE, + Date.now() - YOUTUBE_METADATA_REFRESH_MS, + ) as { videoId: number; sourceUrl: string | null } | null; + if (!candidate?.sourceUrl) { + return; + } + this.captureYoutubeMetadataAsync(candidate.videoId, candidate.sourceUrl); + } + + private backfillYoutubeMetadataForVideo(videoId: number): void { + const candidate = this.db + .prepare( + ` + SELECT + v.source_url AS sourceUrl + FROM imm_videos v + LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id + WHERE + v.video_id = ? + AND v.source_type = ? + AND v.source_url IS NOT NULL + AND ( + LOWER(v.source_url) LIKE 'https://www.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtu.be/%' + ) + AND ( + yv.video_id IS NULL + OR yv.video_title IS NULL + OR yv.channel_name IS NULL + OR yv.channel_thumbnail_url IS NULL + ) + AND ( + yv.fetched_at_ms IS NULL + OR yv.fetched_at_ms <= ? + ) + `, + ) + .get( + videoId, + SOURCE_TYPE_REMOTE, + Date.now() - YOUTUBE_METADATA_REFRESH_MS, + ) as { sourceUrl: string | null } | null; + if (!candidate?.sourceUrl) { + return; + } + this.captureYoutubeMetadataAsync(videoId, candidate.sourceUrl); + } + + private relinkYoutubeAnimeLibrary(): void { + const candidates = this.db + .prepare( + ` + SELECT + v.video_id AS videoId, + yv.youtube_video_id AS youtubeVideoId, + yv.video_url AS videoUrl, + yv.video_title AS videoTitle, + yv.video_thumbnail_url AS videoThumbnailUrl, + yv.channel_id AS channelId, + yv.channel_name AS channelName, + yv.channel_url AS channelUrl, + yv.channel_thumbnail_url AS channelThumbnailUrl, + yv.uploader_id AS uploaderId, + yv.uploader_url AS uploaderUrl, + yv.description AS description, + yv.metadata_json AS metadataJson + FROM imm_videos v + JOIN imm_youtube_videos yv ON yv.video_id = v.video_id + LEFT JOIN imm_anime a ON a.anime_id = v.anime_id + LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id + WHERE + v.source_type = ? + AND v.source_url IS NOT NULL + AND ( + LOWER(v.source_url) LIKE 'https://www.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtu.be/%' + ) + AND yv.channel_name IS NOT NULL + AND ( + v.anime_id IS NULL + OR a.metadata_json IS NULL + OR a.metadata_json NOT LIKE '%"source":"youtube-channel"%' + OR a.canonical_title IS NULL + OR TRIM(a.canonical_title) != TRIM(yv.channel_name) + ) + ORDER BY lm.last_watched_ms DESC, v.video_id DESC + `, + ) + .all(SOURCE_TYPE_REMOTE) as Array<{ + videoId: number; + youtubeVideoId: string | null; + videoUrl: string | null; + videoTitle: string | null; + videoThumbnailUrl: string | null; + channelId: string | null; + channelName: string | null; + channelUrl: string | null; + channelThumbnailUrl: string | null; + uploaderId: string | null; + uploaderUrl: string | null; + description: string | null; + metadataJson: string | null; + }>; + + if (candidates.length === 0) { + return; + } + + for (const candidate of candidates) { + if (!candidate.youtubeVideoId || !candidate.videoUrl) { + continue; + } + linkYoutubeVideoToAnimeRecord(this.db, candidate.videoId, { + youtubeVideoId: candidate.youtubeVideoId, + videoUrl: candidate.videoUrl, + videoTitle: candidate.videoTitle, + videoThumbnailUrl: candidate.videoThumbnailUrl, + channelId: candidate.channelId, + channelName: candidate.channelName, + channelUrl: candidate.channelUrl, + channelThumbnailUrl: candidate.channelThumbnailUrl, + uploaderId: candidate.uploaderId, + uploaderUrl: candidate.uploaderUrl, + description: candidate.description, + metadataJson: candidate.metadataJson, + }); + } + rebuildLifetimeSummaryTables(this.db); + } + handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { const normalizedPath = normalizeMediaPath(mediaPath); const normalizedTitle = normalizeText(mediaTitle); @@ -971,14 +1148,14 @@ export class ImmersionTrackerService { `Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`, ); this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs); - if (sourceType === SOURCE_TYPE_REMOTE) { - const youtubeVideoId = extractYouTubeVideoId(normalizedPath); - if (youtubeVideoId) { - void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId); - this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath); - } + const youtubeVideoId = + sourceType === SOURCE_TYPE_REMOTE ? extractYouTubeVideoId(normalizedPath) : null; + if (youtubeVideoId) { + void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId); + this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath); + } else { + this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); } - this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); } @@ -1006,6 +1183,7 @@ export class ImmersionTrackerService { } const startMs = secToMs(startSec); + const endMs = secToMs(endSec); const subtitleKey = `${startMs}:${cleaned}`; if (this.recordedSubtitleKeys.has(subtitleKey)) { return; @@ -1019,6 +1197,9 @@ export class ImmersionTrackerService { this.sessionState.currentLineIndex += 1; this.sessionState.linesSeen += 1; this.sessionState.tokensSeen += tokenCount; + if (this.sessionState.lastMediaMs === null || endMs > this.sessionState.lastMediaMs) { + this.sessionState.lastMediaMs = endMs; + } this.sessionState.pendingTelemetry = true; const wordOccurrences = new Map(); @@ -1068,8 +1249,8 @@ export class ImmersionTrackerService { sessionId: this.sessionState.sessionId, videoId: this.sessionState.videoId, lineIndex: this.sessionState.currentLineIndex, - segmentStartMs: secToMs(startSec), - segmentEndMs: secToMs(endSec), + segmentStartMs: startMs, + segmentEndMs: endMs, text: cleaned, secondaryText: secondaryText ?? null, wordOccurrences: Array.from(wordOccurrences.values()), diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index 1724510..345c6c1 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -280,6 +280,78 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio } }); +test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const stmts = createTrackerPreparedStatements(db); + const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=zero123', { + canonicalTitle: 'Zero Checkpoint Stream', + sourcePath: null, + sourceUrl: 'https://www.youtube.com/watch?v=zero123', + sourceType: SOURCE_TYPE_REMOTE, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Zero Checkpoint Anime', + canonicalTitle: 'Zero Checkpoint Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'watch?v=zero123', + parsedTitle: 'Zero Checkpoint Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: '{"episode":1}', + }); + db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(600_000, videoId); + + const startedAtMs = 1_200_000; + const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId; + db.prepare( + ` + UPDATE imm_sessions + SET + ended_at_ms = ?, + status = 2, + ended_media_ms = 0, + active_watched_ms = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + `, + ).run(startedAtMs + 30_000, 180_000, startedAtMs + 30_000, sessionId); + stmts.eventInsertStmt.run( + sessionId, + startedAtMs + 29_000, + EVENT_SUBTITLE_LINE, + 1, + 170_000, + 185_000, + 4, + 0, + '{"line":"stream progress"}', + startedAtMs + 29_000, + startedAtMs + 29_000, + ); + + const [episode] = getAnimeEpisodes(db, animeId); + assert.ok(episode); + assert.equal(episode?.endedMediaMs, 185_000); + assert.equal(episode?.durationMs, 600_000); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('getSessionTimeline returns the full session when no limit is provided', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -2774,3 +2846,200 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li cleanupDbPath(dbPath); } }); + +test('deleteSession removes zero-session media from library and trends', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Delete Me Anime', + canonicalTitle: 'Delete Me Anime', + anilistId: 404_404, + titleRomaji: 'Delete Me Anime', + titleEnglish: 'Delete Me Anime', + titleNative: 'Delete Me Anime', + metadataJson: null, + }); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/delete-last-session.mkv', { + canonicalTitle: 'Delete Last Session', + sourcePath: '/tmp/delete-last-session.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'Delete Last Session', + parsedTitle: 'Delete Me Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: '{"episode":1}', + }); + + const startedAtMs = 9_000_000; + const endedAtMs = startedAtMs + 120_000; + const rollupDay = Math.floor(startedAtMs / 86_400_000); + const rollupMonth = 197001; + const { sessionId } = startSessionRecord(db, videoId, startedAtMs); + + db.prepare( + ` + UPDATE imm_sessions + SET + ended_at_ms = ?, + ended_media_ms = ?, + total_watched_ms = ?, + active_watched_ms = ?, + lines_seen = ?, + tokens_seen = ?, + cards_mined = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + `, + ).run(endedAtMs, 120000, 120000, 120000, 12, 120, 3, endedAtMs, sessionId); + + db.prepare( + ` + INSERT INTO imm_lifetime_applied_sessions ( + session_id, + applied_at_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?) + `, + ).run(sessionId, endedAtMs, endedAtMs, endedAtMs); + db.prepare( + ` + INSERT INTO imm_lifetime_media ( + video_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(videoId, 1, 120_000, 3, 12, 120, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs); + db.prepare( + ` + INSERT INTO imm_lifetime_anime ( + anime_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + episodes_started, + episodes_completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(animeId, 1, 120000, 3, 12, 120, 1, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs); + db.prepare( + ` + UPDATE imm_lifetime_global + SET + total_sessions = 1, + total_active_ms = 120000, + total_cards = 3, + active_days = 1, + episodes_started = 1, + episodes_completed = 0, + anime_completed = 0, + last_rebuilt_ms = ?, + LAST_UPDATE_DATE = ? + WHERE global_id = 1 + `, + ).run(endedAtMs, endedAtMs); + db.prepare( + ` + INSERT INTO imm_daily_rollups ( + rollup_day, + video_id, + total_sessions, + total_active_min, + total_lines_seen, + total_tokens_seen, + total_cards, + cards_per_hour, + tokens_per_min, + lookup_hit_rate, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(rollupDay, videoId, 1, 2, 12, 120, 3, 90, 60, null, endedAtMs, endedAtMs); + db.prepare( + ` + INSERT INTO imm_monthly_rollups ( + rollup_month, + video_id, + total_sessions, + total_active_min, + total_lines_seen, + total_tokens_seen, + total_cards, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(rollupMonth, videoId, 1, 2, 12, 120, 3, endedAtMs, endedAtMs); + + deleteSession(db, sessionId); + + assert.deepEqual(getMediaLibrary(db), []); + assert.equal(getMediaDetail(db, videoId) ?? null, null); + assert.deepEqual(getAnimeLibrary(db), []); + assert.equal(getAnimeDetail(db, animeId) ?? null, null); + + const trends = getTrendsDashboard(db, 'all', 'day'); + assert.deepEqual(trends.activity.watchTime, []); + assert.deepEqual(trends.activity.sessions, []); + + const dailyRollups = getDailyRollups(db, 30); + const monthlyRollups = getMonthlyRollups(db, 30); + assert.deepEqual(dailyRollups, []); + assert.deepEqual(monthlyRollups, []); + + const lifetimeMediaCount = Number( + ( + db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?').get( + videoId, + ) as { total: number } + ).total, + ); + const lifetimeAnimeCount = Number( + ( + db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime WHERE anime_id = ?').get( + animeId, + ) as { total: number } + ).total, + ); + const appliedSessionCount = Number( + ( + db + .prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions WHERE session_id = ?') + .get(sessionId) as { total: number } + ).total, + ); + + assert.equal(lifetimeMediaCount, 0); + assert.equal(lifetimeAnimeCount, 0); + assert.equal(appliedSessionCount, 0); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts index f277bef..1119ea5 100644 --- a/src/core/services/immersion-tracker/lifetime.ts +++ b/src/core/services/immersion-tracker/lifetime.ts @@ -134,6 +134,49 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { ).run(nowMs, nowMs); } +function rebuildLifetimeSummariesInternal( + db: DatabaseSync, + rebuiltAtMs: number, +): LifetimeRebuildSummary { + const sessions = db + .prepare( + ` + SELECT + session_id AS sessionId, + video_id AS videoId, + started_at_ms AS startedAtMs, + ended_at_ms AS endedAtMs, + total_watched_ms AS totalWatchedMs, + active_watched_ms AS activeWatchedMs, + lines_seen AS linesSeen, + tokens_seen AS tokensSeen, + cards_mined AS cardsMined, + lookup_count AS lookupCount, + lookup_hits AS lookupHits, + yomitan_lookup_count AS yomitanLookupCount, + pause_count AS pauseCount, + pause_ms AS pauseMs, + seek_forward_count AS seekForwardCount, + seek_backward_count AS seekBackwardCount, + media_buffer_events AS mediaBufferEvents + FROM imm_sessions + WHERE ended_at_ms IS NOT NULL + ORDER BY started_at_ms ASC, session_id ASC + `, + ) + .all() as RetainedSessionRow[]; + + resetLifetimeSummaries(db, rebuiltAtMs); + for (const session of sessions) { + applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs); + } + + return { + appliedSessions: sessions.length, + rebuiltAtMs, + }; +} + function toRebuildSessionState(row: RetainedSessionRow): SessionState { return { sessionId: row.sessionId, @@ -482,50 +525,22 @@ export function applySessionLifetimeSummary( export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary { const rebuiltAtMs = Date.now(); - const sessions = db - .prepare( - ` - SELECT - session_id AS sessionId, - video_id AS videoId, - started_at_ms AS startedAtMs, - ended_at_ms AS endedAtMs, - total_watched_ms AS totalWatchedMs, - active_watched_ms AS activeWatchedMs, - lines_seen AS linesSeen, - tokens_seen AS tokensSeen, - cards_mined AS cardsMined, - lookup_count AS lookupCount, - lookup_hits AS lookupHits, - yomitan_lookup_count AS yomitanLookupCount, - pause_count AS pauseCount, - pause_ms AS pauseMs, - seek_forward_count AS seekForwardCount, - seek_backward_count AS seekBackwardCount, - media_buffer_events AS mediaBufferEvents - FROM imm_sessions - WHERE ended_at_ms IS NOT NULL - ORDER BY started_at_ms ASC, session_id ASC - `, - ) - .all() as RetainedSessionRow[]; - db.exec('BEGIN'); try { - resetLifetimeSummaries(db, rebuiltAtMs); - for (const session of sessions) { - applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs); - } + const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs); db.exec('COMMIT'); + return summary; } catch (error) { db.exec('ROLLBACK'); throw error; } +} - return { - appliedSessions: sessions.length, - rebuiltAtMs, - }; +export function rebuildLifetimeSummariesInTransaction( + db: DatabaseSync, + rebuiltAtMs = Date.now(), +): LifetimeRebuildSummary { + return rebuildLifetimeSummariesInternal(db, rebuiltAtMs); } export function reconcileStaleActiveSessions(db: DatabaseSync): number { diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts index 0f767bf..13f7e39 100644 --- a/src/core/services/immersion-tracker/maintenance.ts +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -113,6 +113,14 @@ function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number): void { ).run(ROLLUP_STATE_KEY, sampleMs); } +function resetRollups(db: DatabaseSync): void { + db.exec(` + DELETE FROM imm_daily_rollups; + DELETE FROM imm_monthly_rollups; + `); + setLastRollupSampleMs(db, ZERO_ID); +} + function upsertDailyRollupsForGroups( db: DatabaseSync, groups: Array<{ rollupDay: number; videoId: number }>, @@ -281,8 +289,20 @@ function dedupeGroups ({ + rollupDay: group.rollupDay, + videoId: group.videoId, + })), + ); + const monthlyGroups = dedupeGroups( + affectedGroups.map((group) => ({ + rollupMonth: group.rollupMonth, + videoId: group.videoId, + })), + ); + + upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs); + upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs); + setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs)); +} + export function runOptimizeMaintenance(db: DatabaseSync): void { db.exec('PRAGMA optimize'); } diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts index cf739d1..8ca52ae 100644 --- a/src/core/services/immersion-tracker/query.ts +++ b/src/core/services/immersion-tracker/query.ts @@ -31,6 +31,8 @@ import type { VocabularyStatsRow, } from './types'; import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage'; +import { rebuildLifetimeSummariesInTransaction } from './lifetime'; +import { rebuildRollupsInTransaction } from './maintenance'; import { PartOfSpeech, type MergedToken } from '../../../types'; import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech'; @@ -1746,7 +1748,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod v.duration_ms AS durationMs, ( SELECT COALESCE( - s_recent.ended_media_ms, + NULLIF(s_recent.ended_media_ms, 0), ( SELECT MAX(line.segment_end_ms) FROM imm_subtitle_lines line @@ -2467,6 +2469,8 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void { try { deleteSessionsByIds(db, sessionIds); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); + rebuildLifetimeSummariesInTransaction(db); + rebuildRollupsInTransaction(db); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); @@ -2483,6 +2487,8 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void { try { deleteSessionsByIds(db, sessionIds); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); + rebuildLifetimeSummariesInTransaction(db); + rebuildRollupsInTransaction(db); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); @@ -2519,6 +2525,8 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void { cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null); db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); + rebuildLifetimeSummariesInTransaction(db); + rebuildRollupsInTransaction(db); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index 150e431..21b404b 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -15,8 +15,14 @@ import { getOrCreateAnimeRecord, getOrCreateVideoRecord, linkVideoToAnimeRecord, + linkYoutubeVideoToAnimeRecord, } from './storage'; -import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types'; +import { + EVENT_SUBTITLE_LINE, + SESSION_STATUS_ENDED, + SOURCE_TYPE_LOCAL, + SOURCE_TYPE_REMOTE, +} from './types'; function makeDbPath(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-')); @@ -817,6 +823,123 @@ test('anime rows are reused by normalized parsed title and upgraded with AniList } }); +test('youtube videos can be regrouped under a shared channel anime identity', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + + const firstVideoId = getOrCreateVideoRecord( + db, + 'remote:https://www.youtube.com/watch?v=video-1', + { + canonicalTitle: 'watch?v video-1', + sourcePath: null, + sourceUrl: 'https://www.youtube.com/watch?v=video-1', + sourceType: SOURCE_TYPE_REMOTE, + }, + ); + const secondVideoId = getOrCreateVideoRecord( + db, + 'remote:https://www.youtube.com/watch?v=video-2', + { + canonicalTitle: 'watch?v video-2', + sourcePath: null, + sourceUrl: 'https://www.youtube.com/watch?v=video-2', + sourceType: SOURCE_TYPE_REMOTE, + }, + ); + + const firstAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'watch?v video-1', + canonicalTitle: 'watch?v video-1', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, firstVideoId, { + animeId: firstAnimeId, + parsedBasename: null, + parsedTitle: 'watch?v video-1', + parsedSeason: null, + parsedEpisode: null, + parserSource: 'fallback', + parserConfidence: 0.2, + parseMetadataJson: '{"source":"fallback"}', + }); + + const secondAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'watch?v video-2', + canonicalTitle: 'watch?v video-2', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, secondVideoId, { + animeId: secondAnimeId, + parsedBasename: null, + parsedTitle: 'watch?v video-2', + parsedSeason: null, + parsedEpisode: null, + parserSource: 'fallback', + parserConfidence: 0.2, + parseMetadataJson: '{"source":"fallback"}', + }); + + linkYoutubeVideoToAnimeRecord(db, firstVideoId, { + youtubeVideoId: 'video-1', + videoUrl: 'https://www.youtube.com/watch?v=video-1', + videoTitle: 'Video One', + videoThumbnailUrl: 'https://i.ytimg.com/vi/video-1/hqdefault.jpg', + channelId: 'UC123', + channelName: 'Channel Name', + channelUrl: 'https://www.youtube.com/channel/UC123', + channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj', + uploaderId: '@channelname', + uploaderUrl: 'https://www.youtube.com/@channelname', + description: null, + metadataJson: '{"id":"video-1"}', + }); + linkYoutubeVideoToAnimeRecord(db, secondVideoId, { + youtubeVideoId: 'video-2', + videoUrl: 'https://www.youtube.com/watch?v=video-2', + videoTitle: 'Video Two', + videoThumbnailUrl: 'https://i.ytimg.com/vi/video-2/hqdefault.jpg', + channelId: 'UC123', + channelName: 'Channel Name', + channelUrl: 'https://www.youtube.com/channel/UC123', + channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj', + uploaderId: '@channelname', + uploaderUrl: 'https://www.youtube.com/@channelname', + description: null, + metadataJson: '{"id":"video-2"}', + }); + + const animeRows = db.prepare('SELECT anime_id, canonical_title FROM imm_anime').all() as Array<{ + anime_id: number; + canonical_title: string; + }>; + const videoRows = db + .prepare('SELECT video_id, anime_id, parsed_title FROM imm_videos ORDER BY video_id ASC') + .all() as Array<{ video_id: number; anime_id: number | null; parsed_title: string | null }>; + + const channelAnimeRows = animeRows.filter((row) => row.canonical_title === 'Channel Name'); + assert.equal(channelAnimeRows.length, 1); + assert.equal(videoRows[0]?.anime_id, channelAnimeRows[0]?.anime_id); + assert.equal(videoRows[1]?.anime_id, channelAnimeRows[0]?.anime_id); + assert.equal(videoRows[0]?.parsed_title, 'Channel Name'); + assert.equal(videoRows[1]?.parsed_title, 'Channel Name'); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('start/finalize session updates ended_at and status', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 8fd40f9..5e4b85e 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -39,6 +39,41 @@ export interface VideoAnimeLinkInput { parseMetadataJson: string | null; } +function buildYoutubeChannelAnimeIdentity(metadata: YoutubeVideoMetadata): { + parsedTitle: string; + canonicalTitle: string; + metadataJson: string; +} | null { + const channelId = metadata.channelId?.trim() || null; + const channelUrl = metadata.channelUrl?.trim() || null; + const channelName = metadata.channelName?.trim() || null; + const uploaderId = metadata.uploaderId?.trim() || null; + const videoTitle = metadata.videoTitle?.trim() || null; + + const parsedTitle = channelId + ? `youtube-channel:${channelId}` + : channelUrl + ? `youtube-channel-url:${channelUrl}` + : channelName + ? `youtube-channel-name:${channelName}` + : null; + if (!parsedTitle) { + return null; + } + + return { + parsedTitle, + canonicalTitle: channelName || uploaderId || videoTitle || parsedTitle, + metadataJson: JSON.stringify({ + source: 'youtube-channel', + channelId, + channelUrl, + channelName, + uploaderId, + }), + }; +} + const COVER_BLOB_REFERENCE_PREFIX = '__subminer_cover_blob_ref__:'; const WAL_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024; @@ -439,6 +474,38 @@ export function linkVideoToAnimeRecord( ); } +export function linkYoutubeVideoToAnimeRecord( + db: DatabaseSync, + videoId: number, + metadata: YoutubeVideoMetadata, +): number | null { + const identity = buildYoutubeChannelAnimeIdentity(metadata); + if (!identity) { + return null; + } + + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: identity.parsedTitle, + canonicalTitle: identity.canonicalTitle, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: identity.metadataJson, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: null, + parsedTitle: identity.canonicalTitle, + parsedSeason: null, + parsedEpisode: null, + parserSource: 'youtube', + parserConfidence: 1, + parseMetadataJson: identity.metadataJson, + }); + return animeId; +} + function migrateLegacyAnimeMetadata(db: DatabaseSync): void { addColumnIfMissing(db, 'imm_videos', 'anime_id', 'INTEGER REFERENCES imm_anime(anime_id)'); addColumnIfMissing(db, 'imm_videos', 'parsed_basename', 'TEXT'); diff --git a/src/main.ts b/src/main.ts index afaebcc..c75c2d5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -412,6 +412,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; +import { isYoutubePlaybackActive } from './main/runtime/youtube-playback'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { @@ -1231,6 +1232,13 @@ const startupOsdSequencer = createStartupOsdSequencer({ showOsd: (message) => showMpvOsd(message), }); +function isYoutubePlaybackActiveNow(): boolean { + return isYoutubePlaybackActive( + appState.currentMediaPath, + appState.mpvClient?.currentVideoPath ?? null, + ); +} + function maybeSignalPluginAutoplayReady( payload: SubtitleData, options?: { forceWhilePaused?: boolean }, @@ -1741,7 +1749,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt getConfig: () => { const config = getResolvedConfig().anilist.characterDictionary; return { - enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(), + enabled: + config.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled() && + !isYoutubePlaybackActiveNow(), maxLoaded: config.maxLoaded, profileScope: config.profileScope, }; @@ -3518,7 +3529,7 @@ const { ); }, scheduleCharacterDictionarySync: () => { - if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) { + if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { return; } characterDictionaryAutoSyncRuntime.scheduleSync(); @@ -3613,7 +3624,8 @@ const { ), getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled && - yomitanProfilePolicy.isCharacterDictionaryEnabled(), + yomitanProfilePolicy.isCharacterDictionaryEnabled() && + !isYoutubePlaybackActiveNow(), getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption( diff --git a/src/main/runtime/anilist-media-guess.test.ts b/src/main/runtime/anilist-media-guess.test.ts index f76d7c8..77e01d8 100644 --- a/src/main/runtime/anilist-media-guess.test.ts +++ b/src/main/runtime/anilist-media-guess.test.ts @@ -68,3 +68,32 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => { }); assert.equal(state.mediaGuessPromise, null); }); + +test('ensureAnilistMediaGuess skips youtube playback urls', async () => { + let state: AnilistMediaGuessRuntimeState = { + mediaKey: 'https://www.youtube.com/watch?v=abc123', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + let calls = 0; + const ensureGuess = createEnsureAnilistMediaGuessHandler({ + getState: () => state, + setState: (next) => { + state = next; + }, + resolveMediaPathForJimaku: (value) => value, + getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=abc123', + getCurrentMediaTitle: () => 'Video', + guessAnilistMediaInfo: async () => { + calls += 1; + return { title: 'Show', season: null, episode: 1, source: 'guessit' }; + }, + }); + + const guess = await ensureGuess('https://www.youtube.com/watch?v=abc123'); + assert.equal(guess, null); + assert.equal(calls, 0); + assert.equal(state.mediaGuess, null); +}); diff --git a/src/main/runtime/anilist-media-guess.ts b/src/main/runtime/anilist-media-guess.ts index 7a0a799..aed73d4 100644 --- a/src/main/runtime/anilist-media-guess.ts +++ b/src/main/runtime/anilist-media-guess.ts @@ -1,4 +1,5 @@ import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; +import { isYoutubeMediaPath } from './youtube-playback'; export type AnilistMediaGuessRuntimeState = { mediaKey: string | null; @@ -26,6 +27,9 @@ export function createMaybeProbeAnilistDurationHandler(deps: { if (state.mediaKey !== mediaKey) { return null; } + if (isYoutubeMediaPath(mediaKey)) { + return null; + } if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) { return state.mediaDurationSec; } @@ -73,6 +77,9 @@ export function createEnsureAnilistMediaGuessHandler(deps: { if (state.mediaKey !== mediaKey) { return null; } + if (isYoutubeMediaPath(mediaKey)) { + return null; + } if (state.mediaGuess) { return state.mediaGuess; } diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts index 8720ccd..26b58aa 100644 --- a/src/main/runtime/anilist-media-state.test.ts +++ b/src/main/runtime/anilist-media-state.test.ts @@ -20,6 +20,18 @@ test('get current anilist media key trims and normalizes empty path', () => { assert.equal(getEmptyKey(), null); }); +test('get current anilist media key skips youtube playback urls', () => { + const getYoutubeKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => ' https://www.youtube.com/watch?v=abc123 ', + }); + const getShortYoutubeKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => 'https://youtu.be/abc123', + }); + + assert.equal(getYoutubeKey(), null); + assert.equal(getShortYoutubeKey(), null); +}); + test('reset anilist media tracking clears duration/guess/probe state', () => { let mediaKey: string | null = 'old'; let mediaDurationSec: number | null = 123; diff --git a/src/main/runtime/anilist-media-state.ts b/src/main/runtime/anilist-media-state.ts index 433967d..1660b67 100644 --- a/src/main/runtime/anilist-media-state.ts +++ b/src/main/runtime/anilist-media-state.ts @@ -1,11 +1,15 @@ import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess'; +import { isYoutubeMediaPath } from './youtube-playback'; export function createGetCurrentAnilistMediaKeyHandler(deps: { getCurrentMediaPath: () => string | null; }) { return (): string | null => { const mediaPath = deps.getCurrentMediaPath()?.trim(); - return mediaPath && mediaPath.length > 0 ? mediaPath : null; + if (!mediaPath || mediaPath.length === 0 || isYoutubeMediaPath(mediaPath)) { + return null; + } + return mediaPath; }; } diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 4deac3a..b4461b5 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -76,3 +76,52 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as assert.ok(calls.includes('inflight:true')); assert.ok(calls.includes('inflight:false')); }); + +test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => { + const calls: string[] = []; + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123', + hasMpvClient: () => true, + getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123', + resetTrackedMedia: () => calls.push('reset'), + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async () => { + calls.push('probe'); + return 1000; + }, + ensureAnilistMediaGuess: async () => { + calls.push('guess'); + return { title: 'Show', season: null, episode: 1 }; + }, + hasAttemptedUpdateKey: () => false, + processNextAnilistRetryUpdate: async () => { + calls.push('process-retry'); + return { ok: true, message: 'noop' }; + }, + refreshAnilistClientSecretState: async () => { + calls.push('refresh-token'); + return 'token'; + }, + enqueueRetry: () => calls.push('enqueue'), + markRetryFailure: () => calls.push('mark-failure'), + markRetrySuccess: () => calls.push('mark-success'), + refreshRetryQueueState: () => calls.push('refresh'), + updateAnilistPostWatchProgress: async () => { + calls.push('update'); + return { status: 'updated', message: 'ok' }; + }, + rememberAttemptedUpdateKey: () => calls.push('remember'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + minWatchSeconds: 600, + minWatchRatio: 0.85, + }); + + await handler(); + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index 27e535b..74fa849 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -1,3 +1,5 @@ +import { isYoutubeMediaPath } from './youtube-playback'; + type AnilistGuess = { title: string; episode: number | null; @@ -130,6 +132,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { if (!mediaKey || !deps.hasMpvClient()) { return; } + if (isYoutubeMediaPath(mediaKey)) { + return; + } if (deps.getTrackedMediaKey() !== mediaKey) { deps.resetTrackedMedia(mediaKey); } diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts index 575a23a..e7b5fd3 100644 --- a/src/main/runtime/immersion-startup.test.ts +++ b/src/main/runtime/immersion-startup.test.ts @@ -56,6 +56,57 @@ test('createImmersionTrackerStartupHandler skips when disabled', () => { assert.equal(tracker, 'unchanged'); }); +test('createImmersionTrackerStartupHandler skips when env disables session tracking', () => { + const calls: string[] = []; + const originalEnv = process.env.SUBMINER_DISABLE_IMMERSION_TRACKING; + process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = '1'; + + try { + let tracker: unknown = 'unchanged'; + const handler = createImmersionTrackerStartupHandler({ + getResolvedConfig: () => { + calls.push('getResolvedConfig'); + return makeConfig(); + }, + getConfiguredDbPath: () => { + calls.push('getConfiguredDbPath'); + return '/tmp/subminer.db'; + }, + createTrackerService: () => { + calls.push('createTrackerService'); + return {}; + }, + setTracker: (nextTracker) => { + tracker = nextTracker; + }, + getMpvClient: () => null, + seedTrackerFromCurrentMedia: () => calls.push('seedTracker'), + logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + }); + + handler(); + + assert.equal(calls.includes('getResolvedConfig'), false); + assert.equal(calls.includes('getConfiguredDbPath'), false); + assert.equal(calls.includes('createTrackerService'), false); + assert.equal(calls.includes('seedTracker'), false); + assert.equal(tracker, 'unchanged'); + assert.ok( + calls.includes( + 'info:Immersion tracking disabled for this session by SUBMINER_DISABLE_IMMERSION_TRACKING=1.', + ), + ); + } finally { + if (originalEnv === undefined) { + delete process.env.SUBMINER_DISABLE_IMMERSION_TRACKING; + } else { + process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = originalEnv; + } + } +}); + test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => { const calls: string[] = []; const trackerInstance = { kind: 'tracker' }; diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts index 20c720b..719f641 100644 --- a/src/main/runtime/immersion-startup.ts +++ b/src/main/runtime/immersion-startup.ts @@ -23,6 +23,8 @@ type ImmersionTrackingConfig = { type ImmersionTrackerPolicy = Omit; +const DISABLE_IMMERSION_TRACKING_SESSION_ENV = 'SUBMINER_DISABLE_IMMERSION_TRACKING'; + type ImmersionTrackerServiceParams = { dbPath: string; policy: ImmersionTrackerPolicy; @@ -49,7 +51,16 @@ export type ImmersionTrackerStartupDeps = { export function createImmersionTrackerStartupHandler( deps: ImmersionTrackerStartupDeps, ): () => void { + const isSessionTrackingDisabled = process.env[DISABLE_IMMERSION_TRACKING_SESSION_ENV] === '1'; + return () => { + if (isSessionTrackingDisabled) { + deps.logInfo( + `Immersion tracking disabled for this session by ${DISABLE_IMMERSION_TRACKING_SESSION_ENV}=1.`, + ); + return; + } + const config = deps.getResolvedConfig(); if (config.immersionTracking?.enabled === false) { deps.logInfo('Immersion tracking disabled in config'); diff --git a/src/main/runtime/youtube-playback.test.ts b/src/main/runtime/youtube-playback.test.ts new file mode 100644 index 0000000..6c98cdd --- /dev/null +++ b/src/main/runtime/youtube-playback.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback'; + +test('isYoutubeMediaPath detects youtube watch and short urls', () => { + assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true); + assert.equal(isYoutubeMediaPath('https://m.youtube.com/watch?v=abc123'), true); + assert.equal(isYoutubeMediaPath('https://youtu.be/abc123'), true); + assert.equal(isYoutubeMediaPath('https://www.youtube-nocookie.com/embed/abc123'), true); +}); + +test('isYoutubeMediaPath ignores local files and non-youtube urls', () => { + assert.equal(isYoutubeMediaPath('/tmp/video.mkv'), false); + assert.equal(isYoutubeMediaPath('https://example.com/watch?v=abc123'), false); + assert.equal(isYoutubeMediaPath(' '), false); + assert.equal(isYoutubeMediaPath(null), false); +}); + +test('isYoutubePlaybackActive checks both current media and mpv video paths', () => { + assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', 'https://youtu.be/abc123'), true); + assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true); + assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false); +}); diff --git a/src/main/runtime/youtube-playback.ts b/src/main/runtime/youtube-playback.ts new file mode 100644 index 0000000..5a09a34 --- /dev/null +++ b/src/main/runtime/youtube-playback.ts @@ -0,0 +1,36 @@ +function trimToNull(value: string | null | undefined): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean { + const normalized = trimToNull(mediaPath); + if (!normalized) { + return false; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + return false; + } + + const host = parsed.hostname.toLowerCase(); + return ( + host === 'youtu.be' || + host.endsWith('.youtu.be') || + host.endsWith('youtube.com') || + host.endsWith('youtube-nocookie.com') + ); +} + +export function isYoutubePlaybackActive( + currentMediaPath: string | null | undefined, + currentVideoPath: string | null | undefined, +): boolean { + return isYoutubeMediaPath(currentMediaPath) || isYoutubeMediaPath(currentVideoPath); +}