fix(ci): add changelog fragment for immersion changes

This commit is contained in:
2026-03-22 19:07:07 -07:00
parent 8928bfdf7e
commit 8da3a26855
17 changed files with 1109 additions and 18 deletions

View File

@@ -0,0 +1,5 @@
type: fixed
area: immersion
- Hardened immersion tracker storage/session/query paths with the updated YouTube metadata flow.
- Added metadata probe support for YouTube subtitle retrieval edge cases.

View File

@@ -22,6 +22,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Subtitle/token pipeline: `src/core/services/tokenizer*`, `src/subtitle/`, `src/tokenizers/`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/`
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
- AniList tracking: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/`

View File

@@ -2297,6 +2297,99 @@ test('reassignAnimeAnilist preserves existing description when description is om
}
});
test('handleMediaChange stores youtube metadata for new youtube sessions', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
const originalFetch = globalThis.fetch;
const originalPath = process.env.PATH;
try {
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-'));
const scriptPath = path.join(fakeBinDir, 'yt-dlp');
fs.writeFileSync(
scriptPath,
`#!/bin/sh
printf '%s\n' '{"id":"abc123","title":"Video Name","webpage_url":"https://www.youtube.com/watch?v=abc123","thumbnail":"https://i.ytimg.com/vi/abc123/hqdefault.jpg","channel_id":"UCcreator123","channel":"Creator Name","channel_url":"https://www.youtube.com/channel/UCcreator123","uploader_id":"@creator","uploader_url":"https://www.youtube.com/@creator","description":"Video description","channel_follower_count":12345,"thumbnails":[{"url":"https://i.ytimg.com/vi/abc123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/channel-avatar=s88"}]}'\n`,
{ mode: 0o755 },
);
process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`;
globalThis.fetch = async (input) => {
const url = String(input);
if (url.includes('/oembed')) {
return new Response(
JSON.stringify({
thumbnail_url: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'Content-Type': 'image/jpeg' },
});
};
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('https://www.youtube.com/watch?v=abc123', 'Player Title');
await waitForPendingAnimeMetadata(tracker);
await new Promise((resolve) => setTimeout(resolve, 25));
const privateApi = tracker as unknown as { db: DatabaseSync };
const row = privateApi.db
.prepare(
`
SELECT
youtube_video_id AS youtubeVideoId,
video_url AS videoUrl,
video_title AS videoTitle,
video_thumbnail_url AS videoThumbnailUrl,
channel_id AS channelId,
channel_name AS channelName,
channel_url AS channelUrl,
channel_thumbnail_url AS channelThumbnailUrl,
uploader_id AS uploaderId,
uploader_url AS uploaderUrl,
description AS description
FROM imm_youtube_videos
`,
)
.get() as {
youtubeVideoId: string;
videoUrl: string;
videoTitle: string;
videoThumbnailUrl: string;
channelId: string;
channelName: string;
channelUrl: string;
channelThumbnailUrl: string;
uploaderId: string;
uploaderUrl: string;
description: string;
} | null;
assert.ok(row);
assert.equal(row.youtubeVideoId, 'abc123');
assert.equal(row.videoUrl, 'https://www.youtube.com/watch?v=abc123');
assert.equal(row.videoTitle, 'Video Name');
assert.equal(row.videoThumbnailUrl, 'https://i.ytimg.com/vi/abc123/hqdefault.jpg');
assert.equal(row.channelId, 'UCcreator123');
assert.equal(row.channelName, 'Creator Name');
assert.equal(row.channelUrl, 'https://www.youtube.com/channel/UCcreator123');
assert.equal(row.channelThumbnailUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
assert.equal(row.uploaderId, '@creator');
assert.equal(row.uploaderUrl, 'https://www.youtube.com/@creator');
assert.equal(row.description, 'Video description');
} finally {
process.env.PATH = originalPath;
globalThis.fetch = originalFetch;
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;

View File

@@ -1,6 +1,7 @@
import path from 'node:path';
import * as fs from 'node:fs';
import { createLogger } from '../../logger';
import { MediaGenerator } from '../../media-generator';
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
import {
@@ -22,6 +23,7 @@ import {
type TrackerPreparedStatements,
updateVideoMetadataRecord,
updateVideoTitleRecord,
upsertYoutubeVideoMetadata,
} from './immersion-tracker/storage';
import {
applySessionLifetimeSummary,
@@ -153,6 +155,104 @@ import {
import type { MergedToken } from '../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage';
import { deriveStoredPartOfSpeech } from './tokenizer/part-of-speech';
import { probeYoutubeVideoMetadata } from './youtube/metadata-probe';
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,}$/;
function isValidYouTubeVideoId(value: string | null): boolean {
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
}
function extractYouTubeVideoId(mediaUrl: string): string | null {
let parsed: URL;
try {
parsed = new URL(mediaUrl);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
if (
host !== 'youtu.be' &&
!host.endsWith('.youtu.be') &&
!host.endsWith('youtube.com') &&
!host.endsWith('youtube-nocookie.com')
) {
return null;
}
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
const pathId = parsed.pathname.split('/').filter(Boolean)[0];
return isValidYouTubeVideoId(pathId ?? null) ? (pathId as string) : null;
}
const queryId = parsed.searchParams.get('v') ?? parsed.searchParams.get('vi') ?? null;
if (isValidYouTubeVideoId(queryId)) {
return queryId;
}
const pathParts = parsed.pathname.split('/').filter(Boolean);
for (let i = 0; i < pathParts.length; i += 1) {
const current = pathParts[i];
const next = pathParts[i + 1];
if (!current || !next) continue;
if (
current.toLowerCase() === 'shorts' ||
current.toLowerCase() === 'embed' ||
current.toLowerCase() === 'live' ||
current.toLowerCase() === 'v'
) {
const candidate = decodeURIComponent(next);
if (isValidYouTubeVideoId(candidate)) {
return candidate;
}
}
}
return null;
}
function buildYouTubeThumbnailUrls(videoId: string): string[] {
return [
`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/sddefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/0.jpg`,
`https://i.ytimg.com/vi/${videoId}/default.jpg`,
];
}
async function fetchYouTubeOEmbedThumbnail(mediaUrl: string): Promise<string | null> {
try {
const response = await fetch(`${YOUTUBE_OEMBED_ENDPOINT}?url=${encodeURIComponent(mediaUrl)}&format=json`);
if (!response.ok) {
return null;
}
const payload = (await response.json()) as { thumbnail_url?: unknown };
const candidate = typeof payload.thumbnail_url === 'string' ? payload.thumbnail_url.trim() : '';
return candidate || null;
} catch {
return null;
}
}
async function downloadImage(url: string): Promise<Buffer | null> {
try {
const response = await fetch(url);
if (!response.ok) return null;
const contentType = response.headers.get('content-type');
if (contentType && !contentType.toLowerCase().startsWith('image/')) {
return null;
}
return Buffer.from(await response.arrayBuffer());
} catch {
return null;
}
}
export type {
AnimeAnilistEntryRow,
@@ -212,9 +312,11 @@ export class ImmersionTrackerService {
private sessionState: SessionState | null = null;
private currentVideoKey = '';
private currentMediaPathOrUrl = '';
private readonly mediaGenerator = new MediaGenerator();
private readonly preparedStatements: TrackerPreparedStatements;
private coverArtFetcher: CoverArtFetcher | null = null;
private readonly pendingCoverFetches = new Map<number, Promise<boolean>>();
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
private readonly recordedSubtitleKeys = new Set<string>();
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
private readonly resolveLegacyVocabularyPos:
@@ -647,6 +749,17 @@ export class ImmersionTrackerService {
if (existing?.coverBlob) {
return true;
}
const row = this.db
.prepare('SELECT source_url AS sourceUrl FROM imm_videos WHERE video_id = ?')
.get(videoId) as { sourceUrl: string | null } | null;
const sourceUrl = row?.sourceUrl?.trim() ?? '';
const youtubeVideoId = sourceUrl ? extractYouTubeVideoId(sourceUrl) : null;
if (youtubeVideoId) {
const youtubePromise = this.ensureYouTubeCoverArt(videoId, sourceUrl, youtubeVideoId);
return await youtubePromise;
}
if (!this.coverArtFetcher) {
return false;
}
@@ -677,6 +790,140 @@ export class ImmersionTrackerService {
}
}
private ensureYouTubeCoverArt(videoId: number, sourceUrl: string, youtubeVideoId: string): Promise<boolean> {
const existing = this.pendingCoverFetches.get(videoId);
if (existing) {
return existing;
}
const promise = this.captureYouTubeCoverArt(videoId, sourceUrl, youtubeVideoId);
this.pendingCoverFetches.set(videoId, promise);
promise.finally(() => {
this.pendingCoverFetches.delete(videoId);
});
return promise;
}
private async captureYouTubeCoverArt(
videoId: number,
sourceUrl: string,
youtubeVideoId: string,
): Promise<boolean> {
if (this.isDestroyed) return false;
const existing = await this.getCoverArt(videoId);
if (existing?.coverBlob) {
return true;
}
if (
existing?.coverUrl === null &&
existing?.anilistId === null &&
existing?.coverBlob === null &&
Date.now() - existing.fetchedAtMs < YOUTUBE_COVER_RETRY_MS
) {
return false;
}
let coverBlob: Buffer | null = null;
let coverUrl: string | null = null;
const embedThumbnailUrl = await fetchYouTubeOEmbedThumbnail(sourceUrl);
if (embedThumbnailUrl) {
const embedBlob = await downloadImage(embedThumbnailUrl);
if (embedBlob) {
coverBlob = embedBlob;
coverUrl = embedThumbnailUrl;
}
}
if (!coverBlob) {
for (const candidate of buildYouTubeThumbnailUrls(youtubeVideoId)) {
const candidateBlob = await downloadImage(candidate);
if (!candidateBlob) {
continue;
}
coverBlob = candidateBlob;
coverUrl = candidate;
break;
}
}
if (!coverBlob) {
const durationMs = getVideoDurationMs(this.db, videoId);
const maxSeconds = durationMs > 0 ? Math.min(durationMs / 1000, YOUTUBE_SCREENSHOT_MAX_SECONDS) : null;
const seekSecond = Math.random() * (maxSeconds ?? YOUTUBE_SCREENSHOT_MAX_SECONDS);
try {
coverBlob = await this.mediaGenerator.generateScreenshot(
sourceUrl,
seekSecond,
{
format: 'jpg',
quality: 90,
maxWidth: 640,
},
);
} catch (error) {
this.logger.warn(
'cover-art: failed to generate YouTube screenshot for videoId=%d: %s',
videoId,
(error as Error).message,
);
}
}
if (coverBlob) {
upsertCoverArt(this.db, videoId, {
anilistId: existing?.anilistId ?? null,
coverUrl,
coverBlob,
titleRomaji: existing?.titleRomaji ?? null,
titleEnglish: existing?.titleEnglish ?? null,
episodesTotal: existing?.episodesTotal ?? null,
});
return true;
}
const shouldCacheNoMatch =
!existing || (existing.coverUrl === null && existing.anilistId === null);
if (shouldCacheNoMatch) {
upsertCoverArt(this.db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: existing?.titleRomaji ?? null,
titleEnglish: existing?.titleEnglish ?? null,
episodesTotal: existing?.episodesTotal ?? null,
});
}
return false;
}
private captureYoutubeMetadataAsync(videoId: number, sourceUrl: string): void {
if (this.pendingYoutubeMetadataFetches.has(videoId)) {
return;
}
const pending = (async () => {
try {
const metadata = await probeYoutubeVideoMetadata(sourceUrl);
if (!metadata) {
return;
}
upsertYoutubeVideoMetadata(this.db, videoId, metadata);
} catch (error) {
this.logger.debug(
'youtube metadata capture skipped for videoId=%d: %s',
videoId,
(error as Error).message,
);
}
})();
this.pendingYoutubeMetadataFetches.set(videoId, pending);
pending.finally(() => {
this.pendingYoutubeMetadataFetches.delete(videoId);
});
}
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const normalizedPath = normalizeMediaPath(mediaPath);
const normalizedTitle = normalizeText(mediaTitle);
@@ -721,6 +968,13 @@ 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);
}
}
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
}

View File

@@ -39,6 +39,7 @@ import {
} from '../query.js';
import {
SOURCE_TYPE_LOCAL,
SOURCE_TYPE_REMOTE,
EVENT_CARD_MINED,
EVENT_SUBTITLE_LINE,
EVENT_YOMITAN_LOOKUP,
@@ -1956,6 +1957,100 @@ test('media library and detail queries read lifetime totals', () => {
}
});
test('media library and detail queries include joined youtube metadata when present', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const mediaOne = getOrCreateVideoRecord(db, 'yt:https://www.youtube.com/watch?v=abc123', {
canonicalTitle: 'Local Fallback Title',
sourcePath: null,
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
sourceType: SOURCE_TYPE_REMOTE,
});
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(mediaOne, 2, 6_000, 1, 5, 80, 0, 1_000, 9_000, 9_000, 9_000);
db.prepare(
`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
mediaOne,
'abc123',
'https://www.youtube.com/watch?v=abc123',
'Tracked Video Title',
'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
'UCcreator123',
'Creator Name',
'https://www.youtube.com/channel/UCcreator123',
'https://yt3.googleusercontent.com/channel-avatar=s88',
'@creator',
'https://www.youtube.com/@creator',
'Video description',
'{"source":"test"}',
10_000,
10_000,
10_000,
);
const library = getMediaLibrary(db);
const detail = getMediaDetail(db, mediaOne);
assert.equal(library.length, 1);
assert.equal(library[0]?.youtubeVideoId, 'abc123');
assert.equal(library[0]?.videoTitle, 'Tracked Video Title');
assert.equal(library[0]?.channelId, 'UCcreator123');
assert.equal(library[0]?.channelName, 'Creator Name');
assert.equal(library[0]?.channelUrl, 'https://www.youtube.com/channel/UCcreator123');
assert.equal(detail?.youtubeVideoId, 'abc123');
assert.equal(detail?.videoUrl, 'https://www.youtube.com/watch?v=abc123');
assert.equal(detail?.videoThumbnailUrl, 'https://i.ytimg.com/vi/abc123/hqdefault.jpg');
assert.equal(detail?.channelThumbnailUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
assert.equal(detail?.uploaderId, '@creator');
assert.equal(detail?.uploaderUrl, 'https://www.youtube.com/@creator');
assert.equal(detail?.description, 'Video description');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);

View File

@@ -1817,6 +1817,17 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
COALESCE(lm.total_cards, 0) AS totalCards,
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
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,
CASE
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
ELSE 0
@@ -1824,6 +1835,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
FROM imm_videos v
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
ORDER BY lm.last_watched_ms DESC
`,
)
@@ -1846,9 +1858,21 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
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
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
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
WHERE v.video_id = ?

View File

@@ -106,6 +106,7 @@ test('ensureSchema creates immersion core tables', () => {
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
assert.ok(tableNames.has('imm_rollup_state'));
assert.ok(tableNames.has('imm_cover_art_blobs'));
assert.ok(tableNames.has('imm_youtube_videos'));
const videoColumns = new Set(
(
@@ -146,6 +147,114 @@ test('ensureSchema creates immersion core tables', () => {
}
});
test('ensureSchema adds youtube metadata table to existing schema version 15 databases', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
db.exec(`
CREATE TABLE imm_schema_version (
schema_version INTEGER PRIMARY KEY,
applied_at_ms INTEGER NOT NULL
);
INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (15, 1000);
CREATE TABLE imm_rollup_state(
state_key TEXT PRIMARY KEY,
state_value INTEGER NOT NULL
);
INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 123);
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 INTEGER,
LAST_UPDATE_DATE INTEGER
);
CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
video_key TEXT NOT NULL UNIQUE,
anime_id INTEGER,
canonical_title TEXT NOT NULL,
source_type INTEGER NOT NULL,
source_path TEXT,
source_url TEXT,
parsed_basename TEXT,
parsed_title TEXT,
parsed_season INTEGER,
parsed_episode INTEGER,
parser_source TEXT,
parser_confidence REAL,
parse_metadata_json TEXT,
watched INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
codec_id INTEGER, container_id INTEGER,
width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
bitrate_kbps INTEGER, audio_codec_id INTEGER,
hash_sha256 TEXT, screenshot_path TEXT,
metadata_json TEXT,
CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
);
`);
ensureSchema(db);
const tables = new Set(
(
db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`).all() as Array<{
name: string;
}>
).map((row) => row.name),
);
assert.ok(tables.has('imm_youtube_videos'));
const columns = new Set(
(
db.prepare('PRAGMA table_info(imm_youtube_videos)').all() as Array<{
name: string;
}>
).map((row) => row.name),
);
assert.deepEqual(
columns,
new Set([
'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',
]),
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('ensureSchema creates large-history performance indexes', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);

View File

@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import { parseMediaInfo } from '../../../jimaku/utils';
import type { DatabaseSync } from './sqlite';
import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
@@ -743,6 +743,27 @@ export function ensureSchema(db: DatabaseSync): void {
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_youtube_videos(
video_id INTEGER PRIMARY KEY,
youtube_video_id TEXT NOT NULL,
video_url TEXT NOT NULL,
video_title TEXT,
video_thumbnail_url TEXT,
channel_id TEXT,
channel_name TEXT,
channel_url TEXT,
channel_thumbnail_url TEXT,
uploader_id TEXT,
uploader_url TEXT,
description TEXT,
metadata_json TEXT,
fetched_at_ms INTEGER NOT NULL,
CREATED_DATE INTEGER,
LAST_UPDATE_DATE INTEGER,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY,
@@ -1134,6 +1155,14 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_media_art_cover_url
ON imm_media_art(cover_url)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_youtube_videos_channel_id
ON imm_youtube_videos(channel_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_youtube_videos_youtube_video_id
ON imm_youtube_videos(youtube_video_id)
`);
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
db.exec('DELETE FROM imm_daily_rollups');
@@ -1506,3 +1535,65 @@ export function updateVideoTitleRecord(
`,
).run(canonicalTitle, Date.now(), videoId);
}
export function upsertYoutubeVideoMetadata(
db: DatabaseSync,
videoId: number,
metadata: YoutubeVideoMetadata,
): void {
const nowMs = Date.now();
db.prepare(
`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(video_id) DO UPDATE SET
youtube_video_id = excluded.youtube_video_id,
video_url = excluded.video_url,
video_title = excluded.video_title,
video_thumbnail_url = excluded.video_thumbnail_url,
channel_id = excluded.channel_id,
channel_name = excluded.channel_name,
channel_url = excluded.channel_url,
channel_thumbnail_url = excluded.channel_thumbnail_url,
uploader_id = excluded.uploader_id,
uploader_url = excluded.uploader_url,
description = excluded.description,
metadata_json = excluded.metadata_json,
fetched_at_ms = excluded.fetched_at_ms,
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
`,
).run(
videoId,
metadata.youtubeVideoId,
metadata.videoUrl,
metadata.videoTitle ?? null,
metadata.videoThumbnailUrl ?? null,
metadata.channelId ?? null,
metadata.channelName ?? null,
metadata.channelUrl ?? null,
metadata.channelThumbnailUrl ?? null,
metadata.uploaderId ?? null,
metadata.uploaderUrl ?? null,
metadata.description ?? null,
metadata.metadataJson ?? null,
nowMs,
nowMs,
nowMs,
);
}

View File

@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 15;
export const SCHEMA_VERSION = 16;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -420,6 +420,17 @@ export interface MediaLibraryRow {
totalTokensSeen: number;
lastWatchedMs: number;
hasCoverArt: 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;
}
export interface MediaDetailRow {
@@ -434,6 +445,32 @@ export interface MediaDetailRow {
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: 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;
}
export interface YoutubeVideoMetadata {
youtubeVideoId: string;
videoUrl: string;
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;
}
export interface AnimeLibraryRow {

View File

@@ -0,0 +1,103 @@
import { spawn } from 'node:child_process';
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
type YtDlpThumbnail = {
url?: string;
width?: number;
height?: number;
};
type YtDlpYoutubeMetadata = {
id?: string;
title?: string;
webpage_url?: string;
thumbnail?: string;
thumbnails?: YtDlpThumbnail[];
channel_id?: string;
channel?: string;
channel_url?: string;
uploader_id?: string;
uploader_url?: string;
description?: string;
};
function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.setEncoding('utf8');
proc.stderr.setEncoding('utf8');
proc.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
proc.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
proc.once('error', reject);
proc.once('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
});
});
}
function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string | null {
if (!Array.isArray(thumbnails)) return null;
for (const thumbnail of thumbnails) {
const candidate = thumbnail.url?.trim();
if (!candidate) continue;
if (candidate.includes('/vi/')) continue;
if (
typeof thumbnail.width === 'number' &&
typeof thumbnail.height === 'number' &&
thumbnail.width > 0 &&
thumbnail.height > 0
) {
const ratio = thumbnail.width / thumbnail.height;
if (ratio >= 0.8 && ratio <= 1.25) {
return candidate;
}
continue;
}
if (candidate.includes('yt3.googleusercontent.com')) {
return candidate;
}
}
return null;
}
export async function probeYoutubeVideoMetadata(
targetUrl: string,
): Promise<YoutubeVideoMetadata | null> {
const { stdout } = await runCapture('yt-dlp', [
'--dump-single-json',
'--no-warnings',
'--skip-download',
targetUrl,
]);
const info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
const youtubeVideoId = info.id?.trim();
const videoUrl = info.webpage_url?.trim() || targetUrl.trim();
if (!youtubeVideoId || !videoUrl) {
return null;
}
return {
youtubeVideoId,
videoUrl,
videoTitle: info.title?.trim() || null,
videoThumbnailUrl: info.thumbnail?.trim() || null,
channelId: info.channel_id?.trim() || null,
channelName: info.channel?.trim() || null,
channelUrl: info.channel_url?.trim() || null,
channelThumbnailUrl: pickChannelThumbnail(info.thumbnails),
uploaderId: info.uploader_id?.trim() || null,
uploaderUrl: info.uploader_url?.trim() || null,
description: info.description?.trim() || null,
metadataJson: JSON.stringify(info),
};
}

View File

@@ -1,15 +1,21 @@
import { useState } from 'react';
import { BASE_URL } from '../../lib/api-client';
import { useEffect, useState } from 'react';
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
interface CoverImageProps {
videoId: number;
title: string;
src?: string | null;
className?: string;
}
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
const [failed, setFailed] = useState(false);
const fallbackChar = title.charAt(0) || '?';
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
useEffect(() => {
setFailed(false);
}, [resolvedSrc]);
if (failed) {
return (
@@ -23,8 +29,9 @@ export function CoverImage({ videoId, title, className = '' }: CoverImageProps)
return (
<img
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
src={resolvedSrc}
alt={title}
loading="lazy"
className={`object-cover bg-ctp-surface2 ${className}`}
onError={() => setFailed(true)}
/>

View File

@@ -1,6 +1,8 @@
import { useState, useMemo } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration } from '../../lib/formatters';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { groupMediaLibraryItems } from '../../lib/media-library-grouping';
import { CoverImage } from './CoverImage';
import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView';
@@ -16,8 +18,18 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
const filtered = useMemo(() => {
if (!search.trim()) return media;
const q = search.toLowerCase();
return media.filter((m) => m.canonicalTitle.toLowerCase().includes(q));
return media.filter((m) => {
const haystacks = [
m.canonicalTitle,
m.videoTitle,
m.channelName,
m.uploaderId,
m.channelId,
].filter(Boolean);
return haystacks.some((value) => value!.toLowerCase().includes(q));
});
}, [media, search]);
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
@@ -26,7 +38,6 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
<MediaDetailView
videoId={selectedVideoId}
onBack={() => setSelectedVideoId(null)}
onNavigateToSession={onNavigateToSession}
/>
);
}
@@ -45,20 +56,63 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
{grouped.length} channel{grouped.length !== 1 ? 's' : ''} · {filtered.length} video
{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
</div>
</div>
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filtered.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
<div className="space-y-6">
{grouped.map((group) => (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{group.channelUrl ? (
<a
href={group.channelUrl}
target="_blank"
rel="noreferrer"
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
>
{group.title}
</a>
) : (
<h3 className="text-base font-semibold text-ctp-text truncate">{group.title}</h3>
)}
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
</section>
))}
</div>
)}

View File

@@ -1,5 +1,6 @@
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
import type { MediaLibraryItem } from '../../types/stats';
interface MediaCardProps {
@@ -17,10 +18,14 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
<CoverImage
videoId={item.videoId}
title={item.canonicalTitle}
src={resolveMediaArtworkUrl(item, 'video')}
className="w-full aspect-[3/4] rounded-t-lg"
/>
<div className="p-3">
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
{item.videoTitle && item.videoTitle !== item.canonicalTitle ? (
<div className="text-xs text-ctp-subtext1 truncate mt-1">{item.videoTitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-1">
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
</div>

View File

@@ -3,6 +3,7 @@ import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
import { getStatsClient } from '../../hooks/useStatsApi';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
import type { MediaDetailData } from '../../types/stats';
interface MediaHeaderProps {
@@ -45,10 +46,27 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
<CoverImage
videoId={detail.videoId}
title={detail.canonicalTitle}
src={resolveMediaArtworkUrl(detail, 'video')}
className="w-32 h-44 rounded-lg shrink-0"
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
{detail.channelName ? (
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
{detail.channelUrl ? (
<a
href={detail.channelUrl}
target="_blank"
rel="noreferrer"
className="hover:text-ctp-blue transition-colors"
>
{detail.channelName}
</a>
) : (
detail.channelName
)}
</div>
) : null}
<div className="grid grid-cols-2 gap-2 mt-3 text-sm">
<div>
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>

View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import type { MediaLibraryItem } from '../types/stats';
import { groupMediaLibraryItems, resolveMediaArtworkUrl } from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage';
const youtubeEpisodeA: MediaLibraryItem = {
videoId: 1,
canonicalTitle: 'Episode 1',
totalSessions: 2,
totalActiveMs: 12_000,
totalCards: 3,
totalTokensSeen: 120,
lastWatchedMs: 3_000,
hasCoverArt: 1,
youtubeVideoId: 'yt-1',
videoUrl: 'https://www.youtube.com/watch?v=yt-1',
videoTitle: 'Video 1',
videoThumbnailUrl: 'https://i.ytimg.com/vi/yt-1/hqdefault.jpg',
channelId: 'UC123',
channelName: 'Creator Name',
channelUrl: 'https://www.youtube.com/channel/UC123',
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
uploaderId: '@creator',
uploaderUrl: 'https://www.youtube.com/@creator',
description: 'desc',
};
const youtubeEpisodeB: MediaLibraryItem = {
...youtubeEpisodeA,
videoId: 2,
canonicalTitle: 'Episode 2',
youtubeVideoId: 'yt-2',
videoUrl: 'https://www.youtube.com/watch?v=yt-2',
videoTitle: 'Video 2',
videoThumbnailUrl: 'https://i.ytimg.com/vi/yt-2/hqdefault.jpg',
lastWatchedMs: 4_000,
};
const localVideo: MediaLibraryItem = {
videoId: 3,
canonicalTitle: 'Local Movie',
totalSessions: 1,
totalActiveMs: 5_000,
totalCards: 0,
totalTokensSeen: 40,
lastWatchedMs: 2_000,
hasCoverArt: 1,
youtubeVideoId: null,
videoUrl: null,
videoTitle: null,
videoThumbnailUrl: null,
channelId: null,
channelName: null,
channelUrl: null,
channelThumbnailUrl: null,
uploaderId: null,
uploaderUrl: null,
description: null,
};
test('groupMediaLibraryItems groups youtube videos by channel and leaves local media standalone', () => {
const groups = groupMediaLibraryItems([youtubeEpisodeA, localVideo, youtubeEpisodeB]);
assert.equal(groups.length, 2);
assert.equal(groups[0]?.title, 'Creator Name');
assert.equal(groups[0]?.items.length, 2);
assert.equal(groups[0]?.items[0]?.videoId, 2);
assert.equal(groups[0]?.imageUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
assert.equal(groups[1]?.title, 'Local Movie');
assert.equal(groups[1]?.items.length, 1);
});
test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel images', () => {
assert.equal(
resolveMediaArtworkUrl(youtubeEpisodeA, 'video'),
'https://i.ytimg.com/vi/yt-1/hqdefault.jpg',
);
assert.equal(
resolveMediaArtworkUrl(youtubeEpisodeA, 'channel'),
'https://yt3.googleusercontent.com/channel-avatar=s88',
);
assert.equal(resolveMediaArtworkUrl(localVideo, 'video'), null);
assert.equal(resolveMediaArtworkUrl(localVideo, 'channel'), null);
});
test('CoverImage renders explicit remote artwork when src is provided', () => {
const markup = renderToStaticMarkup(
<CoverImage
videoId={youtubeEpisodeA.videoId}
title={youtubeEpisodeA.canonicalTitle}
src={youtubeEpisodeA.videoThumbnailUrl}
className="w-8 h-8"
/>,
);
assert.match(markup, /src="https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg"/);
});

View File

@@ -0,0 +1,74 @@
import { BASE_URL } from './api-client';
import type { MediaLibraryItem } from '../types/stats';
export interface MediaLibraryGroup {
key: string;
title: string;
subtitle: string | null;
imageUrl: string | null;
channelUrl: string | null;
items: MediaLibraryItem[];
totalActiveMs: number;
totalCards: number;
lastWatchedMs: number;
}
export function resolveMediaArtworkUrl(
item: Pick<MediaLibraryItem, 'videoThumbnailUrl' | 'channelThumbnailUrl'>,
kind: 'video' | 'channel',
): string | null {
if (kind === 'channel') {
return item.channelThumbnailUrl ?? null;
}
return item.videoThumbnailUrl ?? null;
}
export function resolveMediaCoverApiUrl(videoId: number): string {
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
}
export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryGroup[] {
const groups = new Map<string, MediaLibraryGroup>();
for (const item of items) {
const key = item.channelId?.trim() || `video:${item.videoId}`;
const title =
item.channelName?.trim() ||
item.uploaderId?.trim() ||
item.videoTitle?.trim() ||
item.canonicalTitle;
const subtitle =
item.channelId?.trim() != null && item.channelId?.trim() !== ''
? `${item.channelId}`
: item.videoTitle?.trim() && item.videoTitle !== item.canonicalTitle
? item.videoTitle
: null;
const existing = groups.get(key);
if (existing) {
existing.items.push(item);
existing.totalActiveMs += item.totalActiveMs;
existing.totalCards += item.totalCards;
existing.lastWatchedMs = Math.max(existing.lastWatchedMs, item.lastWatchedMs);
continue;
}
groups.set(key, {
key,
title,
subtitle,
imageUrl: resolveMediaArtworkUrl(item, 'channel') ?? resolveMediaArtworkUrl(item, 'video'),
channelUrl: item.channelUrl ?? null,
items: [item],
totalActiveMs: item.totalActiveMs,
totalCards: item.totalCards,
lastWatchedMs: item.lastWatchedMs,
});
}
return [...groups.values()]
.map((group) => ({
...group,
items: [...group.items].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs),
}))
.sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
}

View File

@@ -130,6 +130,17 @@ export interface MediaLibraryItem {
totalTokensSeen: number;
lastWatchedMs: number;
hasCoverArt: 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;
}
export interface MediaDetailData {
@@ -145,6 +156,17 @@ export interface MediaDetailData {
totalLookupCount: number;
totalLookupHits: number;
totalYomitanLookupCount: 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;
} | null;
sessions: SessionSummary[];
rollups: DailyRollup[];