fix: address latest review feedback

This commit is contained in:
2026-03-22 20:09:16 -07:00
parent 809b57af44
commit d8a7ae77b0
18 changed files with 428 additions and 44 deletions

View File

@@ -37,6 +37,21 @@ async function waitForPendingAnimeMetadata(tracker: ImmersionTrackerService): Pr
await privateApi.pendingAnimeMetadataUpdates?.get(videoId); await privateApi.pendingAnimeMetadataUpdates?.get(videoId);
} }
async function waitForCondition(
predicate: () => boolean,
timeoutMs = 1_000,
intervalMs = 10,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
assert.equal(predicate(), true);
}
function makeMergedToken(overrides: Partial<MergedToken>): MergedToken { function makeMergedToken(overrides: Partial<MergedToken>): MergedToken {
return { return {
surface: '', surface: '',
@@ -2305,13 +2320,24 @@ test('handleMediaChange stores youtube metadata for new youtube sessions', async
try { try {
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-')); const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-'));
const scriptPath = path.join(fakeBinDir, 'yt-dlp'); const ytDlpOutput =
fs.writeFileSync( '{"id":"abc123","title":"Video Name","webpage_url":"https://www.youtube.com/watch?v=abc123","thumbnail":"https://i.ytimg.com/vi/abc123/hqdefault.jpg","channel_id":"UCcreator123","channel":"Creator Name","channel_url":"https://www.youtube.com/channel/UCcreator123","uploader_id":"@creator","uploader_url":"https://www.youtube.com/@creator","description":"Video description","channel_follower_count":12345,"thumbnails":[{"url":"https://i.ytimg.com/vi/abc123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/channel-avatar=s88"}]}';
scriptPath, if (process.platform === 'win32') {
`#!/bin/sh fs.writeFileSync(
printf '%s\n' '{"id":"abc123","title":"Video Name","webpage_url":"https://www.youtube.com/watch?v=abc123","thumbnail":"https://i.ytimg.com/vi/abc123/hqdefault.jpg","channel_id":"UCcreator123","channel":"Creator Name","channel_url":"https://www.youtube.com/channel/UCcreator123","uploader_id":"@creator","uploader_url":"https://www.youtube.com/@creator","description":"Video description","channel_follower_count":12345,"thumbnails":[{"url":"https://i.ytimg.com/vi/abc123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/channel-avatar=s88"}]}'\n`, path.join(fakeBinDir, 'yt-dlp.cmd'),
{ mode: 0o755 }, `@echo off\r\necho ${ytDlpOutput.replace(/"/g, '\\"')}\r\n`,
); 'utf8',
);
} else {
const scriptPath = path.join(fakeBinDir, 'yt-dlp');
fs.writeFileSync(
scriptPath,
`#!/bin/sh
printf '%s\n' '${ytDlpOutput}'
`,
{ mode: 0o755 },
);
}
process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`; process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`;
globalThis.fetch = async (input) => { globalThis.fetch = async (input) => {
@@ -2333,11 +2359,16 @@ printf '%s\n' '{"id":"abc123","title":"Video Name","webpage_url":"https://www.yo
const Ctor = await loadTrackerCtor(); const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath }); tracker = new Ctor({ dbPath });
tracker.handleMediaChange('https://www.youtube.com/watch?v=abc123', 'Player Title'); tracker.handleMediaChange('https://www.youtube.com/watch?v=abc123', 'Player Title');
await waitForPendingAnimeMetadata(tracker);
await new Promise((resolve) => setTimeout(resolve, 25));
const privateApi = tracker as unknown as { db: DatabaseSync }; const privateApi = tracker as unknown as { db: DatabaseSync };
await waitForCondition(
() => {
const stored = privateApi.db
.prepare("SELECT 1 AS ready FROM imm_youtube_videos WHERE youtube_video_id = 'abc123'")
.get() as { ready: number } | null;
return stored?.ready === 1;
},
5_000,
);
const row = privateApi.db const row = privateApi.db
.prepare( .prepare(
` `

View File

@@ -278,6 +278,8 @@ test('ensureSchema creates large-history performance indexes', () => {
assert.ok(indexNames.has('idx_kanji_frequency')); assert.ok(indexNames.has('idx_kanji_frequency'));
assert.ok(indexNames.has('idx_media_art_anilist_id')); assert.ok(indexNames.has('idx_media_art_anilist_id'));
assert.ok(indexNames.has('idx_media_art_cover_url')); assert.ok(indexNames.has('idx_media_art_cover_url'));
assert.ok(indexNames.has('idx_youtube_videos_channel_id'));
assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id'));
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);

View File

@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { probeYoutubeVideoMetadata } from './metadata-probe';
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-metadata-probe-'));
try {
return await fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
process.stdout.write(${JSON.stringify(payload)});
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
fs.chmodSync(scriptPath, 0o755);
}
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
}
async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload);
const originalPath = process.env.PATH ?? '';
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
try {
return await fn();
} finally {
process.env.PATH = originalPath;
}
});
}
test('probeYoutubeVideoMetadata returns null on malformed yt-dlp JSON', async () => {
await withFakeYtDlp('not-json', async () => {
const result = await probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123');
assert.equal(result, null);
});
});

View File

@@ -79,7 +79,12 @@ export async function probeYoutubeVideoMetadata(
'--skip-download', '--skip-download',
targetUrl, targetUrl,
]); ]);
const info = JSON.parse(stdout) as YtDlpYoutubeMetadata; let info: YtDlpYoutubeMetadata;
try {
info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
} catch {
return null;
}
const youtubeVideoId = info.id?.trim(); const youtubeVideoId = info.id?.trim();
const videoUrl = info.webpage_url?.trim() || targetUrl.trim(); const videoUrl = info.webpage_url?.trim() || targetUrl.trim();
if (!youtubeVideoId || !videoUrl) { if (!youtubeVideoId || !videoUrl) {

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { convertYoutubeTimedTextToVtt } from './timedtext';
test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => {
const result = convertYoutubeTimedTextToVtt(
'<timedtext><body><p t="0" d="1000">&#99999999; &#x110000; &#x41;</p></body></timedtext>',
);
assert.equal(
result,
['WEBVTT', '', '00:00:00.000 --> 00:00:01.000', '&#99999999; &#x110000; A', ''].join('\n'),
);
});
test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overlap rows', () => {
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext><body>',
'<p t="0" d="2000">今日は</p>',
'<p t="1000" d="0">今日はいい天気ですね</p>',
'<p t="1000" d="2000">今日はいい天気ですね</p>',
'</body></timedtext>',
].join(''),
);
assert.equal(
result,
[
'WEBVTT',
'',
'00:00:00.000 --> 00:00:00.999',
'今日は',
'',
'00:00:01.000 --> 00:00:03.000',
'いい天気ですね',
'',
].join('\n'),
);
});

View File

@@ -6,6 +6,18 @@ interface YoutubeTimedTextRow {
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']); const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
function decodeNumericEntity(match: string, codePoint: number): string {
if (
!Number.isInteger(codePoint) ||
codePoint < 0 ||
codePoint > 0x10ffff ||
(codePoint >= 0xd800 && codePoint <= 0xdfff)
) {
return match;
}
return String.fromCodePoint(codePoint);
}
function decodeHtmlEntities(value: string): string { function decodeHtmlEntities(value: string): string {
return value return value
.replace(/&amp;/g, '&') .replace(/&amp;/g, '&')
@@ -13,9 +25,11 @@ function decodeHtmlEntities(value: string): string {
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#39;/g, "'") .replace(/&#39;/g, "'")
.replace(/&#(\d+);/g, (_match, codePoint) => String.fromCodePoint(Number(codePoint))) .replace(/&#(\d+);/g, (match, codePoint) =>
.replace(/&#x([0-9a-f]+);/gi, (_match, codePoint) => decodeNumericEntity(match, Number(codePoint)),
String.fromCodePoint(Number.parseInt(codePoint, 16)), )
.replace(/&#x([0-9a-f]+);/gi, (match, codePoint) =>
decodeNumericEntity(match, Number.parseInt(codePoint, 16)),
); );
} }
@@ -85,7 +99,6 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
? Math.max(row.startMs, nextRow.startMs - 1) ? Math.max(row.startMs, nextRow.startMs - 1)
: unclampedEnd; : unclampedEnd;
if (clampedEnd <= row.startMs) { if (clampedEnd <= row.startMs) {
previousText = row.text;
continue; continue;
} }

View File

@@ -303,6 +303,33 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available',
}); });
}); });
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
await withTempDir(async (root) => {
await withStubFetch(
async () => new Response('WEBVTT\n', { status: 200 }),
async () => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:../../ja-orig',
language: 'ja',
sourceLanguage: '../ja-orig/../../evil',
kind: 'auto',
label: 'Japanese (auto)',
downloadUrl: 'https://example.com/subs/ja.vtt',
fileExtension: 'vtt',
},
mode: 'download',
});
assert.equal(path.dirname(result.path), path.join(root, 'out'));
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig-evil.vtt');
},
);
});
});
test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt', async () => { test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt', async () => {
await withTempDir(async (root) => { await withTempDir(async (root) => {
await withStubFetch( await withStubFetch(

View File

@@ -9,6 +9,11 @@ const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
const YOUTUBE_BATCH_PREFIX = 'youtube-batch'; const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
const YOUTUBE_DOWNLOAD_TIMEOUT_MS = 15_000; const YOUTUBE_DOWNLOAD_TIMEOUT_MS = 15_000;
function sanitizeFilenameSegment(value: string): string {
const sanitized = value.trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/-+/g, '-');
return sanitized.replace(/^-+|-+$/g, '') || 'unknown';
}
function createFetchTimeoutSignal(timeoutMs: number): AbortSignal | undefined { function createFetchTimeoutSignal(timeoutMs: number): AbortSignal | undefined {
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') { if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
return AbortSignal.timeout(timeoutMs); return AbortSignal.timeout(timeoutMs);
@@ -154,9 +159,10 @@ async function downloadSubtitleFromUrl(input: {
: YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`) : YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`)
? ext ? ext
: 'vtt'; : 'vtt';
const safeSourceLanguage = sanitizeFilenameSegment(input.track.sourceLanguage);
const targetPath = path.join( const targetPath = path.join(
input.outputDir, input.outputDir,
`${input.prefix}.${input.track.sourceLanguage}.${safeExt}`, `${input.prefix}.${safeSourceLanguage}.${safeExt}`,
); );
const response = await fetch(input.track.downloadUrl, { const response = await fetch(input.track.downloadUrl, {
signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS), signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS),

View File

@@ -14,10 +14,12 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
} }
} }
function makeFakeYtDlpScript(dir: string, payload: unknown): void { function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
const scriptPath = path.join(dir, 'yt-dlp'); const scriptPath = path.join(dir, 'yt-dlp');
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload); const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
const script = `#!/usr/bin/env node const script = rawScript
? stdoutBody
: `#!/usr/bin/env node
process.stdout.write(${JSON.stringify(stdoutBody)}); process.stdout.write(${JSON.stringify(stdoutBody)});
`; `;
fs.writeFileSync(scriptPath, script, 'utf8'); fs.writeFileSync(scriptPath, script, 'utf8');
@@ -27,11 +29,15 @@ process.stdout.write(${JSON.stringify(stdoutBody)});
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8'); fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
} }
async function withFakeYtDlp<T>(payload: unknown, fn: () => Promise<T>): Promise<T> { async function withFakeYtDlp<T>(
payload: unknown,
fn: () => Promise<T>,
options: { rawScript?: boolean } = {},
): Promise<T> {
return await withTempDir(async (root) => { return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin'); const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload); makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
const originalPath = process.env.PATH ?? ''; const originalPath = process.env.PATH ?? '';
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`; process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
try { try {

View File

@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
import type { YoutubeTrackOption } from '../../../types'; import type { YoutubeTrackOption } from '../../../types';
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels'; import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
export type YoutubeTrackProbeResult = { export type YoutubeTrackProbeResult = {
videoId: string; videoId: string;
title: string; title: string;
@@ -17,11 +19,19 @@ type YtDlpInfo = {
automatic_captions?: Record<string, YtDlpSubtitleEntry>; automatic_captions?: Record<string, YtDlpSubtitleEntry>;
}; };
function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { function runCapture(
command: string,
args: string[],
timeoutMs = YOUTUBE_TRACK_PROBE_TIMEOUT_MS,
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
const timer = setTimeout(() => {
proc.kill();
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.stdout.setEncoding('utf8'); proc.stdout.setEncoding('utf8');
proc.stderr.setEncoding('utf8'); proc.stderr.setEncoding('utf8');
proc.stdout.on('data', (chunk) => { proc.stdout.on('data', (chunk) => {
@@ -30,8 +40,12 @@ function runCapture(command: string, args: string[]): Promise<{ stdout: string;
proc.stderr.on('data', (chunk) => { proc.stderr.on('data', (chunk) => {
stderr += String(chunk); stderr += String(chunk);
}); });
proc.once('error', reject); proc.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
proc.once('close', (code) => { proc.once('close', (code) => {
clearTimeout(timer);
if (code === 0) { if (code === 0) {
resolve({ stdout, stderr }); resolve({ stdout, stderr });
return; return;

View File

@@ -506,3 +506,69 @@ test('youtube flow cleans up paused picker state when opening the picker throws'
assert.equal(warns.some((message) => message.includes('picker boom')), true); assert.equal(warns.some((message) => message.includes('picker boom')), true);
assert.equal(runtime.hasActiveSession(), false); assert.equal(runtime.hasActiveSession(), false);
}); });
test('youtube flow reports failure when the primary subtitle never binds', async () => {
const commands: Array<Array<string | number>> = [];
const osdMessages: string[] = [];
const warns: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name) => {
if (name === 'track-list') {
return [];
}
throw new Error(`unexpected property request: ${name}`);
},
refreshCurrentSubtitle: () => {
throw new Error('should not refresh subtitle text on bind failure');
},
wait: async () => {},
showMpvOsd: (text) => {
osdMessages.push(text);
},
warn: (message) => {
warns.push(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.equal(
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] !== 'no'),
false,
);
assert.deepEqual(osdMessages.slice(-1), ['Primary subtitles failed to load.']);
assert.equal(warns.some((message) => message.includes('Unable to bind downloaded primary subtitle track')), true);
});

View File

@@ -258,6 +258,11 @@ async function injectDownloadedSubtitles(
} }
} }
if (primaryTrackId === null) {
deps.showMpvOsd('Primary subtitles failed to load.');
return false;
}
const currentSubText = await deps.requestMpvProperty('sub-text'); const currentSubText = await deps.requestMpvProperty('sub-text');
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) { if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
deps.refreshCurrentSubtitle(currentSubText); deps.refreshCurrentSubtitle(currentSubText);

View File

@@ -21,6 +21,22 @@ function createClassList() {
classes.delete(token); classes.delete(token);
} }
}, },
toggle: (token: string, force?: boolean) => {
if (force === undefined) {
if (classes.has(token)) {
classes.delete(token);
return false;
}
classes.add(token);
return true;
}
if (force) {
classes.add(token);
return true;
}
classes.delete(token);
return false;
},
contains: (token: string) => classes.has(token), contains: (token: string) => classes.has(token),
}; };
} }
@@ -317,6 +333,74 @@ test('subtitle leave restores passthrough while embedded sidebar is open but not
} }
}); });
test('restorePointerInteractionState reapplies the secondary hover class from pointer location', async () => {
const ctx = createMouseTestContext();
ctx.platform.shouldToggleMouseIgnore = true;
const documentListeners = new Map<string, Array<(event: MouseEvent | PointerEvent) => void>>();
const originalDocument = (globalThis as { document?: unknown }).document;
const originalWindow = (globalThis as { window?: unknown }).window;
const secondarySubContainer = ctx.dom.secondarySubContainer as unknown as object;
const overlay = ctx.dom.overlay as unknown as { classList: ReturnType<typeof createClassList> };
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: MouseEvent | PointerEvent) => void) => {
const listeners = documentListeners.get(type) ?? [];
listeners.push(listener);
documentListeners.set(type, listeners);
},
elementFromPoint: () => secondarySubContainer,
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: () => {},
},
innerHeight: 1000,
getSelection: () => ({ rangeCount: 0, isCollapsed: true }),
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
await handlers.handleSecondaryMouseEnter({
clientX: 10,
clientY: 20,
} as unknown as MouseEvent);
handlers.restorePointerInteractionState();
overlay.classList.add('interactive');
const mousemove = documentListeners.get('mousemove')?.[0];
assert.ok(mousemove);
mousemove?.({ clientX: 10, clientY: 20 } as MouseEvent);
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
} finally {
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
}
});
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => { test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = []; const mpvCommands: Array<(string | number)[]> = [];

View File

@@ -57,9 +57,10 @@ export function createMouseHandlers(
); );
ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle; ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle;
if (!overSecondarySubtitle) { ctx.dom.secondarySubContainer.classList.toggle(
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active'); 'secondary-sub-hover-active',
} overSecondarySubtitle,
);
return ctx.state.isOverSubtitle; return ctx.state.isOverSubtitle;
} }

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useMediaLibrary } from '../../hooks/useMediaLibrary'; import { useMediaLibrary } from '../../hooks/useMediaLibrary';
import { formatDuration, formatNumber } from '../../lib/formatters'; import { formatDuration, formatNumber } from '../../lib/formatters';
import { groupMediaLibraryItems } from '../../lib/media-library-grouping'; import { groupMediaLibraryItems, summarizeMediaLibraryGroups } from '../../lib/media-library-grouping';
import { CoverImage } from './CoverImage'; import { CoverImage } from './CoverImage';
import { MediaCard } from './MediaCard'; import { MediaCard } from './MediaCard';
import { MediaDetailView } from './MediaDetailView'; import { MediaDetailView } from './MediaDetailView';
@@ -30,8 +30,7 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
}); });
}, [media, search]); }, [media, search]);
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]); const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
const summary = useMemo(() => summarizeMediaLibraryGroups(grouped), [grouped]);
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
if (selectedVideoId !== null) { if (selectedVideoId !== null) {
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />; return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
@@ -51,8 +50,8 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue" className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/> />
<div className="text-xs text-ctp-overlay2 shrink-0"> <div className="text-xs text-ctp-overlay2 shrink-0">
{grouped.length} channel{grouped.length !== 1 ? 's' : ''} · {filtered.length} video {grouped.length} group{grouped.length !== 1 ? 's' : ''} · {summary.totalVideos} video
{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)} {summary.totalVideos !== 1 ? 's' : ''} · {formatDuration(summary.totalMs)}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
import { CoverImage } from './CoverImage';
import { formatDuration, formatNumber } from '../../lib/formatters'; import { formatDuration, formatNumber } from '../../lib/formatters';
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping'; import { CoverImage } from './CoverImage';
import type { MediaLibraryItem } from '../../types/stats'; import type { MediaLibraryItem } from '../../types/stats';
interface MediaCardProps { interface MediaCardProps {
@@ -18,7 +17,6 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
<CoverImage <CoverImage
videoId={item.videoId} videoId={item.videoId}
title={item.canonicalTitle} title={item.canonicalTitle}
src={resolveMediaArtworkUrl(item, 'video')}
className="w-full aspect-[3/4] rounded-t-lg" className="w-full aspect-[3/4] rounded-t-lg"
/> />
<div className="p-3"> <div className="p-3">

View File

@@ -2,8 +2,13 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import type { MediaLibraryItem } from '../types/stats'; import type { MediaLibraryItem } from '../types/stats';
import { groupMediaLibraryItems, resolveMediaArtworkUrl } from './media-library-grouping'; import {
groupMediaLibraryItems,
resolveMediaArtworkUrl,
summarizeMediaLibraryGroups,
} from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage'; import { CoverImage } from '../components/library/CoverImage';
import { MediaCard } from '../components/library/MediaCard';
const youtubeEpisodeA: MediaLibraryItem = { const youtubeEpisodeA: MediaLibraryItem = {
videoId: 1, videoId: 1,
@@ -85,6 +90,16 @@ test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel im
assert.equal(resolveMediaArtworkUrl(localVideo, 'channel'), null); assert.equal(resolveMediaArtworkUrl(localVideo, '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('CoverImage renders explicit remote artwork when src is provided', () => { test('CoverImage renders explicit remote artwork when src is provided', () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<CoverImage <CoverImage
@@ -97,3 +112,12 @@ test('CoverImage renders explicit remote artwork when src is provided', () => {
assert.match(markup, /src="https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg"/); 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/);
});

View File

@@ -27,21 +27,35 @@ export function resolveMediaCoverApiUrl(videoId: number): string {
return `${BASE_URL}/api/stats/media/${videoId}/cover`; return `${BASE_URL}/api/stats/media/${videoId}/cover`;
} }
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
totalMs: number;
totalVideos: number;
} {
return groups.reduce(
(summary, group) => ({
totalMs: summary.totalMs + group.totalActiveMs,
totalVideos: summary.totalVideos + group.items.length,
}),
{ totalMs: 0, totalVideos: 0 },
);
}
export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryGroup[] { export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryGroup[] {
const groups = new Map<string, MediaLibraryGroup>(); const groups = new Map<string, MediaLibraryGroup>();
for (const item of items) { for (const item of items) {
const key = item.channelId?.trim() || `video:${item.videoId}`; const channelId = item.channelId?.trim() || null;
const channelName = item.channelName?.trim() || null;
const uploaderId = item.uploaderId?.trim() || null;
const videoTitle = item.videoTitle?.trim() || null;
const key = channelId || `video:${item.videoId}`;
const title = const title =
item.channelName?.trim() || channelName || uploaderId || videoTitle || item.canonicalTitle;
item.uploaderId?.trim() ||
item.videoTitle?.trim() ||
item.canonicalTitle;
const subtitle = const subtitle =
item.channelId?.trim() != null && item.channelId?.trim() !== '' channelId
? `${item.channelId}` ? channelId
: item.videoTitle?.trim() && item.videoTitle !== item.canonicalTitle : videoTitle && videoTitle !== item.canonicalTitle
? item.videoTitle ? videoTitle
: null; : null;
const existing = groups.get(key); const existing = groups.get(key);
if (existing) { if (existing) {