mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-23 00:11:28 -07:00
fix: address latest review feedback
This commit is contained in:
@@ -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(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
49
src/core/services/youtube/metadata-probe.test.ts
Normal file
49
src/core/services/youtube/metadata-probe.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
40
src/core/services/youtube/timedtext.test.ts
Normal file
40
src/core/services/youtube/timedtext.test.ts
Normal 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">� � A</p></body></timedtext>',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
['WEBVTT', '', '00:00:00.000 --> 00:00:01.000', '� � 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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -13,9 +25,11 @@ function decodeHtmlEntities(value: string): string {
|
|||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'")
|
.replace(/'/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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)[]> = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user