From 092c56f98ff283c86517389874a3ae7d2c1bd26e Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 3 Mar 2026 00:37:50 -0800 Subject: [PATCH] feat(launcher): migrate aniskip resolution to launcher script opts --- ...okup-orchestration-to-launcher-Electron.md | 63 +++ docs/mpv-plugin.md | 4 +- launcher/aniskip-metadata.test.ts | 102 ++++- launcher/aniskip-metadata.ts | 369 ++++++++++++++++++ launcher/commands/playback-command.ts | 2 +- launcher/mpv.ts | 7 +- plugin/subminer.conf | 3 + plugin/subminer/aniskip.lua | 166 +++++++- plugin/subminer/options.lua | 1 + plugin/subminer/state.lua | 2 + 10 files changed, 712 insertions(+), 7 deletions(-) create mode 100644 backlog/tasks/task-84 - Migrate-AniSkip-metadatalookup-orchestration-to-launcher-Electron.md diff --git a/backlog/tasks/task-84 - Migrate-AniSkip-metadatalookup-orchestration-to-launcher-Electron.md b/backlog/tasks/task-84 - Migrate-AniSkip-metadatalookup-orchestration-to-launcher-Electron.md new file mode 100644 index 0000000..25451e3 --- /dev/null +++ b/backlog/tasks/task-84 - Migrate-AniSkip-metadatalookup-orchestration-to-launcher-Electron.md @@ -0,0 +1,63 @@ +--- +id: TASK-84 +title: Migrate AniSkip metadata+lookup orchestration to launcher/Electron +status: Done +assignee: + - Codex +created_date: '2026-03-03 08:31' +updated_date: '2026-03-03 08:35' +labels: + - enhancement + - aniskip + - launcher + - mpv-plugin +dependencies: [] +references: + - launcher/aniskip-metadata.ts + - launcher/mpv.ts + - plugin/subminer/aniskip.lua + - plugin/subminer/options.lua + - plugin/subminer/state.lua + - plugin/subminer/lifecycle.lua + - plugin/subminer/messages.lua + - plugin/subminer.conf + - launcher/aniskip-metadata.test.ts +documentation: + - docs/mpv-plugin.md + - launcher/aniskip-metadata.ts + - plugin/subminer/aniskip.lua + - docs/architecture.md +priority: medium +--- + +## Description + + +Move AniSkip MAL/title-to-MAL lookup and intro payload resolution from mpv Lua to launcher Electron flow, while keeping mpv-side intro skip UX and chapter/chapter prompt behavior in plugin. Launcher should infer/analyze file metadata, fetch AniSkip payload when launching files, and pass resolved skip window via script options; plugin should trust launcher payload and fall back only when absent. + + +## Acceptance Criteria + +- [x] #1 Launcher infers AniSkip metadata for file targets using existing guessit/fallback logic and performs AniSkip MAL + payload resolution during mpv startup. +- [x] #2 Launcher injects script options containing resolved MAL id and intro window fields (or explicit lookup-failure status) into mpv startup. +- [x] #3 Lua plugin consumes launcher-provided AniSkip intro data and skips all network lookups when payload is present. +- [x] #4 Standalone mpv/plugin usage without launcher payload continues to function using existing async in-plugin lookup path. +- [x] #5 Docs and defaults are updated to document new script-option contract. +- [x] #6 Launcher tests cover payload generation contract and fallback behavior where metadata is unavailable. + + +## Implementation Plan + + +1) Add launcher-side AniSkip payload resolution helpers in launcher/aniskip-metadata.ts (MAL prefix lookup + AniSkip payload fetch + result normalization). +2) Wire launcher/mpv.ts + buildSubminerScriptOpts to pass resolved AniSkip fields/mode in --script-opts for file playback. +3) Update plugin/subminer/aniskip.lua plus options/state to consume injected payload: if intro_start/end present, apply immediately and skip network lookup; otherwise retain existing async behavior. +4) Ensure fallback for standalone mpv usage remains intact for no-launcher/manual refresh. +5) Add/update tests/docs/config references for new script-opt contract and edge cases. + + +## Final Summary + + +Executed end-to-end migration so launcher resolves AniSkip title/MAL/payload before mpv start and injects it via --script-opts. Plugin now parses and consumes launcher payload (JSON/url/base64), applies OP intro from payload, tracks payload metadata in state, and keeps legacy async lookup path for non-launcher/absent payload playback. Added launcher config key aniskip_payload and updated launcher/aniskip-metadata tests for resolve/payload behavior and contract validation. + diff --git a/docs/mpv-plugin.md b/docs/mpv-plugin.md index 4829d26..eb3a798 100644 --- a/docs/mpv-plugin.md +++ b/docs/mpv-plugin.md @@ -137,6 +137,7 @@ aniskip_button_duration=3 | `aniskip_season` | `""` | numeric season | Optional season hint | | `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id | | `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed | +| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup | | `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt | | `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) | | `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) | @@ -208,7 +209,8 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no - You explicitly call `script-message subminer-aniskip-refresh`. - Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`). - MAL/title resolution is cached for the current mpv session. -- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title. +- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls. +- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch. - Install `guessit` for best detection quality (`python3 -m pip install --user guessit`). - If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters. - At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default). diff --git a/launcher/aniskip-metadata.test.ts b/launcher/aniskip-metadata.test.ts index 19b5649..9f00871 100644 --- a/launcher/aniskip-metadata.test.ts +++ b/launcher/aniskip-metadata.test.ts @@ -4,8 +4,38 @@ 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, + fn: () => Promise, +): Promise { + 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 }), @@ -16,6 +46,10 @@ test('parseAniSkipGuessitJson extracts title season and episode', () => { season: 2, episode: 7, source: 'guessit', + malId: null, + introStart: null, + introEnd: null, + lookupStatus: 'lookup_failed', }); }); @@ -34,6 +68,10 @@ test('parseAniSkipGuessitJson prefers series over episode title', () => { season: 1, episode: 10, source: 'guessit', + malId: null, + introStart: null, + introEnd: null, + lookupStatus: 'lookup_failed', }); }); @@ -60,16 +98,78 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen assert.equal(parsed.source, 'fallback'); }); -test('buildSubminerScriptOpts includes aniskip metadata fields', () => { +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', }); + 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.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 payload = JSON.parse(decodeURIComponent(payloadMatch[1])); + 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); }); diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 686aed6..1382da2 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -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; + 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(url: string): Promise { + 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 { + const lookup = season && season > 1 ? `${title} Season ${season}` : title; + const payload = await fetchJson(`${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(`${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 { + 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(','); } diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index d395365..dbe5e0b 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -146,7 +146,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready'); } - startMpv( + await startMpv( selectedTarget.target, selectedTarget.kind, args, diff --git a/launcher/mpv.ts b/launcher/mpv.ts index bdcc3eb..925fe4b 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -6,7 +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 { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; import { commandExists, isExecutable, @@ -419,7 +419,7 @@ export async function loadSubtitleIntoMpv( } } -export function startMpv( +export async function startMpv( target: string, targetKind: 'file' | 'url', args: Args, @@ -479,7 +479,8 @@ export function startMpv( if (options?.startPaused) { mpvArgs.push('--pause=yes'); } - const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null; + const aniSkipMetadata = + targetKind === 'file' ? await resolveAniSkipMetadataForFile(target) : null; const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); if (aniSkipMetadata) { log( diff --git a/plugin/subminer.conf b/plugin/subminer.conf index d27fd41..9e81fe7 100644 --- a/plugin/subminer.conf +++ b/plugin/subminer.conf @@ -53,6 +53,9 @@ aniskip_mal_id= # Force episode number (optional). Leave blank for filename/title detection. aniskip_episode= +# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup. +aniskip_payload= + # Show intro skip OSD button while inside OP range. aniskip_show_button=yes diff --git a/plugin/subminer/aniskip.lua b/plugin/subminer/aniskip.lua index 36e59ac..2129798 100644 --- a/plugin/subminer/aniskip.lua +++ b/plugin/subminer/aniskip.lua @@ -13,6 +13,12 @@ function M.create(ctx) local mal_lookup_cache = {} local payload_cache = {} local title_context_cache = {} + local base64_reverse = {} + local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + + for i = 1, #base64_chars do + base64_reverse[base64_chars:sub(i, i)] = i - 1 + end local function url_encode(text) if type(text) ~= "string" then @@ -25,6 +31,109 @@ function M.create(ctx) return encoded:gsub(" ", "%%20") end + local function parse_json_payload(text) + if type(text) ~= "string" then + return nil + end + local parsed, parse_error = utils.parse_json(text) + if type(parsed) == "table" then + return parsed + end + return nil, parse_error + end + + local function decode_base64(input) + if type(input) ~= "string" then + return nil + end + local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/") + cleaned = cleaned:match("^%s*(.-)%s*$") or "" + if cleaned == "" then + return nil + end + if #cleaned % 4 == 1 then + return nil + end + if #cleaned % 4 ~= 0 then + cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4)) + end + if not cleaned:match("^[A-Za-z0-9+/%=]+$") then + return nil + end + local out = {} + local out_len = 0 + for index = 1, #cleaned, 4 do + local c1 = cleaned:sub(index, index) + local c2 = cleaned:sub(index + 1, index + 1) + local c3 = cleaned:sub(index + 2, index + 2) + local c4 = cleaned:sub(index + 3, index + 3) + local v1 = base64_reverse[c1] + local v2 = base64_reverse[c2] + if not v1 or not v2 then + return nil + end + local v3 = c3 == "=" and 0 or base64_reverse[c3] + local v4 = c4 == "=" and 0 or base64_reverse[c4] + if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then + return nil + end + local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4) + local b1 = math.floor(n / 65536) + local remaining = n % 65536 + local b2 = math.floor(remaining / 256) + local b3 = remaining % 256 + out_len = out_len + 1 + out[out_len] = string.char(b1) + if c3 ~= "=" then + out_len = out_len + 1 + out[out_len] = string.char(b2) + end + if c4 ~= "=" then + out_len = out_len + 1 + out[out_len] = string.char(b3) + end + end + return table.concat(out) + end + + local function resolve_launcher_payload() + local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or "" + local trimmed = raw_payload:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return nil + end + + local parsed, parse_error = parse_json_payload(trimmed) + if type(parsed) == "table" then + return parsed + end + + local url_decoded = trimmed:gsub("%%(%x%x)", function(hex) + local value = tonumber(hex, 16) + if value then + return string.char(value) + end + return "%" + end) + if url_decoded ~= trimmed then + parsed, parse_error = parse_json_payload(url_decoded) + if type(parsed) == "table" then + return parsed + end + end + + local b64_decoded = decode_base64(trimmed) + if type(b64_decoded) == "string" and b64_decoded ~= "" then + parsed, parse_error = parse_json_payload(b64_decoded) + if type(parsed) == "table" then + return parsed + end + end + + subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable")) + return nil + end + local function run_json_curl_async(url, callback) mp.command_native_async({ name = "subprocess", @@ -296,6 +405,8 @@ function M.create(ctx) state.aniskip.episode = nil state.aniskip.intro_start = nil state.aniskip.intro_end = nil + state.aniskip.payload = nil + state.aniskip.payload_source = nil remove_aniskip_chapters() end @@ -366,7 +477,17 @@ function M.create(ctx) state.aniskip.intro_end = intro_end state.aniskip.prompt_shown = false set_intro_chapters(intro_start, intro_end) - subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode)) + subminer_log( + "info", + "aniskip", + string.format( + "Intro window %.3f -> %.3f (MAL %s, ep %s)", + intro_start, + intro_end, + tostring(mal_id or "-"), + tostring(episode or "-") + ) + ) return true end end @@ -374,6 +495,10 @@ function M.create(ctx) return false end + local function has_launcher_payload() + return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil + end + local function is_launcher_context() local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or "" if forced_title ~= "" then @@ -391,6 +516,9 @@ function M.create(ctx) if forced_season and forced_season > 0 then return true end + if has_launcher_payload() then + return true + end return false end @@ -500,6 +628,18 @@ function M.create(ctx) end) end + local function fetch_payload_from_launcher(payload, mal_id, title, episode) + if not payload then + return false + end + state.aniskip.payload = payload + state.aniskip.payload_source = "launcher" + state.aniskip.mal_id = mal_id + state.aniskip.title = title + state.aniskip.episode = episode + return apply_aniskip_payload(mal_id, title, episode, payload) + end + local function fetch_aniskip_for_current_media(trigger_source) local trigger = type(trigger_source) == "string" and trigger_source or "manual" if not opts.aniskip_enabled then @@ -518,6 +658,28 @@ function M.create(ctx) reset_aniskip_fields() local title, episode, season = resolve_title_and_episode() local lookup_titles = resolve_lookup_titles(title) + local launcher_payload = resolve_launcher_payload() + if launcher_payload then + local launcher_mal_id = tonumber(opts.aniskip_mal_id) + if not launcher_mal_id then + launcher_mal_id = nil + end + if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then + subminer_log( + "info", + "aniskip", + string.format( + "Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)", + tostring(title or ""), + tostring(season or "-"), + tostring(episode or "-") + ) + ) + return + end + subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available") + return + end subminer_log( "info", @@ -558,6 +720,8 @@ function M.create(ctx) end return end + state.aniskip.payload = payload + state.aniskip.payload_source = "remote" if not apply_aniskip_payload(mal_id, title, episode, payload) then subminer_log("info", "aniskip", "AniSkip payload did not include OP interval") end diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index 9c4ce7a..9601d74 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -17,6 +17,7 @@ function M.load(options_lib, default_socket_path) aniskip_season = "", aniskip_mal_id = "", aniskip_episode = "", + aniskip_payload = "", aniskip_show_button = true, aniskip_button_text = "You can skip by pressing %s", aniskip_button_key = "y-k", diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index ff227d8..732624e 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -24,6 +24,8 @@ function M.new() episode = nil, intro_start = nil, intro_end = nil, + payload = nil, + payload_source = nil, found = false, prompt_shown = false, },