From e9fc6bf8ec8765f5bf995b6074701b6b7b492d5e Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 23 Mar 2026 00:36:23 -0700 Subject: [PATCH] feat(stats): improve YouTube media metadata and picker key handling --- changes/2026-03-23-immersion-youtube.md | 9 +- docs-site/configuration.md | 8 ++ src/renderer/handlers/keyboard.test.ts | 27 ++++++ src/renderer/handlers/keyboard.ts | 5 +- .../modals/youtube-track-picker.test.ts | 88 +++++++++++++++++++ src/renderer/modals/youtube-track-picker.ts | 2 +- stats/src/components/library/MediaCard.tsx | 10 ++- .../library/MediaDetailView.test.tsx | 41 +++++++++ .../components/library/MediaDetailView.tsx | 12 ++- stats/src/hooks/useMediaLibrary.test.ts | 57 ++++++++++++ stats/src/hooks/useMediaLibrary.ts | 66 ++++++++++---- stats/src/lib/media-library-grouping.test.tsx | 31 +++++++ stats/src/lib/media-library-grouping.ts | 9 +- 13 files changed, 336 insertions(+), 29 deletions(-) create mode 100644 stats/src/components/library/MediaDetailView.test.tsx create mode 100644 stats/src/hooks/useMediaLibrary.test.ts diff --git a/changes/2026-03-23-immersion-youtube.md b/changes/2026-03-23-immersion-youtube.md index deb0018..32c8d2e 100644 --- a/changes/2026-03-23-immersion-youtube.md +++ b/changes/2026-03-23-immersion-youtube.md @@ -1,5 +1,6 @@ -type: fixed -area: immersion +type: changed +area: launcher -- Hardened immersion tracker storage/session/query paths with the updated YouTube metadata flow. -- Added metadata probe support for YouTube subtitle retrieval edge cases. +- Added an app-owned YouTube subtitle flow that pauses mpv, lets the overlay picker choose tracks, and injects downloaded subtitle files before playback resumes. +- Added absPlayer-style YouTube timedtext parsing/conversion so downloaded subtitle tracks load as parsed cues for the sidebar, tokenization, and mining flows. +- Added yt-dlp metadata probing so YouTube playback and immersion tracking keep canonical video and channel metadata. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 93a6a91..6f6400a 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1276,6 +1276,14 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles | `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). | | `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | +You can also disable immersion tracking for a single session using: + +```bash +SUBMINER_DISABLE_IMMERSION_TRACKING=1 subminer +``` + +When this is set, SubMiner skips immersion-tracker startup and does not initialize or read the immersion SQLite database for that session. + Default behavior keeps raw events, telemetry, sessions, and rollups forever while still maintaining lifetime summary tables and daily/monthly rollups for faster reads. If you later want bounded retention, switch `retentionMode` or set explicit `retention.*` values. When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location: diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index dfeb8f7..c4ba4c8 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -619,6 +619,33 @@ test('keyboard mode: configured stats toggle works even while popup is open', as } }); +test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateKeybindings([ + { + key: 'Space', + command: ['cycle', 'pause'], + }, + { + key: 'KeyQ', + command: ['quit'], + }, + ] as never); + + ctx.state.youtubePickerModalOpen = true; + + testGlobals.dispatchKeydown({ key: ' ', code: 'Space' }); + testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' }); + + assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: h moves left when popup is closed', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index ac8bef6..01c423e 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -843,8 +843,9 @@ export function createKeyboardHandlers( return; } if (ctx.state.youtubePickerModalOpen) { - options.handleYoutubePickerKeydown(e); - return; + if (options.handleYoutubePickerKeydown(e)) { + return; + } } if (ctx.state.controllerSelectModalOpen) { options.handleControllerSelectKeydown(e); diff --git a/src/renderer/modals/youtube-track-picker.test.ts b/src/renderer/modals/youtube-track-picker.test.ts index c1c3ff0..1233060 100644 --- a/src/renderer/modals/youtube-track-picker.test.ts +++ b/src/renderer/modals/youtube-track-picker.test.ts @@ -348,3 +348,91 @@ test('youtube track picker surfaces rejected resolve calls as modal status', asy Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); } }); + +test('youtube track picker only consumes handled keys', async () => { + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createFakeElement(), + }, + }); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + dispatchEvent: () => true, + focus: () => {}, + electronAPI: { + notifyOverlayModalOpened: () => {}, + notifyOverlayModalClosed: () => {}, + youtubePickerResolve: async () => ({ ok: true, message: '' }), + setIgnoreMouseEvents: () => {}, + }, + }, + }); + + try { + const state = createRendererState(); + const dom = { + overlay: { + classList: createClassList(), + focus: () => {}, + }, + youtubePickerModal: createFakeElement(), + youtubePickerTitle: createFakeElement(), + youtubePickerPrimarySelect: createFakeElement(), + youtubePickerSecondarySelect: createFakeElement(), + youtubePickerTracks: createFakeElement(), + youtubePickerStatus: createFakeElement(), + youtubePickerContinueButton: createFakeElement(), + youtubePickerCloseButton: createFakeElement(), + }; + + const modal = createYoutubeTrackPickerModal( + { + state, + dom, + platform: { + shouldToggleMouseIgnore: false, + }, + } as never, + { + modalStateReader: { isAnyModalOpen: () => true }, + restorePointerInteractionState: () => {}, + syncSettingsModalSubtitleSuppression: () => {}, + }, + ); + + modal.openYoutubePickerModal({ + sessionId: 'yt-1', + url: 'https://example.com', + mode: 'download', + tracks: [], + defaultPrimaryTrackId: null, + defaultSecondaryTrackId: null, + hasTracks: false, + }); + + assert.equal( + modal.handleYoutubePickerKeydown({ + key: ' ', + preventDefault: () => {}, + } as KeyboardEvent), + false, + ); + assert.equal( + modal.handleYoutubePickerKeydown({ + key: 'Escape', + preventDefault: () => {}, + } as KeyboardEvent), + true, + ); + await Promise.resolve(); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); diff --git a/src/renderer/modals/youtube-track-picker.ts b/src/renderer/modals/youtube-track-picker.ts index 9f3136b..ec5dd11 100644 --- a/src/renderer/modals/youtube-track-picker.ts +++ b/src/renderer/modals/youtube-track-picker.ts @@ -209,7 +209,7 @@ export function createYoutubeTrackPickerModal( return true; } - return true; + return false; } function wireDomEvents(): void { diff --git a/stats/src/components/library/MediaCard.tsx b/stats/src/components/library/MediaCard.tsx index c2ddd0d..57e17a1 100644 --- a/stats/src/components/library/MediaCard.tsx +++ b/stats/src/components/library/MediaCard.tsx @@ -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 ( ) : null} diff --git a/stats/src/hooks/useMediaLibrary.test.ts b/stats/src/hooks/useMediaLibrary.test.ts new file mode 100644 index 0000000..39abbbe --- /dev/null +++ b/stats/src/hooks/useMediaLibrary.test.ts @@ -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, + ); +}); diff --git a/stats/src/hooks/useMediaLibrary.ts b/stats/src/hooks/useMediaLibrary.ts index 685a2fb..2a0d1ae 100644 --- a/stats/src/hooks/useMediaLibrary.ts +++ b/stats/src/hooks/useMediaLibrary.ts @@ -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([]); 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 | 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); + } }; }, []); diff --git a/stats/src/lib/media-library-grouping.test.tsx b/stats/src/lib/media-library-grouping.test.tsx index ff8ed33..7006f57 100644 --- a/stats/src/lib/media-library-grouping.test.tsx +++ b/stats/src/lib/media-library-grouping.test.tsx @@ -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( {}} />); + + assert.match(markup, />Video 1Episode 1