feat(plugin): add AniSkip intro skip flow with launcher metadata hints

This commit is contained in:
2026-02-22 02:14:37 -08:00
parent b3b55de4b9
commit f6e7dd496a
10 changed files with 1056 additions and 1 deletions

View File

@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
inferAniSkipMetadataForFile,
buildSubminerScriptOpts,
parseAniSkipGuessitJson,
} from './aniskip-metadata';
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',
});
});
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',
});
});
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('buildSubminerScriptOpts includes aniskip metadata fields', () => {
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
title: 'Frieren: Beyond Journey\'s End',
season: 1,
episode: 5,
source: 'guessit',
});
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
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/);
});

View File

@@ -0,0 +1,196 @@
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { commandExists } from './util.js';
export interface AniSkipMetadata {
title: string;
season: number | null;
episode: number | null;
source: 'guessit' | 'fallback';
}
interface InferAniSkipDeps {
commandExists: (name: string) => boolean;
runGuessit: (mediaPath: string) => string | null;
}
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 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 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',
};
} 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 pathTitle = inferTitleFromPath(mediaPath);
const fallbackTitle = pathTitle || cleanupTitle(baseName) || baseName;
return {
title: fallbackTitle,
season: detectSeasonFromNameOrDir(mediaPath),
episode: detectEpisodeFromName(baseName),
source: 'fallback',
};
}
function sanitizeScriptOptValue(value: string): string {
return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
aniSkipMetadata: AniSkipMetadata | null,
): string {
const parts = [
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
];
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}`);
}
return parts.join(',');
}

View File

@@ -6,6 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js';
import {
commandExists,
isExecutable,
@@ -472,7 +473,17 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
}
mpvArgs.push(`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`);
const aniSkipMetadata =
targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
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(`--log-file=${getMpvLogPath()}`);
try {