feat(stats): improve YouTube media metadata and picker key handling

This commit is contained in:
2026-03-23 00:36:23 -07:00
parent 2e43d95396
commit e9fc6bf8ec
13 changed files with 336 additions and 29 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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();

View File

@@ -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);

View File

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

View File

@@ -209,7 +209,7 @@ export function createYoutubeTrackPickerModal(
return true;
}
return true;
return false;
}
function wireDomEvents(): void {

View File

@@ -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,9 +24,9 @@ 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>
{item.videoTitle && item.videoTitle !== item.canonicalTitle ? (
<div className="text-xs text-ctp-subtext1 truncate mt-1">{item.videoTitle}</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

View File

@@ -0,0 +1,41 @@
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({
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({
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',
);
});

View File

@@ -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 &rarr;
{relatedCollectionLabel} &rarr;
</button>
) : null}
</div>

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

View File

@@ -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(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
assert.match(markup, />Video 1</);
assert.match(markup, />Episode 1</);
});

View File

@@ -45,9 +45,16 @@ export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryG
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 || `video:${item.videoId}`;
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