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 (
-
{item.canonicalTitle}
- {item.videoTitle && item.videoTitle !== item.canonicalTitle ? (
-
{item.videoTitle}
+
{primaryTitle}
+ {secondaryTitle ? (
+
{secondaryTitle}
) : null}
{formatDuration(item.totalActiveMs)} ยท {formatNumber(item.totalCards)} cards
diff --git a/stats/src/components/library/MediaDetailView.test.tsx b/stats/src/components/library/MediaDetailView.test.tsx
new file mode 100644
index 0000000..51d35fc
--- /dev/null
+++ b/stats/src/components/library/MediaDetailView.test.tsx
@@ -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',
+ );
+});
diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx
index 27bc1b4..4f353c9 100644
--- a/stats/src/components/library/MediaDetailView.tsx
+++ b/stats/src/components/library/MediaDetailView.tsx
@@ -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 →
+ {relatedCollectionLabel} →
) : 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 1);
+ assert.match(markup, />Episode 1);
+});
diff --git a/stats/src/lib/media-library-grouping.ts b/stats/src/lib/media-library-grouping.ts
index ac404f6..46bcd56 100644
--- a/stats/src/lib/media-library-grouping.ts
+++ b/stats/src/lib/media-library-grouping.ts
@@ -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