feat(aniskip): move intro detection from mpv plugin to app runtime (#117)

This commit is contained in:
2026-06-09 23:55:43 -07:00
committed by GitHub
parent d5bfdcae7b
commit 2007e28be8
49 changed files with 900 additions and 1469 deletions
-185
View File
@@ -1,185 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
inferAniSkipMetadataForFile,
buildSubminerScriptOpts,
parseAniSkipGuessitJson,
resolveAniSkipMetadataForFile,
} from './aniskip-metadata';
function makeMockResponse(payload: unknown): Response {
return {
ok: true,
status: 200,
json: async () => payload,
} as Response;
}
function normalizeFetchInput(input: string | URL | Request): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
return input.url;
}
async function withMockFetch(
handler: (input: string | URL | Request) => Promise<Response>,
fn: () => Promise<void>,
): Promise<void> {
const original = globalThis.fetch;
(globalThis as { fetch: typeof fetch }).fetch = (async (input: string | URL | Request) => {
return handler(input);
}) as typeof fetch;
try {
await fn();
} finally {
(globalThis as { fetch: typeof fetch }).fetch = original;
}
}
test('parseAniSkipGuessitJson extracts title season and episode', () => {
const parsed = parseAniSkipGuessitJson(
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
'/tmp/My.Show.S02E07.mkv',
);
assert.deepEqual(parsed, {
title: 'My Show',
season: 2,
episode: 7,
source: 'guessit',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
});
});
test('parseAniSkipGuessitJson prefers series over episode title', () => {
const parsed = parseAniSkipGuessitJson(
JSON.stringify({
title: 'What Is This, a Picnic',
series: 'Solo Leveling',
season: 1,
episode: 10,
}),
'/tmp/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv',
);
assert.deepEqual(parsed, {
title: 'Solo Leveling',
season: 1,
episode: 10,
source: 'guessit',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
});
});
test('inferAniSkipMetadataForFile falls back to filename title when guessit unavailable', () => {
const parsed = inferAniSkipMetadataForFile('/tmp/Another_Show_-_03.mkv', {
commandExists: () => false,
runGuessit: () => null,
});
assert.equal(parsed.title.length > 0, true);
assert.equal(parsed.source, 'fallback');
});
test('inferAniSkipMetadataForFile falls back to anime directory title when filename is episode-only', () => {
const parsed = inferAniSkipMetadataForFile(
'/truenas/jellyfin/anime/Solo Leveling/Season-1/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv',
{
commandExists: () => false,
runGuessit: () => null,
},
);
assert.equal(parsed.title, 'Solo Leveling');
assert.equal(parsed.season, 1);
assert.equal(parsed.episode, 10);
assert.equal(parsed.source, 'fallback');
});
test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
await withMockFetch(
async (input) => {
const url = normalizeFetchInput(input);
if (url.includes('myanimelist.net/search/prefix.json')) {
return makeMockResponse({
categories: [
{
items: [
{ id: '9876', name: 'Wrong Match' },
{ id: '1234', name: 'My Show' },
],
},
],
});
}
if (url.includes('api.aniskip.com/v1/skip-times/1234/7')) {
return makeMockResponse({
found: true,
results: [{ skip_type: 'op', interval: { start_time: 12.5, end_time: 54.2 } }],
});
}
throw new Error(`unexpected url: ${url}`);
},
async () => {
const resolved = await resolveAniSkipMetadataForFile('/media/Anime.My.Show.S01E07.mkv');
assert.equal(resolved.malId, 1234);
assert.equal(resolved.introStart, 12.5);
assert.equal(resolved.introEnd, 54.2);
assert.equal(resolved.lookupStatus, 'ready');
assert.equal(resolved.title, 'Anime My Show');
},
);
});
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
await withMockFetch(
async () => makeMockResponse({ categories: [] }),
async () => {
const resolved = await resolveAniSkipMetadataForFile('/media/NopeShow.S01E03.mkv');
assert.equal(resolved.malId, null);
assert.equal(resolved.lookupStatus, 'missing_mal_id');
},
);
});
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
const opts = buildSubminerScriptOpts(
'/tmp/SubMiner.AppImage',
'/tmp/subminer.sock',
{
title: "Frieren: Beyond Journey's End",
season: 1,
episode: 5,
source: 'guessit',
malId: 1234,
introStart: 30.5,
introEnd: 62,
lookupStatus: 'ready',
},
'debug',
);
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.doesNotMatch(opts, /subminer-log_level=/);
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/);
assert.match(opts, /subminer-aniskip_mal_id=1234/);
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
assert.match(opts, /subminer-aniskip_intro_end=62/);
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
assert.ok(payloadMatch !== null);
const encodedPayload = payloadMatch[1];
assert.ok(encodedPayload !== undefined);
assert.equal(encodedPayload.includes('%'), false);
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
const payload = JSON.parse(payloadJson);
assert.equal(payload.found, true);
const first = payload.results?.[0];
assert.equal(first.skip_type, 'op');
assert.equal(first.interval.start_time, 30.5);
assert.equal(first.interval.end_time, 62);
});
-605
View File
@@ -1,605 +0,0 @@
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import type { LogLevel } from './types.js';
import { commandExists } from './util.js';
export type AniSkipLookupStatus =
| 'ready'
| 'missing_mal_id'
| 'missing_episode'
| 'missing_payload'
| 'lookup_failed';
export interface AniSkipMetadata {
title: string;
season: number | null;
episode: number | null;
source: 'guessit' | 'fallback';
malId: number | null;
introStart: number | null;
introEnd: number | null;
lookupStatus?: AniSkipLookupStatus;
}
interface InferAniSkipDeps {
commandExists: (name: string) => boolean;
runGuessit: (mediaPath: string) => string | null;
}
interface MalSearchResult {
id?: unknown;
name?: unknown;
}
interface MalSearchCategory {
items?: unknown;
}
interface MalSearchResponse {
categories?: unknown;
}
interface AniSkipIntervalPayload {
start_time?: unknown;
end_time?: unknown;
}
interface AniSkipSkipItemPayload {
skip_type?: unknown;
interval?: unknown;
}
interface AniSkipPayloadResponse {
found?: unknown;
results?: unknown;
}
const ROMAN_SEASON_ALIASES: Record<number, readonly string[]> = {
2: [' ii ', ' second season ', ' 2nd season '],
3: [' iii ', ' third season ', ' 3rd season '],
4: [' iv ', ' fourth season ', ' 4th season '],
5: [' v ', ' fifth season ', ' 5th season '],
};
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
const MAL_MATCH_STOPWORDS = new Set([
'the',
'this',
'that',
'world',
'animated',
'series',
'season',
'no',
'on',
'and',
]);
function toPositiveInt(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
}
function toPositiveNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
}
function normalizeForMatch(value: string): string {
return value
.toLowerCase()
.replace(/[^\w]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function tokenizeMatchWords(value: string): string[] {
const words = normalizeForMatch(value)
.split(' ')
.filter((word) => word.length >= 3);
return words.filter((word) => !MAL_MATCH_STOPWORDS.has(word));
}
function titleOverlapScore(expectedTitle: string, candidateTitle: string): number {
const expected = normalizeForMatch(expectedTitle);
const candidate = normalizeForMatch(candidateTitle);
if (!expected || !candidate) return 0;
if (candidate.includes(expected)) return 120;
if (candidate.split(' ').length >= 2 && ` ${expected} `.includes(` ${candidate} `)) {
return 90;
}
const expectedTokens = tokenizeMatchWords(expectedTitle);
if (expectedTokens.length === 0) return 0;
const candidateSet = new Set(tokenizeMatchWords(candidateTitle));
let score = 0;
let matched = 0;
for (const token of expectedTokens) {
if (candidateSet.has(token)) {
score += 30;
matched += 1;
} else {
score -= 20;
}
}
if (matched === 0) {
score -= 80;
}
const coverage = matched / expectedTokens.length;
if (expectedTokens.length >= 2) {
if (coverage >= 0.8) score += 30;
else if (coverage >= 0.6) score += 10;
else score -= 50;
} else if (coverage >= 1) {
score += 10;
}
return score;
}
function hasAnySequelMarker(candidateTitle: string): boolean {
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
if (!normalized.trim()) return false;
const markers = [
'season 2',
'season 3',
'season 4',
'2nd season',
'3rd season',
'4th season',
'second season',
'third season',
'fourth season',
' ii ',
' iii ',
' iv ',
];
return markers.some((marker) => normalized.includes(marker));
}
function seasonSignalScore(requestedSeason: number | null, candidateTitle: string): number {
const season = toPositiveInt(requestedSeason);
if (!season || season < 1) return 0;
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
if (!normalized.trim()) return 0;
if (season === 1) {
return hasAnySequelMarker(candidateTitle) ? -60 : 20;
}
const numericMarker = ` season ${season} `;
const ordinalMarker = ` ${season}th season `;
if (normalized.includes(numericMarker) || normalized.includes(ordinalMarker)) {
return 40;
}
const aliases = ROMAN_SEASON_ALIASES[season] ?? [];
return aliases.some((alias) => normalized.includes(alias))
? 40
: hasAnySequelMarker(candidateTitle)
? -20
: 5;
}
function toMalSearchItems(payload: unknown): MalSearchResult[] {
const parsed = payload as MalSearchResponse;
const categories = Array.isArray(parsed?.categories) ? parsed.categories : null;
if (!categories) return [];
const items: MalSearchResult[] = [];
for (const category of categories) {
const typedCategory = category as MalSearchCategory;
const rawItems = Array.isArray(typedCategory?.items) ? typedCategory.items : [];
for (const rawItem of rawItems) {
const item = rawItem as Record<string, unknown>;
items.push({
id: item?.id,
name: item?.name,
});
}
}
return items;
}
function normalizeEpisodePayload(value: unknown): number | null {
return toPositiveNumber(value);
}
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
const parsed = payload as AniSkipPayloadResponse;
const results = Array.isArray(parsed?.results) ? parsed.results : null;
if (!results) return null;
for (const rawResult of results) {
const result = rawResult as AniSkipSkipItemPayload;
if (
result.skip_type !== 'op' ||
typeof result.interval !== 'object' ||
result.interval === null
) {
continue;
}
const interval = result.interval as AniSkipIntervalPayload;
const start = normalizeEpisodePayload(interval?.start_time);
const end = normalizeEpisodePayload(interval?.end_time);
if (start !== null && end !== null && end > start) {
return { start, end };
}
}
return null;
}
async function fetchJson<T>(url: string): Promise<T | null> {
const response = await fetch(url, {
headers: {
'User-Agent': MAL_USER_AGENT,
},
});
if (!response.ok) return null;
try {
return (await response.json()) as T;
} catch {
return null;
}
}
async function resolveMalIdFromTitle(title: string, season: number | null): Promise<number | null> {
const lookup = season && season > 1 ? `${title} Season ${season}` : title;
const payload = await fetchJson<unknown>(`${MAL_PREFIX_API}${encodeURIComponent(lookup)}`);
const items = toMalSearchItems(payload);
if (!items.length) return null;
let bestScore = Number.NEGATIVE_INFINITY;
let bestMalId: number | null = null;
for (const item of items) {
const id = toPositiveInt(item.id);
if (!id) continue;
const name = typeof item.name === 'string' ? item.name : '';
if (!name) continue;
const score = titleOverlapScore(title, name) + seasonSignalScore(season, name);
if (score > bestScore) {
bestScore = score;
bestMalId = id;
}
}
return bestMalId;
}
async function fetchAniSkipPayload(
malId: number,
episode: number,
): Promise<{ start: number; end: number } | null> {
const payload = await fetchJson<unknown>(
`${ANISKIP_PAYLOAD_API}${malId}/${episode}?types=op&types=ed`,
);
const parsed = payload as AniSkipPayloadResponse;
if (!parsed || parsed.found !== true) return null;
return parseAniSkipPayload(parsed);
}
function detectEpisodeFromName(baseName: string): number | null {
const patterns = [
/[Ss]\d+[Ee](\d{1,3})/,
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
/[-\s](\d{1,3})$/,
];
for (const pattern of patterns) {
const match = baseName.match(pattern);
if (!match || !match[1]) continue;
const parsed = Number.parseInt(match[1], 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return null;
}
function detectSeasonFromNameOrDir(mediaPath: string): number | null {
const baseName = path.basename(mediaPath, path.extname(mediaPath));
const seasonMatch = baseName.match(/[Ss](\d{1,2})[Ee]\d{1,3}/);
if (seasonMatch && seasonMatch[1]) {
const parsed = Number.parseInt(seasonMatch[1], 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
const parent = path.basename(path.dirname(mediaPath));
const parentMatch = parent.match(/(?:Season|S)[\s._-]*(\d{1,2})/i);
if (parentMatch && parentMatch[1]) {
const parsed = Number.parseInt(parentMatch[1], 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return null;
}
function isSeasonDirectoryName(value: string): boolean {
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
}
function isEpisodeOnlyBaseName(value: string): boolean {
return /^(?:[Ss]\d{1,2}[Ee]\d{1,3}|[Ee][Pp]?[\s._-]*\d{1,3}|\d{1,3})(?:$|[\s._-])/.test(
value.trim(),
);
}
function inferTitleFromPath(mediaPath: string): string {
const directory = path.dirname(mediaPath);
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index] || '';
if (!isSeasonDirectoryName(segment)) continue;
const showSegment = segments[index - 1];
if (typeof showSegment === 'string' && showSegment.length > 0) {
const cleaned = cleanupTitle(showSegment);
if (cleaned) return cleaned;
}
}
const parent = path.basename(directory);
if (!isSeasonDirectoryName(parent)) {
const cleanedParent = cleanupTitle(parent);
if (cleanedParent) return cleanedParent;
}
const grandParent = path.basename(path.dirname(directory));
const cleanedGrandParent = cleanupTitle(grandParent);
return cleanedGrandParent;
}
function cleanupTitle(value: string): string {
return value
.replace(/\.[^/.]+$/, '')
.replace(/\[[^\]]+\]/g, ' ')
.replace(/\([^)]+\)/g, ' ')
.replace(/[Ss]\d+[Ee]\d+/g, ' ')
.replace(/[Ee][Pp]?[\s._-]*\d+/g, ' ')
.replace(/[_\-.]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniSkipMetadata | null {
const payload = stdout.trim();
if (!payload) return null;
try {
const parsed = JSON.parse(payload) as {
title?: unknown;
title_original?: unknown;
series?: unknown;
season?: unknown;
episode?: unknown;
episode_list?: unknown;
};
const rawTitle =
(typeof parsed.series === 'string' && parsed.series) ||
(typeof parsed.title === 'string' && parsed.title) ||
(typeof parsed.title_original === 'string' && parsed.title_original) ||
'';
const title = cleanupTitle(rawTitle) || inferTitleFromPath(mediaPath);
if (!title) return null;
const season = toPositiveInt(parsed.season);
const episodeFromDirect = toPositiveInt(parsed.episode);
const episodeFromList =
Array.isArray(parsed.episode_list) && parsed.episode_list.length > 0
? toPositiveInt(parsed.episode_list[0])
: null;
return {
title,
season,
episode: episodeFromDirect ?? episodeFromList,
source: 'guessit',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
};
} catch {
return null;
}
}
function defaultRunGuessit(mediaPath: string): string | null {
const fileName = path.basename(mediaPath);
const result = spawnSync('guessit', ['--json', fileName], {
cwd: path.dirname(mediaPath),
encoding: 'utf8',
maxBuffer: 2_000_000,
windowsHide: true,
});
if (result.error || result.status !== 0) return null;
return result.stdout || null;
}
export function inferAniSkipMetadataForFile(
mediaPath: string,
deps: InferAniSkipDeps = { commandExists, runGuessit: defaultRunGuessit },
): AniSkipMetadata {
if (deps.commandExists('guessit')) {
const stdout = deps.runGuessit(mediaPath);
if (typeof stdout === 'string') {
const parsed = parseAniSkipGuessitJson(stdout, mediaPath);
if (parsed) return parsed;
}
}
const baseName = path.basename(mediaPath, path.extname(mediaPath));
const cleanedBaseName = cleanupTitle(baseName);
const pathTitle = inferTitleFromPath(mediaPath);
const fallbackTitle = isEpisodeOnlyBaseName(baseName)
? pathTitle || cleanedBaseName || baseName
: cleanedBaseName || pathTitle || baseName;
return {
title: fallbackTitle,
season: detectSeasonFromNameOrDir(mediaPath),
episode: detectEpisodeFromName(baseName),
source: 'fallback',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
};
}
export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<AniSkipMetadata> {
const inferred = inferAniSkipMetadataForFile(mediaPath);
if (!inferred.title) {
return { ...inferred, lookupStatus: 'lookup_failed' };
}
try {
const malId = await resolveMalIdFromTitle(inferred.title, inferred.season);
if (!malId) {
return {
...inferred,
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'missing_mal_id',
};
}
if (!inferred.episode) {
return {
...inferred,
malId,
introStart: null,
introEnd: null,
lookupStatus: 'missing_episode',
};
}
const payload = await fetchAniSkipPayload(malId, inferred.episode);
if (!payload) {
return {
...inferred,
malId,
introStart: null,
introEnd: null,
lookupStatus: 'missing_payload',
};
}
return {
...inferred,
malId,
introStart: payload.start,
introEnd: payload.end,
lookupStatus: 'ready',
};
} catch {
return {
...inferred,
malId: inferred.malId,
introStart: inferred.introStart,
introEnd: inferred.introEnd,
lookupStatus: 'lookup_failed',
};
}
}
function sanitizeScriptOptValue(value: string): string {
return value
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | null {
if (!aniSkipMetadata.malId || !aniSkipMetadata.introStart || !aniSkipMetadata.introEnd) {
return null;
}
if (aniSkipMetadata.introEnd <= aniSkipMetadata.introStart) {
return null;
}
const payload = {
found: true,
results: [
{
skip_type: 'op',
interval: {
start_time: aniSkipMetadata.introStart,
end_time: aniSkipMetadata.introEnd,
},
},
],
};
// mpv --script-opts treats `%` as an escape prefix, so URL-encoding can break parsing.
// Base64url stays script-opts-safe and is decoded by the plugin launcher payload parser.
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
aniSkipMetadata: AniSkipMetadata | null,
_logLevel: LogLevel = 'info',
extraParts: string[] = [],
): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
const parts = [
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
...extraParts.map(sanitizeScriptOptValue),
];
if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
}
if (aniSkipMetadata && aniSkipMetadata.season && aniSkipMetadata.season > 0) {
parts.push(`subminer-aniskip_season=${aniSkipMetadata.season}`);
}
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
}
if (aniSkipMetadata && aniSkipMetadata.malId && aniSkipMetadata.malId > 0) {
parts.push(`subminer-aniskip_mal_id=${aniSkipMetadata.malId}`);
}
if (aniSkipMetadata && aniSkipMetadata.introStart !== null && aniSkipMetadata.introStart > 0) {
parts.push(`subminer-aniskip_intro_start=${aniSkipMetadata.introStart}`);
}
if (aniSkipMetadata && aniSkipMetadata.introEnd !== null && aniSkipMetadata.introEnd > 0) {
parts.push(`subminer-aniskip_intro_end=${aniSkipMetadata.introEnd}`);
}
if (aniSkipMetadata?.lookupStatus) {
parts.push(
`subminer-aniskip_lookup_status=${sanitizeScriptOptValue(aniSkipMetadata.lookupStatus)}`,
);
}
const aniskipPayload = aniSkipMetadata ? buildLauncherAniSkipPayload(aniSkipMetadata) : null;
if (aniskipPayload) {
parts.push(`subminer-aniskip_payload=${sanitizeScriptOptValue(aniskipPayload)}`);
}
return parts.join(',');
}
@@ -46,8 +46,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {},
@@ -83,8 +83,6 @@ function createContext(): LauncherCommandContext {
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
@@ -210,8 +208,6 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
@@ -273,8 +269,6 @@ test('plugin auto-start playback attaches a warm background app through the laun
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = [];
@@ -342,8 +336,6 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined;
@@ -404,8 +396,6 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
-20
View File
@@ -91,8 +91,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
autoStartSubMiner: false,
pauseUntilOverlayReady: false,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
@@ -102,8 +100,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
assert.equal(parsed.autoStartSubMiner, false);
assert.equal(parsed.pauseUntilOverlayReady, false);
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
@@ -138,8 +134,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
});
@@ -150,8 +144,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
@@ -161,8 +153,6 @@ test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, true);
assert.equal(parsed.aniskipButtonKey, 'TAB');
});
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
@@ -176,8 +166,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
'/fallback/SubMiner.AppImage',
),
@@ -189,8 +177,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
'subminer-auto_start_visible_overlay=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8',
],
);
});
@@ -206,8 +192,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'F8,\nF9',
},
'/fallback/SubMiner.AppImage',
),
@@ -219,8 +203,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
'subminer-auto_start_visible_overlay=no',
'subminer-auto_start_pause_until_ready=yes',
'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8 F9',
],
);
});
@@ -244,8 +226,6 @@ test('parseLauncherMpvConfig reads configured mpv profile', () => {
pauseUntilOverlayReady: undefined,
subminerBinaryPath: undefined,
profile: 'anime',
aniskipEnabled: undefined,
aniskipButtonKey: undefined,
},
);
-2
View File
@@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
pauseUntilOverlayReady:
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
};
}
+1 -3
View File
@@ -54,8 +54,6 @@ export function parsePluginRuntimeConfigFromMainConfig(
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
};
}
@@ -72,7 +70,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log(
'debug',
logLevel,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}`,
);
return parsed;
}
-35
View File
@@ -23,8 +23,6 @@ import {
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadataForLaunch,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
state,
@@ -388,31 +386,12 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
}),
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
);
});
test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin AniSkip', () => {
assert.equal(
shouldResolveAniSkipMetadataForLaunch('/tmp/video.mkv', 'file', undefined, {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'TAB',
}),
false,
);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
@@ -565,20 +544,6 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
}
});
test('shouldResolveAniSkipMetadata skips URL and YouTube-preloaded playback', () => {
assert.equal(shouldResolveAniSkipMetadata('/media/show.mkv', 'file'), true);
assert.equal(
shouldResolveAniSkipMetadata('https://www.youtube.com/watch?v=test123', 'url'),
false,
);
assert.equal(
shouldResolveAniSkipMetadata('/tmp/video123.webm', 'file', {
primaryPath: '/tmp/video123.ja.srt',
}),
false,
);
});
function makeArgs(overrides: Partial<Args> = {}): Args {
return {
backend: 'x11',
+3 -50
View File
@@ -27,7 +27,7 @@ import {
shouldForwardLogLevel,
} from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { buildSubminerScriptOpts } from './script-opts.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
import { nowMs } from './time.js';
import {
@@ -823,20 +823,6 @@ export async function loadSubtitleIntoMpv(
}
}
export function shouldResolveAniSkipMetadata(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
): boolean {
if (targetKind !== 'file') {
return false;
}
if (preloadedSubtitles?.primaryPath || preloadedSubtitles?.secondaryPath) {
return false;
}
return !isYoutubeTarget(target);
}
type StartMpvOptions = {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
@@ -844,18 +830,6 @@ type StartMpvOptions = {
runtimePluginConfig?: PluginRuntimeConfig;
};
export function shouldResolveAniSkipMetadataForLaunch(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
runtimePluginConfig?: PluginRuntimeConfig,
): boolean {
if (runtimePluginConfig?.aniskipEnabled === false) {
return false;
}
return shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles);
}
export function buildRuntimeExtraScriptOptParts(
target: string,
targetKind: 'file' | 'url',
@@ -946,29 +920,14 @@ export async function startMpv(
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
target,
targetKind,
preloadedSubtitles,
options?.runtimePluginConfig,
)
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
const runtimeScriptOpts = options?.runtimePluginConfig
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, [
...runtimeScriptOpts,
...extraScriptOpts,
]);
if (aniSkipMetadata) {
log(
'debug',
args.logLevel,
`AniSkip metadata (${aniSkipMetadata.source}): title="${aniSkipMetadata.title}" season=${aniSkipMetadata.season ?? '-'} episode=${aniSkipMetadata.episode ?? '-'}`,
);
}
mpvArgs.push(`--script-opts=${scriptOpts}`);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
@@ -1701,13 +1660,7 @@ export function launchMpvIdleDetached(
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
mpvArgs.push(
`--script-opts=${buildSubminerScriptOpts(
appPath,
socketPath,
null,
args.logLevel,
runtimeScriptOpts,
)}`,
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, runtimeScriptOpts)}`,
);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
mpvArgs.push(`--input-ipc-server=${socketPath}`);
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildSubminerScriptOpts } from './script-opts';
test('buildSubminerScriptOpts preserves app and socket paths verbatim', () => {
const scriptOpts = buildSubminerScriptOpts(
'/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner',
'/tmp/subminer socket.sock',
['subminer-backend=x11'],
);
assert.equal(
scriptOpts,
'subminer-binary_path=/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner,subminer-socket_path=/tmp/subminer socket.sock,subminer-backend=x11',
);
});
test('buildSubminerScriptOpts rejects delimiter-bearing default paths', () => {
assert.throws(
() => buildSubminerScriptOpts('/tmp/SubMiner,canary', '/tmp/subminer.sock'),
/subminer-binary_path contains unsupported script option delimiter/,
);
assert.throws(
() => buildSubminerScriptOpts('/tmp/SubMiner', '/tmp/subminer\nsocket.sock'),
/subminer-socket_path contains unsupported script option delimiter/,
);
});
+34
View File
@@ -0,0 +1,34 @@
function sanitizeScriptOptValue(value: string): string {
return value
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function assertScriptOptPathValue(name: string, value: string): void {
if (/[,\r\n]/.test(value)) {
throw new Error(`${name} contains unsupported script option delimiter`);
}
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
extraParts: string[] = [],
): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
if (!hasBinaryPath) {
assertScriptOptPathValue('subminer-binary_path', appPath);
}
if (!hasSocketPath) {
assertScriptOptPathValue('subminer-socket_path', socketPath);
}
const parts = [
...(hasBinaryPath ? [] : [`subminer-binary_path=${appPath}`]),
...(hasSocketPath ? [] : [`subminer-socket_path=${socketPath}`]),
...extraParts.map(sanitizeScriptOptValue),
];
return parts.join(',');
}
-1
View File
@@ -559,7 +559,6 @@ test(
socketPath: smokeCase.socketPath,
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
aniskipEnabled: false,
},
}),
);
-4
View File
@@ -191,8 +191,6 @@ export interface LauncherMpvConfig {
autoStartSubMiner?: boolean;
pauseUntilOverlayReady?: boolean;
subminerBinaryPath?: string;
aniskipEnabled?: boolean;
aniskipButtonKey?: string;
}
export interface LauncherLoggingConfig {
@@ -210,8 +208,6 @@ export interface PluginRuntimeConfig {
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
texthookerEnabled: boolean;
aniskipEnabled: boolean;
aniskipButtonKey: string;
}
export interface CommandExecOptions {