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,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,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);
}
};
}, []);