mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-23 00:11:28 -07:00
fix(ci): add changelog fragment for immersion changes
This commit is contained in:
5
changes/2026-03-23-immersion-youtube.md
Normal file
5
changes/2026-03-23-immersion-youtube.md
Normal 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.
|
||||||
@@ -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/`
|
- Subtitle/token pipeline: `src/core/services/tokenizer*`, `src/subtitle/`, `src/tokenizers/`
|
||||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
- 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-*`
|
- AniList tracking: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
|
|||||||
@@ -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 () => {
|
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { MediaGenerator } from '../../media-generator';
|
||||||
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
|
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
|
||||||
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
|
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
type TrackerPreparedStatements,
|
type TrackerPreparedStatements,
|
||||||
updateVideoMetadataRecord,
|
updateVideoMetadataRecord,
|
||||||
updateVideoTitleRecord,
|
updateVideoTitleRecord,
|
||||||
|
upsertYoutubeVideoMetadata,
|
||||||
} from './immersion-tracker/storage';
|
} from './immersion-tracker/storage';
|
||||||
import {
|
import {
|
||||||
applySessionLifetimeSummary,
|
applySessionLifetimeSummary,
|
||||||
@@ -153,6 +155,104 @@ import {
|
|||||||
import type { MergedToken } from '../../types';
|
import type { MergedToken } from '../../types';
|
||||||
import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage';
|
import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage';
|
||||||
import { deriveStoredPartOfSpeech } from './tokenizer/part-of-speech';
|
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 {
|
export type {
|
||||||
AnimeAnilistEntryRow,
|
AnimeAnilistEntryRow,
|
||||||
@@ -212,9 +312,11 @@ export class ImmersionTrackerService {
|
|||||||
private sessionState: SessionState | null = null;
|
private sessionState: SessionState | null = null;
|
||||||
private currentVideoKey = '';
|
private currentVideoKey = '';
|
||||||
private currentMediaPathOrUrl = '';
|
private currentMediaPathOrUrl = '';
|
||||||
|
private readonly mediaGenerator = new MediaGenerator();
|
||||||
private readonly preparedStatements: TrackerPreparedStatements;
|
private readonly preparedStatements: TrackerPreparedStatements;
|
||||||
private coverArtFetcher: CoverArtFetcher | null = null;
|
private coverArtFetcher: CoverArtFetcher | null = null;
|
||||||
private readonly pendingCoverFetches = new Map<number, Promise<boolean>>();
|
private readonly pendingCoverFetches = new Map<number, Promise<boolean>>();
|
||||||
|
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
||||||
private readonly recordedSubtitleKeys = new Set<string>();
|
private readonly recordedSubtitleKeys = new Set<string>();
|
||||||
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
||||||
private readonly resolveLegacyVocabularyPos:
|
private readonly resolveLegacyVocabularyPos:
|
||||||
@@ -647,6 +749,17 @@ export class ImmersionTrackerService {
|
|||||||
if (existing?.coverBlob) {
|
if (existing?.coverBlob) {
|
||||||
return true;
|
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) {
|
if (!this.coverArtFetcher) {
|
||||||
return false;
|
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 {
|
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
const normalizedTitle = normalizeText(mediaTitle);
|
const normalizedTitle = normalizeText(mediaTitle);
|
||||||
@@ -721,6 +968,13 @@ export class ImmersionTrackerService {
|
|||||||
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
||||||
);
|
);
|
||||||
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
|
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.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
} from '../query.js';
|
} from '../query.js';
|
||||||
import {
|
import {
|
||||||
SOURCE_TYPE_LOCAL,
|
SOURCE_TYPE_LOCAL,
|
||||||
|
SOURCE_TYPE_REMOTE,
|
||||||
EVENT_CARD_MINED,
|
EVENT_CARD_MINED,
|
||||||
EVENT_SUBTITLE_LINE,
|
EVENT_SUBTITLE_LINE,
|
||||||
EVENT_YOMITAN_LOOKUP,
|
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', () => {
|
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -1817,6 +1817,17 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
|||||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||||
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
|
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
|
CASE
|
||||||
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
|
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
@@ -1824,6 +1835,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
|||||||
FROM imm_videos v
|
FROM imm_videos v
|
||||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
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_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
|
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(lm.total_lines_seen, 0) AS totalLinesSeen,
|
||||||
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
|
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.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
|
FROM imm_videos v
|
||||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
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 imm_sessions s ON s.video_id = v.video_id
|
||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
WHERE v.video_id = ?
|
WHERE v.video_id = ?
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ test('ensureSchema creates immersion core tables', () => {
|
|||||||
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
||||||
assert.ok(tableNames.has('imm_rollup_state'));
|
assert.ok(tableNames.has('imm_rollup_state'));
|
||||||
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
||||||
|
assert.ok(tableNames.has('imm_youtube_videos'));
|
||||||
|
|
||||||
const videoColumns = new Set(
|
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', () => {
|
test('ensureSchema creates large-history performance indexes', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { SCHEMA_VERSION } from './types';
|
import { SCHEMA_VERSION } from './types';
|
||||||
import type { QueuedWrite, VideoMetadata } from './types';
|
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||||
|
|
||||||
export interface TrackerPreparedStatements {
|
export interface TrackerPreparedStatements {
|
||||||
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
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
|
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(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
|
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
|
||||||
blob_hash TEXT PRIMARY KEY,
|
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
|
CREATE INDEX IF NOT EXISTS idx_media_art_cover_url
|
||||||
ON imm_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) {
|
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
|
||||||
db.exec('DELETE FROM imm_daily_rollups');
|
db.exec('DELETE FROM imm_daily_rollups');
|
||||||
@@ -1506,3 +1535,65 @@ export function updateVideoTitleRecord(
|
|||||||
`,
|
`,
|
||||||
).run(canonicalTitle, Date.now(), videoId);
|
).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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const SCHEMA_VERSION = 15;
|
export const SCHEMA_VERSION = 16;
|
||||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||||
export const DEFAULT_BATCH_SIZE = 25;
|
export const DEFAULT_BATCH_SIZE = 25;
|
||||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||||
@@ -420,6 +420,17 @@ export interface MediaLibraryRow {
|
|||||||
totalTokensSeen: number;
|
totalTokensSeen: number;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
hasCoverArt: 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 {
|
export interface MediaDetailRow {
|
||||||
@@ -434,6 +445,32 @@ export interface MediaDetailRow {
|
|||||||
totalLookupCount: number;
|
totalLookupCount: number;
|
||||||
totalLookupHits: number;
|
totalLookupHits: number;
|
||||||
totalYomitanLookupCount: 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 {
|
export interface AnimeLibraryRow {
|
||||||
|
|||||||
103
src/core/services/youtube/metadata-probe.ts
Normal file
103
src/core/services/youtube/metadata-probe.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BASE_URL } from '../../lib/api-client';
|
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
|
||||||
|
|
||||||
interface CoverImageProps {
|
interface CoverImageProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
src?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
|
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const fallbackChar = title.charAt(0) || '?';
|
const fallbackChar = title.charAt(0) || '?';
|
||||||
|
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFailed(false);
|
||||||
|
}, [resolvedSrc]);
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
return (
|
return (
|
||||||
@@ -23,8 +29,9 @@ export function CoverImage({ videoId, title, className = '' }: CoverImageProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
|
src={resolvedSrc}
|
||||||
alt={title}
|
alt={title}
|
||||||
|
loading="lazy"
|
||||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
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 { MediaCard } from './MediaCard';
|
||||||
import { MediaDetailView } from './MediaDetailView';
|
import { MediaDetailView } from './MediaDetailView';
|
||||||
|
|
||||||
@@ -16,8 +18,18 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return media;
|
if (!search.trim()) return media;
|
||||||
const q = search.toLowerCase();
|
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]);
|
}, [media, search]);
|
||||||
|
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
||||||
|
|
||||||
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||||
|
|
||||||
@@ -26,7 +38,6 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
|||||||
<MediaDetailView
|
<MediaDetailView
|
||||||
videoId={selectedVideoId}
|
videoId={selectedVideoId}
|
||||||
onBack={() => setSelectedVideoId(null)}
|
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"
|
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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
<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">
|
<div className="space-y-6">
|
||||||
{filtered.map((item) => (
|
{grouped.map((group) => (
|
||||||
<MediaCard
|
<section
|
||||||
key={item.videoId}
|
key={group.key}
|
||||||
item={item}
|
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||||
onClick={() => setSelectedVideoId(item.videoId)}
|
>
|
||||||
/>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CoverImage } from './CoverImage';
|
import { CoverImage } from './CoverImage';
|
||||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||||
|
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
|
||||||
import type { MediaLibraryItem } from '../../types/stats';
|
import type { MediaLibraryItem } from '../../types/stats';
|
||||||
|
|
||||||
interface MediaCardProps {
|
interface MediaCardProps {
|
||||||
@@ -17,10 +18,14 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
<CoverImage
|
<CoverImage
|
||||||
videoId={item.videoId}
|
videoId={item.videoId}
|
||||||
title={item.canonicalTitle}
|
title={item.canonicalTitle}
|
||||||
|
src={resolveMediaArtworkUrl(item, 'video')}
|
||||||
className="w-full aspect-[3/4] rounded-t-lg"
|
className="w-full aspect-[3/4] rounded-t-lg"
|
||||||
/>
|
/>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
|
<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">
|
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||||
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CoverImage } from './CoverImage';
|
|||||||
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
|
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
|
||||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||||
|
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
|
||||||
import type { MediaDetailData } from '../../types/stats';
|
import type { MediaDetailData } from '../../types/stats';
|
||||||
|
|
||||||
interface MediaHeaderProps {
|
interface MediaHeaderProps {
|
||||||
@@ -45,10 +46,27 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
|||||||
<CoverImage
|
<CoverImage
|
||||||
videoId={detail.videoId}
|
videoId={detail.videoId}
|
||||||
title={detail.canonicalTitle}
|
title={detail.canonicalTitle}
|
||||||
|
src={resolveMediaArtworkUrl(detail, 'video')}
|
||||||
className="w-32 h-44 rounded-lg shrink-0"
|
className="w-32 h-44 rounded-lg shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
<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 className="grid grid-cols-2 gap-2 mt-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>
|
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>
|
||||||
|
|||||||
99
stats/src/lib/media-library-grouping.test.tsx
Normal file
99
stats/src/lib/media-library-grouping.test.tsx
Normal 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"/);
|
||||||
|
});
|
||||||
74
stats/src/lib/media-library-grouping.ts
Normal file
74
stats/src/lib/media-library-grouping.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -130,6 +130,17 @@ export interface MediaLibraryItem {
|
|||||||
totalTokensSeen: number;
|
totalTokensSeen: number;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
hasCoverArt: 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 {
|
export interface MediaDetailData {
|
||||||
@@ -145,6 +156,17 @@ export interface MediaDetailData {
|
|||||||
totalLookupCount: number;
|
totalLookupCount: number;
|
||||||
totalLookupHits: number;
|
totalLookupHits: number;
|
||||||
totalYomitanLookupCount: 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;
|
} | null;
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
rollups: DailyRollup[];
|
rollups: DailyRollup[];
|
||||||
|
|||||||
Reference in New Issue
Block a user