feat(stats): improve YouTube media metadata and picker key handling

This commit is contained in:
2026-03-23 00:36:23 -07:00
parent 2e43d95396
commit e9fc6bf8ec
13 changed files with 336 additions and 29 deletions

View File

@@ -8,6 +8,10 @@ interface MediaCardProps {
}
export function MediaCard({ item, onClick }: MediaCardProps) {
const primaryTitle = item.videoTitle?.trim() || item.canonicalTitle;
const secondaryTitle =
item.videoTitle?.trim() && item.videoTitle !== item.canonicalTitle ? item.canonicalTitle : null;
return (
<button
type="button"
@@ -20,9 +24,9 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
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>
<div className="text-sm font-medium text-ctp-text truncate">{primaryTitle}</div>
{secondaryTitle ? (
<div className="text-xs text-ctp-subtext1 truncate mt-1">{secondaryTitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-1">
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getRelatedCollectionLabel } from './MediaDetailView';
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
assert.equal(
getRelatedCollectionLabel({
animeId: 1,
canonicalTitle: 'Video',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 0,
totalTokensSeen: 0,
totalLinesSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
channelName: 'Creator',
}),
'View Channel',
);
});
test('getRelatedCollectionLabel returns View Anime for non-youtube media', () => {
assert.equal(
getRelatedCollectionLabel({
animeId: 1,
canonicalTitle: 'Episode 5',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 0,
totalTokensSeen: 0,
totalLinesSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
channelName: null,
}),
'View Anime',
);
});

View File

@@ -5,7 +5,14 @@ import { confirmSessionDelete } from '../../lib/delete-confirm';
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
import { MediaHeader } from './MediaHeader';
import { MediaSessionList } from './MediaSessionList';
import type { SessionSummary } from '../../types/stats';
import type { MediaDetailData, SessionSummary } from '../../types/stats';
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
if (detail?.channelName?.trim()) {
return 'View Channel';
}
return 'View Anime';
}
interface MediaDetailViewProps {
videoId: number;
@@ -53,6 +60,7 @@ export function MediaDetailView({
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
};
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
const handleDeleteSession = async (session: SessionSummary) => {
if (!confirmSessionDelete()) return;
@@ -87,7 +95,7 @@ export function MediaDetailView({
onClick={() => onNavigateToAnime(animeId)}
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
>
View Anime &rarr;
{relatedCollectionLabel} &rarr;
</button>
) : null}
</div>

View File

@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MediaLibraryItem } from '../types/stats';
import { shouldRefreshMediaLibraryRows } from './useMediaLibrary';
const baseItem: MediaLibraryItem = {
videoId: 1,
canonicalTitle: 'watch?v=abc123',
totalSessions: 1,
totalActiveMs: 60_000,
totalCards: 0,
totalTokensSeen: 10,
lastWatchedMs: 1_000,
hasCoverArt: 0,
youtubeVideoId: 'abc123',
videoUrl: 'https://www.youtube.com/watch?v=abc123',
videoTitle: null,
videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
channelId: null,
channelName: null,
channelUrl: null,
channelThumbnailUrl: null,
uploaderId: null,
uploaderUrl: null,
description: null,
};
test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => {
assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true);
});
test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => {
assert.equal(
shouldRefreshMediaLibraryRows([
{
...baseItem,
videoTitle: 'Video Name',
channelName: 'Creator Name',
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
},
]),
false,
);
});
test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
assert.equal(
shouldRefreshMediaLibraryRows([
{
...baseItem,
youtubeVideoId: null,
videoUrl: null,
},
]),
false,
);
});

View File

@@ -2,6 +2,18 @@ import { useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { MediaLibraryItem } from '../types/stats';
const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500;
const MEDIA_LIBRARY_MAX_RETRIES = 3;
export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean {
return rows.some((row) => {
if (!row.youtubeVideoId) {
return false;
}
return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim();
});
}
export function useMediaLibrary() {
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -9,24 +21,46 @@ export function useMediaLibrary() {
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStatsClient()
.getMediaLibrary()
.then((rows) => {
if (cancelled) return;
setMedia(rows);
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
let retryCount = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const load = (isInitial = false) => {
if (isInitial) {
setLoading(true);
setError(null);
}
getStatsClient()
.getMediaLibrary()
.then((rows) => {
if (cancelled) return;
setMedia(rows);
if (
shouldRefreshMediaLibraryRows(rows) &&
retryCount < MEDIA_LIBRARY_MAX_RETRIES
) {
retryCount += 1;
retryTimer = setTimeout(() => {
retryTimer = null;
load(false);
}, MEDIA_LIBRARY_REFRESH_DELAY_MS);
}
})
.catch((err: Error) => {
if (cancelled) return;
setError(err.message);
})
.finally(() => {
if (cancelled || !isInitial) return;
setLoading(false);
});
};
load(true);
return () => {
cancelled = true;
if (retryTimer) {
clearTimeout(retryTimer);
}
};
}, []);

View File

@@ -77,6 +77,30 @@ test('groupMediaLibraryItems groups youtube videos by channel and leaves local m
assert.equal(groups[1]?.items.length, 1);
});
test('groupMediaLibraryItems falls back to channel metadata when youtube channel id is missing', () => {
const first = {
...youtubeEpisodeA,
videoId: 20,
youtubeVideoId: 'yt-20',
videoUrl: 'https://www.youtube.com/watch?v=yt-20',
channelId: null,
};
const second = {
...youtubeEpisodeB,
videoId: 21,
youtubeVideoId: 'yt-21',
videoUrl: 'https://www.youtube.com/watch?v=yt-21',
channelId: null,
};
const groups = groupMediaLibraryItems([first, second]);
assert.equal(groups.length, 1);
assert.equal(groups[0]?.title, 'Creator Name');
assert.equal(groups[0]?.items.length, 2);
assert.equal(groups[0]?.channelUrl, 'https://www.youtube.com/channel/UC123');
});
test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel images', () => {
assert.equal(
resolveMediaArtworkUrl(youtubeEpisodeA, 'video'),
@@ -147,3 +171,10 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/);
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
});
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
assert.match(markup, />Video 1</);
assert.match(markup, />Episode 1</);
});

View File

@@ -45,9 +45,16 @@ export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryG
for (const item of items) {
const channelId = item.channelId?.trim() || null;
const channelName = item.channelName?.trim() || null;
const channelUrl = item.channelUrl?.trim() || null;
const uploaderId = item.uploaderId?.trim() || null;
const videoTitle = item.videoTitle?.trim() || null;
const key = channelId || `video:${item.videoId}`;
const key = channelId
? `youtube:channel:${channelId}`
: channelUrl
? `youtube:channel-url:${channelUrl}`
: channelName
? `youtube:channel-name:${channelName.toLowerCase()}`
: `video:${item.videoId}`;
const title = channelName || uploaderId || videoTitle || item.canonicalTitle;
const subtitle = channelId
? channelId