mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-03 06:22:41 -08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
092c56f98f
|
|||
|
10ef535f9a
|
|||
|
6c80bd5843
|
|||
|
f0bd0ba355
|
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -278,11 +278,13 @@ jobs:
|
|||||||
echo "$CHANGES" >> $GITHUB_OUTPUT
|
echo "$CHANGES" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create Release
|
- name: Publish Release
|
||||||
uses: softprops/action-gh-release@v2
|
env:
|
||||||
with:
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
name: ${{ steps.version.outputs.VERSION }}
|
run: |
|
||||||
body: |
|
set -euo pipefail
|
||||||
|
|
||||||
|
cat > release-body.md <<'EOF'
|
||||||
## Changes
|
## Changes
|
||||||
${{ steps.changelog.outputs.CHANGES }}
|
${{ steps.changelog.outputs.CHANGES }}
|
||||||
|
|
||||||
@@ -311,12 +313,35 @@ jobs:
|
|||||||
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
files: |
|
EOF
|
||||||
|
|
||||||
|
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||||
|
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release-body.md \
|
||||||
|
--prerelease false
|
||||||
|
else
|
||||||
|
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release-body.md \
|
||||||
|
--prerelease false
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
artifacts=(
|
||||||
release/*.AppImage
|
release/*.AppImage
|
||||||
release/*.dmg
|
release/*.dmg
|
||||||
release/*.zip
|
release/*.zip
|
||||||
release/*.tar.gz
|
release/*.tar.gz
|
||||||
release/SHA256SUMS.txt
|
release/SHA256SUMS.txt
|
||||||
dist/launcher/subminer
|
dist/launcher/subminer
|
||||||
draft: false
|
)
|
||||||
prerelease: false
|
|
||||||
|
if [ "${#artifacts[@]}" -eq 0 ]; then
|
||||||
|
echo "No release artifacts found for upload."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for asset in "${artifacts[@]}"; do
|
||||||
|
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||||
|
done
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
id: TASK-84
|
||||||
|
title: 'Docs Plausible endpoint uses /api/event path'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 00:00'
|
||||||
|
updated_date: '2026-03-03 00:00'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 12000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Fix VitePress docs Plausible tracker config to post to hosted worker API event endpoint instead of worker root URL.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Docs theme Plausible `endpoint` points to `https://worker.subminer.moe/api/event`.
|
||||||
|
- [x] #2 Plausible docs test asserts `/api/event` endpoint path.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Updated docs Plausible tracker endpoint to `https://worker.subminer.moe/api/event` and updated regression test expectation accordingly.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [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.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
|
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ async function initPlausibleTracker() {
|
|||||||
const { init } = await import('@plausible-analytics/tracker');
|
const { init } = await import('@plausible-analytics/tracker');
|
||||||
init({
|
init({
|
||||||
domain: 'subminer.moe',
|
domain: 'subminer.moe',
|
||||||
endpoint: 'https://worker.subminer.moe',
|
endpoint: 'https://worker.subminer.moe/api/event',
|
||||||
outboundLinks: true,
|
outboundLinks: true,
|
||||||
fileDownloads: true,
|
fileDownloads: true,
|
||||||
formSubmissions: true,
|
formSubmissions: true,
|
||||||
|
captureOnLocalhost: false,
|
||||||
});
|
});
|
||||||
plausibleTrackerInitialized = true;
|
plausibleTrackerInitialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -771,7 +771,8 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
"defaultMode": "auto",
|
"defaultMode": "auto",
|
||||||
"alass_path": "",
|
"alass_path": "",
|
||||||
"ffsubsync_path": "",
|
"ffsubsync_path": "",
|
||||||
"ffmpeg_path": ""
|
"ffmpeg_path": "",
|
||||||
|
"replace": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -782,6 +783,7 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
||||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||||
|
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||||
|
|
||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ aniskip_button_duration=3
|
|||||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
| `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_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_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) |
|
| `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`.
|
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||||
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||||
- MAL/title resolution is cached for the current mpv session.
|
- 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`).
|
- 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.
|
- 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).
|
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { readFileSync } from 'node:fs';
|
|||||||
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||||
|
|
||||||
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe', () => {
|
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe api endpoint', () => {
|
||||||
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
|
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
|
||||||
expect(docsThemeContents).toContain('const { init } = await import');
|
expect(docsThemeContents).toContain('const { init } = await import');
|
||||||
expect(docsThemeContents).toContain("domain: 'subminer.moe'");
|
expect(docsThemeContents).toContain("domain: 'subminer.moe'");
|
||||||
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe'");
|
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe/api/event'");
|
||||||
expect(docsThemeContents).toContain('outboundLinks: true');
|
expect(docsThemeContents).toContain('outboundLinks: true');
|
||||||
expect(docsThemeContents).toContain('fileDownloads: true');
|
expect(docsThemeContents).toContain('fileDownloads: true');
|
||||||
expect(docsThemeContents).toContain('formSubmissions: true');
|
expect(docsThemeContents).toContain('formSubmissions: true');
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
|
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -4,8 +4,38 @@ import {
|
|||||||
inferAniSkipMetadataForFile,
|
inferAniSkipMetadataForFile,
|
||||||
buildSubminerScriptOpts,
|
buildSubminerScriptOpts,
|
||||||
parseAniSkipGuessitJson,
|
parseAniSkipGuessitJson,
|
||||||
|
resolveAniSkipMetadataForFile,
|
||||||
} from './aniskip-metadata';
|
} 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', () => {
|
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
||||||
const parsed = parseAniSkipGuessitJson(
|
const parsed = parseAniSkipGuessitJson(
|
||||||
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
||||||
@@ -16,6 +46,10 @@ test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
|||||||
season: 2,
|
season: 2,
|
||||||
episode: 7,
|
episode: 7,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +68,10 @@ test('parseAniSkipGuessitJson prefers series over episode title', () => {
|
|||||||
season: 1,
|
season: 1,
|
||||||
episode: 10,
|
episode: 10,
|
||||||
source: 'guessit',
|
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');
|
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', {
|
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||||
title: "Frieren: Beyond Journey's End",
|
title: "Frieren: Beyond Journey's End",
|
||||||
season: 1,
|
season: 1,
|
||||||
episode: 5,
|
episode: 5,
|
||||||
source: 'guessit',
|
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-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
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_title=Frieren: Beyond Journey's End/);
|
||||||
assert.match(opts, /subminer-aniskip_season=1/);
|
assert.match(opts, /subminer-aniskip_season=1/);
|
||||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ import path from 'node:path';
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { commandExists } from './util.js';
|
import { commandExists } from './util.js';
|
||||||
|
|
||||||
|
export type AniSkipLookupStatus =
|
||||||
|
| 'ready'
|
||||||
|
| 'missing_mal_id'
|
||||||
|
| 'missing_episode'
|
||||||
|
| 'missing_payload'
|
||||||
|
| 'lookup_failed';
|
||||||
|
|
||||||
export interface AniSkipMetadata {
|
export interface AniSkipMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
source: 'guessit' | 'fallback';
|
source: 'guessit' | 'fallback';
|
||||||
|
malId: number | null;
|
||||||
|
introStart: number | null;
|
||||||
|
introEnd: number | null;
|
||||||
|
lookupStatus?: AniSkipLookupStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InferAniSkipDeps {
|
interface InferAniSkipDeps {
|
||||||
@@ -14,6 +25,50 @@ interface InferAniSkipDeps {
|
|||||||
runGuessit: (mediaPath: string) => string | null;
|
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 {
|
function toPositiveInt(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
return Math.floor(value);
|
return Math.floor(value);
|
||||||
@@ -27,6 +82,217 @@ function toPositiveInt(value: unknown): number | null {
|
|||||||
return 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 {
|
function detectEpisodeFromName(baseName: string): number | null {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/[Ss]\d+[Ee](\d{1,3})/,
|
/[Ss]\d+[Ee](\d{1,3})/,
|
||||||
@@ -133,6 +399,10 @@ export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniS
|
|||||||
season,
|
season,
|
||||||
episode: episodeFromDirect ?? episodeFromList,
|
episode: episodeFromDirect ?? episodeFromList,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -171,9 +441,70 @@ export function inferAniSkipMetadataForFile(
|
|||||||
season: detectSeasonFromNameOrDir(mediaPath),
|
season: detectSeasonFromNameOrDir(mediaPath),
|
||||||
episode: detectEpisodeFromName(baseName),
|
episode: detectEpisodeFromName(baseName),
|
||||||
source: 'fallback',
|
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 {
|
function sanitizeScriptOptValue(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/,/g, ' ')
|
.replace(/,/g, ' ')
|
||||||
@@ -182,6 +513,28 @@ function sanitizeScriptOptValue(value: string): string {
|
|||||||
.trim();
|
.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(
|
export function buildSubminerScriptOpts(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
@@ -200,5 +553,21 @@ export function buildSubminerScriptOpts(
|
|||||||
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
||||||
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
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(',');
|
return parts.join(',');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
startMpv(
|
await startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
args,
|
args,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|||||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail, getMpvLogPath } from './log.js';
|
||||||
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
isExecutable,
|
isExecutable,
|
||||||
@@ -419,7 +419,7 @@ export async function loadSubtitleIntoMpv(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startMpv(
|
export async function startMpv(
|
||||||
target: string,
|
target: string,
|
||||||
targetKind: 'file' | 'url',
|
targetKind: 'file' | 'url',
|
||||||
args: Args,
|
args: Args,
|
||||||
@@ -479,7 +479,8 @@ export function startMpv(
|
|||||||
if (options?.startPaused) {
|
if (options?.startPaused) {
|
||||||
mpvArgs.push('--pause=yes');
|
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);
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ aniskip_mal_id=
|
|||||||
# Force episode number (optional). Leave blank for filename/title detection.
|
# Force episode number (optional). Leave blank for filename/title detection.
|
||||||
aniskip_episode=
|
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.
|
# Show intro skip OSD button while inside OP range.
|
||||||
aniskip_show_button=yes
|
aniskip_show_button=yes
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ function M.create(ctx)
|
|||||||
local mal_lookup_cache = {}
|
local mal_lookup_cache = {}
|
||||||
local payload_cache = {}
|
local payload_cache = {}
|
||||||
local title_context_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)
|
local function url_encode(text)
|
||||||
if type(text) ~= "string" then
|
if type(text) ~= "string" then
|
||||||
@@ -25,6 +31,109 @@ function M.create(ctx)
|
|||||||
return encoded:gsub(" ", "%%20")
|
return encoded:gsub(" ", "%%20")
|
||||||
end
|
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)
|
local function run_json_curl_async(url, callback)
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
@@ -296,6 +405,8 @@ function M.create(ctx)
|
|||||||
state.aniskip.episode = nil
|
state.aniskip.episode = nil
|
||||||
state.aniskip.intro_start = nil
|
state.aniskip.intro_start = nil
|
||||||
state.aniskip.intro_end = nil
|
state.aniskip.intro_end = nil
|
||||||
|
state.aniskip.payload = nil
|
||||||
|
state.aniskip.payload_source = nil
|
||||||
remove_aniskip_chapters()
|
remove_aniskip_chapters()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -366,7 +477,17 @@ function M.create(ctx)
|
|||||||
state.aniskip.intro_end = intro_end
|
state.aniskip.intro_end = intro_end
|
||||||
state.aniskip.prompt_shown = false
|
state.aniskip.prompt_shown = false
|
||||||
set_intro_chapters(intro_start, intro_end)
|
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
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -374,6 +495,10 @@ function M.create(ctx)
|
|||||||
return false
|
return false
|
||||||
end
|
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 function is_launcher_context()
|
||||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
if forced_title ~= "" then
|
if forced_title ~= "" then
|
||||||
@@ -391,6 +516,9 @@ function M.create(ctx)
|
|||||||
if forced_season and forced_season > 0 then
|
if forced_season and forced_season > 0 then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
if has_launcher_payload() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -500,6 +628,18 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
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 function fetch_aniskip_for_current_media(trigger_source)
|
||||||
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
if not opts.aniskip_enabled then
|
if not opts.aniskip_enabled then
|
||||||
@@ -518,6 +658,28 @@ function M.create(ctx)
|
|||||||
reset_aniskip_fields()
|
reset_aniskip_fields()
|
||||||
local title, episode, season = resolve_title_and_episode()
|
local title, episode, season = resolve_title_and_episode()
|
||||||
local lookup_titles = resolve_lookup_titles(title)
|
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(
|
subminer_log(
|
||||||
"info",
|
"info",
|
||||||
@@ -558,6 +720,8 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
state.aniskip.payload = payload
|
||||||
|
state.aniskip.payload_source = "remote"
|
||||||
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function M.load(options_lib, default_socket_path)
|
|||||||
aniskip_season = "",
|
aniskip_season = "",
|
||||||
aniskip_mal_id = "",
|
aniskip_mal_id = "",
|
||||||
aniskip_episode = "",
|
aniskip_episode = "",
|
||||||
|
aniskip_payload = "",
|
||||||
aniskip_show_button = true,
|
aniskip_show_button = true,
|
||||||
aniskip_button_text = "You can skip by pressing %s",
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
aniskip_button_key = "y-k",
|
aniskip_button_key = "y-k",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function M.new()
|
|||||||
episode = nil,
|
episode = nil,
|
||||||
intro_start = nil,
|
intro_start = nil,
|
||||||
intro_end = nil,
|
intro_end = nil,
|
||||||
|
payload = nil,
|
||||||
|
payload_source = nil,
|
||||||
found = false,
|
found = false,
|
||||||
prompt_shown = false,
|
prompt_shown = false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
alass_path: '',
|
alass_path: '',
|
||||||
ffsubsync_path: '',
|
ffsubsync_path: '',
|
||||||
ffmpeg_path: '',
|
ffmpeg_path: '',
|
||||||
|
replace: true,
|
||||||
},
|
},
|
||||||
startupWarmups: {
|
startupWarmups: {
|
||||||
lowPowerMode: false,
|
lowPowerMode: false,
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.subsync.defaultMode,
|
defaultValue: defaultConfig.subsync.defaultMode,
|
||||||
description: 'Subsync default mode.',
|
description: 'Subsync default mode.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subsync.replace',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subsync.replace,
|
||||||
|
description: 'Replace the active subtitle file when sync completes.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'startupWarmups.lowPowerMode',
|
path: 'startupWarmups.lowPowerMode',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
||||||
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
||||||
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
||||||
|
const replace = asBoolean(src.subsync.replace);
|
||||||
|
if (replace !== undefined) {
|
||||||
|
resolved.subsync.replace = replace;
|
||||||
|
} else if (src.subsync.replace !== undefined) {
|
||||||
|
warn('subsync.replace', src.subsync.replace, resolved.subsync.replace, 'Expected boolean.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.subtitlePosition)) {
|
if (isObject(src.subtitlePosition)) {
|
||||||
|
|||||||
@@ -209,10 +209,73 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
|||||||
assert.ok(ffArgs.includes(primaryPath));
|
assert.ok(ffArgs.includes(primaryPath));
|
||||||
assert.ok(ffArgs.includes('--reference-stream'));
|
assert.ok(ffArgs.includes('--reference-stream'));
|
||||||
assert.ok(ffArgs.includes('0:2'));
|
assert.ok(ffArgs.includes('0:2'));
|
||||||
|
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||||
|
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||||
|
assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath);
|
||||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual writes deterministic _retimed filename when replace is false', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-no-replace-'));
|
||||||
|
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'episode.ja.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'sub');
|
||||||
|
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(
|
||||||
|
ffsubsyncPath,
|
||||||
|
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': primaryPath,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
defaultMode: 'manual',
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
replace: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
|
||||||
|
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||||
|
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||||
|
const outputPath = ffArgs[ffOutputFlagIndex + 1];
|
||||||
|
assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt'));
|
||||||
|
});
|
||||||
|
|
||||||
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
||||||
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
@@ -281,6 +344,76 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
|||||||
assert.equal(alassArgs[1], primaryPath);
|
assert.equal(alassArgs[1], primaryPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'primary.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'sub');
|
||||||
|
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(
|
||||||
|
ffmpegPath,
|
||||||
|
'#!/bin/sh\nout=""\nfor arg in "$@"; do out="$arg"; done\nif [ -n "$out" ]; then : > "$out"; fi\nexit 0\n',
|
||||||
|
);
|
||||||
|
writeExecutableScript(
|
||||||
|
alassPath,
|
||||||
|
'#!/bin/sh\nsleep 0.2\nif [ ! -f "$1" ]; then echo "missing reference subtitle" >&2; exit 1; fi\nif [ ! -f "$2" ]; then echo "missing input subtitle" >&2; exit 1; fi\n: > "$3"\nexit 0\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentCommands: Array<Array<string | number>> = [];
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: (payload) => {
|
||||||
|
sentCommands.push(payload.command);
|
||||||
|
},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': primaryPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: false,
|
||||||
|
'ff-index': 2,
|
||||||
|
codec: 'ass',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
defaultMode: 'manual',
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.message, 'Subtitle synchronized with alass');
|
||||||
|
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||||
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
|
});
|
||||||
|
|
||||||
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
||||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRetimedPath(subPath: string): string {
|
function buildRetimedPath(subPath: string, replace: boolean): string {
|
||||||
|
if (replace) return subPath;
|
||||||
const parsed = path.parse(subPath);
|
const parsed = path.parse(subPath);
|
||||||
const suffix = `_retimed_${Date.now()}`;
|
return path.join(parsed.dir, `${parsed.name}_retimed${parsed.ext || '.srt'}`);
|
||||||
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAlassSync(
|
async function runAlassSync(
|
||||||
@@ -265,7 +265,8 @@ async function subsyncToReference(
|
|||||||
context.videoPath,
|
context.videoPath,
|
||||||
context.primaryTrack,
|
context.primaryTrack,
|
||||||
);
|
);
|
||||||
const outputPath = buildRetimedPath(primaryExtraction.path);
|
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
||||||
|
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: CommandResult;
|
let result: CommandResult;
|
||||||
@@ -389,7 +390,7 @@ export async function runSubsyncManual(
|
|||||||
let sourceExtraction: FileExtractionResult | null = null;
|
let sourceExtraction: FileExtractionResult | null = null;
|
||||||
try {
|
try {
|
||||||
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
||||||
return subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
||||||
} finally {
|
} finally {
|
||||||
if (sourceExtraction) {
|
if (sourceExtraction) {
|
||||||
cleanupTemporaryFile(sourceExtraction);
|
cleanupTemporaryFile(sourceExtraction);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { codecToExtension } from './utils';
|
import { codecToExtension, getSubsyncConfig } from './utils';
|
||||||
|
|
||||||
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
||||||
assert.equal(codecToExtension('subrip'), 'srt');
|
assert.equal(codecToExtension('subrip'), 'srt');
|
||||||
@@ -12,3 +12,13 @@ test('codecToExtension maps stream/web formats to ffmpeg extractable extensions'
|
|||||||
test('codecToExtension returns null for unsupported codecs', () => {
|
test('codecToExtension returns null for unsupported codecs', () => {
|
||||||
assert.equal(codecToExtension('unsupported-codec'), null);
|
assert.equal(codecToExtension('unsupported-codec'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getSubsyncConfig defaults replace to true', () => {
|
||||||
|
assert.equal(getSubsyncConfig(undefined).replace, true);
|
||||||
|
assert.equal(getSubsyncConfig({}).replace, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSubsyncConfig respects explicit replace value', () => {
|
||||||
|
assert.equal(getSubsyncConfig({ replace: false }).replace, false);
|
||||||
|
assert.equal(getSubsyncConfig({ replace: true }).replace, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface SubsyncResolvedConfig {
|
|||||||
alassPath: string;
|
alassPath: string;
|
||||||
ffsubsyncPath: string;
|
ffsubsyncPath: string;
|
||||||
ffmpegPath: string;
|
ffmpegPath: string;
|
||||||
|
replace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
||||||
@@ -55,6 +56,7 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso
|
|||||||
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
||||||
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
||||||
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
||||||
|
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface SubsyncConfig {
|
|||||||
alass_path?: string;
|
alass_path?: string;
|
||||||
ffsubsync_path?: string;
|
ffsubsync_path?: string;
|
||||||
ffmpeg_path?: string;
|
ffmpeg_path?: string;
|
||||||
|
replace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartupWarmupsConfig {
|
export interface StartupWarmupsConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user