Files
SubMiner/src/core/services/subsync-alass-input.ts
T
sudacode 36cd19794e fix(anime): thread episode metadata through stream playback and fix seas
- Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8"
- Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess
- Add a Season field to the TsukiHime modal and include later seasons in its search query
- Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT
- Update CHANGELOG and troubleshooting docs
2026-08-06 21:48:00 -07:00

156 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { FileExtractionResult } from './subsync-extract';
import { createLogger } from '../../logger';
const logger = createLogger('subsync');
/**
* alass picks its parser purely from the file extension, and WebVTT is not one
* of the formats it knows. A `.vtt` reference is therefore treated as a *video*
* ("no audio stream in file"), and the same file renamed to `.srt` dies in the
* SubRip parser on the `WEBVTT` header. Extension-backed anime streams hand out
* VTT almost exclusively, so every alass input is converted to SRT first.
*/
const ALASS_SUBTITLE_EXTENSIONS = new Set(['srt', 'ass', 'ssa', 'sub', 'idx']);
/** How much of the file is decoded to recognise a WebVTT header. */
const SNIFF_BYTES = 1024;
const CUE_TIMING_PATTERN =
/^\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/;
interface RawCue {
start: number;
end: number;
text: string;
}
function toSeconds(
hours: string | undefined,
minutes: string,
seconds: string,
millis: string,
): number {
return (
Number(hours ?? 0) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(millis.padEnd(3, '0')) / 1000
);
}
function formatTimestamp(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const millis = Math.round(clamped * 1000);
const hours = Math.floor(millis / 3_600_000);
const minutes = Math.floor((millis % 3_600_000) / 60_000);
const seconds = Math.floor((millis % 60_000) / 1000);
const remainder = millis % 1000;
const pad = (value: number, width: number): string => String(value).padStart(width, '0');
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(remainder, 3)}`;
}
/**
* Read timed cues out of a WebVTT (or SRT-shaped) file.
*
* The payload is kept verbatim rather than sanitised: this same file is what
* alass retimes and mpv then loads, so stripping inline tags here would show up
* on screen. Cue identifiers, `NOTE`/`STYLE`/`REGION` blocks and the `WEBVTT`
* header all fall out for free — only lines that follow a timing line are kept.
*/
export function parseTimedCues(content: string): RawCue[] {
const lines = content.replace(/^/, '').split(/\r?\n/);
const cues: RawCue[] = [];
for (let index = 0; index < lines.length; index += 1) {
const timing = CUE_TIMING_PATTERN.exec(lines[index]!);
if (!timing) continue;
const start = toSeconds(timing[1], timing[2]!, timing[3]!, timing[4]!);
const end = toSeconds(timing[5], timing[6]!, timing[7]!, timing[8]!);
const textLines: string[] = [];
index += 1;
while (index < lines.length && lines[index]!.trim().length > 0) {
textLines.push(lines[index]!);
index += 1;
}
const text = textLines.join('\n').trim();
if (text.length > 0) {
cues.push({ start, end, text });
}
}
return cues;
}
export function formatCuesAsSrt(cues: RawCue[]): string {
return cues
.map(
(cue, index) =>
`${index + 1}\n${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}\n`,
)
.join('\n');
}
function readHead(filePath: string): string {
const handle = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(SNIFF_BYTES);
const bytesRead = fs.readSync(handle, buffer, 0, SNIFF_BYTES, 0);
return buffer.subarray(0, bytesRead).toString('utf8');
} finally {
fs.closeSync(handle);
}
}
function isWebVttContent(head: string): boolean {
return /^WEBVTT(\s|$)/.test(head.replace(/^/, '').trimStart());
}
/**
* Decide whether alass can read the file as-is.
*
* Content wins over the extension in one direction only: a file alass would
* accept by extension still needs converting when it turns out to hold VTT,
* while an unrecognised extension is always converted. Anything else is passed
* through untouched, which also keeps its original charset intact — alass
* detects encodings that a UTF-8 round trip here would mangle.
*/
export function needsAlassConversion(filePath: string, head: string): boolean {
const extension = path.extname(filePath).slice(1).toLowerCase();
if (!ALASS_SUBTITLE_EXTENSIONS.has(extension)) return true;
return isWebVttContent(head);
}
/**
* Rewrite a subtitle file as SRT for alass, or return null when alass can
* already read it. The result is a temporary file in its own directory, so
* `cleanupTemporaryFile` can remove it (and keep the retimed output beside it).
*/
export function convertSubtitleForAlass(filePath: string): FileExtractionResult | null {
if (!needsAlassConversion(filePath, readHead(filePath))) return null;
const cues = parseTimedCues(fs.readFileSync(filePath, 'utf8'));
if (cues.length === 0) {
throw new Error(`Could not read subtitle timings for alass from ${path.basename(filePath)}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-alass-'));
const outputPath = path.join(tempDir, `${path.parse(filePath).name}.srt`);
try {
fs.writeFileSync(outputPath, formatCuesAsSrt(cues), 'utf8');
} catch (error) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
throw error;
}
logger.info(`Converted ${filePath} to SRT for alass: ${outputPath} (${cues.length} cues)`);
return { path: outputPath, temporary: true };
}