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:
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</);
|
||||
});
|
||||
Reference in New Issue
Block a user