feat(launcher): migrate aniskip resolution to launcher script opts

This commit is contained in:
2026-03-03 00:37:50 -08:00
parent 10ef535f9a
commit 092c56f98f
10 changed files with 712 additions and 7 deletions

View File

@@ -2,11 +2,22 @@ import path from 'node:path';
import { spawnSync } from 'node:child_process';
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 {
@@ -14,6 +25,50 @@ interface InferAniSkipDeps {
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 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);
@@ -27,6 +82,217 @@ function toPositiveInt(value: unknown): number | null {
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;
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 romanAliases = {
2: [' ii ', ' second season ', ' 2nd season '],
3: [' iii ', ' third season ', ' 3rd season '],
4: [' iv ', ' fourth season ', ' 4th season '],
5: [' v ', ' fifth season ', ' 5th season '],
} as const;
const aliases = romanAliases[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})/,
@@ -133,6 +399,10 @@ export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniS
season,
episode: episodeFromDirect ?? episodeFromList,
source: 'guessit',
malId: null,
introStart: null,
introEnd: null,
lookupStatus: 'lookup_failed',
};
} catch {
return null;
@@ -171,9 +441,70 @@ export function inferAniSkipMetadataForFile(
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, ' ')
@@ -182,6 +513,28 @@ function sanitizeScriptOptValue(value: string): string {
.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,
},
},
],
};
return encodeURIComponent(JSON.stringify(payload));
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
@@ -200,5 +553,21 @@ export function buildSubminerScriptOpts(
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(',');
}