mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix: address coderabbit review findings
This commit is contained in:
@@ -9,7 +9,7 @@ Minimize the time between a subtitle line appearing and annotations being displa
|
|||||||
|
|
||||||
## Current Pipeline (Warm State)
|
## Current Pipeline (Warm State)
|
||||||
|
|
||||||
```
|
```text
|
||||||
MPV subtitle change (0ms)
|
MPV subtitle change (0ms)
|
||||||
-> IPC to main (5ms)
|
-> IPC to main (5ms)
|
||||||
-> Cache check (2ms)
|
-> Cache check (2ms)
|
||||||
@@ -25,7 +25,7 @@ MPV subtitle change (0ms)
|
|||||||
|
|
||||||
## Target Pipeline
|
## Target Pipeline
|
||||||
|
|
||||||
```
|
```text
|
||||||
MPV subtitle change (0ms)
|
MPV subtitle change (0ms)
|
||||||
-> IPC to main (5ms)
|
-> IPC to main (5ms)
|
||||||
-> Cache check (2ms)
|
-> Cache check (2ms)
|
||||||
@@ -142,7 +142,7 @@ Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFr
|
|||||||
|
|
||||||
### Current Flow (4 passes, 4 array copies)
|
### Current Flow (4 passes, 4 array copies)
|
||||||
|
|
||||||
```
|
```text
|
||||||
tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
||||||
-> applyKnownWordMarking() // .map() -> new array
|
-> applyKnownWordMarking() // .map() -> new array
|
||||||
-> applyFrequencyMarking() // .map() -> new array (POS-based filtering only)
|
-> applyFrequencyMarking() // .map() -> new array (POS-based filtering only)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|------|---------------|
|
|------|---------------|
|
||||||
| `src/core/services/subtitle-cue-parser.ts` | Parse SRT/VTT/ASS files into `SubtitleCue[]` (timing + text) |
|
| `src/core/services/subtitle-cue-parser.ts` | Parse SRT/VTT/ASS files into `SubtitleCue[]` (timing + text) |
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
| `src/core/services/subtitle-prefetch.test.ts` | Tests for prefetch service |
|
| `src/core/services/subtitle-prefetch.test.ts` | Tests for prefetch service |
|
||||||
|
|
||||||
### Modified Files
|
### Modified Files
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `src/core/services/subtitle-processing-controller.ts` | Add `preCacheTokenization` + `isCacheFull` to public interface |
|
| `src/core/services/subtitle-processing-controller.ts` | Add `preCacheTokenization` + `isCacheFull` to public interface |
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
| `src/renderer/subtitle-render.ts` | `cloneNode` template + `replaceChildren()` |
|
| `src/renderer/subtitle-render.ts` | `cloneNode` template + `replaceChildren()` |
|
||||||
| `src/main.ts` | Wire up prefetch service |
|
| `src/main.ts` | Wire up prefetch service |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chunk 1: Batched Annotation Pass + DOM Template Pooling
|
## Chunk 1: Batched Annotation Pass + DOM Template Pooling
|
||||||
@@ -1554,17 +1557,32 @@ subtitle processing, and restarts on cache invalidation."
|
|||||||
|
|
||||||
### Task 10: Full test suite and type check
|
### Task 10: Full test suite and type check
|
||||||
|
|
||||||
- [ ] **Step 1: Run full test suite**
|
- [ ] **Step 1: Run handoff type check**
|
||||||
|
|
||||||
Run: `bun run test`
|
Run: `bun run typecheck`
|
||||||
Expected: 500+ pass, 1 pre-existing fail in immersion-tracker-service.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run TypeScript type check**
|
|
||||||
|
|
||||||
Run: `bun run tsc`
|
|
||||||
Expected: No type errors.
|
Expected: No type errors.
|
||||||
|
|
||||||
- [ ] **Step 3: Review all commits on the branch**
|
- [ ] **Step 2: Run fast test lane**
|
||||||
|
|
||||||
|
Run: `bun run test:fast`
|
||||||
|
Expected: Fast unit/integration tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run environment-sensitive test lane**
|
||||||
|
|
||||||
|
Run: `bun run test:env`
|
||||||
|
Expected: Environment/runtime-sensitive tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run production build**
|
||||||
|
|
||||||
|
Run: `bun run build`
|
||||||
|
Expected: Production build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run dist smoke test**
|
||||||
|
|
||||||
|
Run: `bun run test:smoke:dist`
|
||||||
|
Expected: Dist smoke tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Review all commits on the branch**
|
||||||
|
|
||||||
Run: `git log --oneline main..HEAD`
|
Run: `git log --oneline main..HEAD`
|
||||||
Expected: ~8 focused commits, one per logical change.
|
Expected: ~8 focused commits, one per logical change.
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
|||||||
'media-title',
|
'media-title',
|
||||||
'secondary-sub-visibility',
|
'secondary-sub-visibility',
|
||||||
'sub-visibility',
|
'sub-visibility',
|
||||||
|
'sid',
|
||||||
|
'track-list',
|
||||||
];
|
];
|
||||||
|
|
||||||
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
|||||||
emitSubtitleAssChange: (payload) => state.events.push(payload),
|
emitSubtitleAssChange: (payload) => state.events.push(payload),
|
||||||
emitSubtitleTiming: (payload) => state.events.push(payload),
|
emitSubtitleTiming: (payload) => state.events.push(payload),
|
||||||
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
|
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
|
||||||
|
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
||||||
|
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
|
||||||
getCurrentSubText: () => state.subText,
|
getCurrentSubText: () => state.subText,
|
||||||
setCurrentSubText: (text) => {
|
setCurrentSubText: (text) => {
|
||||||
state.subText = text;
|
state.subText = text;
|
||||||
@@ -120,6 +122,24 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
|||||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
|
||||||
|
const { deps, state } = createDeps({
|
||||||
|
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
||||||
|
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'property-change', name: 'sid', data: '3' },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
||||||
const { deps, state } = createDeps({
|
const { deps, state } = createDeps({
|
||||||
isVisibleOverlayVisible: () => true,
|
isVisibleOverlayVisible: () => true,
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
emitSubtitleAssChange: (payload: { text: string }) => void;
|
emitSubtitleAssChange: (payload: { text: string }) => void;
|
||||||
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||||
emitSecondarySubtitleChange: (payload: { text: string }) => void;
|
emitSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||||
|
emitSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||||
|
emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||||
getCurrentSubText: () => string;
|
getCurrentSubText: () => string;
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
setCurrentSubStart: (value: number) => void;
|
setCurrentSubStart: (value: number) => void;
|
||||||
@@ -160,6 +162,18 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
const nextSubText = (msg.data as string) || '';
|
const nextSubText = (msg.data as string) || '';
|
||||||
deps.setCurrentSecondarySubText(nextSubText);
|
deps.setCurrentSecondarySubText(nextSubText);
|
||||||
deps.emitSecondarySubtitleChange({ text: nextSubText });
|
deps.emitSecondarySubtitleChange({ text: nextSubText });
|
||||||
|
} else if (msg.name === 'sid') {
|
||||||
|
const sid =
|
||||||
|
typeof msg.data === 'number'
|
||||||
|
? msg.data
|
||||||
|
: typeof msg.data === 'string'
|
||||||
|
? Number(msg.data)
|
||||||
|
: null;
|
||||||
|
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null });
|
||||||
|
} else if (msg.name === 'track-list') {
|
||||||
|
deps.emitSubtitleTrackListChange({
|
||||||
|
trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null,
|
||||||
|
});
|
||||||
} else if (msg.name === 'aid') {
|
} else if (msg.name === 'aid') {
|
||||||
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
|
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
|
||||||
deps.syncCurrentAudioStreamIndex();
|
deps.syncCurrentAudioStreamIndex();
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ export interface MpvIpcClientEventMap {
|
|||||||
'duration-change': { duration: number };
|
'duration-change': { duration: number };
|
||||||
'pause-change': { paused: boolean };
|
'pause-change': { paused: boolean };
|
||||||
'secondary-subtitle-change': { text: string };
|
'secondary-subtitle-change': { text: string };
|
||||||
|
'subtitle-track-change': { sid: number | null };
|
||||||
|
'subtitle-track-list-change': { trackList: unknown[] | null };
|
||||||
'media-path-change': { path: string };
|
'media-path-change': { path: string };
|
||||||
'media-title-change': { title: string | null };
|
'media-title-change': { title: string | null };
|
||||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||||
@@ -325,6 +327,12 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
emitSecondarySubtitleChange: (payload) => {
|
emitSecondarySubtitleChange: (payload) => {
|
||||||
this.emit('secondary-subtitle-change', payload);
|
this.emit('secondary-subtitle-change', payload);
|
||||||
},
|
},
|
||||||
|
emitSubtitleTrackChange: (payload) => {
|
||||||
|
this.emit('subtitle-track-change', payload);
|
||||||
|
},
|
||||||
|
emitSubtitleTrackListChange: (payload) => {
|
||||||
|
this.emit('subtitle-track-list-change', payload);
|
||||||
|
},
|
||||||
getCurrentSubText: () => this.currentSubText,
|
getCurrentSubText: () => this.currentSubText,
|
||||||
setCurrentSubText: (text: string) => {
|
setCurrentSubText: (text: string) => {
|
||||||
this.currentSubText = text;
|
this.currentSubText = text;
|
||||||
|
|||||||
@@ -186,6 +186,21 @@ test('parseAssCues handles hour timestamps', () => {
|
|||||||
assert.equal(cues[0]!.endTime, 5405.0);
|
assert.equal(cues[0]!.endTime, 5405.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseAssCues respects dynamic field ordering from the Format row', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Style, Start, End, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,Default,0:00:01.00,0:00:04.00,,0,0,0,,順番が違う',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseAssCues(content);
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.startTime, 1.0);
|
||||||
|
assert.equal(cues[0]!.endTime, 4.0);
|
||||||
|
assert.equal(cues[0]!.text, '順番が違う');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues auto-detects SRT format', () => {
|
test('parseSubtitleCues auto-detects SRT format', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'1',
|
'1',
|
||||||
@@ -244,3 +259,16 @@ test('parseSubtitleCues returns cues sorted by start time', () => {
|
|||||||
assert.equal(cues[0]!.text, '一番目');
|
assert.equal(cues[0]!.text, '一番目');
|
||||||
assert.equal(cues[1]!.text, '二番目');
|
assert.equal(cues[1]!.text, '二番目');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
|
||||||
|
const assContent = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,URLテスト',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(assContent, 'https://host/subs.ass?lang=ja#track');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.text, 'URLテスト');
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export function parseSrtCues(content: string): SubtitleCue[] {
|
|||||||
const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g;
|
const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g;
|
||||||
|
|
||||||
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
|
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
|
||||||
|
const ASS_FORMAT_PREFIX = 'Format:';
|
||||||
|
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
|
||||||
|
|
||||||
function parseAssTimestamp(raw: string): number | null {
|
function parseAssTimestamp(raw: string): number | null {
|
||||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||||
@@ -73,12 +75,20 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
|||||||
const cues: SubtitleCue[] = [];
|
const cues: SubtitleCue[] = [];
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
let inEventsSection = false;
|
let inEventsSection = false;
|
||||||
|
let startFieldIndex = -1;
|
||||||
|
let endFieldIndex = -1;
|
||||||
|
let textFieldIndex = -1;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
|
||||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
inEventsSection = trimmed.toLowerCase() === '[events]';
|
inEventsSection = trimmed.toLowerCase() === '[events]';
|
||||||
|
if (!inEventsSection) {
|
||||||
|
startFieldIndex = -1;
|
||||||
|
endFieldIndex = -1;
|
||||||
|
textFieldIndex = -1;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,34 +96,45 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!trimmed.startsWith('Dialogue:')) {
|
if (trimmed.startsWith(ASS_FORMAT_PREFIX)) {
|
||||||
|
const formatFields = trimmed
|
||||||
|
.slice(ASS_FORMAT_PREFIX.length)
|
||||||
|
.split(',')
|
||||||
|
.map((field) => field.trim().toLowerCase());
|
||||||
|
startFieldIndex = formatFields.indexOf('start');
|
||||||
|
endFieldIndex = formatFields.indexOf('end');
|
||||||
|
textFieldIndex = formatFields.indexOf('text');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split on first 9 commas (ASS v4+ has 10 fields; last is Text which can contain commas)
|
if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) {
|
||||||
const afterPrefix = trimmed.slice('Dialogue:'.length);
|
|
||||||
const fields: string[] = [];
|
|
||||||
let remaining = afterPrefix;
|
|
||||||
for (let fieldIndex = 0; fieldIndex < 9; fieldIndex += 1) {
|
|
||||||
const commaIndex = remaining.indexOf(',');
|
|
||||||
if (commaIndex < 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
fields.push(remaining.slice(0, commaIndex));
|
|
||||||
remaining = remaining.slice(commaIndex + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.length < 9) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = parseAssTimestamp(fields[1]!);
|
if (startFieldIndex < 0 || endFieldIndex < 0 || textFieldIndex < 0) {
|
||||||
const endTime = parseAssTimestamp(fields[2]!);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
|
||||||
|
if (
|
||||||
|
startFieldIndex >= fields.length ||
|
||||||
|
endFieldIndex >= fields.length ||
|
||||||
|
textFieldIndex >= fields.length
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = parseAssTimestamp(fields[startFieldIndex]!);
|
||||||
|
const endTime = parseAssTimestamp(fields[endFieldIndex]!);
|
||||||
if (startTime === null || endTime === null) {
|
if (startTime === null || endTime === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawText = remaining.replace(ASS_OVERRIDE_TAG_PATTERN, '').trim();
|
const rawText = fields
|
||||||
|
.slice(textFieldIndex)
|
||||||
|
.join(',')
|
||||||
|
.replace(ASS_OVERRIDE_TAG_PATTERN, '')
|
||||||
|
.trim();
|
||||||
if (rawText) {
|
if (rawText) {
|
||||||
cues.push({ startTime, endTime, text: rawText });
|
cues.push({ startTime, endTime, text: rawText });
|
||||||
}
|
}
|
||||||
@@ -122,8 +143,15 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
|||||||
return cues;
|
return cues;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectSubtitleFormat(filename: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
|
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
|
||||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
const normalizedSource = (() => {
|
||||||
|
try {
|
||||||
|
return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source;
|
||||||
|
} catch {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
})().split(/[?#]/, 1)[0];
|
||||||
|
const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? '';
|
||||||
if (ext === 'srt') return 'srt';
|
if (ext === 'srt') return 'srt';
|
||||||
if (ext === 'vtt') return 'vtt';
|
if (ext === 'vtt') return 'vtt';
|
||||||
if (ext === 'ass' || ext === 'ssa') return 'ass';
|
if (ext === 'ass' || ext === 'ssa') return 'ass';
|
||||||
|
|||||||
@@ -173,6 +173,29 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
|||||||
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
|
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prefetch service still warms the priority window when cache is full', async () => {
|
||||||
|
const cues = makeCues(20);
|
||||||
|
const cachedTexts: string[] = [];
|
||||||
|
|
||||||
|
const service = createSubtitlePrefetchService({
|
||||||
|
cues,
|
||||||
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
|
preCacheTokenization: (text) => {
|
||||||
|
cachedTexts.push(text);
|
||||||
|
},
|
||||||
|
isCacheFull: () => true,
|
||||||
|
priorityWindowSize: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.start(0);
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
await flushMicrotasks();
|
||||||
|
}
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
assert.deepEqual(cachedTexts.slice(0, 3), ['line-0', 'line-1', 'line-2']);
|
||||||
|
});
|
||||||
|
|
||||||
test('prefetch service pause/resume halts and continues tokenization', async () => {
|
test('prefetch service pause/resume halts and continues tokenization', async () => {
|
||||||
const cues = makeCues(20);
|
const cues = makeCues(20);
|
||||||
let tokenizeCalls = 0;
|
let tokenizeCalls = 0;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function createSubtitlePrefetchService(
|
|||||||
async function tokenizeCueList(
|
async function tokenizeCueList(
|
||||||
cuesToProcess: SubtitleCue[],
|
cuesToProcess: SubtitleCue[],
|
||||||
runId: number,
|
runId: number,
|
||||||
|
options: { allowWhenCacheFull?: boolean } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const cue of cuesToProcess) {
|
for (const cue of cuesToProcess) {
|
||||||
if (stopped || runId !== currentRunId) {
|
if (stopped || runId !== currentRunId) {
|
||||||
@@ -73,7 +74,7 @@ export function createSubtitlePrefetchService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deps.isCacheFull()) {
|
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ export function createSubtitlePrefetchService(
|
|||||||
|
|
||||||
// Phase 1: Priority window
|
// Phase 1: Priority window
|
||||||
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
|
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
|
||||||
await tokenizeCueList(priorityCues, runId);
|
await tokenizeCueList(priorityCues, runId, { allowWhenCacheFull: true });
|
||||||
|
|
||||||
if (stopped || runId !== currentRunId) {
|
if (stopped || runId !== currentRunId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
84
src/main.ts
84
src/main.ts
@@ -421,6 +421,10 @@ import { resolveConfigDir } from './config/path-resolution';
|
|||||||
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
||||||
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
||||||
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
||||||
|
import {
|
||||||
|
getActiveExternalSubtitleSource,
|
||||||
|
resolveSubtitleSourcePath,
|
||||||
|
} from './main/runtime/subtitle-prefetch-source';
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||||
@@ -1077,9 +1081,17 @@ const subtitleProcessingController = createSubtitleProcessingController(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
|
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
|
||||||
|
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastObservedTimePos = 0;
|
let lastObservedTimePos = 0;
|
||||||
const SEEK_THRESHOLD_SECONDS = 3;
|
const SEEK_THRESHOLD_SECONDS = 3;
|
||||||
|
|
||||||
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||||
|
if (subtitlePrefetchRefreshTimer) {
|
||||||
|
clearTimeout(subtitlePrefetchRefreshTimer);
|
||||||
|
subtitlePrefetchRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initSubtitlePrefetch(
|
async function initSubtitlePrefetch(
|
||||||
externalFilename: string,
|
externalFilename: string,
|
||||||
currentTimePos: number,
|
currentTimePos: number,
|
||||||
@@ -1111,6 +1123,37 @@ async function initSubtitlePrefetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
||||||
|
const client = appState.mpvClient;
|
||||||
|
if (!client?.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [trackListRaw, sidRaw] = await Promise.all([
|
||||||
|
client.requestProperty('track-list'),
|
||||||
|
client.requestProperty('sid'),
|
||||||
|
]);
|
||||||
|
const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
|
||||||
|
if (!externalFilename) {
|
||||||
|
subtitlePrefetchService?.stop();
|
||||||
|
subtitlePrefetchService = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await initSubtitlePrefetch(externalFilename, lastObservedTimePos);
|
||||||
|
} catch {
|
||||||
|
// Track list query failed; skip subtitle prefetch refresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||||
|
clearScheduledSubtitlePrefetchRefresh();
|
||||||
|
subtitlePrefetchRefreshTimer = setTimeout(() => {
|
||||||
|
subtitlePrefetchRefreshTimer = null;
|
||||||
|
void refreshSubtitlePrefetchFromActiveTrack();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||||
createBuildOverlayShortcutsRuntimeMainDepsHandler({
|
createBuildOverlayShortcutsRuntimeMainDepsHandler({
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
@@ -2896,42 +2939,13 @@ const {
|
|||||||
autoPlayReadySignalMediaPath = null;
|
autoPlayReadySignalMediaPath = null;
|
||||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||||
startupOsdSequencer.reset();
|
startupOsdSequencer.reset();
|
||||||
|
clearScheduledSubtitlePrefetchRefresh();
|
||||||
subtitlePrefetchService?.stop();
|
subtitlePrefetchService?.stop();
|
||||||
subtitlePrefetchService = null;
|
subtitlePrefetchService = null;
|
||||||
if (path) {
|
if (path) {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
// Attempt to initialize subtitle prefetch for external subtitle tracks.
|
|
||||||
// Delay slightly to allow MPV's track-list to be populated.
|
// Delay slightly to allow MPV's track-list to be populated.
|
||||||
setTimeout(() => {
|
scheduleSubtitlePrefetchRefresh(500);
|
||||||
const client = appState.mpvClient;
|
|
||||||
if (!client?.connected) return;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const [trackListRaw, sidRaw] = await Promise.all([
|
|
||||||
client.requestProperty('track-list'),
|
|
||||||
client.requestProperty('sid'),
|
|
||||||
]);
|
|
||||||
if (!Array.isArray(trackListRaw) || sidRaw == null) return;
|
|
||||||
const sid = typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
|
|
||||||
if (sid == null || !Number.isFinite(sid)) return;
|
|
||||||
const activeTrack = trackListRaw.find(
|
|
||||||
(entry: unknown) => {
|
|
||||||
if (!entry || typeof entry !== 'object') return false;
|
|
||||||
const t = entry as Record<string, unknown>;
|
|
||||||
return t.type === 'sub' && t.id === sid && t.external === true;
|
|
||||||
},
|
|
||||||
) as Record<string, unknown> | undefined;
|
|
||||||
if (!activeTrack) return;
|
|
||||||
const externalFilename = typeof activeTrack['external-filename'] === 'string'
|
|
||||||
? (activeTrack['external-filename'] as string).trim()
|
|
||||||
: '';
|
|
||||||
if (!externalFilename) return;
|
|
||||||
void initSubtitlePrefetch(externalFilename, lastObservedTimePos);
|
|
||||||
} catch {
|
|
||||||
// Track list query failed — not critical, skip prefetch.
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
mediaRuntime.updateCurrentMediaPath(path);
|
mediaRuntime.updateCurrentMediaPath(path);
|
||||||
},
|
},
|
||||||
@@ -2982,6 +2996,12 @@ const {
|
|||||||
}
|
}
|
||||||
lastObservedTimePos = time;
|
lastObservedTimePos = time;
|
||||||
},
|
},
|
||||||
|
onSubtitleTrackChange: () => {
|
||||||
|
scheduleSubtitlePrefetchRefresh();
|
||||||
|
},
|
||||||
|
onSubtitleTrackListChange: () => {
|
||||||
|
scheduleSubtitlePrefetchRefresh();
|
||||||
|
},
|
||||||
updateSubtitleRenderMetrics: (patch) => {
|
updateSubtitleRenderMetrics: (patch) => {
|
||||||
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
|
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
|
||||||
},
|
},
|
||||||
@@ -3590,7 +3610,7 @@ async function loadSubtitleSourceText(source: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source;
|
const filePath = resolveSubtitleSourcePath(source);
|
||||||
return fs.promises.readFile(filePath, 'utf8');
|
return fs.promises.readFile(filePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ test('mpv event bindings register all expected events', () => {
|
|||||||
onSubtitleChange: () => {},
|
onSubtitleChange: () => {},
|
||||||
onSubtitleAssChange: () => {},
|
onSubtitleAssChange: () => {},
|
||||||
onSecondarySubtitleChange: () => {},
|
onSecondarySubtitleChange: () => {},
|
||||||
|
onSubtitleTrackChange: () => {},
|
||||||
|
onSubtitleTrackListChange: () => {},
|
||||||
onSubtitleTiming: () => {},
|
onSubtitleTiming: () => {},
|
||||||
onMediaPathChange: () => {},
|
onMediaPathChange: () => {},
|
||||||
onMediaTitleChange: () => {},
|
onMediaTitleChange: () => {},
|
||||||
@@ -92,6 +94,8 @@ test('mpv event bindings register all expected events', () => {
|
|||||||
'subtitle-change',
|
'subtitle-change',
|
||||||
'subtitle-ass-change',
|
'subtitle-ass-change',
|
||||||
'secondary-subtitle-change',
|
'secondary-subtitle-change',
|
||||||
|
'subtitle-track-change',
|
||||||
|
'subtitle-track-list-change',
|
||||||
'subtitle-timing',
|
'subtitle-timing',
|
||||||
'media-path-change',
|
'media-path-change',
|
||||||
'media-title-change',
|
'media-title-change',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ type MpvBindingEventName =
|
|||||||
| 'subtitle-change'
|
| 'subtitle-change'
|
||||||
| 'subtitle-ass-change'
|
| 'subtitle-ass-change'
|
||||||
| 'secondary-subtitle-change'
|
| 'secondary-subtitle-change'
|
||||||
|
| 'subtitle-track-change'
|
||||||
|
| 'subtitle-track-list-change'
|
||||||
| 'subtitle-timing'
|
| 'subtitle-timing'
|
||||||
| 'media-path-change'
|
| 'media-path-change'
|
||||||
| 'media-title-change'
|
| 'media-title-change'
|
||||||
@@ -69,6 +71,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
|||||||
onSubtitleChange: (payload: { text: string }) => void;
|
onSubtitleChange: (payload: { text: string }) => void;
|
||||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||||
|
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||||
|
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||||
onMediaPathChange: (payload: { path: string | null }) => void;
|
onMediaPathChange: (payload: { path: string | null }) => void;
|
||||||
onMediaTitleChange: (payload: { title: string | null }) => void;
|
onMediaTitleChange: (payload: { title: string | null }) => void;
|
||||||
@@ -83,6 +87,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
|||||||
mpvClient.on('subtitle-change', deps.onSubtitleChange);
|
mpvClient.on('subtitle-change', deps.onSubtitleChange);
|
||||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||||
|
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
|
||||||
|
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
|
||||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||||
mpvClient.on('media-title-change', deps.onMediaTitleChange);
|
mpvClient.on('media-title-change', deps.onMediaTitleChange);
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
|
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
|
||||||
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
||||||
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
||||||
|
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
|
||||||
|
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
|
||||||
|
|
||||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
@@ -65,6 +67,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||||
|
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||||
|
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||||
handlers.get('media-path-change')?.({ path: '' });
|
handlers.get('media-path-change')?.({ path: '' });
|
||||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||||
handlers.get('time-pos-change')?.({ time: 2.5 });
|
handlers.get('time-pos-change')?.({ time: 2.5 });
|
||||||
@@ -73,6 +77,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
assert.ok(calls.includes('set-sub:line'));
|
assert.ok(calls.includes('set-sub:line'));
|
||||||
assert.ok(calls.includes('broadcast-sub:line'));
|
assert.ok(calls.includes('broadcast-sub:line'));
|
||||||
assert.ok(calls.includes('subtitle-change:line'));
|
assert.ok(calls.includes('subtitle-change:line'));
|
||||||
|
assert.ok(calls.includes('subtitle-track-change'));
|
||||||
|
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||||
assert.ok(calls.includes('media-title:Episode 1'));
|
assert.ok(calls.includes('media-title:Episode 1'));
|
||||||
assert.ok(calls.includes('restore-mpv-sub'));
|
assert.ok(calls.includes('restore-mpv-sub'));
|
||||||
assert.ok(calls.includes('reset-guess-state'));
|
assert.ok(calls.includes('reset-guess-state'));
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
setCurrentSubAssText: (text: string) => void;
|
setCurrentSubAssText: (text: string) => void;
|
||||||
broadcastSubtitleAss: (text: string) => void;
|
broadcastSubtitleAss: (text: string) => void;
|
||||||
broadcastSecondarySubtitle: (text: string) => void;
|
broadcastSecondarySubtitle: (text: string) => void;
|
||||||
|
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||||
|
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||||
|
|
||||||
updateCurrentMediaPath: (path: string) => void;
|
updateCurrentMediaPath: (path: string) => void;
|
||||||
restoreMpvSubVisibility: () => void;
|
restoreMpvSubVisibility: () => void;
|
||||||
@@ -146,6 +148,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
onSubtitleChange: handleMpvSubtitleChange,
|
onSubtitleChange: handleMpvSubtitleChange,
|
||||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||||
|
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
|
||||||
|
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
|
||||||
onSubtitleTiming: handleMpvSubtitleTiming,
|
onSubtitleTiming: handleMpvSubtitleTiming,
|
||||||
onMediaPathChange: handleMpvMediaPathChange,
|
onMediaPathChange: handleMpvMediaPathChange,
|
||||||
onMediaTitleChange: handleMpvMediaTitleChange,
|
onMediaTitleChange: handleMpvMediaTitleChange,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||||
onSubtitleChange: (text: string) => void;
|
onSubtitleChange: (text: string) => void;
|
||||||
|
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||||
|
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||||
updateCurrentMediaPath: (path: string) => void;
|
updateCurrentMediaPath: (path: string) => void;
|
||||||
restoreMpvSubVisibility: () => void;
|
restoreMpvSubVisibility: () => void;
|
||||||
getCurrentAnilistMediaKey: () => string | null;
|
getCurrentAnilistMediaKey: () => string | null;
|
||||||
@@ -101,6 +103,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
|
broadcastSubtitle: (payload: { text: string; tokens: null }) =>
|
||||||
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
||||||
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
||||||
|
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
||||||
|
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
||||||
|
: undefined,
|
||||||
|
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||||
|
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||||
|
: undefined,
|
||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
setCurrentSubAssText: (text: string) => {
|
setCurrentSubAssText: (text: string) => {
|
||||||
deps.appState.currentSubAssText = text;
|
deps.appState.currentSubAssText = text;
|
||||||
|
|||||||
41
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
41
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
getActiveExternalSubtitleSource,
|
||||||
|
resolveSubtitleSourcePath,
|
||||||
|
} from './subtitle-prefetch-source';
|
||||||
|
|
||||||
|
test('getActiveExternalSubtitleSource returns the active external subtitle path', () => {
|
||||||
|
const source = getActiveExternalSubtitleSource(
|
||||||
|
[
|
||||||
|
{ type: 'sub', id: 1, external: false },
|
||||||
|
{ type: 'sub', id: 2, external: true, 'external-filename': ' https://host/subs.ass ' },
|
||||||
|
],
|
||||||
|
'2',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(source, 'https://host/subs.ass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => {
|
||||||
|
const source = getActiveExternalSubtitleSource(
|
||||||
|
[{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(source, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => {
|
||||||
|
const fileUrl = process.platform === 'win32'
|
||||||
|
? 'file:///C:/Users/test/Sub%20Folder/subs.ass'
|
||||||
|
: 'file:///tmp/Sub%20Folder/subs.ass';
|
||||||
|
|
||||||
|
const resolved = resolveSubtitleSourcePath(fileUrl);
|
||||||
|
|
||||||
|
assert.ok(resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {
|
||||||
|
assert.equal(resolveSubtitleSourcePath('/tmp/subs.ass'), '/tmp/subs.ass');
|
||||||
|
});
|
||||||
34
src/main/runtime/subtitle-prefetch-source.ts
Normal file
34
src/main/runtime/subtitle-prefetch-source.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export function getActiveExternalSubtitleSource(
|
||||||
|
trackListRaw: unknown,
|
||||||
|
sidRaw: unknown,
|
||||||
|
): string | null {
|
||||||
|
if (!Array.isArray(trackListRaw) || sidRaw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid =
|
||||||
|
typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
|
||||||
|
if (sid == null || !Number.isFinite(sid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTrack = trackListRaw.find((entry: unknown) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const track = entry as Record<string, unknown>;
|
||||||
|
return track.type === 'sub' && track.id === sid && track.external === true;
|
||||||
|
}) as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const externalFilename =
|
||||||
|
typeof activeTrack?.['external-filename'] === 'string'
|
||||||
|
? activeTrack['external-filename'].trim()
|
||||||
|
: '';
|
||||||
|
return externalFilename || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSubtitleSourcePath(source: string): string {
|
||||||
|
return source.startsWith('file://') ? fileURLToPath(new URL(source)) : source;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user