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:
2026-03-24 00:01:24 -07:00
committed by GitHub
parent c17f0a4080
commit 5feed360ca
219 changed files with 12778 additions and 1052 deletions

View 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</);
});

View 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);
}