mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-24 12:11:29 -07:00
feat: add app-owned YouTube subtitle flow with absPlayer-style parsing (#31)
* fix: harden preload argv parsing for popup windows * fix: align youtube playback with shared overlay startup * fix: unwrap mpv youtube streams for anki media mining * docs: update docs for youtube subtitle and mining flow * refactor: unify cli and runtime wiring for startup and youtube flow * feat: update subtitle sidebar overlay behavior * chore: add shared log-file source for diagnostics * fix(ci): add changelog fragment for immersion changes * fix: address CodeRabbit review feedback * fix: persist canonical title from youtube metadata * style: format stats library tab * fix: address latest review feedback * style: format stats library files * test: stub launcher youtube deps in CI * test: isolate launcher youtube flow deps * test: stub launcher youtube deps in failing case * test: force x11 backend in launcher ci harness * test: address latest review feedback * fix(launcher): preserve user YouTube ytdl raw options * docs(backlog): update task tracking notes * fix(immersion): special-case youtube media paths in runtime and tracking * feat(stats): improve YouTube media metadata and picker key handling * fix(ci): format stats media library hook * fix: address latest CodeRabbit review items * docs: update youtube release notes and docs * feat: auto-load youtube subtitles before manual picker * fix: restore app-owned youtube subtitle flow * docs: update youtube playback docs and config copy * refactor: remove legacy youtube launcher mode plumbing * fix: refine youtube subtitle startup binding * docs: clarify youtube subtitle startup behavior * fix: address PR #31 latest review follow-ups * fix: address PR #31 follow-up review comments * test: harden youtube picker test harness * udpate backlog * fix: add timeout to youtube metadata probe * docs: refresh youtube and stats docs * update backlog * update backlog * chore: release v0.9.0
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import {
|
||||
groupMediaLibraryItems,
|
||||
summarizeMediaLibraryGroups,
|
||||
} from '../../lib/media-library-grouping';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
@@ -16,19 +21,22 @@ 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 totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
||||
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return (
|
||||
<MediaDetailView
|
||||
videoId={selectedVideoId}
|
||||
onBack={() => setSelectedVideoId(null)}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
/>
|
||||
);
|
||||
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
@@ -45,20 +53,65 @@ 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} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
|
||||
{summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import type { MediaLibraryItem } from '../../types/stats';
|
||||
|
||||
interface MediaCardProps {
|
||||
@@ -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,7 +24,10 @@ 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>
|
||||
<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
|
||||
</div>
|
||||
|
||||
43
stats/src/components/library/MediaDetailView.test.tsx
Normal file
43
stats/src/components/library/MediaDetailView.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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({
|
||||
videoId: 1,
|
||||
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({
|
||||
videoId: 2,
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -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 →
|
||||
{relatedCollectionLabel} →
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
57
stats/src/hooks/useMediaLibrary.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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,43 @@ 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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
180
stats/src/lib/media-library-grouping.test.tsx
Normal file
180
stats/src/lib/media-library-grouping.test.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
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,
|
||||
summarizeMediaLibraryGroups,
|
||||
} from './media-library-grouping';
|
||||
import { CoverImage } from '../components/library/CoverImage';
|
||||
import { MediaCard } from '../components/library/MediaCard';
|
||||
|
||||
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('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'),
|
||||
'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('resolveMediaArtworkUrl normalizes blank thumbnail urls to null', () => {
|
||||
const item = {
|
||||
videoThumbnailUrl: ' ',
|
||||
channelThumbnailUrl: '',
|
||||
};
|
||||
|
||||
assert.equal(resolveMediaArtworkUrl(item, 'video'), null);
|
||||
assert.equal(resolveMediaArtworkUrl(item, 'channel'), null);
|
||||
});
|
||||
|
||||
test('summarizeMediaLibraryGroups stays aligned with rendered group buckets', () => {
|
||||
const groups = groupMediaLibraryItems([youtubeEpisodeA, localVideo, youtubeEpisodeB]);
|
||||
const summary = summarizeMediaLibraryGroups(groups);
|
||||
|
||||
assert.deepEqual(summary, {
|
||||
totalMs: 29_000,
|
||||
totalVideos: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('groupMediaLibraryItems backfills missing group artwork from later items', () => {
|
||||
const first = {
|
||||
...youtubeEpisodeA,
|
||||
videoId: 10,
|
||||
videoThumbnailUrl: null,
|
||||
channelThumbnailUrl: null,
|
||||
};
|
||||
const second = {
|
||||
...youtubeEpisodeB,
|
||||
videoId: 11,
|
||||
channelThumbnailUrl: null,
|
||||
};
|
||||
|
||||
const groups = groupMediaLibraryItems([first, second]);
|
||||
|
||||
assert.equal(groups[0]?.imageUrl, second.videoThumbnailUrl);
|
||||
});
|
||||
|
||||
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"/);
|
||||
});
|
||||
|
||||
test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => {
|
||||
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
||||
|
||||
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</);
|
||||
});
|
||||
96
stats/src/lib/media-library-grouping.ts
Normal file
96
stats/src/lib/media-library-grouping.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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 {
|
||||
const raw = kind === 'channel' ? item.channelThumbnailUrl : item.videoThumbnailUrl;
|
||||
const normalized = raw?.trim() ?? '';
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function resolveMediaCoverApiUrl(videoId: number): string {
|
||||
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
||||
}
|
||||
|
||||
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
|
||||
totalMs: number;
|
||||
totalVideos: number;
|
||||
} {
|
||||
return groups.reduce(
|
||||
(summary, group) => ({
|
||||
totalMs: summary.totalMs + group.totalActiveMs,
|
||||
totalVideos: summary.totalVideos + group.items.length,
|
||||
}),
|
||||
{ totalMs: 0, totalVideos: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryGroup[] {
|
||||
const groups = new Map<string, MediaLibraryGroup>();
|
||||
|
||||
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
|
||||
? `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
|
||||
: videoTitle && videoTitle !== item.canonicalTitle
|
||||
? 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);
|
||||
if (!existing.imageUrl) {
|
||||
existing.imageUrl =
|
||||
resolveMediaArtworkUrl(item, 'channel') ?? resolveMediaArtworkUrl(item, 'video');
|
||||
}
|
||||
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;
|
||||
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[];
|
||||
|
||||
Reference in New Issue
Block a user