Compare commits
128 Commits
e49a04c576
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bbdba040b | |||
| 748145a372 | |||
| e6684dd748 | |||
| 3212ada36f | |||
| 3198002836 | |||
| 42189d95bb | |||
| 1e78d7f190 | |||
| 2c7dfef722 | |||
| 5d0af08b79 | |||
| 0e2f027f7a | |||
| 7953d1b789 | |||
| c045f6ad80 | |||
| c838ecdbd2 | |||
| 6b9606a05b | |||
| d38e4d4290 | |||
| cbae475281 | |||
| b738f6878d | |||
| 39fcac10b5 | |||
| 50e1ea66f4 | |||
| 0a8996dc33 | |||
| 56be720494 | |||
| f31e84e186 | |||
| 78d569189f | |||
| 6721b8caf3 | |||
| 7900f450a7 | |||
| 789a9f7bfe | |||
| 6325bdb3e9 | |||
| 7fa2404d13 | |||
| 9d65dd12be | |||
| 203c1aa154 | |||
| e0fe061b74 | |||
| 6f4d265385 | |||
| 686434f5c3 | |||
| 6b01de5f30 | |||
| 5bea914b14 | |||
| 6022ed09b2 | |||
| 8a95026728 | |||
| fbfd492e18 | |||
| 9b04ed87d2 | |||
| 444d2eb733 | |||
| 7cb2714793 | |||
| 7d12241ccb | |||
| 96bf208a16 | |||
| 4fe651f822 | |||
| c11ba795e6 | |||
| e0a43f1a54 | |||
| 089cfe8640 | |||
| abdfa2a790 | |||
| 65acc683b9 | |||
| c985bb9f04 | |||
| df08065aa4 | |||
| 1e749e0188 | |||
| e6d3c179fe | |||
| fd1fb8c77b | |||
| f00360b128 | |||
| c3ee64974c | |||
| 3341ceed14 | |||
| fbb8bdf272 | |||
| b7f6144a6a | |||
| ce37aec647 | |||
| b7ee1f387b | |||
| ebbb305a58 | |||
| 2701441a1c | |||
| 43d934ff68 | |||
| aafe5b2ec1 | |||
| c251d22638 | |||
| 5aa8041f52 | |||
| 56a1aa3b65 | |||
| ffa6d95d6f | |||
| babc6414e5 | |||
| 648a17b763 | |||
| ed085a5e17 | |||
| 94610d05b7 | |||
| a5847d0219 | |||
| 7509bb40fd | |||
| a1da644aa3 | |||
| c1baf3e476 | |||
| ee9add076a | |||
| 8112bfeb65 | |||
| fada511ecc | |||
| 05a1345750 | |||
| 0fd3624d9f | |||
| a21bcefb54 | |||
| 76a16ba84c | |||
| 91d8ed67b8 | |||
| 794680ec17 | |||
| 201c02c810 | |||
| 495a40a6c6 | |||
| 84e669922b | |||
| 0d560743f3 | |||
| 331cc7b58e | |||
| d8d1b43556 | |||
| 13b2630a09 | |||
| 56f16c5ad3 | |||
| 6265301d47 | |||
| 583fa3e218 | |||
| 24d15a5057 | |||
| 330d3de425 | |||
| ac8772e3bf | |||
| b9b4a50e8a | |||
| da5bd6cac2 | |||
| 75104402fa | |||
| 43b190a1a0 | |||
| 6faa5986a3 | |||
| 114b6687c6 | |||
| 82c8c89fb9 | |||
| 67f1b9440e | |||
| 8e1deb39d5 | |||
| 455796ebb6 | |||
| c595ad3792 | |||
| ef417bea09 | |||
| 68771bb980 | |||
| b2d10bd3d4 | |||
| 0c595a787e | |||
| 7d30e6c1a6 | |||
| a2bdecd298 | |||
| c6698db51a | |||
| 604fdc5c6c | |||
| c22642630d | |||
| ab65909e6e | |||
| 07c98f36f0 | |||
| 4e96382097 | |||
| 3f910873eb | |||
| 3f848c0d31 | |||
| 967d2f56ad | |||
| 45f4175929 | |||
| e040c9a234 | |||
| b0d06a1d8c |
+30
-18
@@ -1,9 +1,16 @@
|
||||
# netfelix-audio-fix — Project Memory
|
||||
|
||||
## What it does
|
||||
Bun + Hono JSON REST API + React 19 SPA to scan a Jellyfin library, compute which audio/subtitle
|
||||
Bun + Hono JSON REST API + React 19 SPA to scan a Jellyfin library, compute which audio
|
||||
tracks to remove from each file, let you review and edit those decisions, then
|
||||
execute FFmpeg (copy mode) to strip/reorder streams. Remote nodes via SSH.
|
||||
execute FFmpeg (copy mode) to strip/reorder audio streams and extract every
|
||||
subtitle stream to a sidecar file next to the video. Remote nodes via SSH.
|
||||
|
||||
**Scope note (2026-04-19):** subtitle *management* was ripped out and lives in
|
||||
the sibling project `~/Developer/netfelix-subtitles-manager/` — this project
|
||||
still extracts subtitles into sidecar files during ffmpeg, but no longer
|
||||
tracks/renames/deletes them in a UI. `subtitle_files` table removed;
|
||||
`review_plans.subs_extracted` flag kept for the verify pass.
|
||||
|
||||
## Key technical decisions
|
||||
- **Runtime**: Bun + Hono JSON REST API backend; React 19 SPA frontend via Vite
|
||||
@@ -24,9 +31,9 @@ server/ ← Backend (Bun + Hono, JSON API at /api/*)
|
||||
db/index.ts ← getDb(), getConfig(), setConfig(), getEnvLockedKeys()
|
||||
db/schema.ts ← SCHEMA DDL string + DEFAULT_CONFIG
|
||||
services/
|
||||
jellyfin.ts / radarr.ts / sonarr.ts / analyzer.ts / ffmpeg.ts / ssh.ts
|
||||
jellyfin.ts / radarr.ts / sonarr.ts / analyzer.ts / ffmpeg.ts / ssh.ts / language-resolver.ts
|
||||
api/
|
||||
dashboard.ts / scan.ts / review.ts / execute.ts / nodes.ts / setup.ts / subtitles.ts
|
||||
dashboard.ts / scan.ts / review.ts / execute.ts / nodes.ts / setup.ts
|
||||
src/ ← Frontend (React SPA, built with Vite)
|
||||
main.tsx ← entry, RouterProvider
|
||||
index.css ← Tailwind v4 @import
|
||||
@@ -34,15 +41,13 @@ src/ ← Frontend (React SPA, built with Vite)
|
||||
routes/
|
||||
__root.tsx ← nav layout with Link components
|
||||
index.tsx / scan.tsx / execute.tsx / nodes.tsx / setup.tsx
|
||||
review.tsx ← layout route with Audio/Subtitles tab bar + Outlet
|
||||
review/index.tsx (redirect → /review/audio)
|
||||
review/audio/index.tsx ($filter) / review/audio/$id.tsx
|
||||
review/subtitles/index.tsx ($filter) / review/subtitles/$id.tsx
|
||||
review/index.tsx (redirect → /pipeline)
|
||||
review/audio/$id.tsx
|
||||
features/
|
||||
dashboard/DashboardPage.tsx
|
||||
scan/ScanPage.tsx (SSE for live progress)
|
||||
review/AudioListPage.tsx / AudioDetailPage.tsx
|
||||
subtitles/SubtitleListPage.tsx / SubtitleDetailPage.tsx
|
||||
review/AudioDetailPage.tsx
|
||||
pipeline/PipelinePage.tsx (+ column components)
|
||||
execute/ExecutePage.tsx (SSE for job updates)
|
||||
nodes/NodesPage.tsx
|
||||
setup/SetupPage.tsx
|
||||
@@ -57,16 +62,19 @@ biome.json / vite.config.ts / tsconfig.json / tsconfig.server.json / index.html
|
||||
- Video/Data streams: always keep
|
||||
- Audio: keep original_language + configured `audio_languages` (if OG unknown → keep all, flag needs_review)
|
||||
- Audio order: OG first, then additional languages in `audio_languages` config order
|
||||
- Subtitles: ALL removed from container, extracted to sidecar files on disk
|
||||
- `subtitle_files` table tracks extracted sidecar files (file manager UI)
|
||||
- `review_plans.subs_extracted` flag tracks extraction status
|
||||
- Subtitles: ALL removed from container, extracted to sidecar files on disk (extraction only — managing them lives in netfelix-subtitles-manager)
|
||||
- `review_plans.subs_extracted` flag tracks extraction status (used by verify.ts)
|
||||
- `is_noop` only considers audio changes (subtitle extraction is implicit)
|
||||
|
||||
## Scan flow
|
||||
1. Jellyfin paginated API → upsert media_items + media_streams
|
||||
2. Cross-check with Radarr (movies) / Sonarr (episodes) for language
|
||||
3. analyzeItem() → upsert review_plans + stream_decisions
|
||||
4. SSE events stream progress to browser (React EventSource in ScanPage)
|
||||
## Pipeline flow (scan → process → execute)
|
||||
1. **Scan** (discovery only): Jellyfin paginated API → upsert media_items + media_streams + stub review_plans. No Sonarr/Radarr lookups. Items land in Inbox.
|
||||
2. **Process Inbox** (classification): loads Sonarr/Radarr libraries, resolves original language via `resolveLanguage()`, runs `reanalyze()`, distributes: auto → Queue, heuristic/manual → Review, noop → skip.
|
||||
3. **Execute**: FFmpeg jobs run from the Queue.
|
||||
4. If `auto_processing` config is on, processInbox runs automatically after scan/webhook/rescan.
|
||||
5. Per-item rescan (`POST /:id/rescan`): resets to inbox, refreshes Jellyfin metadata.
|
||||
6. Series/season rescan (`POST /rescan-series`): resets all episodes, discovers new ones from Jellyfin.
|
||||
|
||||
Key files: `server/services/rescan.ts` (scan), `server/services/language-resolver.ts` (Sonarr/Radarr resolution), `server/api/review.ts` (`processInbox`, rescan endpoints).
|
||||
|
||||
## Running locally
|
||||
```fish
|
||||
@@ -76,9 +84,13 @@ mise exec bun -- bun start # production: Hono serves dist/ + API on :3000
|
||||
```
|
||||
|
||||
## Forward-looking rules
|
||||
- [Always bump version before pushing](feedback_bump_and_push.md) — every push needs a unique CalVer version; CI tags Docker images with it
|
||||
- [Schema changes need migrations going forward](feedback_schema_migrations.md) — resurrect the try/catch ALTER TABLE pattern in `server/db/index.ts` whenever touching table columns
|
||||
- [Never use locale-aware time/number widgets](feedback_iso8601_no_locale.md) — use `TimeInput` + `formatThousands`, never `<input type="time">` or `toLocaleString()`
|
||||
|
||||
## Open investigations
|
||||
- [Sonarr OG-language miss — Arrow case](project_sonarr_og_miss.md) — **RESOLVED (2026-04-20)**: root cause was hypothesis 4 (episode-level TVDB ID instead of series). Fixed by `resolveSeriesTvdb` which fetches the series item from Jellyfin. Then the whole Sonarr/Radarr resolution was moved from scan to `processInbox` as part of the pipeline refactor.
|
||||
|
||||
## Workflow rules
|
||||
- **Always bump version** in `package.json` before committing/pushing. CalVer with dot-suffix per global AGENTS.md (`YYYY.MM.DD.N`). `.gitea/workflows/build.yml` tags a Docker image with this version, so the `+N` form breaks CI with `invalid reference format`.
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: Always bump version before pushing
|
||||
description: Every push to gitea must include a version bump in package.json — CI tags Docker images with this version, so unchanged versions mean the server never gets the new code.
|
||||
type: feedback
|
||||
originSessionId: e0dcc686-9d57-401e-9c2b-409cad1db104
|
||||
---
|
||||
Always bump the CalVer version in `package.json` before pushing to gitea.
|
||||
|
||||
**Why:** The CI workflow tags the Docker image with the version from package.json. If the version doesn't change, the server (Unraid) won't see a new image tag and won't pull the update. The user has been burned multiple times by deploying what they think is the latest code but actually running stale builds.
|
||||
|
||||
**How to apply:** Before every `git push gitea main`, check if `package.json` version was bumped since the last push. If not, bump it (CalVer `YYYY.MM.DD.N` with dot-suffix). This applies even for small changes — every push must have a unique version.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: Sonarr OG-language miss (Arrow case) — next-session prep
|
||||
description: Bug investigation handoff — series episodes land as auto_class=manual because sonarrLang() returns null despite Sonarr having originalLanguage populated. Start here when picking this up again.
|
||||
type: project
|
||||
originSessionId: a0435ebc-13cf-4496-8e59-559cebf618ee
|
||||
---
|
||||
**Problem:** Series episodes (e.g. Arrow) are classified `auto_class="manual"`
|
||||
in the Review column even when the decision is unambiguous — drop Italian
|
||||
(default in file), keep English, make English default. User expectation: these
|
||||
should go straight to the Queue.
|
||||
|
||||
**Why:** the analyzer gates `auto` behind `authoritativeOg` = `orig_lang_source`
|
||||
in `{radarr, sonarr, manual}` AND `needs_review=0`. In the failing case,
|
||||
`orig_lang_source="jellyfin"` (audio-track guess from the default track, which
|
||||
is Italian on Arrow) and `needs_review=1`. Classification correctly refuses to
|
||||
trust the guess.
|
||||
|
||||
**User's claim** (to verify, but take at face value for scope): Sonarr has
|
||||
`originalLanguage` populated for basically everything, including this series.
|
||||
So `sonarrLang()` *should* be returning "eng" — it isn't. That's the bug.
|
||||
|
||||
**Why:** to confirm — investigate why `getOriginalLanguage` in
|
||||
`server/services/sonarr.ts` returns null even though Sonarr has the data.
|
||||
**How to apply:** don't re-litigate the classification/UX options — the user
|
||||
already chose "fix at the source." Skip straight to debugging the Sonarr path.
|
||||
|
||||
## Where to look
|
||||
|
||||
- `server/services/sonarr.ts` — `getOriginalLanguage` (library lookup + HTTP
|
||||
fallback to `/api/v3/series/lookup?term=tvdb:X`) and `loadLibrary` (populates
|
||||
`byTvdbId`). `nameToIso` translates the language-name string to ISO 639-2.
|
||||
- `server/services/rescan.ts:123-142` — the Episode branch that calls
|
||||
`sonarrLang`. Needs `tvdbId` on the item; for episodes we prefer
|
||||
`SeriesProviderIds.Tvdb` → fall back to `ProviderIds.Tvdb`.
|
||||
|
||||
## Hypotheses ranked by likelihood
|
||||
|
||||
1. **tvdbId mismatch.** Library is keyed by string-coerced tvdbId; Sonarr may
|
||||
return it as a number. `byTvdbId.get(tvdbId)` could miss. Easy to check:
|
||||
log both the queried key and the first few library keys.
|
||||
2. **`nameToIso` rejects Sonarr's language name.** If Sonarr ever returns
|
||||
something unexpected like `"English (US)"` or an ISO3 code we don't map,
|
||||
`nameToIso` warns and returns null. Grep logs for "Sonarr language name not
|
||||
recognised".
|
||||
3. **Library not loaded.** `cfg.sonarrLibrary` could be null on the rescan
|
||||
path (webhook-driven or post-execute rescan) even though sonarr is
|
||||
configured. Confirm `loadLibrary` ran for this scan.
|
||||
4. **Episode missing `tvdbId`** — `seriesProviderIds.Tvdb` isn't present on
|
||||
all Jellyfin episode payloads. Falls to `providerIds.Tvdb` which is the
|
||||
episode's tvdb id, not the series's — and Sonarr's library is keyed by
|
||||
series tvdb. This would silently miss.
|
||||
|
||||
## Verification steps
|
||||
|
||||
1. Hit the running instance's Sonarr directly:
|
||||
`curl -H 'X-Api-Key: <key>' '<sonarr-url>/api/v3/series?tvdbId=<ARROW_TVDB>'`
|
||||
→ confirm `originalLanguage` is populated.
|
||||
2. Query the app's DB for one Arrow episode:
|
||||
`sqlite3 data/netfelix.db "SELECT jellyfin_id, tvdb_id, original_language, orig_lang_source, needs_review FROM media_items WHERE series_name='Arrow' LIMIT 1"`.
|
||||
- If `tvdb_id IS NULL` or looks like an episode-level tvdb: hypothesis 4.
|
||||
- If `tvdb_id` is the series id but `orig_lang_source='jellyfin'`: library/lookup
|
||||
path didn't match.
|
||||
3. Add one-shot debug logs in `getOriginalLanguage` (behind a dev flag or just
|
||||
temporarily) to dump the tvdbId it received, whether the library hit,
|
||||
whether the HTTP lookup fired, and what `nameToIso` got.
|
||||
|
||||
## Don't forget
|
||||
|
||||
- The user rejected options 2 and 3 from the earlier design discussion (the
|
||||
series-chip + auto_heuristic fallback). Sonarr is the source of truth; make
|
||||
it work. If after investigation Sonarr really doesn't have the data for some
|
||||
edge case, revisit.
|
||||
- This session also added per-stream language override
|
||||
(`stream_decisions.custom_language` + PATCH endpoint + detail UI), delete +
|
||||
delete-and-refetch buttons, collapsed the Library page into PipelineHeader,
|
||||
and fixed optimistic auto-process checkboxes. Those are shipped and live at
|
||||
HEAD `c1baf3e` (or later on main).
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: git.felixfoertsch.de
|
||||
IMAGE: felixfoertsch/netfelix-audio-fix
|
||||
|
||||
+5
-2
@@ -9,8 +9,11 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
# Runtime: start from Debian to keep the ffmpeg layer stable even when the bun
|
||||
# tag resolves to a new digest. Bun is a single static binary — just copy it.
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /usr/local/bin/bun /usr/local/bin/bun
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
# Review column lazy-load + season grouping — Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use superpowers:subagent-driven-development. Checkbox (`- [ ]`) syntax tracks progress.
|
||||
|
||||
**Goal:** Replace the 500-item review cap with group-paginated infinite scroll; nest season sub-groups inside series when they have pending work across >1 season; wire the existing `/season/:key/:season/approve-all` endpoint into the UI.
|
||||
|
||||
**Architecture:** Move the grouping logic from the client to the server so groups are always returned complete. New `GET /api/review/groups?offset=N&limit=25` endpoint. Client's ReviewColumn becomes a stateful list that extends itself via `IntersectionObserver` on a sentinel.
|
||||
|
||||
**Tech Stack:** Bun + Hono (server), React 19 + TanStack Router (client), bun:sqlite.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Server — build grouped data structure + new endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/api/review.ts`
|
||||
|
||||
- [ ] **Step 1: Add shared types + builder**
|
||||
|
||||
At the top of `server/api/review.ts` (near the other type definitions), add exported types:
|
||||
|
||||
```ts
|
||||
export type ReviewGroup =
|
||||
| { kind: "movie"; item: PipelineReviewItem }
|
||||
| {
|
||||
kind: "series";
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
seriesJellyfinId: string | null;
|
||||
episodeCount: number;
|
||||
minConfidence: "high" | "low";
|
||||
originalLanguage: string | null;
|
||||
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||
};
|
||||
|
||||
export interface ReviewGroupsResponse {
|
||||
groups: ReviewGroup[];
|
||||
totalGroups: number;
|
||||
totalItems: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Add a helper after the existing `enrichWithStreamsAndReasons` helper:
|
||||
|
||||
```ts
|
||||
function buildReviewGroups(db: ReturnType<typeof getDb>): {
|
||||
groups: ReviewGroup[];
|
||||
totalItems: number;
|
||||
} {
|
||||
// Fetch ALL pending non-noop items. Grouping + pagination happen in memory.
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
|
||||
mi.jellyfin_id,
|
||||
mi.season_number, mi.episode_number, mi.type, mi.container,
|
||||
mi.original_language, mi.orig_lang_source, mi.file_path
|
||||
FROM review_plans rp
|
||||
JOIN media_items mi ON mi.id = rp.item_id
|
||||
WHERE rp.status = 'pending' AND rp.is_noop = 0
|
||||
ORDER BY
|
||||
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
|
||||
COALESCE(mi.series_name, mi.name),
|
||||
mi.season_number, mi.episode_number
|
||||
`)
|
||||
.all() as PipelineReviewItem[];
|
||||
|
||||
const movies: PipelineReviewItem[] = [];
|
||||
const seriesMap = new Map<
|
||||
string,
|
||||
{
|
||||
seriesName: string;
|
||||
seriesJellyfinId: string | null;
|
||||
seasons: Map<number | null, PipelineReviewItem[]>;
|
||||
originalLanguage: string | null;
|
||||
minConfidence: "high" | "low";
|
||||
firstName: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.type === "Movie") {
|
||||
movies.push(row);
|
||||
continue;
|
||||
}
|
||||
const key = row.series_jellyfin_id ?? row.series_name ?? String(row.item_id);
|
||||
let entry = seriesMap.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
seriesName: row.series_name ?? "",
|
||||
seriesJellyfinId: row.series_jellyfin_id,
|
||||
seasons: new Map(),
|
||||
originalLanguage: row.original_language,
|
||||
minConfidence: row.confidence,
|
||||
firstName: row.series_name ?? "",
|
||||
};
|
||||
seriesMap.set(key, entry);
|
||||
}
|
||||
const season = row.season_number;
|
||||
let bucket = entry.seasons.get(season);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
entry.seasons.set(season, bucket);
|
||||
}
|
||||
bucket.push(row);
|
||||
if (row.confidence === "high" && entry.minConfidence === "low") {
|
||||
// Keep minConfidence as the "best" confidence across episodes — if any
|
||||
// episode is high, that's the group's dominant confidence for sort.
|
||||
// Actually we want the LOWEST (low wins) so user sees low-confidence
|
||||
// groups sorted after high-confidence ones. Revisit: keep low if present.
|
||||
}
|
||||
if (row.confidence === "low") entry.minConfidence = "low";
|
||||
}
|
||||
|
||||
// Sort season keys within each series (nulls last), episodes by episode_number.
|
||||
const seriesGroups: ReviewGroup[] = [];
|
||||
for (const [seriesKey, entry] of seriesMap) {
|
||||
const seasonKeys = [...entry.seasons.keys()].sort((a, b) => {
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return a - b;
|
||||
});
|
||||
const seasons = seasonKeys.map((season) => ({
|
||||
season,
|
||||
episodes: (entry.seasons.get(season) ?? []).sort(
|
||||
(a, b) => (a.episode_number ?? 0) - (b.episode_number ?? 0),
|
||||
),
|
||||
}));
|
||||
const episodeCount = seasons.reduce((sum, s) => sum + s.episodes.length, 0);
|
||||
seriesGroups.push({
|
||||
kind: "series",
|
||||
seriesKey,
|
||||
seriesName: entry.seriesName,
|
||||
seriesJellyfinId: entry.seriesJellyfinId,
|
||||
episodeCount,
|
||||
minConfidence: entry.minConfidence,
|
||||
originalLanguage: entry.originalLanguage,
|
||||
seasons,
|
||||
});
|
||||
}
|
||||
|
||||
// Interleave movies + series, sort by (minConfidence, name).
|
||||
const movieGroups: ReviewGroup[] = movies.map((m) => ({ kind: "movie" as const, item: m }));
|
||||
const allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => {
|
||||
const confA = a.kind === "movie" ? a.item.confidence : a.minConfidence;
|
||||
const confB = b.kind === "movie" ? b.item.confidence : b.minConfidence;
|
||||
const rankA = confA === "high" ? 0 : 1;
|
||||
const rankB = confB === "high" ? 0 : 1;
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
const nameA = a.kind === "movie" ? a.item.name : a.seriesName;
|
||||
const nameB = b.kind === "movie" ? b.item.name : b.seriesName;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
const totalItems = movieGroups.length + seriesGroups.reduce((sum, g) => sum + (g as { episodeCount: number }).episodeCount, 0);
|
||||
return { groups: allGroups, totalItems };
|
||||
}
|
||||
```
|
||||
|
||||
(Delete the stray comment block inside the loop about "keep minConfidence as the best" — the actual logic below it is correct. I left a TODO-style note while drafting; clean it up when editing.)
|
||||
|
||||
- [ ] **Step 2: Add the `/groups` endpoint**
|
||||
|
||||
Add before `app.get("/pipeline", …)`:
|
||||
|
||||
```ts
|
||||
app.get("/groups", (c) => {
|
||||
const db = getDb();
|
||||
const offset = Math.max(0, Number.parseInt(c.req.query("offset") ?? "0", 10) || 0);
|
||||
const limit = Math.max(1, Math.min(200, Number.parseInt(c.req.query("limit") ?? "25", 10) || 25));
|
||||
|
||||
const { groups, totalItems } = buildReviewGroups(db);
|
||||
const page = groups.slice(offset, offset + limit);
|
||||
|
||||
// Enrich each visible episode/movie with audio streams + transcode reasons
|
||||
// (same shape the existing UI expects — reuse the helper already in this file).
|
||||
const flatItemsForEnrichment: Array<{ id: number; plan_id?: number; item_id: number; transcode_reasons?: string[]; audio_streams?: PipelineAudioStream[] }> = [];
|
||||
for (const g of page) {
|
||||
if (g.kind === "movie") flatItemsForEnrichment.push(g.item as never);
|
||||
else for (const s of g.seasons) for (const ep of s.episodes) flatItemsForEnrichment.push(ep as never);
|
||||
}
|
||||
enrichWithStreamsAndReasons(flatItemsForEnrichment);
|
||||
|
||||
return c.json<ReviewGroupsResponse>({
|
||||
groups: page,
|
||||
totalGroups: groups.length,
|
||||
totalItems,
|
||||
hasMore: offset + limit < groups.length,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`PipelineAudioStream` already imported; if not, add to existing import block.
|
||||
|
||||
- [ ] **Step 3: Modify `/pipeline` to drop `review`/`reviewTotal`**
|
||||
|
||||
In the existing `app.get("/pipeline", …)` handler (around line 270):
|
||||
|
||||
- Delete the `review` SELECT (lines ~278–293) and the enrichment of `review` rows.
|
||||
- Delete the `reviewTotal` count query (lines ~294–296).
|
||||
- Add in its place: `const reviewItemsTotal = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;`
|
||||
- In the final `return c.json({...})` (line ~430), replace `review, reviewTotal` with `reviewItemsTotal`.
|
||||
|
||||
- [ ] **Step 4: Run tests + lint + tsc**
|
||||
|
||||
```
|
||||
mise exec bun -- bun test
|
||||
mise exec bun -- bun run lint
|
||||
mise exec bun -- bunx tsc --noEmit --project tsconfig.server.json
|
||||
```
|
||||
|
||||
All must pass. If tests that hit `/pipeline` fail because they expect `review[]`, update them in the same commit (they need to migrate anyway).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/api/review.ts
|
||||
git commit -m "review: add /groups endpoint with server-side grouping + pagination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Server — test `/groups` endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `server/api/__tests__/review-groups.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the test file**
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import reviewRoutes from "../review";
|
||||
import { setupTestDb, seedItem, seedPlan } from "./test-helpers"; // adjust to the project's test helpers; see existing webhook.test.ts for how tests wire up a DB
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/api/review", reviewRoutes);
|
||||
|
||||
describe("GET /api/review/groups", () => {
|
||||
test("returns complete series even when total items exceed limit", async () => {
|
||||
const db = setupTestDb();
|
||||
// Seed 1 series with 30 episodes, all pending non-noop
|
||||
for (let i = 1; i <= 30; i++) seedItem(db, { type: "Episode", seriesName: "Breaking Bad", seasonNumber: 1, episodeNumber: i });
|
||||
for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false });
|
||||
|
||||
const res = await app.request("/api/review/groups?offset=0&limit=25");
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.groups).toHaveLength(1);
|
||||
expect(body.groups[0].kind).toBe("series");
|
||||
expect(body.groups[0].episodeCount).toBe(30);
|
||||
expect(body.groups[0].seasons[0].episodes).toHaveLength(30);
|
||||
expect(body.totalItems).toBe(30);
|
||||
expect(body.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
test("paginates groups with hasMore=true", async () => {
|
||||
const db = setupTestDb();
|
||||
for (let i = 1; i <= 50; i++) seedItem(db, { type: "Movie", name: `Movie ${String(i).padStart(2, "0")}` });
|
||||
for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false });
|
||||
|
||||
const page1 = await (await app.request("/api/review/groups?offset=0&limit=25")).json();
|
||||
const page2 = await (await app.request("/api/review/groups?offset=25&limit=25")).json();
|
||||
|
||||
expect(page1.groups).toHaveLength(25);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
expect(page2.groups).toHaveLength(25);
|
||||
expect(page2.hasMore).toBe(false);
|
||||
const ids1 = page1.groups.map((g: { item: { item_id: number } }) => g.item.item_id);
|
||||
const ids2 = page2.groups.map((g: { item: { item_id: number } }) => g.item.item_id);
|
||||
expect(ids1.filter((id: number) => ids2.includes(id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("buckets episodes by season, nulls last", async () => {
|
||||
const db = setupTestDb();
|
||||
for (let ep = 1; ep <= 3; ep++) seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: 1, episodeNumber: ep });
|
||||
for (let ep = 1; ep <= 2; ep++) seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: 2, episodeNumber: ep });
|
||||
seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: null, episodeNumber: null });
|
||||
for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false });
|
||||
|
||||
const body = await (await app.request("/api/review/groups?offset=0&limit=25")).json();
|
||||
const lost = body.groups[0];
|
||||
expect(lost.kind).toBe("series");
|
||||
expect(lost.seasons.map((s: { season: number | null }) => s.season)).toEqual([1, 2, null]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Important: this test file needs the project's actual test-helpers pattern. Before writing, look at `server/services/__tests__/webhook.test.ts` (the 60-line one that's still in the repo after the verified-flag block was removed) and **copy its setup style** — including how it creates a test DB, how it seeds media_items and review_plans, and how it invokes the Hono app. Replace the placeholder `setupTestDb`, `seedItem`, `seedPlan` calls with whatever the real helpers are.
|
||||
|
||||
- [ ] **Step 2: Run the tests**
|
||||
|
||||
```
|
||||
mise exec bun -- bun test server/api/__tests__/review-groups.test.ts
|
||||
```
|
||||
|
||||
Expected: 3 passes.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/api/__tests__/review-groups.test.ts
|
||||
git commit -m "test: /groups endpoint — series completeness, pagination, season buckets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Client types + PipelinePage
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/shared/lib/types.ts`
|
||||
- Modify: `src/features/pipeline/PipelinePage.tsx`
|
||||
|
||||
- [ ] **Step 1: Update shared types**
|
||||
|
||||
In `src/shared/lib/types.ts`, replace the `PipelineData` interface's `review` and `reviewTotal` fields with `reviewItemsTotal: number`. Add types for the new groups response:
|
||||
|
||||
```ts
|
||||
export type ReviewGroup =
|
||||
| { kind: "movie"; item: PipelineReviewItem }
|
||||
| {
|
||||
kind: "series";
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
seriesJellyfinId: string | null;
|
||||
episodeCount: number;
|
||||
minConfidence: "high" | "low";
|
||||
originalLanguage: string | null;
|
||||
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||
};
|
||||
|
||||
export interface ReviewGroupsResponse {
|
||||
groups: ReviewGroup[];
|
||||
totalGroups: number;
|
||||
totalItems: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
The `PipelineData` interface becomes:
|
||||
```ts
|
||||
export interface PipelineData {
|
||||
reviewItemsTotal: number;
|
||||
queued: PipelineJobItem[];
|
||||
processing: PipelineJobItem[];
|
||||
done: PipelineJobItem[];
|
||||
doneCount: number;
|
||||
jellyfinUrl: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update PipelinePage**
|
||||
|
||||
Change `PipelinePage.tsx`:
|
||||
|
||||
- Add state for the initial groups page: `const [initialGroups, setInitialGroups] = useState<ReviewGroupsResponse | null>(null);`
|
||||
- In `load()`, fetch both in parallel:
|
||||
```ts
|
||||
const [pipelineRes, groupsRes] = await Promise.all([
|
||||
api.get<PipelineData>("/api/review/pipeline"),
|
||||
api.get<ReviewGroupsResponse>("/api/review/groups?offset=0&limit=25"),
|
||||
]);
|
||||
setData(pipelineRes);
|
||||
setInitialGroups(groupsRes);
|
||||
```
|
||||
- Wait for both before rendering (loading gate: `if (loading || !data || !initialGroups) return <Loading />`).
|
||||
- Pass to ReviewColumn: `<ReviewColumn initialResponse={initialGroups} totalItems={data.reviewItemsTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />` — drop `items` and `total` props.
|
||||
|
||||
- [ ] **Step 3: Tsc + lint**
|
||||
|
||||
```
|
||||
mise exec bun -- bunx tsc --noEmit
|
||||
mise exec bun -- bun run lint
|
||||
```
|
||||
|
||||
Expected: errors in `ReviewColumn.tsx` because its props type hasn't been updated yet — that's fine, Task 4 fixes it. For this step, only verify that types.ts and PipelinePage.tsx themselves compile internally. If the build breaks because of ReviewColumn, commit these two files anyway and proceed to Task 4 immediately.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/lib/types.ts src/features/pipeline/PipelinePage.tsx
|
||||
git commit -m "pipeline: fetch review groups endpoint in parallel with pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Client — ReviewColumn with infinite scroll
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/pipeline/ReviewColumn.tsx`
|
||||
|
||||
- [ ] **Step 1: Rewrite ReviewColumn**
|
||||
|
||||
Replace the file contents with:
|
||||
|
||||
```tsx
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types";
|
||||
import { ColumnShell } from "./ColumnShell";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
import { SeriesCard } from "./SeriesCard";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface ReviewColumnProps {
|
||||
initialResponse: ReviewGroupsResponse;
|
||||
totalItems: number;
|
||||
jellyfinUrl: string;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function ReviewColumn({ initialResponse, totalItems, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||
const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
|
||||
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Reset when parent passes a new initial page (onMutate refetch)
|
||||
useEffect(() => {
|
||||
setGroups(initialResponse.groups);
|
||||
setHasMore(initialResponse.hasMore);
|
||||
}, [initialResponse]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const res = await api.get<ReviewGroupsResponse>(`/api/review/groups?offset=${groups.length}&limit=${PAGE_SIZE}`);
|
||||
setGroups((prev) => [...prev, ...res.groups]);
|
||||
setHasMore(res.hasMore);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [groups.length, hasMore, loadingMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) loadMore();
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore]);
|
||||
|
||||
const skipAll = async () => {
|
||||
if (!confirm(`Skip all ${totalItems} pending items? They won't be processed unless you unskip them.`)) return;
|
||||
await api.post("/api/review/skip-all");
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const autoApprove = async () => {
|
||||
const res = await api.post<{ ok: boolean; count: number }>("/api/review/auto-approve");
|
||||
onMutate();
|
||||
if (res.count === 0) alert("No high-confidence items to auto-approve.");
|
||||
};
|
||||
|
||||
const approveItem = async (itemId: number) => {
|
||||
await api.post(`/api/review/${itemId}/approve`);
|
||||
onMutate();
|
||||
};
|
||||
const skipItem = async (itemId: number) => {
|
||||
await api.post(`/api/review/${itemId}/skip`);
|
||||
onMutate();
|
||||
};
|
||||
const approveBatch = async (itemIds: number[]) => {
|
||||
if (itemIds.length === 0) return;
|
||||
await api.post<{ ok: boolean; count: number }>("/api/review/approve-batch", { itemIds });
|
||||
onMutate();
|
||||
};
|
||||
|
||||
// Compute ids per visible group for "Approve above"
|
||||
const idsByGroup: number[][] = groups.map((g) =>
|
||||
g.kind === "movie" ? [g.item.item_id] : g.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id)),
|
||||
);
|
||||
const priorIds = (index: number): number[] => idsByGroup.slice(0, index).flat();
|
||||
|
||||
const actions =
|
||||
totalItems > 0
|
||||
? [
|
||||
{ label: "Auto Review", onClick: autoApprove, primary: true },
|
||||
{ label: "Skip all", onClick: skipAll },
|
||||
]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ColumnShell title="Review" count={totalItems} actions={actions}>
|
||||
<div className="space-y-2">
|
||||
{groups.map((group, index) => {
|
||||
const prior = index > 0 ? priorIds(index) : null;
|
||||
const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
|
||||
if (group.kind === "movie") {
|
||||
return (
|
||||
<PipelineCard
|
||||
key={group.item.id}
|
||||
item={group.item}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
onToggleStream={async (streamId, action) => {
|
||||
await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action });
|
||||
onMutate();
|
||||
}}
|
||||
onApprove={() => approveItem(group.item.item_id)}
|
||||
onSkip={() => skipItem(group.item.item_id)}
|
||||
onApproveUpToHere={onApproveUpToHere}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeriesCard
|
||||
key={group.seriesKey}
|
||||
seriesKey={group.seriesKey}
|
||||
seriesName={group.seriesName}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
seriesJellyfinId={group.seriesJellyfinId}
|
||||
seasons={group.seasons}
|
||||
episodeCount={group.episodeCount}
|
||||
originalLanguage={group.originalLanguage}
|
||||
onMutate={onMutate}
|
||||
onApproveUpToHere={onApproveUpToHere}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{groups.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-4 text-center text-xs text-gray-400">
|
||||
{loadingMore ? "Loading more…" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ColumnShell>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tsc + lint**
|
||||
|
||||
```
|
||||
mise exec bun -- bunx tsc --noEmit
|
||||
mise exec bun -- bun run lint
|
||||
```
|
||||
|
||||
Expected: the call site in ReviewColumn passes `seasons`, `episodeCount`, `originalLanguage` props to SeriesCard — this will fail until Task 5 updates SeriesCard. Same handling as Task 3 step 3: commit and proceed.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/pipeline/ReviewColumn.tsx
|
||||
git commit -m "review column: infinite scroll with IntersectionObserver sentinel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Client — SeriesCard season nesting
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/pipeline/SeriesCard.tsx`
|
||||
|
||||
- [ ] **Step 1: Rewrite SeriesCard**
|
||||
|
||||
Replace the file contents with:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||
import type { PipelineReviewItem } from "~/shared/lib/types";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
|
||||
interface SeriesCardProps {
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
jellyfinUrl: string;
|
||||
seriesJellyfinId: string | null;
|
||||
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||
episodeCount: number;
|
||||
originalLanguage: string | null;
|
||||
onMutate: () => void;
|
||||
onApproveUpToHere?: () => void;
|
||||
}
|
||||
|
||||
export function SeriesCard({
|
||||
seriesKey,
|
||||
seriesName,
|
||||
jellyfinUrl,
|
||||
seriesJellyfinId,
|
||||
seasons,
|
||||
episodeCount,
|
||||
originalLanguage,
|
||||
onMutate,
|
||||
onApproveUpToHere,
|
||||
}: SeriesCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const flatEpisodes = seasons.flatMap((s) => s.episodes);
|
||||
const highCount = flatEpisodes.filter((e) => e.confidence === "high").length;
|
||||
const lowCount = flatEpisodes.filter((e) => e.confidence === "low").length;
|
||||
const multipleSeasons = seasons.length > 1;
|
||||
|
||||
const setSeriesLanguage = async (lang: string) => {
|
||||
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const approveSeries = async () => {
|
||||
await api.post(`/api/review/series/${encodeURIComponent(seriesKey)}/approve-all`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const approveSeason = async (season: number | null) => {
|
||||
if (season == null) return;
|
||||
await api.post(`/api/review/season/${encodeURIComponent(seriesKey)}/${season}/approve-all`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const jellyfinLink =
|
||||
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
|
||||
|
||||
return (
|
||||
<div className="group/series rounded-lg border bg-white overflow-hidden">
|
||||
{/* Title row */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 shrink-0">{expanded ? "▼" : "▶"}</span>
|
||||
{jellyfinLink ? (
|
||||
<a
|
||||
href={jellyfinLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium truncate hover:text-blue-600 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{seriesName}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm font-medium truncate">{seriesName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-2 px-3 pb-3 pt-1">
|
||||
<span className="text-xs text-gray-500 shrink-0">{episodeCount} eps</span>
|
||||
{multipleSeasons && <span className="text-xs text-gray-500 shrink-0">· {seasons.length} seasons</span>}
|
||||
{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
|
||||
{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
|
||||
<div className="flex-1" />
|
||||
<select
|
||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
|
||||
value={originalLanguage ?? ""}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSeriesLanguage(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">unknown</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{onApproveUpToHere && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApproveUpToHere();
|
||||
}}
|
||||
title="Approve every card listed above this one"
|
||||
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer whitespace-nowrap shrink-0 opacity-0 group-hover/series:opacity-100 transition-opacity"
|
||||
>
|
||||
↑ Approve above
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
approveSeries();
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
|
||||
>
|
||||
Approve series
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t">
|
||||
{multipleSeasons
|
||||
? seasons.map((s) => (
|
||||
<SeasonGroup
|
||||
key={s.season ?? "unknown"}
|
||||
season={s.season}
|
||||
episodes={s.episodes}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
onApproveSeason={() => approveSeason(s.season)}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
))
|
||||
: flatEpisodes.map((ep) => (
|
||||
<EpisodeRow key={ep.id} ep={ep} jellyfinUrl={jellyfinUrl} onMutate={onMutate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonGroup({
|
||||
season,
|
||||
episodes,
|
||||
jellyfinUrl,
|
||||
onApproveSeason,
|
||||
onMutate,
|
||||
}: {
|
||||
season: number | null;
|
||||
episodes: PipelineReviewItem[];
|
||||
jellyfinUrl: string;
|
||||
onApproveSeason: () => void;
|
||||
onMutate: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const highCount = episodes.filter((e) => e.confidence === "high").length;
|
||||
const lowCount = episodes.filter((e) => e.confidence === "low").length;
|
||||
const label = season == null ? "No season" : `Season ${String(season).padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<div className="border-t first:border-t-0">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 shrink-0">{open ? "▼" : "▶"}</span>
|
||||
<span className="text-xs font-medium shrink-0">{label}</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">· {episodes.length} eps</span>
|
||||
{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
|
||||
{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
|
||||
<div className="flex-1" />
|
||||
{season != null && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApproveSeason();
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer whitespace-nowrap shrink-0"
|
||||
>
|
||||
Approve season
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 space-y-2 pt-2">
|
||||
{episodes.map((ep) => (
|
||||
<EpisodeRow key={ep.id} ep={ep} jellyfinUrl={jellyfinUrl} onMutate={onMutate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EpisodeRow({ ep, jellyfinUrl, onMutate }: { ep: PipelineReviewItem; jellyfinUrl: string; onMutate: () => void }) {
|
||||
return (
|
||||
<div className="px-3 py-1">
|
||||
<PipelineCard
|
||||
item={ep}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
onToggleStream={async (streamId, action) => {
|
||||
await api.patch(`/api/review/${ep.item_id}/stream/${streamId}`, { action });
|
||||
onMutate();
|
||||
}}
|
||||
onApprove={async () => {
|
||||
await api.post(`/api/review/${ep.item_id}/approve`);
|
||||
onMutate();
|
||||
}}
|
||||
onSkip={async () => {
|
||||
await api.post(`/api/review/${ep.item_id}/skip`);
|
||||
onMutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
(The `EpisodeRow` wrapper keeps the padding consistent whether episodes render directly under the series or under a season group.)
|
||||
|
||||
- [ ] **Step 2: Lint + tsc + test + build**
|
||||
|
||||
```
|
||||
mise exec bun -- bun run lint
|
||||
mise exec bun -- bunx tsc --noEmit
|
||||
mise exec bun -- bun test
|
||||
mise exec bun -- bun run build
|
||||
```
|
||||
|
||||
All must pass now that the whole pipeline (server → types → PipelinePage → ReviewColumn → SeriesCard) is consistent.
|
||||
|
||||
- [ ] **Step 3: Manual smoke test**
|
||||
|
||||
```
|
||||
mise exec bun -- bun run dev
|
||||
```
|
||||
|
||||
Navigate to the Pipeline page:
|
||||
- Confirm no "Showing first 500 of N" banner.
|
||||
- Scroll the Review column to the bottom; new groups auto-load.
|
||||
- Find a series with pending work in >1 season; expand it; confirm nested seasons with working `Approve season` button.
|
||||
- Find a series with pending work in a single season; expand it; confirm flat episode list (no season nesting).
|
||||
- Click `Approve series` on a series with many pending episodes; confirm the whole series vanishes from the column.
|
||||
|
||||
Kill the dev server.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/pipeline/SeriesCard.tsx
|
||||
git commit -m "series card: nest seasons when >1 pending, add Approve season button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Version bump + final push
|
||||
|
||||
- [ ] **Step 1: Bump CalVer**
|
||||
|
||||
In `package.json`, set version to today's next free dot-suffix (today is 2026-04-15; prior releases are `.1` and `.2`, so use `.3` unless already taken).
|
||||
|
||||
- [ ] **Step 2: Final checks**
|
||||
|
||||
```
|
||||
mise exec bun -- bun run lint
|
||||
mise exec bun -- bunx tsc --noEmit
|
||||
mise exec bun -- bunx tsc --noEmit --project tsconfig.server.json
|
||||
mise exec bun -- bun test
|
||||
mise exec bun -- bun run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit + push**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "v2026.04.15.3 — review column lazy-load + season grouping"
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Guided Gates (user-verified)
|
||||
|
||||
- **GG-1:** No "Showing first 500 of N" banner.
|
||||
- **GG-2:** A series with episodes previously split across the cap now shows the correct episode count.
|
||||
- **GG-3:** A series with >1 pending season expands into nested season groups, each with a working `Approve season` button.
|
||||
- **GG-4:** A series with 1 pending season expands flat (no extra nesting).
|
||||
- **GG-5:** Scrolling to the bottom of Review auto-loads the next page; no scroll = no extra fetch.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Scan Page Rework Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rework the Scan page to prioritize progress + fresh ingest visibility, and add a scalable filterable/lazy-loaded library table.
|
||||
|
||||
**Architecture:** Keep `/api/scan` lightweight for status/progress and compact recent ingest rows. Add `/api/scan/items` for paginated/filterable DB browsing. Update `ScanPage` to render: scan card header count, compact 5-row recent ingest table, then a filterable lazy-loaded library table.
|
||||
|
||||
**Tech Stack:** Bun + Hono, React 19 + TanStack Router, bun:test, Biome.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend scan payload + items endpoint (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/api/__tests__/scan.test.ts`
|
||||
- Modify: `server/db/schema.ts`
|
||||
- Modify: `server/db/index.ts`
|
||||
- Modify: `server/services/rescan.ts`
|
||||
- Modify: `server/api/scan.ts`
|
||||
|
||||
- [ ] Add failing tests for scan item query parsing/normalization and SQL filter behavior helpers.
|
||||
- [ ] Run targeted tests to verify failure.
|
||||
- [ ] Add `media_items.ingest_source` schema + migration, set value on upsert (`scan`/`webhook`).
|
||||
- [ ] Extend `GET /api/scan` recent item shape with timestamp + ingest source and clamp to 5 rows.
|
||||
- [ ] Add `GET /api/scan/items` with filters (`q,status,type,source`) + pagination (`offset,limit`), returning `{ rows,total,hasMore }`.
|
||||
- [ ] Run targeted and full backend tests.
|
||||
|
||||
### Task 2: Scan page UI rework + lazy table
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/scan/ScanPage.tsx`
|
||||
|
||||
- [ ] Refactor scan box header to show scanned count in top-right.
|
||||
- [ ] Replace large recent-items table with a compact 5-row recent ingest list directly under progress bar.
|
||||
- [ ] Add filter controls for library table (`q,status,type,source`) with default “All”.
|
||||
- [ ] Add lazy loading flow (initial fetch + load more) against `/api/scan/items`.
|
||||
- [ ] Render new table with useful file metadata columns and consistent truncation/tooltips.
|
||||
|
||||
### Task 3: Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: none
|
||||
|
||||
- [ ] Run `bun test`.
|
||||
- [ ] Run `bun run lint` and format if needed.
|
||||
- [ ] Confirm no regressions in scan start/stop/progress behavior.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
# Review column lazy-load + season grouping
|
||||
|
||||
Date: 2026-04-15
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the Review column's 500-item hard cap with server-side group-paginated lazy loading. Series are always returned complete (every pending non-noop episode, grouped by season), eliminating the "2 eps" mirage caused by groups getting split across the cap. When a series has pending work in more than one season, the UI nests seasons as collapsible sub-groups, each with its own "Approve season" button.
|
||||
|
||||
## Motivation
|
||||
|
||||
`server/api/review.ts:277` caps the pipeline's review list at 500 items. ReviewColumn groups client-side, so any series whose episodes spill beyond the cap shows a wrong episode count and partial episode list. The banner "Showing first 500 of N" is present but misleading — the *groups* don't survive the cut, not just the tail.
|
||||
|
||||
The existing "Approve all" button on a series card already calls `/series/:seriesKey/approve-all`, which operates on the DB directly and does approve every pending episode — so functionality works, only the display is wrong. Still, partial groups are confusing and the 500 cap forces users to approve in waves.
|
||||
|
||||
## Server changes
|
||||
|
||||
### New endpoint `GET /api/review/groups?offset=0&limit=25`
|
||||
|
||||
Response:
|
||||
```ts
|
||||
{
|
||||
groups: ReviewGroup[];
|
||||
totalGroups: number;
|
||||
totalItems: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
type ReviewGroup =
|
||||
| { kind: "movie"; item: PipelineReviewItem }
|
||||
| {
|
||||
kind: "series";
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
seriesJellyfinId: string | null;
|
||||
episodeCount: number;
|
||||
minConfidence: "high" | "low";
|
||||
originalLanguage: string | null;
|
||||
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||
};
|
||||
```
|
||||
|
||||
Ordering:
|
||||
- Groups ordered by (min confidence across group ASC — `high` < `low`), then (series_name or movie name ASC)
|
||||
- Within a series, seasons ordered by `season_number` ASC (`null` last)
|
||||
- Within a season, episodes ordered by `episode_number` ASC
|
||||
|
||||
Implementation outline:
|
||||
1. Query all pending non-noop plans joined to media_items (existing `review` query minus the LIMIT).
|
||||
2. Walk once in sort order, producing groups: a Movie becomes a one-shot `{ kind: "movie" }`; consecutive Episodes sharing `series_jellyfin_id` (or `series_name` fallback) accumulate into a `{ kind: "series" }` with `seasons` bucketed by `season_number`.
|
||||
3. Apply `.slice(offset, offset + limit)` over the full group list, enrich per-episode audio streams + transcode reasons for episodes that survive (reuse existing `enrichWithStreamsAndReasons`).
|
||||
4. `totalGroups` = full group count before slicing. `totalItems` = sum of episode counts + movie count (unchanged from today's `reviewTotal`). `hasMore` = `offset + limit < totalGroups`.
|
||||
|
||||
### `GET /api/review/pipeline` changes
|
||||
|
||||
Drop `review` and `reviewTotal` from the response. Add `reviewItemsTotal: number` so the column header shows a count before the groups endpoint resolves. Queue / Processing / Done / doneCount stay unchanged.
|
||||
|
||||
### Kept as-is
|
||||
|
||||
- `POST /api/review/series/:seriesKey/approve-all` (`review.ts:529`)
|
||||
- `POST /api/review/season/:seriesKey/:season/approve-all` (`review.ts:549`) — already implemented, just unused by the UI until now
|
||||
|
||||
## Client changes
|
||||
|
||||
### PipelinePage
|
||||
|
||||
Fetches `/api/review/pipeline` for queue columns (existing) and separately `/api/review/groups?offset=0&limit=25` for the Review column's initial page. `onMutate` refetches both. Pass `reviewGroups`, `reviewGroupsTotalItems`, `reviewHasMore` into `ReviewColumn`.
|
||||
|
||||
### ReviewColumn
|
||||
|
||||
Replace the hard-cap rendering with infinite scroll:
|
||||
- Render the current loaded groups.
|
||||
- Append a sentinel `<div>` at the bottom when `hasMore`. An `IntersectionObserver` attached to it triggers a fetch of the next page when it enters the scroll viewport.
|
||||
- Pagination state (`offset`, `groups`, `hasMore`, `loading`) lives locally in ReviewColumn — parent passes `initialGroups` on mount and whenever the filter changes (`onMutate` → parent refetches page 0).
|
||||
- Remove the "Showing first N of M" banner and the `truncated` logic.
|
||||
|
||||
### SeriesCard
|
||||
|
||||
When `seasons.length > 1`:
|
||||
- Render seasons as collapsible sub-groups inside the expanded series body.
|
||||
- Each season header: `S{NN} — {episodeCount} eps · {high} high / {low} low` + an `Approve season` button.
|
||||
|
||||
When `seasons.length === 1`:
|
||||
- Render the current flat episode list (no extra nesting).
|
||||
|
||||
Rename the existing header button `Approve all` → `Approve series`.
|
||||
|
||||
### "Approve above"
|
||||
|
||||
Keeps its current "approve every group currently visible above this card" semantic. With lazy loading, that means "everything the user has scrolled past". Compute item ids client-side across the loaded groups as today. No endpoint change.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. PipelinePage mounts → parallel fetch `/pipeline` + `/groups?offset=0&limit=25`.
|
||||
2. User scrolls; sentinel becomes visible → fetch `/groups?offset=25&limit=25`; appended to the list.
|
||||
3. User clicks `Approve series` on a card → `POST /series/:key/approve-all` → `onMutate` → parent refetches `/pipeline` + `/groups?offset=0&limit=25`. Series gone from list.
|
||||
4. User clicks `Approve season S02` on a nested season → `POST /season/:key/2/approve-all` → `onMutate` → same refetch.
|
||||
|
||||
## Testing
|
||||
|
||||
- Server unit test: `/groups` endpoint returns a series with all pending episodes even when the total item count exceeds `limit * offset_pages`.
|
||||
- Server unit test: offset/limit/hasMore correctness across the group boundary.
|
||||
- Server unit test: seasons array is populated, sorted, with `null` season_number ordered last.
|
||||
- Manual: scroll through the Review column on a library with >1000 pending items and confirm episode counts match `SELECT COUNT(*) ... WHERE pending AND is_noop=0` scoped per series.
|
||||
|
||||
## Guided Gates
|
||||
|
||||
- **GG-1:** No "Showing first 500 of N" banner ever appears.
|
||||
- **GG-2:** A series whose episodes previously split across the cap now shows the correct episode count immediately on first page load (if the series is in the first page) or after scroll (if not).
|
||||
- **GG-3:** A series with pending episodes in 2+ seasons expands into nested season sub-groups, each with an `Approve season` button that approves only that season.
|
||||
- **GG-4:** A series with pending episodes in exactly one season expands into the flat episode list as before.
|
||||
- **GG-5:** Scrolling to the bottom of the Review column auto-fetches the next page without a click; scrolling stops fetching when `hasMore` is false.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Inbox column & auto-processing — design
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Status:** Draft for implementation
|
||||
|
||||
## Summary
|
||||
|
||||
Split today's single `Review` column on the Pipeline page into two user-facing buckets
|
||||
(**Inbox** and **Review**) plus an automatic distribution step. The analyzer
|
||||
classifies each plan into `auto` / `auto_heuristic` / `manual`. A per-install
|
||||
`auto_processing` toggle controls whether the distribution runs automatically
|
||||
after every scan or requires a manual "Auto Review" click.
|
||||
|
||||
## Motivation
|
||||
|
||||
Today the Review column mixes two very different workloads:
|
||||
|
||||
- Items where the correct decision is mechanical (one language track, authoritative
|
||||
OG language, no ambiguity) — a human click adds no value.
|
||||
- Items where the decision is genuinely ambiguous (unknown OG, unlabeled tracks,
|
||||
OG not present in the file) — a human must look.
|
||||
|
||||
The user currently has to visually scan the whole column and cherry-pick. The
|
||||
existing `confidence = 'high'` flag partially captures this, but it tracks
|
||||
metadata source authority, not decision clarity: a `confidence=high` item can
|
||||
still be one where the analyzer dropped a same-language commentary track via a
|
||||
title heuristic.
|
||||
|
||||
## Goals
|
||||
|
||||
- Route mechanical decisions straight to the Queue (optionally, on scan).
|
||||
- Give the user a single "glance and approve" lane for decisions that were
|
||||
automatic but relied on a text heuristic.
|
||||
- Keep genuinely ambiguous items in a dedicated manual-review lane.
|
||||
- Do not regress the current workflow when auto-processing is disabled.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Parallel ffmpeg execution (multiple jobs at once). The Processing column's
|
||||
header gains a ready-to-populate subtitle slot, but the runtime change is a
|
||||
separate spec.
|
||||
- Webhook ingestion fixes. Webhook-ingested items will flow through the same
|
||||
Inbox → Review/Queue path as scanned items, but we are not fixing any
|
||||
existing webhook reliability issue in this work.
|
||||
- Changes to noop handling, subtitle extraction, or the job runner.
|
||||
|
||||
## Column layout
|
||||
|
||||
Five columns, left to right: **Inbox → Review → Queue → Processing → Done**.
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ ┌──────┐
|
||||
│ Inbox │→ │ Review │→ │ Queue │→ │ Processing │→ │ Done │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ [Auto ▸] │ │ [Approve │ │ │ │ │ │ │
|
||||
│ │ │ ready] │ │ │ │ │ │ │
|
||||
└──────────┘ └──────────┘ └────────┘ └────────────┘ └──────┘
|
||||
```
|
||||
|
||||
Each column header uses a common subtitle slot:
|
||||
|
||||
| Column | Subtitle content |
|
||||
|------------|--------------------------------------------------------------|
|
||||
| Inbox | `auto-processing on` / `auto-processing off` |
|
||||
| Review | `N ready · M need decisions` |
|
||||
| Queue | `scheduled: HH:MM–HH:MM` or `running anytime` |
|
||||
| Processing | `sequential` (placeholder — populated when parallel lands) |
|
||||
| Done | `N in desired state` (replaces today's top-right counter) |
|
||||
|
||||
The pipeline page header gains a single `Auto-process new items` checkbox,
|
||||
mirroring the `auto_processing` setting — flipping it writes to config
|
||||
immediately and (when turning on) kicks off a one-shot sort so the Inbox drains.
|
||||
|
||||
## Classification rules
|
||||
|
||||
The analyzer (`server/services/analyzer.ts`) gains an `auto_class` output
|
||||
alongside the existing `is_noop`, `job_type`, etc. Values:
|
||||
|
||||
**`auto`** — safe to enqueue without a glance. All must hold:
|
||||
|
||||
- `original_language` is set AND `orig_lang_source` ∈ `{radarr, sonarr, manual}`.
|
||||
- `needs_review = 0` (no conflict between Jellyfin's language and the
|
||||
authoritative source).
|
||||
- At least one kept audio track has a language tag matching OG (OG is
|
||||
actually present in the file).
|
||||
- Every kept audio track has an explicit language tag (no `und`/null).
|
||||
- No same-language dedup was resolved by the commentary/AD title heuristic
|
||||
(`NON_PRIMARY_AUDIO_TITLE` in `server/services/analyzer.ts`).
|
||||
|
||||
Covers the "one English track → copy/transcode", "English + additional German
|
||||
kept", and "two English tracks resolved by channel count" cases.
|
||||
|
||||
**`auto_heuristic`** — decision is automatic but relied on a title pattern match.
|
||||
Same as `auto` except a track within a kept-language group was dropped because
|
||||
its title matched the commentary/AD/descriptive pattern.
|
||||
|
||||
**`manual`** — user must look. Any of:
|
||||
|
||||
- OG language unknown.
|
||||
- OG known but `orig_lang_source` not in `{radarr, sonarr, manual}`.
|
||||
- `needs_review = 1`.
|
||||
- OG known but not present in any kept audio track (can't fulfil).
|
||||
- Any kept audio track has a null/`und` language tag.
|
||||
|
||||
Noop items (`is_noop = 1`) never enter Inbox, Review, or Queue — unchanged
|
||||
from today.
|
||||
|
||||
### Classifier placement
|
||||
|
||||
`auto_class` is computed inside `analyzeItem()` and returned on the `PlanResult`
|
||||
type. It is persisted to `review_plans.auto_class` at the same point that
|
||||
`is_noop`, `apple_compat`, `job_type`, and `notes` are written today (the
|
||||
upsert in `server/api/review.ts` around line 165 and the equivalent upsert in
|
||||
`server/services/rescan.ts`).
|
||||
|
||||
## Data model
|
||||
|
||||
### `review_plans` changes
|
||||
|
||||
Add:
|
||||
|
||||
- `auto_class TEXT` — nullable. `'auto' | 'auto_heuristic' | 'manual'`.
|
||||
`NULL` only during the migration window before backfill completes.
|
||||
- `sorted INTEGER NOT NULL DEFAULT 0` — `0` = in Inbox, `1` = distributed.
|
||||
|
||||
Drop:
|
||||
|
||||
- `confidence` — subsumed by `auto_class`. The `auto_class = 'auto'` case is
|
||||
strictly stronger than `confidence = 'high'` (adds the
|
||||
no-heuristic-used constraint).
|
||||
|
||||
Indexes:
|
||||
|
||||
- Add `CREATE INDEX idx_review_plans_sorted ON review_plans(sorted)`.
|
||||
- Drop `idx_review_plans_status` only if unused after the queries below are in
|
||||
place; keep it otherwise (the column-filter queries still benefit).
|
||||
|
||||
### Migration
|
||||
|
||||
Per the project's forward-looking rule, schema changes use the try/catch
|
||||
`ALTER TABLE` pattern in `server/db/index.ts`. Three SQL statements, each
|
||||
wrapped in its own try/catch so existing databases skip already-applied steps:
|
||||
|
||||
```sql
|
||||
ALTER TABLE review_plans ADD COLUMN auto_class TEXT;
|
||||
ALTER TABLE review_plans ADD COLUMN sorted INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE review_plans DROP COLUMN confidence;
|
||||
```
|
||||
|
||||
Backfill on first boot after the migration:
|
||||
|
||||
- Re-run the classifier for every plan with `auto_class IS NULL` and write the
|
||||
result. This re-uses the analyzer — no separate code path.
|
||||
- Set `sorted = 1` for every plan with `status IN ('pending','approved','skipped','done','error')`.
|
||||
Existing plans were already past the Inbox stage in the old world; dumping
|
||||
them back into Inbox on upgrade would create noise.
|
||||
|
||||
New plans created post-migration start with `sorted = 0` unless the
|
||||
`auto_processing` flow upgrades them in the same transaction.
|
||||
|
||||
### `config` changes
|
||||
|
||||
Add key `auto_processing` with default `"0"`. Also add to
|
||||
`server/db/schema.ts` `DEFAULT_CONFIG`.
|
||||
|
||||
## Status & sort transitions
|
||||
|
||||
```
|
||||
scan / rescan creates plan with:
|
||||
status = 'pending'
|
||||
sorted = 0
|
||||
auto_class = <classifier result>
|
||||
|
||||
Sort distributor (sort-inbox endpoint OR auto-on-scan hook) walks every
|
||||
sorted = 0 plan:
|
||||
|
||||
auto_class = 'auto' → sorted = 1, status = 'approved',
|
||||
reviewed_at = now(), job enqueued → Queue
|
||||
auto_class = 'auto_heuristic' → sorted = 1 → Review ⚡
|
||||
auto_class = 'manual' → sorted = 1 → Review ✋
|
||||
```
|
||||
|
||||
`sorted = 1` is a terminal state for the Inbox concept — a rescan does not
|
||||
reset it. If a rescan changes `auto_class` (e.g., radarr now provides a
|
||||
language), the new value is persisted but the plan stays where the user last
|
||||
saw it; the user approves it manually from Review if they want to act on the
|
||||
change.
|
||||
|
||||
## Column queries
|
||||
|
||||
| Column | Query |
|
||||
|------------|-----------------------------------------------------------------------|
|
||||
| Inbox | `status='pending' AND is_noop=0 AND sorted=0` |
|
||||
| Review | `status='pending' AND is_noop=0 AND sorted=1` |
|
||||
| Queue | unchanged — `jobs.status='pending'` |
|
||||
| Processing | unchanged — `jobs.status='running'` |
|
||||
| Done | unchanged |
|
||||
|
||||
Within Review, the badge is driven by `auto_class`:
|
||||
- `auto_heuristic` → ⚡ Ready
|
||||
- `manual` → ✋ Needs decision
|
||||
|
||||
## Backend
|
||||
|
||||
### New endpoints
|
||||
|
||||
- `POST /api/review/sort-inbox` — distributor. Walks every plan with
|
||||
`sorted = 0`, writes the transitions above in a single transaction, enqueues
|
||||
jobs for `auto` items via the existing `enqueueAudioJob` helper. Returns
|
||||
`{ moved_to_queue: number, moved_to_review: number }`. Broadcasts an SSE
|
||||
`inbox_sorted` event on the existing `/api/execute/events` stream so the
|
||||
Pipeline page refetches.
|
||||
|
||||
- `POST /api/review/approve-ready` — bulk-approves every plan with
|
||||
`status='pending' AND sorted=1 AND auto_class='auto_heuristic'`. Re-uses the
|
||||
existing per-item approve path (`approveBatch` semantics). Returns
|
||||
`{ count: number }`.
|
||||
|
||||
### Removed endpoint
|
||||
|
||||
- `POST /api/review/auto-approve` — removed. Its responsibilities split into
|
||||
`sort-inbox` (distribution) and `approve-ready` (bulk-confirm). Any UI
|
||||
calling it is updated in this change.
|
||||
|
||||
### Modified endpoints
|
||||
|
||||
- `GET /api/review/pipeline` — add fields to the response:
|
||||
- `inboxTotal: number`
|
||||
- `reviewReadyCount: number` (pending+sorted+`auto_heuristic`)
|
||||
- `reviewManualCount: number` (pending+sorted+`manual`)
|
||||
- `autoProcessing: boolean`
|
||||
- existing `reviewItemsTotal` now reflects only the Review column
|
||||
(`sorted=1`). Note this in the endpoint's doc comment.
|
||||
|
||||
- `GET /api/review/groups` — add `?bucket=inbox|review` query parameter.
|
||||
Default `review` for back-compat during rollout; the frontend passes it
|
||||
explicitly. Grouping / season-nesting / lazy-load pagination are shared
|
||||
between buckets.
|
||||
|
||||
- `POST /api/settings/auto-processing` — new endpoint following the
|
||||
per-topic pattern used by `/api/settings/audio-languages` and
|
||||
`/api/settings/mqtt`. Accepts `{ enabled: boolean }`. When flipped from
|
||||
`false` → `true`, schedule a `sort-inbox` pass before returning.
|
||||
|
||||
### Rescan hook
|
||||
|
||||
In `server/services/rescan.ts`, after a scan run finishes processing items,
|
||||
check `getConfig('auto_processing')`. When enabled, call the same distributor
|
||||
used by `/sort-inbox` so freshly-scanned items drain automatically. Emits the
|
||||
same `inbox_sorted` SSE event on completion.
|
||||
|
||||
## Frontend
|
||||
|
||||
### New components
|
||||
|
||||
- `src/features/pipeline/InboxColumn.tsx` — mirrors `ReviewColumn.tsx`:
|
||||
lazy-loaded via `/api/review/groups?bucket=inbox`, same season-grouped card
|
||||
layout. Header action: **Auto Review** → `POST /sort-inbox` → `onMutate()`.
|
||||
Secondary action: **Skip all** (reuses `skip-all` endpoint; Inbox items are
|
||||
`status='pending'` so `skip-all` already matches).
|
||||
|
||||
### Modified components
|
||||
|
||||
- `src/features/pipeline/ReviewColumn.tsx` — header primary action switches to
|
||||
**Approve all ready** (rendered only when `reviewReadyCount > 0`) →
|
||||
`POST /approve-ready`. Skip all stays. The column's fetch URL passes
|
||||
`?bucket=review`.
|
||||
|
||||
- `src/features/pipeline/PipelineCard.tsx` and `SeriesCard.tsx` — accept
|
||||
optional `autoClass` (for movie cards) / an aggregate `readyCount` (for
|
||||
series cards) to render the ⚡ / ✋ badge. Series cards show the badge on
|
||||
each episode row that needs it, and the series header surfaces "N ready"
|
||||
when any episode is `auto_heuristic`.
|
||||
|
||||
- `src/features/pipeline/ColumnShell.tsx` — add `subtitle?: string` prop,
|
||||
rendered under the title above the action row. All five columns use it.
|
||||
|
||||
- `src/features/pipeline/PipelinePage.tsx` — renders five columns. Adds the
|
||||
`Auto-process new items` checkbox inline in the page header, replacing
|
||||
today's standalone `N files in desired state` counter (the counter moves
|
||||
into Done's subtitle). Fetches `inboxInitialGroups` in parallel with the
|
||||
existing review groups fetch.
|
||||
|
||||
- `src/features/settings/SettingsPage.tsx` — add a corresponding
|
||||
`Auto-process new items` toggle in the existing processing/schedule
|
||||
section, posting to `/api/settings/auto-processing` on change.
|
||||
|
||||
### SSE handling
|
||||
|
||||
Add `inbox_sorted` listener to the existing EventSource setup in
|
||||
`PipelinePage.tsx`. On event, call the debounced `loadPipeline()` reload
|
||||
(same path used by `job_update`).
|
||||
|
||||
## Tests
|
||||
|
||||
New:
|
||||
|
||||
- `server/services/analyzer.test.ts` — `auto_class` outcomes for the five
|
||||
cases enumerated during brainstorming (all five exercised explicitly,
|
||||
including language-tag-missing and title-heuristic-triggered cases).
|
||||
- `server/api/__tests__/review-sort-inbox.test.ts` — unsorted plans
|
||||
distribute to the right buckets; `auto` items produce job rows; already-sorted
|
||||
plans are untouched; SSE event is broadcast.
|
||||
- `server/api/__tests__/review-approve-ready.test.ts` — only `auto_heuristic`
|
||||
pending items get approved; `manual` items are untouched; jobs are enqueued.
|
||||
|
||||
Modified:
|
||||
|
||||
- `server/api/__tests__/review-groups.test.ts` — add `bucket=inbox|review`
|
||||
assertions. The existing confidence-sorted test is rewritten in terms of
|
||||
`auto_class` (high → auto, low → manual mapping for the test fixtures).
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Ship migration + analyzer change + new endpoints behind no feature flag;
|
||||
existing UI continues to work during deploy because the new columns render
|
||||
empty data until the backfill runs.
|
||||
2. Backfill runs on first boot. Existing Review items stay in Review
|
||||
(`sorted = 1` at migration time), so the user sees no surprise movement.
|
||||
3. `auto_processing` defaults to `"0"` — no behavior change until the user
|
||||
opts in.
|
||||
|
||||
## Guided Gates
|
||||
|
||||
- **GG-1:** Trigger a scan with the existing setup, flip `auto_processing` off,
|
||||
and confirm every new plan lands in Inbox. Items already in Review at
|
||||
upgrade time stay in Review.
|
||||
- **GG-2:** Click **Auto Review** on a populated Inbox. Verify that items
|
||||
matching the `auto` rule set move to Queue (jobs created) and the rest land
|
||||
in Review with the correct ⚡ / ✋ badge.
|
||||
- **GG-3:** Flip `auto_processing` to on, trigger a scan, and confirm Inbox
|
||||
drains automatically. Verify the one-shot sort also fires when the toggle
|
||||
flips from off to on while Inbox is non-empty.
|
||||
- **GG-4:** In Review, click **Approve all ready** and confirm only
|
||||
`auto_heuristic` items move to Queue; `manual` items are untouched.
|
||||
- **GG-5:** Confirm the Processing column renders its subtitle slot (currently
|
||||
"sequential") — placeholder is ready for the future parallel work.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Pipeline Refactor: Scan/Process Separation + Rescan
|
||||
|
||||
## Problem
|
||||
|
||||
Sonarr/Radarr language lookups are baked into the scan step (`upsertJellyfinItem`).
|
||||
This means:
|
||||
|
||||
- Items that miss Sonarr data at scan time stay misclassified until a full rescan
|
||||
- Moving items back to the inbox (unsort) doesn't re-run language resolution
|
||||
- The scan step does too much: discovery + classification + analysis in one pass
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Scan step (discovery only)
|
||||
|
||||
`upsertJellyfinItem` becomes a pure Jellyfin data ingest:
|
||||
|
||||
- Populate `media_items` (metadata, provider IDs, file info) + `media_streams`
|
||||
- Store the Jellyfin audio-track guess in `original_language` /
|
||||
`orig_lang_source="jellyfin"` (or null if no guess)
|
||||
- Do **not** call Sonarr/Radarr
|
||||
- Do **not** run the analyzer
|
||||
- Items land with `sorted=0`, `status='pending'` — in the inbox
|
||||
|
||||
Remove from `upsertJellyfinItem` and its callers (`scan.ts`, `webhook.ts`):
|
||||
|
||||
- Sonarr/Radarr config and library loading
|
||||
- `resolveSeriesTvdb` callback
|
||||
- All language-source resolution logic (the `radarrLang()` / `sonarrLang()` calls)
|
||||
|
||||
Preserve:
|
||||
|
||||
- Manual override guard (`orig_lang_source='manual'` survives rescan)
|
||||
- Provider ID extraction (imdbId, tmdbId, tvdbId from Jellyfin payload)
|
||||
- The tvdbId resolution logic (SeriesProviderIds.Tvdb preference, resolveSeriesTvdb
|
||||
fallback) — this moves to the process step
|
||||
|
||||
`RescanConfig` shrinks to just `audioLanguages` (or is removed entirely if the
|
||||
analyzer is no longer called at scan time). Consider renaming for clarity.
|
||||
|
||||
### 2. Process step (classification + distribution)
|
||||
|
||||
Rename `sortInbox()` to `processInbox()`. For each unsorted item:
|
||||
|
||||
1. **Resolve language** — load Sonarr/Radarr libraries (once per run), resolve
|
||||
series TVDB when needed (using the `resolveSeriesTvdb` pattern with caching),
|
||||
look up original language via Sonarr/Radarr, update
|
||||
`media_items.original_language` / `orig_lang_source` / `needs_review`
|
||||
2. **Analyze** — run `reanalyze()` (stream decisions, auto_class, is_noop)
|
||||
3. **Distribute** — auto → Queue, manual/heuristic → Review, noop → skip
|
||||
|
||||
Library loading happens once at the start of `processInbox`, reused for all items
|
||||
in the batch.
|
||||
|
||||
The webhook path: after `upsertJellyfinItem` lands the item in the inbox, if
|
||||
`auto_processing` is on, trigger processing for that item.
|
||||
|
||||
### 3. Rescan (reset + re-fetch + inbox)
|
||||
|
||||
New function `rescanItems(db, jellyfinCfg, jellyfinIds[])`:
|
||||
|
||||
- Reset item state: clear `original_language` and `orig_lang_source` to
|
||||
jellyfin guess (or null), reset `needs_review`
|
||||
- Set `sorted=0`, `status='pending'` on `review_plans` (back to inbox)
|
||||
- Re-fetch each item's metadata + streams from Jellyfin via `getItem()`
|
||||
- Re-run `upsertJellyfinItem` with the fresh Jellyfin data
|
||||
- If `auto_processing` is on, trigger `processInbox` for these items afterward
|
||||
|
||||
#### Scoping
|
||||
|
||||
- **Per-item**: rescan one `jellyfin_id`
|
||||
- **Per-season**: all items matching `series_jellyfin_id` + `season_number`
|
||||
- **Per-series**: all items matching `series_jellyfin_id`; also query Jellyfin for
|
||||
all episodes of that series to discover new ones not yet in the DB
|
||||
|
||||
#### API endpoints
|
||||
|
||||
- `POST /api/review/:id/rescan` — existing endpoint, adjusted to new flow (reset
|
||||
→ re-fetch → inbox; no longer calls reanalyze directly)
|
||||
- `POST /api/review/rescan-series` — body:
|
||||
`{ seriesJellyfinId: string, seasonNumber?: number }`. When `seasonNumber` is
|
||||
omitted, rescans the entire series including new episode discovery.
|
||||
|
||||
#### UI
|
||||
|
||||
- **Pipeline page**: rescan button on series/season group headers
|
||||
- **Audio detail page**: per-item rescan (existing button, adjusted behavior),
|
||||
plus series/season rescan buttons
|
||||
|
||||
### 4. Renames
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| "Auto Review" button label | "Process Inbox" |
|
||||
| `sortInbox()` function | `processInbox()` |
|
||||
| `sort-inbox` API route | `process-inbox` (update frontend calls) |
|
||||
| "Sort" / "sorted" in SSE events | "Process" / "processed" |
|
||||
|
||||
The `auto_processing` config key stays as-is (no DB migration).
|
||||
|
||||
### 5. Done column: remove 50-item cap
|
||||
|
||||
The Done column currently caps at 50 items. Remove this limit — show all done/
|
||||
noop items. If performance becomes an issue, paginate rather than cap.
|
||||
|
||||
### 7. What stays the same
|
||||
|
||||
- `reanalyze()` — still used inside the process step, unchanged
|
||||
- `analyzeItem()` — unchanged
|
||||
- Manual override preservation — `orig_lang_source='manual'` is never overwritten
|
||||
- Pipeline columns (Inbox, Review, Queue, Processing, Done) — same queries
|
||||
- Per-item approve/skip/delete actions — unchanged
|
||||
- The `resolveSeriesTvdb` logic — moves but doesn't change
|
||||
|
||||
### 8. Future work (out of scope)
|
||||
|
||||
- **Sonarr/Radarr library caching with TTL**: currently loads fresh each
|
||||
`processInbox` run. Revisit when fixing webhooks — frequent single-item
|
||||
webhook arrivals with auto-process on would benefit from a cache so we don't
|
||||
re-fetch the full Sonarr/Radarr library per item.
|
||||
|
||||
## Guided Gates
|
||||
|
||||
- GG-1: Full scan populates items in inbox without Sonarr/Radarr data. Verify an
|
||||
Arrow episode shows `orig_lang_source="jellyfin"` (or null) after scan, not
|
||||
"sonarr".
|
||||
- GG-2: "Process Inbox" resolves Arrow episodes via Sonarr → `orig_lang_source=
|
||||
"sonarr"`, `original_language="eng"`, `needs_review=0`, auto-classified to Queue.
|
||||
- GG-3: Rescan a single item from the detail page → item resets to inbox, shows
|
||||
in inbox count. If auto-process is on, it gets processed immediately.
|
||||
- GG-4: Rescan a series from the pipeline page → all episodes of that series reset
|
||||
to inbox + new episodes discovered from Jellyfin appear.
|
||||
- GG-5: Rescan a season from the pipeline page → only that season's episodes reset.
|
||||
- GG-6: "Unsort all" → items return to inbox → "Process Inbox" re-runs full
|
||||
classification including Sonarr/Radarr lookups.
|
||||
- GG-7: Done column shows all items, not capped at 50.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netfelix-audio-fix",
|
||||
"version": "2026.04.15.1",
|
||||
"version": "2026.04.24.2",
|
||||
"scripts": {
|
||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||
"dev:client": "vite",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import { clearQueue } from "../execute";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedQueuedItem(db: Database, id: number, autoClass: "auto" | "auto_heuristic" | "manual") {
|
||||
db
|
||||
.prepare("INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')")
|
||||
.run(id, `Item ${id}`, `/x/${id}.mkv`);
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, 'approved', 0, ?, 1, 'direct_play', 'copy')",
|
||||
)
|
||||
.run(id, autoClass);
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, 'ffmpeg …', 'audio', 'pending')").run(id);
|
||||
}
|
||||
|
||||
describe("clearQueue", () => {
|
||||
test("deletes pending jobs and returns plans to the Inbox (sorted=0, pending)", () => {
|
||||
const db = makeDb();
|
||||
seedQueuedItem(db, 1, "auto");
|
||||
seedQueuedItem(db, 2, "auto");
|
||||
|
||||
const cleared = clearQueue(db);
|
||||
expect(cleared).toBe(2);
|
||||
|
||||
const plans = db.prepare("SELECT item_id, status, sorted FROM review_plans ORDER BY item_id").all() as {
|
||||
item_id: number;
|
||||
status: string;
|
||||
sorted: number;
|
||||
}[];
|
||||
expect(plans).toEqual([
|
||||
{ item_id: 1, status: "pending", sorted: 0 },
|
||||
{ item_id: 2, status: "pending", sorted: 0 },
|
||||
]);
|
||||
|
||||
const jobCount = (db.prepare("SELECT COUNT(*) as n FROM jobs WHERE status = 'pending'").get() as { n: number }).n;
|
||||
expect(jobCount).toBe(0);
|
||||
});
|
||||
|
||||
test("leaves running + completed jobs alone", () => {
|
||||
const db = makeDb();
|
||||
seedQueuedItem(db, 1, "auto");
|
||||
db.prepare("UPDATE jobs SET status = 'running' WHERE item_id = 1").run();
|
||||
seedQueuedItem(db, 2, "auto");
|
||||
db.prepare("UPDATE jobs SET status = 'done' WHERE item_id = 2").run();
|
||||
seedQueuedItem(db, 3, "auto"); // stays pending
|
||||
|
||||
const cleared = clearQueue(db);
|
||||
expect(cleared).toBe(1);
|
||||
|
||||
const surviving = db.prepare("SELECT item_id, status FROM jobs ORDER BY item_id").all() as {
|
||||
item_id: number;
|
||||
status: string;
|
||||
}[];
|
||||
expect(surviving).toEqual([
|
||||
{ item_id: 1, status: "running" },
|
||||
{ item_id: 2, status: "done" },
|
||||
]);
|
||||
|
||||
// Only the plan whose job was pending should be reset.
|
||||
const plan3 = db.prepare("SELECT status, sorted FROM review_plans WHERE item_id = 3").get() as {
|
||||
status: string;
|
||||
sorted: number;
|
||||
};
|
||||
expect(plan3).toEqual({ status: "pending", sorted: 0 });
|
||||
const plan1 = db.prepare("SELECT status, sorted FROM review_plans WHERE item_id = 1").get() as {
|
||||
status: string;
|
||||
sorted: number;
|
||||
};
|
||||
expect(plan1).toEqual({ status: "approved", sorted: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractErrorSummary } from "../execute";
|
||||
import { enqueueUnseenJobs, extractErrorSummary, shouldSendLiveUpdate, yieldAfterChunk } from "../execute";
|
||||
|
||||
describe("extractErrorSummary", () => {
|
||||
test("pulls the real error line out of ffmpeg's banner", () => {
|
||||
@@ -47,3 +47,39 @@ describe("extractErrorSummary", () => {
|
||||
expect(summary).toBe("Error: no space left on device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSendLiveUpdate", () => {
|
||||
test("throttles updates until interval passes", () => {
|
||||
expect(shouldSendLiveUpdate(1_000, 800, 500)).toBe(false);
|
||||
expect(shouldSendLiveUpdate(1_301, 800, 500)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yieldAfterChunk", () => {
|
||||
test("yields once threshold is reached, resets chunk counter", async () => {
|
||||
let yieldCalls = 0;
|
||||
const sleep = async (_ms: number) => {
|
||||
yieldCalls += 1;
|
||||
};
|
||||
let chunks = 0;
|
||||
chunks = await yieldAfterChunk(chunks, 3, sleep);
|
||||
expect(chunks).toBe(1);
|
||||
chunks = await yieldAfterChunk(chunks, 3, sleep);
|
||||
expect(chunks).toBe(2);
|
||||
chunks = await yieldAfterChunk(chunks, 3, sleep);
|
||||
expect(chunks).toBe(0);
|
||||
expect(yieldCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueUnseenJobs", () => {
|
||||
test("appends only unseen job ids to the active queue", () => {
|
||||
const queue = [{ id: 1 }, { id: 2 }] as { id: number }[];
|
||||
const seen = new Set([1, 2]);
|
||||
const added = enqueueUnseenJobs(queue, seen, [{ id: 2 }, { id: 3 }, { id: 4 }] as { id: number }[]);
|
||||
expect(added).toBe(2);
|
||||
expect(queue.map((j) => j.id)).toEqual([1, 2, 3, 4]);
|
||||
expect(seen.has(3)).toBeTrue();
|
||||
expect(seen.has(4)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import { approveReady } from "../review";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedSortedPlan(db: Database, id: number, autoClass: "auto_heuristic" | "manual") {
|
||||
db
|
||||
.prepare("INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')")
|
||||
.run(id, `Item ${id}`, `/x/${id}.mkv`);
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO media_streams (item_id, stream_index, type, codec, language) VALUES (?, 0, 'Audio', 'eac3', 'eng')",
|
||||
)
|
||||
.run(id);
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, 'pending', 0, ?, 1, 'direct_play', 'copy')",
|
||||
)
|
||||
.run(id, autoClass);
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO stream_decisions (plan_id, stream_id, action, target_index) SELECT rp.id, ms.id, 'keep', 0 FROM review_plans rp, media_streams ms WHERE rp.item_id = ? AND ms.item_id = ?",
|
||||
)
|
||||
.run(id, id);
|
||||
}
|
||||
|
||||
describe("approveReady", () => {
|
||||
test("approves auto_heuristic only, leaves manual alone", () => {
|
||||
const db = makeDb();
|
||||
seedSortedPlan(db, 1, "auto_heuristic");
|
||||
seedSortedPlan(db, 2, "manual");
|
||||
seedSortedPlan(db, 3, "auto_heuristic");
|
||||
|
||||
const count = approveReady(db);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const statuses = db.prepare("SELECT item_id, status FROM review_plans ORDER BY item_id").all() as {
|
||||
item_id: number;
|
||||
status: string;
|
||||
}[];
|
||||
expect(statuses).toEqual([
|
||||
{ item_id: 1, status: "approved" },
|
||||
{ item_id: 2, status: "pending" },
|
||||
{ item_id: 3, status: "approved" },
|
||||
]);
|
||||
|
||||
const jobCount = (db.prepare("SELECT COUNT(*) as n FROM jobs").get() as { n: number }).n;
|
||||
expect(jobCount).toBe(2);
|
||||
});
|
||||
|
||||
test("noop when nothing is ready", () => {
|
||||
const db = makeDb();
|
||||
seedSortedPlan(db, 1, "manual");
|
||||
expect(approveReady(db)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import { buildReviewGroups } from "../review";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
interface SeedOpts {
|
||||
id: number;
|
||||
type: "Movie" | "Episode";
|
||||
name?: string;
|
||||
seriesName?: string | null;
|
||||
seriesKey?: string | null;
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
autoClass?: "auto" | "auto_heuristic" | "manual" | null;
|
||||
sorted?: 0 | 1;
|
||||
}
|
||||
|
||||
function seed(db: Database, opts: SeedOpts) {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
name = `Item ${id}`,
|
||||
seriesName = null,
|
||||
seriesKey = null,
|
||||
seasonNumber = null,
|
||||
episodeNumber = null,
|
||||
autoClass = "manual",
|
||||
sorted = 1,
|
||||
} = opts;
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO media_items (id, type, name, series_name, series_key, season_number, episode_number, file_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(id, type, name, seriesName, seriesKey, seasonNumber, episodeNumber, `/x/${id}.mkv`);
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type, notes) VALUES (?, 'pending', 0, ?, ?, 'direct_play', 'copy', NULL)",
|
||||
)
|
||||
.run(id, autoClass, sorted);
|
||||
}
|
||||
|
||||
describe("buildReviewGroups", () => {
|
||||
test("returns a complete series with every pending episode", () => {
|
||||
const db = makeDb();
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
seed(db, {
|
||||
id: i,
|
||||
type: "Episode",
|
||||
seriesName: "Breaking Bad",
|
||||
seriesKey: "bb",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: i,
|
||||
});
|
||||
}
|
||||
|
||||
const { groups, totalItems } = buildReviewGroups(db, { bucket: "review" });
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
const series = groups[0];
|
||||
expect(series.kind).toBe("series");
|
||||
if (series.kind !== "series") throw new Error("expected series");
|
||||
expect(series.episodeCount).toBe(30);
|
||||
expect(series.seasons).toHaveLength(1);
|
||||
expect(series.seasons[0].episodes).toHaveLength(30);
|
||||
expect(totalItems).toBe(30);
|
||||
});
|
||||
|
||||
test("buckets episodes by season with null ordered last", () => {
|
||||
const db = makeDb();
|
||||
for (let ep = 1; ep <= 3; ep++) {
|
||||
seed(db, {
|
||||
id: ep,
|
||||
type: "Episode",
|
||||
seriesName: "Lost",
|
||||
seriesKey: "lost",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: ep,
|
||||
});
|
||||
}
|
||||
for (let ep = 1; ep <= 2; ep++) {
|
||||
seed(db, {
|
||||
id: 10 + ep,
|
||||
type: "Episode",
|
||||
seriesName: "Lost",
|
||||
seriesKey: "lost",
|
||||
seasonNumber: 2,
|
||||
episodeNumber: ep,
|
||||
});
|
||||
}
|
||||
seed(db, { id: 99, type: "Episode", seriesName: "Lost", seriesKey: "lost", seasonNumber: null });
|
||||
|
||||
const { groups } = buildReviewGroups(db, { bucket: "review" });
|
||||
expect(groups).toHaveLength(1);
|
||||
const lost = groups[0];
|
||||
if (lost.kind !== "series") throw new Error("expected series");
|
||||
expect(lost.seasons.map((s) => s.season)).toEqual([1, 2, null]);
|
||||
expect(lost.seasons[0].episodes).toHaveLength(3);
|
||||
expect(lost.seasons[1].episodes).toHaveLength(2);
|
||||
expect(lost.seasons[2].episodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("sorts groups: auto_heuristic (ready) first, then manual, then by name", () => {
|
||||
const db = makeDb();
|
||||
seed(db, { id: 1, type: "Movie", name: "Zodiac", autoClass: "auto_heuristic" });
|
||||
seed(db, { id: 2, type: "Movie", name: "Arrival", autoClass: "manual" });
|
||||
seed(db, { id: 3, type: "Movie", name: "Blade Runner", autoClass: "auto_heuristic" });
|
||||
|
||||
const { groups } = buildReviewGroups(db, { bucket: "review" });
|
||||
const names = groups.map((g) => (g.kind === "movie" ? g.item.name : g.seriesName));
|
||||
expect(names).toEqual(["Blade Runner", "Zodiac", "Arrival"]);
|
||||
});
|
||||
|
||||
test("series readyCount counts auto_heuristic episodes", () => {
|
||||
const db = makeDb();
|
||||
seed(db, {
|
||||
id: 1,
|
||||
type: "Episode",
|
||||
seriesName: "Show",
|
||||
seriesKey: "s",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
autoClass: "auto_heuristic",
|
||||
});
|
||||
seed(db, {
|
||||
id: 2,
|
||||
type: "Episode",
|
||||
seriesName: "Show",
|
||||
seriesKey: "s",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
autoClass: "manual",
|
||||
});
|
||||
|
||||
const { groups } = buildReviewGroups(db, { bucket: "review" });
|
||||
if (groups[0].kind !== "series") throw new Error("expected series");
|
||||
expect(groups[0].readyCount).toBe(1);
|
||||
});
|
||||
|
||||
test("excludes plans that are not pending or are is_noop=1", () => {
|
||||
const db = makeDb();
|
||||
seed(db, { id: 1, type: "Movie", name: "Pending" });
|
||||
seed(db, { id: 2, type: "Movie", name: "Approved" });
|
||||
db.prepare("UPDATE review_plans SET status = 'approved' WHERE item_id = ?").run(2);
|
||||
seed(db, { id: 3, type: "Movie", name: "Noop" });
|
||||
db.prepare("UPDATE review_plans SET is_noop = 1 WHERE item_id = ?").run(3);
|
||||
|
||||
const { groups, totalItems } = buildReviewGroups(db, { bucket: "review" });
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(totalItems).toBe(1);
|
||||
if (groups[0].kind !== "movie") throw new Error("expected movie");
|
||||
expect(groups[0].item.name).toBe("Pending");
|
||||
});
|
||||
|
||||
test("bucket=inbox returns sorted=0 plans only", () => {
|
||||
const db = makeDb();
|
||||
seed(db, { id: 1, type: "Movie", name: "Fresh", autoClass: null, sorted: 0 });
|
||||
seed(db, { id: 2, type: "Movie", name: "Old", autoClass: "manual", sorted: 1 });
|
||||
|
||||
const inbox = buildReviewGroups(db, { bucket: "inbox" });
|
||||
expect(inbox.groups).toHaveLength(1);
|
||||
if (inbox.groups[0].kind !== "movie") throw new Error("expected movie");
|
||||
expect(inbox.groups[0].item.name).toBe("Fresh");
|
||||
|
||||
const review = buildReviewGroups(db, { bucket: "review" });
|
||||
expect(review.groups).toHaveLength(1);
|
||||
if (review.groups[0].kind !== "movie") throw new Error("expected movie");
|
||||
expect(review.groups[0].item.name).toBe("Old");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import { processInbox } from "../review";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
interface AudioSeed {
|
||||
stream_index: number;
|
||||
language: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
interface SeedOpts {
|
||||
id: number;
|
||||
origLang: string | null;
|
||||
origLangSource: "probe" | "radarr" | "sonarr" | "manual" | null;
|
||||
needsReview?: number;
|
||||
audio: AudioSeed[];
|
||||
}
|
||||
|
||||
function seedItem(db: Database, opts: SeedOpts): void {
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO media_items (id, type, name, file_path, container, original_language, orig_lang_source, needs_review) VALUES (?, 'Movie', ?, ?, 'mkv', ?, ?, ?)",
|
||||
)
|
||||
.run(opts.id, `Item ${opts.id}`, `/x/${opts.id}.mkv`, opts.origLang, opts.origLangSource, opts.needsReview ?? 0);
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO media_streams (item_id, stream_index, type, codec, language) VALUES (?, 0, 'Video', 'h264', NULL)",
|
||||
)
|
||||
.run(opts.id);
|
||||
for (const a of opts.audio) {
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO media_streams (item_id, stream_index, type, codec, language, title, channels) VALUES (?, ?, 'Audio', 'eac3', ?, ?, 2)",
|
||||
)
|
||||
.run(opts.id, a.stream_index, a.language, a.title ?? null);
|
||||
}
|
||||
// Placeholder plan. processInbox reanalyzes and overwrites auto_class /
|
||||
// is_noop / decisions, so the seeded values don't need to be correct.
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, 'pending', 0, 'manual', 0, 'direct_play', 'copy')",
|
||||
)
|
||||
.run(opts.id);
|
||||
}
|
||||
|
||||
describe("processInbox", () => {
|
||||
test("authoritative OG with only OG-language audio → auto → queue", async () => {
|
||||
const db = makeDb();
|
||||
seedItem(db, {
|
||||
id: 1,
|
||||
origLang: "eng",
|
||||
origLangSource: "radarr",
|
||||
audio: [{ stream_index: 1, language: "eng" }],
|
||||
});
|
||||
|
||||
const result = await processInbox(db, []);
|
||||
|
||||
expect(result.moved_to_queue).toBe(1);
|
||||
expect(result.moved_to_review).toBe(0);
|
||||
const plan = db.prepare("SELECT status, sorted, auto_class FROM review_plans WHERE item_id = 1").get() as {
|
||||
status: string;
|
||||
sorted: number;
|
||||
auto_class: string;
|
||||
};
|
||||
expect(plan.status).toBe("approved");
|
||||
expect(plan.sorted).toBe(1);
|
||||
expect(plan.auto_class).toBe("auto");
|
||||
const job = db.prepare("SELECT status FROM jobs WHERE item_id = 1").get() as { status: string };
|
||||
expect(job.status).toBe("pending");
|
||||
});
|
||||
|
||||
test("commentary track triggers auto_heuristic → review, no job", async () => {
|
||||
const db = makeDb();
|
||||
seedItem(db, {
|
||||
id: 1,
|
||||
origLang: "eng",
|
||||
origLangSource: "radarr",
|
||||
audio: [
|
||||
{ stream_index: 1, language: "eng" },
|
||||
{ stream_index: 2, language: "eng", title: "Director's Commentary" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await processInbox(db, []);
|
||||
|
||||
expect(result.moved_to_queue).toBe(0);
|
||||
expect(result.moved_to_review).toBe(1);
|
||||
const plan = db.prepare("SELECT status, sorted, auto_class FROM review_plans WHERE item_id = 1").get() as {
|
||||
status: string;
|
||||
sorted: number;
|
||||
auto_class: string;
|
||||
};
|
||||
expect(plan.status).toBe("pending");
|
||||
expect(plan.sorted).toBe(1);
|
||||
expect(plan.auto_class).toBe("auto_heuristic");
|
||||
const jobCount = (db.prepare("SELECT COUNT(*) as n FROM jobs WHERE item_id = 1").get() as { n: number }).n;
|
||||
expect(jobCount).toBe(0);
|
||||
});
|
||||
|
||||
test("missing authoritative OG → manual → review, no job", async () => {
|
||||
const db = makeDb();
|
||||
seedItem(db, {
|
||||
id: 1,
|
||||
origLang: null,
|
||||
origLangSource: null,
|
||||
needsReview: 1,
|
||||
audio: [{ stream_index: 1, language: "eng" }],
|
||||
});
|
||||
|
||||
const result = await processInbox(db, []);
|
||||
|
||||
expect(result.moved_to_review).toBe(1);
|
||||
const plan = db.prepare("SELECT status, sorted, auto_class FROM review_plans WHERE item_id = 1").get() as {
|
||||
status: string;
|
||||
sorted: number;
|
||||
auto_class: string;
|
||||
};
|
||||
expect(plan.sorted).toBe(1);
|
||||
expect(plan.status).toBe("pending");
|
||||
expect(plan.auto_class).toBe("manual");
|
||||
});
|
||||
|
||||
test("already sorted plans are untouched", async () => {
|
||||
const db = makeDb();
|
||||
seedItem(db, {
|
||||
id: 1,
|
||||
origLang: "eng",
|
||||
origLangSource: "radarr",
|
||||
audio: [{ stream_index: 1, language: "eng" }],
|
||||
});
|
||||
db.prepare("UPDATE review_plans SET sorted = 1 WHERE item_id = 1").run();
|
||||
|
||||
const result = await processInbox(db, []);
|
||||
|
||||
expect(result.moved_to_queue).toBe(0);
|
||||
expect(result.moved_to_review).toBe(0);
|
||||
});
|
||||
|
||||
// Regression for: settings change (drop a language from audio_languages)
|
||||
// followed by "back to inbox" + "auto review" left the old decisions in
|
||||
// place. processInbox must re-run the analyzer against the current config
|
||||
// so the dropped language is actually removed this time around.
|
||||
test("reanalyzes on each run → honors current audio_languages", async () => {
|
||||
const db = makeDb();
|
||||
seedItem(db, {
|
||||
id: 1,
|
||||
origLang: "eng",
|
||||
origLangSource: "radarr",
|
||||
audio: [
|
||||
{ stream_index: 1, language: "eng" },
|
||||
{ stream_index: 2, language: "deu" },
|
||||
],
|
||||
});
|
||||
|
||||
// First pass: user had "keep German" on, so German is kept and the
|
||||
// plan auto-queues with both tracks preserved.
|
||||
const firstPass = await processInbox(db, ["deu"]);
|
||||
expect(firstPass.moved_to_queue).toBe(1);
|
||||
const firstActions = db
|
||||
.prepare(`
|
||||
SELECT ms.language, sd.action
|
||||
FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
JOIN review_plans rp ON rp.id = sd.plan_id
|
||||
WHERE rp.item_id = 1 AND ms.type = 'Audio'
|
||||
`)
|
||||
.all() as { language: string; action: string }[];
|
||||
expect(Object.fromEntries(firstActions.map((r) => [r.language, r.action]))).toEqual({
|
||||
eng: "keep",
|
||||
deu: "keep",
|
||||
});
|
||||
|
||||
// User changes their mind, batches the plan back to Inbox, and
|
||||
// toggles German off. The stored decisions still say "keep" for
|
||||
// German — but the next processInbox must re-derive them.
|
||||
db.prepare("UPDATE review_plans SET status = 'pending', sorted = 0, reviewed_at = NULL WHERE item_id = 1").run();
|
||||
db.prepare("DELETE FROM jobs WHERE item_id = 1").run();
|
||||
|
||||
const secondPass = await processInbox(db, []);
|
||||
expect(secondPass.moved_to_queue).toBe(1);
|
||||
const secondActions = db
|
||||
.prepare(`
|
||||
SELECT ms.language, sd.action
|
||||
FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
JOIN review_plans rp ON rp.id = sd.plan_id
|
||||
WHERE rp.item_id = 1 AND ms.type = 'Audio'
|
||||
`)
|
||||
.all() as { language: string; action: string }[];
|
||||
expect(Object.fromEntries(secondActions.map((r) => [r.language, r.action]))).toEqual({
|
||||
eng: "keep",
|
||||
deu: "remove",
|
||||
});
|
||||
|
||||
const job = db.prepare("SELECT command FROM jobs WHERE item_id = 1 AND status = 'pending'").get() as
|
||||
| { command: string }
|
||||
| undefined;
|
||||
expect(job).toBeDefined();
|
||||
// The rebuilt ffmpeg command maps only the English audio. In
|
||||
// type-relative specifiers (0:a:N) English is a:0 and German
|
||||
// would have been a:1 — the latter must be absent.
|
||||
expect(job?.command).toContain("-map 0:a:0");
|
||||
expect(job?.command).not.toContain("-map 0:a:1");
|
||||
});
|
||||
|
||||
test("emits start + progress hooks once per item", async () => {
|
||||
const db = makeDb();
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
seedItem(db, {
|
||||
id: i,
|
||||
origLang: "eng",
|
||||
origLangSource: "radarr",
|
||||
audio: [{ stream_index: 1, language: "eng" }],
|
||||
});
|
||||
}
|
||||
|
||||
const startCalls: number[] = [];
|
||||
const progressCalls: Array<{ processed: number; total: number }> = [];
|
||||
await processInbox(db, [], undefined, {
|
||||
onStart: (total) => startCalls.push(total),
|
||||
onProgress: (processed, total) => progressCalls.push({ processed, total }),
|
||||
});
|
||||
|
||||
expect(startCalls).toEqual([3]);
|
||||
expect(progressCalls).toEqual([
|
||||
{ processed: 1, total: 3 },
|
||||
{ processed: 2, total: 3 },
|
||||
{ processed: 3, total: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import { reopenAllDone, unsortAll } from "../review";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedPlan(db: Database, id: number, opts: { sorted?: 0 | 1; status?: string; isNoop?: 0 | 1 } = {}) {
|
||||
const { sorted = 1, status = "pending", isNoop = 0 } = opts;
|
||||
db
|
||||
.prepare("INSERT INTO media_items (id, type, name, file_path, container) VALUES (?, 'Movie', ?, ?, 'mkv')")
|
||||
.run(id, `Item ${id}`, `/x/${id}.mkv`);
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, ?, ?, 'auto_heuristic', ?, 'direct_play', 'copy')",
|
||||
)
|
||||
.run(id, status, isNoop, sorted);
|
||||
}
|
||||
|
||||
describe("unsortAll", () => {
|
||||
test("flips sorted=1 pending plans back to sorted=0, skips is_noop and non-pending", () => {
|
||||
const db = makeDb();
|
||||
seedPlan(db, 1, { sorted: 1, status: "pending" });
|
||||
seedPlan(db, 2, { sorted: 1, status: "pending" });
|
||||
seedPlan(db, 3, { sorted: 0, status: "pending" }); // already in inbox
|
||||
seedPlan(db, 4, { sorted: 1, status: "approved" }); // queued
|
||||
seedPlan(db, 5, { sorted: 1, status: "pending", isNoop: 1 }); // noop
|
||||
|
||||
const count = unsortAll(db);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const rows = db.prepare("SELECT item_id, sorted, status FROM review_plans ORDER BY item_id").all() as {
|
||||
item_id: number;
|
||||
sorted: number;
|
||||
status: string;
|
||||
}[];
|
||||
expect(rows).toEqual([
|
||||
{ item_id: 1, sorted: 0, status: "pending" },
|
||||
{ item_id: 2, sorted: 0, status: "pending" },
|
||||
{ item_id: 3, sorted: 0, status: "pending" },
|
||||
{ item_id: 4, sorted: 1, status: "approved" },
|
||||
{ item_id: 5, sorted: 1, status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("noop when review column is empty", () => {
|
||||
const db = makeDb();
|
||||
expect(unsortAll(db)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reopenAllDone", () => {
|
||||
test("flips done/error plans back to pending + inbox (sorted=0) and drops their jobs", () => {
|
||||
const db = makeDb();
|
||||
seedPlan(db, 1, { status: "done" });
|
||||
seedPlan(db, 2, { status: "error" });
|
||||
seedPlan(db, 3, { status: "approved" }); // untouched
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (1, 'ffmpeg', 'copy', 'done')").run();
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (2, 'ffmpeg', 'copy', 'error')").run();
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (3, 'ffmpeg', 'copy', 'pending')").run();
|
||||
|
||||
const count = reopenAllDone(db);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const statuses = db
|
||||
.prepare("SELECT item_id, status, sorted, reviewed_at FROM review_plans ORDER BY item_id")
|
||||
.all() as {
|
||||
item_id: number;
|
||||
status: string;
|
||||
sorted: number;
|
||||
reviewed_at: string | null;
|
||||
}[];
|
||||
expect(statuses[0]).toMatchObject({ status: "pending", sorted: 0, reviewed_at: null });
|
||||
expect(statuses[1]).toMatchObject({ status: "pending", sorted: 0, reviewed_at: null });
|
||||
// Untouched plan keeps its pre-existing sorted=1 (default for the seed).
|
||||
expect(statuses[2]).toMatchObject({ status: "approved", sorted: 1 });
|
||||
|
||||
const jobs = db.prepare("SELECT item_id, status FROM jobs ORDER BY item_id").all();
|
||||
expect(jobs).toEqual([{ item_id: 3, status: "pending" }]);
|
||||
});
|
||||
|
||||
test("noop when nothing is done or errored", () => {
|
||||
const db = makeDb();
|
||||
seedPlan(db, 1, { status: "pending" });
|
||||
expect(reopenAllDone(db)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,7 @@ function makeDb(): Database {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
db
|
||||
.prepare("INSERT INTO media_items (id, jellyfin_id, type, name, file_path) VALUES (?, ?, 'Movie', 'T', '/x.mkv')")
|
||||
.run(1, "jf-1");
|
||||
db.prepare("INSERT INTO media_items (id, type, name, file_path) VALUES (?, 'Movie', 'T', '/x.mkv')").run(1);
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,23 +7,16 @@ app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const totalItems = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n;
|
||||
const scanned = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
|
||||
).n;
|
||||
const needsAction = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
|
||||
).n;
|
||||
const noChange = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }).n;
|
||||
const approved = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }
|
||||
).n;
|
||||
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
||||
const queued = (db.prepare("SELECT COUNT(*) as n FROM jobs WHERE status = 'pending'").get() as { n: number }).n;
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
||||
const scanRunning = getConfig("scan_running") === "1";
|
||||
const setupComplete = getConfig("setup_complete") === "1";
|
||||
|
||||
return c.json({
|
||||
stats: { totalItems, scanned, needsAction, approved, done, errors, noChange },
|
||||
stats: { totalItems, needsAction, queued, errors },
|
||||
scanRunning,
|
||||
setupComplete,
|
||||
});
|
||||
|
||||
+238
-40
@@ -1,9 +1,12 @@
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import { Hono } from "hono";
|
||||
import { stream } from "hono/streaming";
|
||||
import { getDb } from "../db/index";
|
||||
import { getConfig, getDb } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { predictExtractedFiles } from "../services/ffmpeg";
|
||||
import { parseId } from "../lib/validate";
|
||||
import { isExtractableSubtitle } from "../services/ffmpeg";
|
||||
import * as radarr from "../services/radarr";
|
||||
import * as sonarr from "../services/sonarr";
|
||||
import {
|
||||
getScheduleConfig,
|
||||
isInProcessWindow,
|
||||
@@ -20,8 +23,39 @@ const app = new Hono();
|
||||
// ─── Sequential local queue ──────────────────────────────────────────────────
|
||||
|
||||
let queueRunning = false;
|
||||
let queueAbort: AbortController | null = null;
|
||||
let runningProc: ReturnType<typeof Bun.spawn> | null = null;
|
||||
let runningJobId: number | null = null;
|
||||
let activeQueue: Job[] | null = null;
|
||||
let activeSeen: Set<number> | null = null;
|
||||
const LIVE_UPDATE_INTERVAL_MS = 500;
|
||||
const STREAM_CHUNKS_BEFORE_YIELD = 24;
|
||||
|
||||
export function shouldSendLiveUpdate(now: number, lastSentAt: number, intervalMs = LIVE_UPDATE_INTERVAL_MS): boolean {
|
||||
return now - lastSentAt > intervalMs;
|
||||
}
|
||||
|
||||
export async function yieldAfterChunk(
|
||||
chunksSinceYield: number,
|
||||
chunksBeforeYield = STREAM_CHUNKS_BEFORE_YIELD,
|
||||
sleep: (ms: number) => Promise<unknown> = (ms) => Bun.sleep(ms),
|
||||
): Promise<number> {
|
||||
const next = chunksSinceYield + 1;
|
||||
if (next < chunksBeforeYield) return next;
|
||||
await sleep(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function enqueueUnseenJobs<T extends { id: number }>(queue: T[], seen: Set<number>, jobs: T[]): number {
|
||||
let added = 0;
|
||||
for (const job of jobs) {
|
||||
if (seen.has(job.id)) continue;
|
||||
queue.push(job);
|
||||
seen.add(job.id);
|
||||
added += 1;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function emitQueueStatus(
|
||||
status: "running" | "paused" | "sleeping" | "idle",
|
||||
@@ -31,12 +65,22 @@ function emitQueueStatus(
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
async function runSequential(jobs: Job[]): Promise<void> {
|
||||
async function runSequential(initial: Job[], { drain = true } = {}): Promise<void> {
|
||||
if (queueRunning) return;
|
||||
queueRunning = true;
|
||||
queueAbort = new AbortController();
|
||||
const { signal } = queueAbort;
|
||||
try {
|
||||
let first = true;
|
||||
for (const job of jobs) {
|
||||
const queue: Job[] = [...initial];
|
||||
const seen = new Set<number>(queue.map((j) => j.id));
|
||||
activeQueue = queue;
|
||||
activeSeen = seen;
|
||||
|
||||
while (queue.length > 0) {
|
||||
if (signal.aborted) break;
|
||||
const job = queue.shift() as Job;
|
||||
|
||||
// Pause outside the processing window
|
||||
if (!isInProcessWindow()) {
|
||||
emitQueueStatus("paused", {
|
||||
@@ -70,9 +114,20 @@ async function runSequential(jobs: Job[]): Promise<void> {
|
||||
} catch (err) {
|
||||
logError(`Job ${job.id} failed:`, err);
|
||||
}
|
||||
|
||||
// When the local queue drains, re-check the DB for jobs that were
|
||||
// approved mid-run. Without this they'd sit pending until the user
|
||||
// manually clicks "Run all" again. Skip if aborted or drain=false.
|
||||
if (drain && queue.length === 0 && !signal.aborted) {
|
||||
const more = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[];
|
||||
enqueueUnseenJobs(queue, seen, more);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeQueue = null;
|
||||
activeSeen = null;
|
||||
queueRunning = false;
|
||||
queueAbort = null;
|
||||
emitQueueStatus("idle");
|
||||
}
|
||||
}
|
||||
@@ -91,6 +146,56 @@ function emitJobProgress(jobId: number, seconds: number, total: number): void {
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
export function emitInboxSorted(result: { moved_to_queue: number; moved_to_review: number }): void {
|
||||
const line = `event: inbox_sorted\ndata: ${JSON.stringify(result)}\n\n`;
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
export function emitInboxSortStart(total: number): void {
|
||||
const line = `event: inbox_sort_start\ndata: ${JSON.stringify({ total })}\n\n`;
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
export function emitInboxSortProgress(processed: number, total: number): void {
|
||||
const line = `event: inbox_sort_progress\ndata: ${JSON.stringify({ processed, total })}\n\n`;
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
/** Lightweight nudge so the pipeline page refreshes column data. */
|
||||
export function emitPipelineChanged(): void {
|
||||
const line = "event: pipeline_changed\ndata: {}\n\n";
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the queue runner is idle and the auto_process_queue config is on, kick
|
||||
* off a sequential pass over whatever's currently pending. Used by the
|
||||
* enqueue path (so a fresh approval immediately starts draining) and by the
|
||||
* settings toggle (so flipping the checkbox drains existing queue items).
|
||||
* Returns true when a new run was started.
|
||||
*/
|
||||
export function maybeStartQueueProcessor(): boolean {
|
||||
if (queueRunning) return false;
|
||||
if (getConfig("auto_process_queue") !== "1") return false;
|
||||
const pending = getDb().prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[];
|
||||
if (pending.length === 0) return false;
|
||||
runSequential(pending).catch((err) => logError("Queue failed:", err));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the queue loop so no more jobs start after the current one. The
|
||||
* currently running ffmpeg keeps going — use POST /stop to also kill it.
|
||||
* Mirrors stopAutoProcessLoop() for the inbox sorter.
|
||||
*/
|
||||
export function stopQueueProcessor(): boolean {
|
||||
if (queueAbort) {
|
||||
queueAbort.abort();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parse "Duration: HH:MM:SS.MS" from ffmpeg startup output. */
|
||||
function parseFFmpegDuration(line: string): number | null {
|
||||
const match = line.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/);
|
||||
@@ -137,21 +242,17 @@ function loadJobRow(jobId: number) {
|
||||
return { job: row as unknown as Job, item };
|
||||
}
|
||||
|
||||
// ─── Param helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function parseId(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
// ─── Start all pending ────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/start", (c) => {
|
||||
const db = getDb();
|
||||
const pending = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[];
|
||||
if (queueRunning && activeQueue && activeSeen) {
|
||||
const queued = enqueueUnseenJobs(activeQueue, activeSeen, pending);
|
||||
return c.json({ ok: true, started: 0, queued });
|
||||
}
|
||||
runSequential(pending).catch((err) => logError("Queue failed:", err));
|
||||
return c.json({ ok: true, started: pending.length });
|
||||
return c.json({ ok: true, started: pending.length, queued: pending.length });
|
||||
});
|
||||
|
||||
// ─── Run single ───────────────────────────────────────────────────────────────
|
||||
@@ -167,7 +268,7 @@ app.post("/job/:id/run", async (c) => {
|
||||
if (!result) return c.notFound();
|
||||
return c.json(result);
|
||||
}
|
||||
runSequential([job]).catch((err) => logError(`Job ${job.id} failed:`, err));
|
||||
runSequential([job], { drain: false }).catch((err) => logError(`Job ${job.id} failed:`, err));
|
||||
const result = loadJobRow(jobId);
|
||||
if (!result) return c.notFound();
|
||||
return c.json(result);
|
||||
@@ -185,17 +286,29 @@ app.post("/job/:id/cancel", (c) => {
|
||||
|
||||
// ─── Clear queue ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/clear", (c) => {
|
||||
const db = getDb();
|
||||
/**
|
||||
* Cancel every pending job and send its plan back to the Inbox so the
|
||||
* distributor can re-classify it. Without `sorted = 0` the plan stays in
|
||||
* Review with `auto_class='auto'` — where "Approve all ready" (auto_heuristic
|
||||
* only) can't re-queue it and "Auto Review" (sort-inbox, sorted=0 only) can't
|
||||
* see it, leaving the item stranded until the user manually approves.
|
||||
*/
|
||||
export function clearQueue(db: ReturnType<typeof getDb>): number {
|
||||
const rows = db.prepare("SELECT item_id FROM jobs WHERE status = 'pending'").all() as { item_id: number }[];
|
||||
for (const { item_id } of rows) {
|
||||
db
|
||||
.prepare(`
|
||||
UPDATE review_plans SET status = 'pending', reviewed_at = NULL
|
||||
WHERE item_id IN (SELECT item_id FROM jobs WHERE status = 'pending')
|
||||
AND status = 'approved'
|
||||
`)
|
||||
.run();
|
||||
const result = db.prepare("DELETE FROM jobs WHERE status = 'pending'").run();
|
||||
return c.json({ ok: true, cleared: result.changes });
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'pending', reviewed_at = NULL, sorted = 0, auto_class = NULL WHERE item_id = ? AND status != 'running'",
|
||||
)
|
||||
.run(item_id);
|
||||
db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('pending', 'done', 'error')").run(item_id);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
app.post("/clear", (c) => {
|
||||
const cleared = clearQueue(getDb());
|
||||
return c.json({ ok: true, cleared });
|
||||
});
|
||||
|
||||
app.post("/clear-completed", (c) => {
|
||||
@@ -207,8 +320,12 @@ app.post("/clear-completed", (c) => {
|
||||
// ─── Stop running job ─────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/stop", (c) => {
|
||||
// Abort the queue loop so no more jobs start after the current one
|
||||
if (queueAbort) queueAbort.abort();
|
||||
|
||||
if (!runningProc || runningJobId == null) {
|
||||
return c.json({ ok: false, error: "No job is currently running" }, 409);
|
||||
// No active ffmpeg but queue loop might be between jobs — abort is enough
|
||||
return c.json({ ok: true, stopped: null });
|
||||
}
|
||||
const stoppedId = runningJobId;
|
||||
try {
|
||||
@@ -318,14 +435,16 @@ async function runJob(job: Job): Promise<void> {
|
||||
const updateOutput = db.prepare("UPDATE jobs SET output = ? WHERE id = ?");
|
||||
|
||||
const flush = (final = false) => {
|
||||
const text = outputLines.join("\n");
|
||||
const now = Date.now();
|
||||
if (final || now - lastFlushAt > 500) {
|
||||
if (!final && !shouldSendLiveUpdate(now, lastFlushAt)) {
|
||||
pendingFlush = true;
|
||||
return;
|
||||
}
|
||||
const text = outputLines.join("\n");
|
||||
if (final || shouldSendLiveUpdate(now, lastFlushAt)) {
|
||||
updateOutput.run(text, job.id);
|
||||
lastFlushAt = now;
|
||||
pendingFlush = false;
|
||||
} else {
|
||||
pendingFlush = true;
|
||||
}
|
||||
emitJobUpdate(job.id, "running", text);
|
||||
};
|
||||
@@ -338,7 +457,7 @@ async function runJob(job: Job): Promise<void> {
|
||||
const progressed = parseFFmpegProgress(line);
|
||||
if (progressed != null && totalSeconds > 0) {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressEmit > 500) {
|
||||
if (shouldSendLiveUpdate(now, lastProgressEmit)) {
|
||||
emitJobProgress(job.id, progressed, totalSeconds);
|
||||
lastProgressEmit = now;
|
||||
}
|
||||
@@ -353,6 +472,7 @@ async function runJob(job: Job): Promise<void> {
|
||||
const reader = readable.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let chunksSinceYield = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -366,6 +486,8 @@ async function runJob(job: Job): Promise<void> {
|
||||
consumeProgress(line);
|
||||
}
|
||||
flush();
|
||||
// Let pending HTTP requests run even when ffmpeg floods stdout/stderr.
|
||||
chunksSinceYield = await yieldAfterChunk(chunksSinceYield);
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
outputLines.push(prefix + buffer);
|
||||
@@ -382,14 +504,13 @@ async function runJob(job: Job): Promise<void> {
|
||||
|
||||
const fullOutput = outputLines.join("\n");
|
||||
|
||||
// Gather sidecar files to record
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(job.item_id) as MediaItem | undefined;
|
||||
// Post-job bookkeeping. The extraction itself is driven by the ffmpeg
|
||||
// command (see buildExtractionOutputs) — sidecars are already on disk.
|
||||
// Here we just need to flip subs_extracted on the plan so verify.ts
|
||||
// can tell the extraction step has run.
|
||||
const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(job.item_id) as MediaStream[];
|
||||
const files = item && streams.length > 0 ? predictExtractedFiles(item, streams) : [];
|
||||
const hadExtractableSubs = streams.some((s) => s.type === "Subtitle" && isExtractableSubtitle(s.codec));
|
||||
|
||||
const insertFile = db.prepare(
|
||||
"INSERT OR IGNORE INTO subtitle_files (item_id, file_path, language, codec, is_forced, is_hearing_impaired) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
const markJobDone = db.prepare(
|
||||
"UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?",
|
||||
);
|
||||
@@ -399,14 +520,15 @@ async function runJob(job: Job): Promise<void> {
|
||||
db.transaction(() => {
|
||||
markJobDone.run(fullOutput, job.id);
|
||||
markPlanDone.run(job.item_id);
|
||||
for (const f of files) {
|
||||
insertFile.run(job.item_id, f.file_path, f.language, f.codec, f.is_forced ? 1 : 0, f.is_hearing_impaired ? 1 : 0);
|
||||
}
|
||||
if (files.length > 0) markSubsExtracted.run(job.item_id);
|
||||
if (hadExtractableSubs) markSubsExtracted.run(job.item_id);
|
||||
})();
|
||||
|
||||
log(`Job ${job.id} completed successfully`);
|
||||
emitJobUpdate(job.id, "done", fullOutput);
|
||||
|
||||
// Trigger Radarr/Sonarr rescan + rename in the background.
|
||||
// Non-blocking: rename failures must not affect job status.
|
||||
triggerPostJobRename(job.item_id).catch((e) => warn(`Post-job rename for item ${job.item_id}: ${e}`));
|
||||
} catch (err) {
|
||||
logError(`Job ${job.id} failed:`, err);
|
||||
const fullOutput = `${outputLines.join("\n")}\n${String(err)}`;
|
||||
@@ -471,4 +593,80 @@ export function parseFFmpegProgress(line: string): number | null {
|
||||
return h * 3600 + m * 60 + s;
|
||||
}
|
||||
|
||||
// ─── Radarr/Sonarr rename ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trigger Radarr/Sonarr rename for the given items, deduplicated: one call
|
||||
* per movie, one call per series (covers every episode of that series in
|
||||
* one shot). After each call, updates file_path on every media_items row
|
||||
* whose basename was renamed.
|
||||
*
|
||||
* Idempotent — *arr returns no work to do when filenames already match.
|
||||
* Used after a job completes (fix-up post-transcode) and after processInbox
|
||||
* classifies items as noop (catch lying filenames on already-clean files).
|
||||
*/
|
||||
async function triggerRenameFor(itemIds: number[]): Promise<void> {
|
||||
if (itemIds.length === 0) return;
|
||||
const db = getDb();
|
||||
const placeholders = itemIds.map(() => "?").join(",");
|
||||
const items = db
|
||||
.prepare(`SELECT id, type, file_path, tmdb_id, imdb_id, tvdb_id FROM media_items WHERE id IN (${placeholders})`)
|
||||
.all(...itemIds) as Pick<MediaItem, "id" | "type" | "file_path" | "tmdb_id" | "imdb_id" | "tvdb_id">[];
|
||||
|
||||
const movies = items.filter((i) => i.type === "Movie");
|
||||
const seriesByTvdb = new Map<string, typeof items>();
|
||||
for (const ep of items.filter((i) => i.type === "Episode")) {
|
||||
if (!ep.tvdb_id) continue;
|
||||
const list = seriesByTvdb.get(ep.tvdb_id) ?? [];
|
||||
list.push(ep);
|
||||
seriesByTvdb.set(ep.tvdb_id, list);
|
||||
}
|
||||
|
||||
const radarrCfg: radarr.RadarrConfig = { url: getConfig("radarr_url") ?? "", apiKey: getConfig("radarr_api_key") ?? "" };
|
||||
const sonarrCfg: sonarr.SonarrConfig = { url: getConfig("sonarr_url") ?? "", apiKey: getConfig("sonarr_api_key") ?? "" };
|
||||
|
||||
for (const movie of movies) {
|
||||
const result = await radarr.triggerMovieRename(radarrCfg, { tmdbId: movie.tmdb_id, imdbId: movie.imdb_id });
|
||||
if (!result.ok) {
|
||||
warn(`Rename for movie ${movie.id}: ${result.error}`);
|
||||
continue;
|
||||
}
|
||||
applyRenamesToDb(db, result.renames);
|
||||
}
|
||||
|
||||
for (const tvdbId of seriesByTvdb.keys()) {
|
||||
const result = await sonarr.triggerSeriesRename(sonarrCfg, { tvdbId });
|
||||
if (!result.ok) {
|
||||
warn(`Rename for series tvdb=${tvdbId}: ${result.error}`);
|
||||
continue;
|
||||
}
|
||||
applyRenamesToDb(db, result.renames);
|
||||
}
|
||||
}
|
||||
|
||||
function applyRenamesToDb(db: ReturnType<typeof getDb>, renames: Map<string, string>): void {
|
||||
if (renames.size === 0) return;
|
||||
const sel = db.prepare("SELECT id, file_path FROM media_items WHERE file_path LIKE ?");
|
||||
const upd = db.prepare("UPDATE media_items SET file_path = ? WHERE id = ?");
|
||||
for (const [oldName, newName] of renames) {
|
||||
if (oldName === newName) continue;
|
||||
const rows = sel.all(`%/${oldName}`) as { id: number; file_path: string }[];
|
||||
for (const r of rows) {
|
||||
const newPath = `${r.file_path.slice(0, r.file_path.length - oldName.length)}${newName}`;
|
||||
upd.run(newPath, r.id);
|
||||
log(`Item ${r.id} renamed: ${r.file_path} → ${newPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget rename trigger. Errors are logged, never thrown. */
|
||||
export function triggerRenameForItems(itemIds: number[]): void {
|
||||
triggerRenameFor(itemIds).catch((e) => warn(`Rename batch failed: ${e}`));
|
||||
}
|
||||
|
||||
/** Single-item helper used by the per-job post-success path. */
|
||||
function triggerPostJobRename(itemId: number): Promise<void> {
|
||||
return triggerRenameFor([itemId]);
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
+1074
-238
File diff suppressed because it is too large
Load Diff
+65
-103
@@ -1,12 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { stream } from "hono/streaming";
|
||||
import { getAllConfig, getConfig, getDb, setConfig } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { getAllItems, getDevItems } from "../services/jellyfin";
|
||||
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
|
||||
import { upsertJellyfinItem } from "../services/rescan";
|
||||
import { log, error as logError } from "../lib/log";
|
||||
import { discoverVideoFiles } from "../services/discover";
|
||||
import { parsePath } from "../services/path-parser";
|
||||
import { probeFile } from "../services/probe";
|
||||
import { upsertScannedItem } from "../services/rescan";
|
||||
import { isInScanWindow, msUntilScanWindow, nextScanWindowTime, waitForScanWindow } from "../services/scheduler";
|
||||
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
|
||||
import { emitPipelineChanged } from "./execute";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -38,16 +39,6 @@ function currentScanLimit(): number | null {
|
||||
return v ? Number(v) : null;
|
||||
}
|
||||
|
||||
function parseLanguageList(raw: string | null, fallback: string[]): string[] {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/", (c) => {
|
||||
@@ -60,12 +51,28 @@ app.get("/", (c) => {
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number })
|
||||
.n;
|
||||
const recentItems = db
|
||||
.prepare("SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50")
|
||||
.all() as { name: string; type: string; scan_status: string; file_path: string }[];
|
||||
.prepare(
|
||||
"SELECT name, type, scan_status, file_path, last_scanned_at FROM media_items ORDER BY COALESCE(last_scanned_at, created_at) DESC, id DESC LIMIT 5",
|
||||
)
|
||||
.all() as {
|
||||
name: string;
|
||||
type: string;
|
||||
scan_status: string;
|
||||
file_path: string;
|
||||
last_scanned_at: string | null;
|
||||
}[];
|
||||
|
||||
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
|
||||
});
|
||||
|
||||
app.get("/errors", (c) => {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare("SELECT name, file_path, scan_error FROM media_items WHERE scan_status = 'error' ORDER BY name LIMIT 100")
|
||||
.all() as { name: string; file_path: string; scan_error: string | null }[];
|
||||
return c.json({ errors: rows });
|
||||
});
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/start", async (c) => {
|
||||
@@ -149,85 +156,39 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
log(`Scan started${limit ? ` (limit: ${limit})` : ""}`);
|
||||
scanAbort = new AbortController();
|
||||
const { signal } = scanAbort;
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const db = getDb();
|
||||
|
||||
if (isDev) {
|
||||
// Order matters only if foreign keys are enforced without CASCADE; we
|
||||
// have ON DELETE CASCADE on media_streams/review_plans/stream_decisions/
|
||||
// subtitle_files/jobs, so deleting media_items would be enough. List
|
||||
// them explicitly for clarity and to survive future schema drift.
|
||||
db.prepare("DELETE FROM jobs").run();
|
||||
db.prepare("DELETE FROM subtitle_files").run();
|
||||
db.prepare("DELETE FROM stream_decisions").run();
|
||||
db.prepare("DELETE FROM review_plans").run();
|
||||
db.prepare("DELETE FROM media_streams").run();
|
||||
db.prepare("DELETE FROM media_items").run();
|
||||
}
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
||||
const audioLanguages = parseLanguageList(cfg.audio_languages ?? null, []);
|
||||
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
|
||||
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
|
||||
// 'enabled' in config means the user toggled it on. Only actually use it
|
||||
// if the URL+key pass URL parsing — otherwise we'd hit ERR_INVALID_URL on
|
||||
// every item. Refuse to call invalid endpoints rather than spamming logs.
|
||||
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
|
||||
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
|
||||
const moviesRoot = cfg.movies_root || "/movies";
|
||||
const tvRoot = cfg.tv_root || "/tv";
|
||||
|
||||
if (cfg.radarr_enabled === "1" && !radarrEnabled) {
|
||||
warn(`Radarr is enabled in config but URL/API key is invalid (url='${cfg.radarr_url}') — skipping Radarr lookups`);
|
||||
}
|
||||
if (cfg.sonarr_enabled === "1" && !sonarrEnabled) {
|
||||
warn(`Sonarr is enabled in config but URL/API key is invalid (url='${cfg.sonarr_url}') — skipping Sonarr lookups`);
|
||||
}
|
||||
|
||||
// Pre-load both libraries once so per-item lookups are O(1) cache hits
|
||||
// instead of HTTP round-trips. The previous code called /api/v3/movie
|
||||
// (the entire library!) once per item that didn't match by tmdbId.
|
||||
const [radarrLibrary, sonarrLibrary] = await Promise.all([
|
||||
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
|
||||
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
log(
|
||||
`External language sources: radarr=${radarrEnabled ? `enabled (${cfg.radarr_url}, ${radarrLibrary?.byTmdbId.size ?? 0} movies in library)` : "disabled"}, sonarr=${sonarrEnabled ? `enabled (${cfg.sonarr_url}, ${sonarrLibrary?.byTvdbId.size ?? 0} series in library)` : "disabled"}`,
|
||||
);
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
let total = 0;
|
||||
|
||||
const rescanCfg = {
|
||||
audioLanguages,
|
||||
radarr: radarrEnabled ? radarrCfg : null,
|
||||
sonarr: sonarrEnabled ? sonarrCfg : null,
|
||||
radarrLibrary,
|
||||
sonarrLibrary,
|
||||
};
|
||||
emitSse("log", { message: "Discovering files..." });
|
||||
const allFiles = await discoverVideoFiles([moviesRoot, tvRoot]);
|
||||
|
||||
let radarrMisses = 0;
|
||||
let radarrHits = 0;
|
||||
let sonarrMisses = 0;
|
||||
let sonarrHits = 0;
|
||||
let missingProviderIds = 0;
|
||||
// Skip files already scanned — makes the scan resumable across stops/restarts.
|
||||
const alreadyScanned = new Set(
|
||||
(db.prepare("SELECT file_path FROM media_items WHERE scan_status = 'scanned'").all() as { file_path: string }[]).map(
|
||||
(r) => r.file_path,
|
||||
),
|
||||
);
|
||||
const pending = allFiles.filter((f) => !alreadyScanned.has(f));
|
||||
const skipped = allFiles.length - pending.length;
|
||||
if (skipped > 0) log(`Scan: skipping ${skipped} already-scanned files, ${pending.length} remaining`);
|
||||
|
||||
const itemSource = isDev
|
||||
? getDevItems(jellyfinCfg)
|
||||
: getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => {
|
||||
total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal;
|
||||
});
|
||||
for await (const jellyfinItem of itemSource) {
|
||||
const total = limit != null ? Math.min(limit, pending.length) : pending.length;
|
||||
|
||||
emitSse("progress", { scanned: 0, total, current_item: null, errors, running: true });
|
||||
|
||||
for (const filePath of pending) {
|
||||
if (signal.aborted) break;
|
||||
if (!isDev && limit != null && processed >= limit) break;
|
||||
if (!jellyfinItem.Name || !jellyfinItem.Path) {
|
||||
warn(`Skipping item without name/path: id=${jellyfinItem.Id}`);
|
||||
continue;
|
||||
}
|
||||
if (limit != null && processed >= limit) break;
|
||||
|
||||
// Honour the scan window between items so overnight-only setups don't hog
|
||||
// Jellyfin during the day. Checked between items rather than mid-item so
|
||||
// we don't leave a partial upsert sitting in flight.
|
||||
// Honour the scan window between items so overnight-only setups don't
|
||||
// hog the filesystem during the day. Checked between items rather than
|
||||
// mid-item so we don't leave a partial upsert in flight.
|
||||
if (!isInScanWindow()) {
|
||||
emitSse("paused", {
|
||||
until: nextScanWindowTime(),
|
||||
@@ -238,40 +199,41 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
emitSse("resumed", {});
|
||||
}
|
||||
|
||||
const parsed = parsePath(filePath, moviesRoot, tvRoot);
|
||||
if (!parsed) continue;
|
||||
|
||||
processed++;
|
||||
emitSse("progress", { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
|
||||
emitSse("progress", { scanned: processed, total, current_item: parsed.name, errors, running: true });
|
||||
|
||||
try {
|
||||
const result = await upsertJellyfinItem(db, jellyfinItem, rescanCfg);
|
||||
if (result.radarrHit) radarrHits++;
|
||||
if (result.radarrMiss) radarrMisses++;
|
||||
if (result.sonarrHit) sonarrHits++;
|
||||
if (result.sonarrMiss) sonarrMisses++;
|
||||
if (result.missingProviderId) missingProviderIds++;
|
||||
emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "scanned", file: jellyfinItem.Path });
|
||||
const probe = await probeFile(filePath);
|
||||
upsertScannedItem(db, filePath, parsed, probe);
|
||||
if (processed % 25 === 0) emitPipelineChanged();
|
||||
emitSse("log", { name: parsed.name, type: parsed.type, status: "scanned", file: filePath });
|
||||
} catch (err) {
|
||||
errors++;
|
||||
logError(`Error scanning ${jellyfinItem.Name}:`, err);
|
||||
logError(`Error scanning ${filePath}:`, err);
|
||||
try {
|
||||
db
|
||||
.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?")
|
||||
.run(String(err), jellyfinItem.Id);
|
||||
.prepare(`
|
||||
INSERT INTO media_items (file_path, type, name, scan_status, scan_error, last_scanned_at)
|
||||
VALUES (?, ?, ?, 'error', ?, datetime('now'))
|
||||
ON CONFLICT(file_path) DO UPDATE SET scan_status = 'error', scan_error = ?, last_scanned_at = datetime('now')
|
||||
`)
|
||||
.run(filePath, parsed.type, parsed.name, String(err), String(err));
|
||||
} catch (dbErr) {
|
||||
// Failed to persist the error status — log it so the incident
|
||||
// doesn't disappear silently. We can't do much more; the outer
|
||||
// loop continues so the scan still finishes.
|
||||
logError(`Failed to record scan error for ${jellyfinItem.Id}:`, dbErr);
|
||||
// doesn't disappear silently.
|
||||
logError(`Failed to record scan error for ${filePath}:`, dbErr);
|
||||
}
|
||||
emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "error", file: jellyfinItem.Path });
|
||||
emitSse("log", { name: parsed.name, type: parsed.type, status: "error", file: filePath });
|
||||
}
|
||||
}
|
||||
|
||||
setConfig("scan_running", "0");
|
||||
log(`Scan complete: ${processed} scanned, ${errors} errors`);
|
||||
log(
|
||||
` language sources: radarr hits=${radarrHits} misses=${radarrMisses}, sonarr hits=${sonarrHits} misses=${sonarrMisses}, no provider id=${missingProviderIds}`,
|
||||
);
|
||||
emitSse("complete", { scanned: processed, total, errors });
|
||||
// Auto-process loop (if enabled) picks up new inbox items automatically.
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
+64
-112
@@ -1,52 +1,38 @@
|
||||
import { Hono } from "hono";
|
||||
import { getAllConfig, getConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
|
||||
import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
|
||||
import { getMqttStatus, startMqttClient, testMqttConnection } from "../services/mqtt";
|
||||
import { testConnection as testRadarr } from "../services/radarr";
|
||||
import { getScheduleConfig, type ScheduleConfig, updateScheduleConfig } from "../services/scheduler";
|
||||
import { testConnection as testSonarr } from "../services/sonarr";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Config keys that hold credentials. `GET /` returns these as "***" when set,
|
||||
// "" when unset. Real values only reach the client via the explicit
|
||||
// GET /reveal?key=<key> endpoint (eye-icon toggle in the settings UI).
|
||||
const SECRET_KEYS = new Set(["radarr_api_key", "sonarr_api_key"]);
|
||||
|
||||
app.get("/", (c) => {
|
||||
const config = getAllConfig();
|
||||
for (const key of SECRET_KEYS) {
|
||||
if (config[key]) config[key] = "***";
|
||||
}
|
||||
const envLocked = Array.from(getEnvLockedKeys());
|
||||
return c.json({ config, envLocked });
|
||||
});
|
||||
|
||||
app.post("/jellyfin", async (c) => {
|
||||
const body = await c.req.json<{ url: string; api_key: string }>();
|
||||
const url = body.url?.replace(/\/$/, "");
|
||||
const apiKey = body.api_key;
|
||||
|
||||
if (!url || !apiKey) return c.json({ ok: false, error: "URL and API key are required" }, 400);
|
||||
|
||||
// Save first so the user's input is never silently dropped on a test
|
||||
// failure (matches the Radarr/Sonarr pattern). The frontend reads the
|
||||
// { ok, saved, testError } shape to decide what message to show.
|
||||
setConfig("jellyfin_url", url);
|
||||
setConfig("jellyfin_api_key", apiKey);
|
||||
|
||||
const result = await testJellyfin({ url, apiKey });
|
||||
|
||||
// Only mark setup complete when the connection actually works. Setting
|
||||
// setup_complete=1 on a failing test would let the user click past the
|
||||
// wizard into an app that then dies on the first Jellyfin call.
|
||||
if (result.ok) {
|
||||
setConfig("setup_complete", "1");
|
||||
// Best-effort admin discovery only when the connection works; ignore failures.
|
||||
try {
|
||||
const users = await getUsers({ url, apiKey });
|
||||
const admin = users.find((u) => u.Name === "admin") ?? users[0];
|
||||
if (admin?.Id) setConfig("jellyfin_user_id", admin.Id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: result.ok, saved: true, testError: result.ok ? undefined : result.error });
|
||||
app.get("/reveal", (c) => {
|
||||
const key = c.req.query("key") ?? "";
|
||||
if (!SECRET_KEYS.has(key)) return c.json({ error: "not a secret key" }, 400);
|
||||
return c.json({ value: getConfig(key) ?? "" });
|
||||
});
|
||||
|
||||
// The UI sends "***" as a sentinel meaning "user didn't touch this field,
|
||||
// keep the stored value". Save endpoints call this before writing a secret.
|
||||
function resolveSecret(incoming: string | undefined, storedKey: string): string {
|
||||
if (incoming === "***") return getConfig(storedKey) ?? "";
|
||||
return incoming ?? "";
|
||||
}
|
||||
|
||||
// Persist values BEFORE testing the connection. The previous behaviour
|
||||
// silently dropped what the user typed when the test failed (e.g. Sonarr
|
||||
// not yet reachable), making the field appear to "forget" the input on
|
||||
@@ -54,7 +40,7 @@ app.post("/jellyfin", async (c) => {
|
||||
app.post("/radarr", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||
const url = body.url?.replace(/\/$/, "");
|
||||
const apiKey = body.api_key;
|
||||
const apiKey = resolveSecret(body.api_key, "radarr_api_key");
|
||||
|
||||
if (!url || !apiKey) {
|
||||
setConfig("radarr_enabled", "0");
|
||||
@@ -72,7 +58,7 @@ app.post("/radarr", async (c) => {
|
||||
app.post("/sonarr", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||
const url = body.url?.replace(/\/$/, "");
|
||||
const apiKey = body.api_key;
|
||||
const apiKey = resolveSecret(body.api_key, "sonarr_api_key");
|
||||
|
||||
if (!url || !apiKey) {
|
||||
setConfig("sonarr_enabled", "0");
|
||||
@@ -93,6 +79,47 @@ app.post("/audio-languages", async (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Toggle the auto-processing flag. When flipped on, start a continuous
|
||||
// polling loop that monitors the inbox and processes items as they arrive.
|
||||
// When flipped off, stop the loop and abort any in-progress auto-run.
|
||||
app.post("/auto-processing", async (c) => {
|
||||
const body = await c.req.json<{ enabled?: unknown }>().catch(() => ({ enabled: null }));
|
||||
if (typeof body.enabled !== "boolean") {
|
||||
return c.json({ ok: false, error: "enabled must be a boolean" }, 400);
|
||||
}
|
||||
setConfig("auto_processing", body.enabled ? "1" : "0");
|
||||
|
||||
if (body.enabled) {
|
||||
const { startAutoProcessLoop } = await import("./review");
|
||||
startAutoProcessLoop();
|
||||
} else {
|
||||
const { stopAutoProcessLoop, stopProcessInbox } = await import("./review");
|
||||
stopAutoProcessLoop();
|
||||
stopProcessInbox();
|
||||
}
|
||||
return c.json({ ok: true, enabled: body.enabled });
|
||||
});
|
||||
|
||||
// Toggle the auto-process-queue flag. When flipped on, kick the queue
|
||||
// processor so any already-pending jobs start draining immediately without
|
||||
// waiting for the next approval to trigger it.
|
||||
app.post("/auto-process-queue", async (c) => {
|
||||
const body = await c.req.json<{ enabled?: unknown }>().catch(() => ({ enabled: null }));
|
||||
if (typeof body.enabled !== "boolean") {
|
||||
return c.json({ ok: false, error: "enabled must be a boolean" }, 400);
|
||||
}
|
||||
setConfig("auto_process_queue", body.enabled ? "1" : "0");
|
||||
|
||||
if (body.enabled) {
|
||||
const { maybeStartQueueProcessor } = await import("./execute");
|
||||
const started = maybeStartQueueProcessor();
|
||||
return c.json({ ok: true, enabled: true, started });
|
||||
}
|
||||
const { stopQueueProcessor } = await import("./execute");
|
||||
const stopped = stopQueueProcessor();
|
||||
return c.json({ ok: true, enabled: false, stopped });
|
||||
});
|
||||
|
||||
app.get("/schedule", (c) => {
|
||||
return c.json(getScheduleConfig());
|
||||
});
|
||||
@@ -107,86 +134,12 @@ app.patch("/schedule", async (c) => {
|
||||
return c.json(getScheduleConfig());
|
||||
});
|
||||
|
||||
// ─── MQTT ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/mqtt", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
topic?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>();
|
||||
const enabled = body.enabled === true;
|
||||
const url = (body.url ?? "").trim();
|
||||
const topic = (body.topic ?? "jellyfin/events").trim();
|
||||
const username = (body.username ?? "").trim();
|
||||
const password = body.password ?? "";
|
||||
|
||||
setConfig("mqtt_enabled", enabled ? "1" : "0");
|
||||
setConfig("mqtt_url", url);
|
||||
setConfig("mqtt_topic", topic || "jellyfin/events");
|
||||
setConfig("mqtt_username", username);
|
||||
// Only overwrite password when a non-empty value is sent, so the UI can
|
||||
// leave the field blank to indicate "keep the existing one".
|
||||
if (password) setConfig("mqtt_password", password);
|
||||
|
||||
// Reconnect with the new config. Best-effort; failures surface in status.
|
||||
startMqttClient().catch(() => {});
|
||||
|
||||
return c.json({ ok: true, saved: true });
|
||||
});
|
||||
|
||||
app.get("/mqtt/status", (c) => {
|
||||
return c.json(getMqttStatus());
|
||||
});
|
||||
|
||||
app.post("/mqtt/test", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>();
|
||||
const url = (body.url ?? "").trim();
|
||||
if (!url) return c.json({ ok: false, error: "Broker URL required" }, 400);
|
||||
const topic = (body.topic ?? "jellyfin/events").trim() || "jellyfin/events";
|
||||
const password = body.password || getConfig("mqtt_password") || "";
|
||||
|
||||
// The user triggers real activity in Jellyfin (start playback / add an
|
||||
// item) while the test runs — a blind metadata refresh from here often
|
||||
// doesn't fire any webhook (the plugin only emits Item Added on actual
|
||||
// additions, which a no-op refresh isn't).
|
||||
const result = await testMqttConnection(
|
||||
{ url, topic, username: (body.username ?? "").trim(), password },
|
||||
async () => null,
|
||||
30_000,
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns whether Jellyfin has the Webhook plugin installed. The Settings
|
||||
* panel uses this to decide between "setup steps" vs "install this plugin".
|
||||
*/
|
||||
app.get("/jellyfin/webhook-plugin", async (c) => {
|
||||
const url = getConfig("jellyfin_url");
|
||||
const apiKey = getConfig("jellyfin_api_key");
|
||||
if (!url || !apiKey) return c.json({ ok: false, error: "Jellyfin not configured" }, 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/Plugins`, { headers: { "X-Emby-Token": apiKey } });
|
||||
if (!res.ok) return c.json({ ok: false, error: `HTTP ${res.status}` }, 502);
|
||||
const plugins = (await res.json()) as { Name?: string; Id?: string; Version?: string }[];
|
||||
const hit = plugins.find((p) => typeof p.Name === "string" && p.Name.toLowerCase().includes("webhook"));
|
||||
return c.json({ ok: true, installed: !!hit, plugin: hit ?? null });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: String(err) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/clear-scan", (c) => {
|
||||
const db = getDb();
|
||||
// Delete children first to avoid slow cascade deletes
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM stream_decisions").run();
|
||||
db.prepare("DELETE FROM jobs").run();
|
||||
db.prepare("DELETE FROM subtitle_files").run();
|
||||
db.prepare("DELETE FROM review_plans").run();
|
||||
db.prepare("DELETE FROM media_streams").run();
|
||||
db.prepare("DELETE FROM media_items").run();
|
||||
@@ -198,8 +151,8 @@ app.post("/clear-scan", (c) => {
|
||||
/**
|
||||
* Full factory reset. Truncates every table including config, re-seeds the
|
||||
* defaults so the setup wizard reappears, and returns. Env-backed config
|
||||
* keys (JELLYFIN_URL, etc.) continue to resolve via getConfig's env fallback
|
||||
* — they don't live in the DB to begin with.
|
||||
* keys continue to resolve via getConfig's env fallback — they don't live
|
||||
* in the DB to begin with.
|
||||
*/
|
||||
app.post("/reset", (c) => {
|
||||
const db = getDb();
|
||||
@@ -207,7 +160,6 @@ app.post("/reset", (c) => {
|
||||
// Order matters when ON DELETE CASCADE isn't consistent across versions.
|
||||
db.prepare("DELETE FROM stream_decisions").run();
|
||||
db.prepare("DELETE FROM jobs").run();
|
||||
db.prepare("DELETE FROM subtitle_files").run();
|
||||
db.prepare("DELETE FROM review_plans").run();
|
||||
db.prepare("DELETE FROM media_streams").run();
|
||||
db.prepare("DELETE FROM media_items").run();
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { dirname, resolve as resolvePath, sep } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import { getAllConfig, getDb } from "../db/index";
|
||||
import { error as logError } from "../lib/log";
|
||||
import { parseId } from "../lib/validate";
|
||||
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types";
|
||||
import { reanalyze, titleKey } from "./review";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SubListItem {
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
year: number | null;
|
||||
original_language: string | null;
|
||||
file_path: string;
|
||||
subs_extracted: number | null;
|
||||
sub_count: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface SubSeriesGroup {
|
||||
series_key: string;
|
||||
series_name: string;
|
||||
original_language: string | null;
|
||||
season_count: number;
|
||||
episode_count: number;
|
||||
not_extracted_count: number;
|
||||
extracted_count: number;
|
||||
no_subs_count: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
|
||||
if (!item) return null;
|
||||
|
||||
const subtitleStreams = db
|
||||
.prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index")
|
||||
.all(itemId) as MediaStream[];
|
||||
const files = db
|
||||
.prepare("SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path")
|
||||
.all(itemId) as SubtitleFile[];
|
||||
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(itemId) as ReviewPlan | undefined;
|
||||
const decisions = plan
|
||||
? (db
|
||||
.prepare(
|
||||
"SELECT sd.* FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle'",
|
||||
)
|
||||
.all(plan.id) as StreamDecision[])
|
||||
: [];
|
||||
return {
|
||||
item,
|
||||
subtitleStreams,
|
||||
files,
|
||||
plan: plan ?? null,
|
||||
decisions,
|
||||
subs_extracted: plan?.subs_extracted ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSubWhere(filter: string): string {
|
||||
switch (filter) {
|
||||
case "not_extracted":
|
||||
return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
|
||||
case "extracted":
|
||||
return "rp.subs_extracted = 1";
|
||||
case "no_subs":
|
||||
return "sub_count = 0";
|
||||
default:
|
||||
return "1=1";
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
const filter = c.req.query("filter") ?? "all";
|
||||
const where = buildSubWhere(filter);
|
||||
|
||||
// Movies
|
||||
const movieRows = db
|
||||
.prepare(`
|
||||
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||
rp.subs_extracted,
|
||||
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count,
|
||||
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE mi.type = 'Movie' AND ${where}
|
||||
ORDER BY mi.name LIMIT 500
|
||||
`)
|
||||
.all() as SubListItem[];
|
||||
|
||||
// Series groups
|
||||
const series = db
|
||||
.prepare(`
|
||||
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key,
|
||||
mi.series_name,
|
||||
MAX(mi.original_language) as original_language,
|
||||
COUNT(DISTINCT mi.season_number) as season_count,
|
||||
COUNT(mi.id) as episode_count,
|
||||
SUM(CASE WHEN sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0 THEN 1 ELSE 0 END) as not_extracted_count,
|
||||
SUM(CASE WHEN rp.subs_extracted = 1 THEN 1 ELSE 0 END) as extracted_count,
|
||||
SUM(CASE WHEN sub_count = 0 THEN 1 ELSE 0 END) as no_subs_count
|
||||
FROM (
|
||||
SELECT mi.*,
|
||||
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count
|
||||
FROM media_items mi
|
||||
WHERE mi.type = 'Episode'
|
||||
) mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE ${where}
|
||||
GROUP BY series_key ORDER BY mi.series_name
|
||||
`)
|
||||
.all() as SubSeriesGroup[];
|
||||
|
||||
const totalAll = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n;
|
||||
const totalExtracted = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1").get() as { n: number }
|
||||
).n;
|
||||
const totalNoSubs = (
|
||||
db
|
||||
.prepare(`
|
||||
SELECT COUNT(*) as n FROM media_items mi
|
||||
WHERE NOT EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle')
|
||||
`)
|
||||
.get() as { n: number }
|
||||
).n;
|
||||
const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
|
||||
|
||||
return c.json({
|
||||
movies: movieRows,
|
||||
series,
|
||||
filter,
|
||||
totalCounts: { all: totalAll, not_extracted: totalNotExtracted, extracted: totalExtracted, no_subs: totalNoSubs },
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Series episodes (subtitles) ─────────────────────────────────────────────
|
||||
|
||||
app.get("/series/:seriesKey/episodes", (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||
rp.subs_extracted,
|
||||
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count,
|
||||
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE mi.type = 'Episode'
|
||||
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
ORDER BY mi.season_number, mi.episode_number
|
||||
`)
|
||||
.all(seriesKey, seriesKey) as SubListItem[];
|
||||
|
||||
const seasonMap = new Map<number | null, SubListItem[]>();
|
||||
for (const r of rows) {
|
||||
const season = r.season_number ?? null;
|
||||
if (!seasonMap.has(season)) seasonMap.set(season, []);
|
||||
seasonMap.get(season)!.push(r);
|
||||
}
|
||||
|
||||
const seasons = Array.from(seasonMap.entries())
|
||||
.sort(([a], [b]) => (a ?? -1) - (b ?? -1))
|
||||
.map(([season, episodes]) => ({
|
||||
season,
|
||||
episodes,
|
||||
extractedCount: episodes.filter((e) => e.subs_extracted === 1).length,
|
||||
notExtractedCount: episodes.filter((e) => e.sub_count > 0 && !e.subs_extracted).length,
|
||||
noSubsCount: episodes.filter((e) => e.sub_count === 0).length,
|
||||
}));
|
||||
|
||||
return c.json({ seasons });
|
||||
});
|
||||
|
||||
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CategoryRow {
|
||||
language: string | null;
|
||||
is_forced: number;
|
||||
is_hearing_impaired: number;
|
||||
cnt: number;
|
||||
}
|
||||
|
||||
function variantOf(row: { is_forced: number; is_hearing_impaired: number }): "forced" | "cc" | "standard" {
|
||||
if (row.is_forced) return "forced";
|
||||
if (row.is_hearing_impaired) return "cc";
|
||||
return "standard";
|
||||
}
|
||||
|
||||
function catKey(lang: string | null, variant: string) {
|
||||
return `${lang ?? "__null__"}|${variant}`;
|
||||
}
|
||||
|
||||
app.get("/summary", (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Embedded count — items with subtitle streams where subs_extracted = 0
|
||||
const embeddedCount = (
|
||||
db
|
||||
.prepare(`
|
||||
SELECT COUNT(DISTINCT mi.id) as n FROM media_items mi
|
||||
JOIN media_streams ms ON ms.item_id = mi.id AND ms.type = 'Subtitle'
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE COALESCE(rp.subs_extracted, 0) = 0
|
||||
`)
|
||||
.get() as { n: number }
|
||||
).n;
|
||||
|
||||
// Stream counts by (language, variant)
|
||||
const streamRows = db
|
||||
.prepare(`
|
||||
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||
FROM media_streams WHERE type = 'Subtitle'
|
||||
GROUP BY language, is_forced, is_hearing_impaired
|
||||
`)
|
||||
.all() as CategoryRow[];
|
||||
|
||||
// File counts by (language, variant)
|
||||
const fileRows = db
|
||||
.prepare(`
|
||||
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||
FROM subtitle_files
|
||||
GROUP BY language, is_forced, is_hearing_impaired
|
||||
`)
|
||||
.all() as CategoryRow[];
|
||||
|
||||
// Merge into categories
|
||||
const catMap = new Map<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>();
|
||||
for (const r of streamRows) {
|
||||
const v = variantOf(r);
|
||||
const k = catKey(r.language, v);
|
||||
catMap.set(k, { language: r.language, variant: v, streamCount: r.cnt, fileCount: 0 });
|
||||
}
|
||||
for (const r of fileRows) {
|
||||
const v = variantOf(r);
|
||||
const k = catKey(r.language, v);
|
||||
const existing = catMap.get(k);
|
||||
if (existing) {
|
||||
existing.fileCount = r.cnt;
|
||||
} else {
|
||||
catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt });
|
||||
}
|
||||
}
|
||||
const categories = Array.from(catMap.values()).sort((a, b) => {
|
||||
const la = a.language ?? "zzz";
|
||||
const lb = b.language ?? "zzz";
|
||||
if (la !== lb) return la.localeCompare(lb);
|
||||
return a.variant.localeCompare(b.variant);
|
||||
});
|
||||
|
||||
// Title grouping
|
||||
const titleRows = db
|
||||
.prepare(`
|
||||
SELECT language, title, COUNT(*) as cnt
|
||||
FROM media_streams WHERE type = 'Subtitle'
|
||||
GROUP BY language, title
|
||||
ORDER BY language, cnt DESC
|
||||
`)
|
||||
.all() as { language: string | null; title: string | null; cnt: number }[];
|
||||
|
||||
// Determine canonical title per language (most common)
|
||||
const canonicalByLang = new Map<string | null, string | null>();
|
||||
for (const r of titleRows) {
|
||||
if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title);
|
||||
}
|
||||
|
||||
const titles = titleRows.map((r) => ({
|
||||
language: r.language,
|
||||
title: r.title,
|
||||
count: r.cnt,
|
||||
isCanonical: canonicalByLang.get(r.language) === r.title,
|
||||
}));
|
||||
|
||||
return c.json({ embeddedCount, categories, titles });
|
||||
});
|
||||
|
||||
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const detail = loadDetail(db, id);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Edit stream language ────────────────────────────────────────────────────
|
||||
|
||||
app.patch("/:id/stream/:streamId/language", async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const streamId = parseId(c.req.param("streamId"));
|
||||
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||
const body = await c.req.json<{ language: string }>();
|
||||
const lang = (body.language ?? "").trim() || null;
|
||||
|
||||
const stream = db.prepare("SELECT * FROM media_streams WHERE id = ? AND item_id = ?").get(streamId, itemId) as
|
||||
| MediaStream
|
||||
| undefined;
|
||||
if (!stream) return c.notFound();
|
||||
|
||||
const normalized = lang ? normalizeLanguage(lang) : null;
|
||||
db.prepare("UPDATE media_streams SET language = ? WHERE id = ?").run(normalized, streamId);
|
||||
|
||||
const detail = loadDetail(db, itemId);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Edit stream title ──────────────────────────────────────────────────────
|
||||
|
||||
app.patch("/:id/stream/:streamId/title", async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const streamId = parseId(c.req.param("streamId"));
|
||||
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||
const body = await c.req.json<{ title: string }>();
|
||||
const title = (body.title ?? "").trim() || null;
|
||||
|
||||
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db
|
||||
.prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?")
|
||||
.run(title, plan.id, streamId);
|
||||
|
||||
const detail = loadDetail(db, itemId);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Delete file ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify a sidecar file path lives inside the directory of its owning
|
||||
* media item. Guards against path-traversal via malformed DB state.
|
||||
*/
|
||||
function isSidecarOfItem(filePath: string, videoPath: string): boolean {
|
||||
const videoDir = resolvePath(dirname(videoPath));
|
||||
const targetDir = resolvePath(dirname(filePath));
|
||||
return targetDir === videoDir || targetDir.startsWith(videoDir + sep);
|
||||
}
|
||||
|
||||
app.delete("/:id/files/:fileId", (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const fileId = parseId(c.req.param("fileId"));
|
||||
if (itemId == null || fileId == null) return c.json({ error: "invalid id" }, 400);
|
||||
|
||||
const file = db.prepare("SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?").get(fileId, itemId) as
|
||||
| SubtitleFile
|
||||
| undefined;
|
||||
if (!file) return c.notFound();
|
||||
|
||||
const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(itemId) as
|
||||
| { file_path: string }
|
||||
| undefined;
|
||||
if (!item || !isSidecarOfItem(file.file_path, item.file_path)) {
|
||||
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
||||
db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(fileId);
|
||||
return c.json({ ok: false, error: "file path outside media directory; DB entry removed without touching disk" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(file.file_path);
|
||||
} catch {
|
||||
/* file may not exist */
|
||||
}
|
||||
db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(fileId);
|
||||
|
||||
const files = db
|
||||
.prepare("SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path")
|
||||
.all(itemId) as SubtitleFile[];
|
||||
return c.json({ ok: true, files });
|
||||
});
|
||||
|
||||
// ─── Rescan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/:id/rescan", async (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(id) as MediaItem | undefined;
|
||||
if (!item) return c.notFound();
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jfCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
||||
|
||||
await refreshItem(jfCfg, item.jellyfin_id);
|
||||
|
||||
// Snapshot custom_titles before the DELETE cascades stream_decisions away,
|
||||
// so reanalyze() can re-attach them to the corresponding new stream rows.
|
||||
// Without this rescanning subtitles also wipes per-audio-stream title
|
||||
// overrides the user made in the review UI.
|
||||
const preservedTitles = new Map<string, string>();
|
||||
const oldTitleRows = db
|
||||
.prepare(`
|
||||
SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title
|
||||
FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
JOIN review_plans rp ON rp.id = sd.plan_id
|
||||
WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL
|
||||
`)
|
||||
.all(id) as {
|
||||
type: string;
|
||||
language: string | null;
|
||||
stream_index: number;
|
||||
title: string | null;
|
||||
custom_title: string;
|
||||
}[];
|
||||
for (const r of oldTitleRows) {
|
||||
preservedTitles.set(titleKey(r), r.custom_title);
|
||||
}
|
||||
|
||||
const fresh = await getItem(jfCfg, item.jellyfin_id);
|
||||
if (fresh) {
|
||||
const insertStream = db.prepare(`
|
||||
INSERT INTO media_streams (item_id, stream_index, type, codec, language, language_display,
|
||||
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
// DELETE cascades to stream_decisions via FK. reanalyze() below
|
||||
// rebuilds them from the fresh streams; without it the plan would
|
||||
// keep status='done'/'approved' but reference zero decisions, and
|
||||
// ffmpeg would emit a no-op command.
|
||||
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(id);
|
||||
for (const jStream of fresh.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue;
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(
|
||||
id,
|
||||
s.stream_index,
|
||||
s.type,
|
||||
s.codec,
|
||||
s.language,
|
||||
s.language_display,
|
||||
s.title,
|
||||
s.is_default,
|
||||
s.is_forced,
|
||||
s.is_hearing_impaired,
|
||||
s.channels,
|
||||
s.channel_layout,
|
||||
s.bit_rate,
|
||||
s.sample_rate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
reanalyze(db, id, preservedTitles);
|
||||
|
||||
const detail = loadDetail(db, id);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Batch delete subtitle files ─────────────────────────────────────────────
|
||||
|
||||
app.post("/batch-delete", async (c) => {
|
||||
const db = getDb();
|
||||
const body = await c.req.json<{ categories: { language: string | null; variant: "standard" | "forced" | "cc" }[] }>();
|
||||
|
||||
let deleted = 0;
|
||||
for (const cat of body.categories) {
|
||||
const isForced = cat.variant === "forced" ? 1 : 0;
|
||||
const isHI = cat.variant === "cc" ? 1 : 0;
|
||||
|
||||
let files: SubtitleFile[];
|
||||
if (cat.language === null) {
|
||||
files = db
|
||||
.prepare(`
|
||||
SELECT * FROM subtitle_files
|
||||
WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ?
|
||||
`)
|
||||
.all(isForced, isHI) as SubtitleFile[];
|
||||
} else {
|
||||
files = db
|
||||
.prepare(`
|
||||
SELECT * FROM subtitle_files
|
||||
WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ?
|
||||
`)
|
||||
.all(cat.language, isForced, isHI) as SubtitleFile[];
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(file.item_id) as
|
||||
| { file_path: string }
|
||||
| undefined;
|
||||
if (item && isSidecarOfItem(file.file_path, item.file_path)) {
|
||||
try {
|
||||
unlinkSync(file.file_path);
|
||||
} catch {
|
||||
/* file may not exist */
|
||||
}
|
||||
} else {
|
||||
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
||||
}
|
||||
db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(file.id);
|
||||
deleted++;
|
||||
}
|
||||
|
||||
// Reset subs_extracted for affected items that now have no subtitle files
|
||||
const affectedItems = new Set(files.map((f) => f.item_id));
|
||||
for (const itemId of affectedItems) {
|
||||
const remaining = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM subtitle_files WHERE item_id = ?").get(itemId) as { n: number }
|
||||
).n;
|
||||
if (remaining === 0) {
|
||||
db.prepare("UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?").run(itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: true, deleted });
|
||||
});
|
||||
|
||||
// ─── Normalize titles ────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/normalize-titles", (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Get title groups per language
|
||||
const titleRows = db
|
||||
.prepare(`
|
||||
SELECT language, title, COUNT(*) as cnt
|
||||
FROM media_streams WHERE type = 'Subtitle'
|
||||
GROUP BY language, title
|
||||
ORDER BY language, cnt DESC
|
||||
`)
|
||||
.all() as { language: string | null; title: string | null; cnt: number }[];
|
||||
|
||||
// Find canonical (most common) title per language
|
||||
const canonicalByLang = new Map<string | null, string | null>();
|
||||
for (const r of titleRows) {
|
||||
if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title);
|
||||
}
|
||||
|
||||
let normalized = 0;
|
||||
for (const r of titleRows) {
|
||||
const canonical = canonicalByLang.get(r.language) ?? null;
|
||||
if (r.title === canonical) continue;
|
||||
|
||||
// Find all streams matching this language+title and set custom_title on their decisions
|
||||
let streams: { id: number; item_id: number }[];
|
||||
if (r.language === null) {
|
||||
streams = db
|
||||
.prepare(`
|
||||
SELECT id, item_id FROM media_streams
|
||||
WHERE type = 'Subtitle' AND language IS NULL AND title IS ?
|
||||
`)
|
||||
.all(r.title) as { id: number; item_id: number }[];
|
||||
} else {
|
||||
streams = db
|
||||
.prepare(`
|
||||
SELECT id, item_id FROM media_streams
|
||||
WHERE type = 'Subtitle' AND language = ? AND title IS ?
|
||||
`)
|
||||
.all(r.language, r.title) as { id: number; item_id: number }[];
|
||||
}
|
||||
|
||||
for (const stream of streams) {
|
||||
// Ensure review_plan exists
|
||||
let plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(stream.item_id) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (!plan) {
|
||||
db.prepare("INSERT INTO review_plans (item_id, status, is_noop) VALUES (?, 'pending', 0)").run(stream.item_id);
|
||||
plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(stream.item_id) as { id: number };
|
||||
}
|
||||
|
||||
// Upsert stream_decision with custom_title
|
||||
const existing = db
|
||||
.prepare("SELECT id FROM stream_decisions WHERE plan_id = ? AND stream_id = ?")
|
||||
.get(plan.id, stream.id);
|
||||
if (existing) {
|
||||
db
|
||||
.prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?")
|
||||
.run(canonical, plan.id, stream.id);
|
||||
} else {
|
||||
db
|
||||
.prepare("INSERT INTO stream_decisions (plan_id, stream_id, action, custom_title) VALUES (?, ?, 'keep', ?)")
|
||||
.run(plan.id, stream.id, canonical);
|
||||
}
|
||||
normalized++;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: true, normalized });
|
||||
});
|
||||
|
||||
export default app;
|
||||
+37
-10
@@ -12,9 +12,6 @@ const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db");
|
||||
// ─── Env-var → config key mapping ─────────────────────────────────────────────
|
||||
|
||||
const ENV_MAP: Record<string, string> = {
|
||||
jellyfin_url: "JELLYFIN_URL",
|
||||
jellyfin_api_key: "JELLYFIN_API_KEY",
|
||||
jellyfin_user_id: "JELLYFIN_USER_ID",
|
||||
radarr_url: "RADARR_URL",
|
||||
radarr_api_key: "RADARR_API_KEY",
|
||||
radarr_enabled: "RADARR_ENABLED",
|
||||
@@ -22,11 +19,6 @@ const ENV_MAP: Record<string, string> = {
|
||||
sonarr_api_key: "SONARR_API_KEY",
|
||||
sonarr_enabled: "SONARR_ENABLED",
|
||||
audio_languages: "AUDIO_LANGUAGES",
|
||||
mqtt_enabled: "MQTT_ENABLED",
|
||||
mqtt_url: "MQTT_URL",
|
||||
mqtt_topic: "MQTT_TOPIC",
|
||||
mqtt_username: "MQTT_USERNAME",
|
||||
mqtt_password: "MQTT_PASSWORD",
|
||||
};
|
||||
|
||||
/** Read a config key from environment variables (returns null if not set). */
|
||||
@@ -41,9 +33,9 @@ function envValue(key: string): string | null {
|
||||
return val;
|
||||
}
|
||||
|
||||
/** True when minimum required Jellyfin env vars are present — skips the setup wizard. */
|
||||
/** True when env vars are configured enough to skip the setup wizard. */
|
||||
function isEnvConfigured(): boolean {
|
||||
return !!(process.env.JELLYFIN_URL && process.env.JELLYFIN_API_KEY);
|
||||
return !!(process.env.MOVIES_ROOT || process.env.TV_ROOT);
|
||||
}
|
||||
|
||||
// ─── Database ──────────────────────────────────────────────────────────────────
|
||||
@@ -56,6 +48,9 @@ export function getDb(): Database {
|
||||
_db.exec(SCHEMA);
|
||||
migrate(_db);
|
||||
seedDefaults(_db);
|
||||
// Clear stale flags/jobs from a previous container that was killed mid-operation.
|
||||
_db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running' AND value = '1'").run();
|
||||
_db.prepare("UPDATE jobs SET status = 'error', completed_at = datetime('now') WHERE status = 'running'").run();
|
||||
return _db;
|
||||
}
|
||||
|
||||
@@ -79,6 +74,38 @@ function migrate(db: Database): void {
|
||||
// RENAME COLUMN preserves values; both alters are no-ops on fresh DBs.
|
||||
alter("ALTER TABLE review_plans RENAME COLUMN webhook_verified TO verified");
|
||||
alter("ALTER TABLE review_plans DROP COLUMN verified");
|
||||
alter("ALTER TABLE media_items ADD COLUMN ingest_source TEXT NOT NULL DEFAULT 'scan'");
|
||||
alter("ALTER TABLE review_plans ADD COLUMN auto_class TEXT");
|
||||
alter("ALTER TABLE review_plans ADD COLUMN sorted INTEGER NOT NULL DEFAULT 0");
|
||||
alter("ALTER TABLE review_plans DROP COLUMN confidence");
|
||||
// Per-stream language override — lets the user correct an "und" (or
|
||||
// mislabeled) audio track without round-tripping through Jellyfin. Read
|
||||
// in preference to stream.language by the analyzer and the ffmpeg
|
||||
// command builder; preserved across reanalyze and rescan like custom_title.
|
||||
alter("ALTER TABLE stream_decisions ADD COLUMN custom_language TEXT");
|
||||
// Indexes for new columns — must run after the columns exist on existing DBs
|
||||
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_sorted ON review_plans(sorted)");
|
||||
alter("CREATE INDEX IF NOT EXISTS idx_review_plans_auto_class ON review_plans(auto_class)");
|
||||
alter("ALTER TABLE review_plans ADD COLUMN reasons TEXT");
|
||||
alter("ALTER TABLE media_streams ADD COLUMN width INTEGER");
|
||||
alter("ALTER TABLE media_streams ADD COLUMN height INTEGER");
|
||||
alter("ALTER TABLE media_items ADD COLUMN container_title TEXT");
|
||||
alter("ALTER TABLE media_items ADD COLUMN container_comment TEXT");
|
||||
// drop-jellyfin refactor (2026-04-20): new columns replacing jellyfin-specific ones
|
||||
// drop-jellyfin: if old schema detected, wipe all tables so SCHEMA
|
||||
// recreates them with the new structure (file_path UNIQUE, no jellyfin columns).
|
||||
// Data must be rescanned anyway since the source changed from Jellyfin to ffprobe.
|
||||
const hasJellyfinId = db
|
||||
.prepare("SELECT COUNT(*) as n FROM pragma_table_info('media_items') WHERE name = 'jellyfin_id'")
|
||||
.get() as { n: number };
|
||||
if (hasJellyfinId.n > 0) {
|
||||
db.exec("DROP TABLE IF EXISTS jobs");
|
||||
db.exec("DROP TABLE IF EXISTS stream_decisions");
|
||||
db.exec("DROP TABLE IF EXISTS review_plans");
|
||||
db.exec("DROP TABLE IF EXISTS media_streams");
|
||||
db.exec("DROP TABLE IF EXISTS media_items");
|
||||
db.exec(SCHEMA);
|
||||
}
|
||||
}
|
||||
|
||||
function seedDefaults(db: Database): void {
|
||||
|
||||
+15
-33
@@ -9,28 +9,25 @@ CREATE TABLE IF NOT EXISTS config (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jellyfin_id TEXT NOT NULL UNIQUE,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
original_title TEXT,
|
||||
series_name TEXT,
|
||||
series_jellyfin_id TEXT,
|
||||
series_key TEXT,
|
||||
season_number INTEGER,
|
||||
episode_number INTEGER,
|
||||
year INTEGER,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
container TEXT,
|
||||
runtime_ticks INTEGER,
|
||||
date_last_refreshed TEXT,
|
||||
duration_seconds REAL,
|
||||
original_language TEXT,
|
||||
orig_lang_source TEXT,
|
||||
needs_review INTEGER NOT NULL DEFAULT 1,
|
||||
imdb_id TEXT,
|
||||
tmdb_id TEXT,
|
||||
tvdb_id TEXT,
|
||||
jellyfin_raw TEXT,
|
||||
external_raw TEXT,
|
||||
container_title TEXT,
|
||||
container_comment TEXT,
|
||||
scan_status TEXT NOT NULL DEFAULT 'pending',
|
||||
scan_error TEXT,
|
||||
last_scanned_at TEXT,
|
||||
@@ -46,7 +43,6 @@ CREATE TABLE IF NOT EXISTS media_streams (
|
||||
codec TEXT,
|
||||
profile TEXT,
|
||||
language TEXT,
|
||||
language_display TEXT,
|
||||
title TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_forced INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -56,6 +52,8 @@ CREATE TABLE IF NOT EXISTS media_streams (
|
||||
bit_rate INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bit_depth INTEGER,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
UNIQUE(item_id, stream_index)
|
||||
);
|
||||
|
||||
@@ -64,11 +62,13 @@ CREATE TABLE IF NOT EXISTS review_plans (
|
||||
item_id INTEGER NOT NULL UNIQUE REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
is_noop INTEGER NOT NULL DEFAULT 0,
|
||||
confidence TEXT NOT NULL DEFAULT 'low',
|
||||
auto_class TEXT,
|
||||
sorted INTEGER NOT NULL DEFAULT 0,
|
||||
apple_compat TEXT,
|
||||
job_type TEXT NOT NULL DEFAULT 'copy',
|
||||
subs_extracted INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
reasons TEXT,
|
||||
reviewed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -80,22 +80,11 @@ CREATE TABLE IF NOT EXISTS stream_decisions (
|
||||
action TEXT NOT NULL,
|
||||
target_index INTEGER,
|
||||
custom_title TEXT,
|
||||
custom_language TEXT,
|
||||
transcode_codec TEXT,
|
||||
UNIQUE(plan_id, stream_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subtitle_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
language TEXT,
|
||||
codec TEXT,
|
||||
is_forced INTEGER NOT NULL DEFAULT 0,
|
||||
is_hearing_impaired INTEGER NOT NULL DEFAULT 0,
|
||||
file_size INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
@@ -112,21 +101,18 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
CREATE INDEX IF NOT EXISTS idx_review_plans_status ON review_plans(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_plans_is_noop ON review_plans(is_noop);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_decisions_plan_id ON stream_decisions(plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_series_jf ON media_items(series_jellyfin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_series_name ON media_items(series_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_type ON media_items(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_streams_item_id ON media_streams(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_streams_type ON media_streams(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_subtitle_files_item_id ON subtitle_files(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_item_id ON jobs(item_id);
|
||||
`;
|
||||
|
||||
export const DEFAULT_CONFIG: Record<string, string> = {
|
||||
setup_complete: "0",
|
||||
jellyfin_url: "",
|
||||
jellyfin_api_key: "",
|
||||
jellyfin_user_id: "",
|
||||
movies_root: "/movies",
|
||||
tv_root: "/tv",
|
||||
radarr_url: "",
|
||||
radarr_api_key: "",
|
||||
radarr_enabled: "0",
|
||||
@@ -134,6 +120,8 @@ export const DEFAULT_CONFIG: Record<string, string> = {
|
||||
sonarr_api_key: "",
|
||||
sonarr_enabled: "0",
|
||||
audio_languages: "[]",
|
||||
auto_processing: "0",
|
||||
auto_process_queue: "0",
|
||||
|
||||
scan_running: "0",
|
||||
job_sleep_seconds: "0",
|
||||
@@ -143,10 +131,4 @@ export const DEFAULT_CONFIG: Record<string, string> = {
|
||||
process_schedule_enabled: "0",
|
||||
process_schedule_start: "01:00",
|
||||
process_schedule_end: "07:00",
|
||||
|
||||
mqtt_enabled: "0",
|
||||
mqtt_url: "",
|
||||
mqtt_topic: "jellyfin/events",
|
||||
mqtt_username: "",
|
||||
mqtt_password: "",
|
||||
};
|
||||
|
||||
+6
-6
@@ -7,10 +7,8 @@ import pathsRoutes from "./api/paths";
|
||||
import reviewRoutes from "./api/review";
|
||||
import scanRoutes from "./api/scan";
|
||||
import settingsRoutes from "./api/settings";
|
||||
import subtitlesRoutes from "./api/subtitles";
|
||||
import { getDb } from "./db/index";
|
||||
import { log, error as logError } from "./lib/log";
|
||||
import { startMqttClient } from "./services/mqtt";
|
||||
import { getConfig, getDb } from "./db/index";
|
||||
import { log } from "./lib/log";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -39,7 +37,6 @@ app.route("/api/settings", settingsRoutes);
|
||||
app.route("/api/scan", scanRoutes);
|
||||
app.route("/api/review", reviewRoutes);
|
||||
app.route("/api/execute", executeRoutes);
|
||||
app.route("/api/subtitles", subtitlesRoutes);
|
||||
app.route("/api/paths", pathsRoutes);
|
||||
|
||||
// ─── Static assets (production: serve Vite build) ────────────────────────────
|
||||
@@ -69,7 +66,10 @@ log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`);
|
||||
|
||||
getDb();
|
||||
|
||||
startMqttClient().catch((err) => logError("MQTT bootstrap failed:", err));
|
||||
// Resume auto-process loop if it was enabled before the server restarted
|
||||
if (getConfig("auto_processing") === "1") {
|
||||
import("./api/review").then(({ startAutoProcessLoop }) => startAutoProcessLoop());
|
||||
}
|
||||
|
||||
export default {
|
||||
port,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { MediaStream } from "../../types";
|
||||
import type { MediaItem, MediaStream } from "../../types";
|
||||
import { analyzeItem } from "../analyzer";
|
||||
|
||||
type OrigLangSource = MediaItem["orig_lang_source"];
|
||||
|
||||
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">;
|
||||
|
||||
function stream(o: StreamOverride): MediaStream {
|
||||
@@ -10,7 +12,6 @@ function stream(o: StreamOverride): MediaStream {
|
||||
codec: null,
|
||||
profile: null,
|
||||
language: null,
|
||||
language_display: null,
|
||||
title: null,
|
||||
is_default: 0,
|
||||
is_forced: 0,
|
||||
@@ -20,11 +21,28 @@ function stream(o: StreamOverride): MediaStream {
|
||||
bit_rate: null,
|
||||
sample_rate: null,
|
||||
bit_depth: null,
|
||||
width: null,
|
||||
height: null,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
const ITEM_DEFAULTS = { needs_review: 0 as number, container: "mkv" as string | null };
|
||||
const ITEM_DEFAULTS = {
|
||||
needs_review: 0 as number,
|
||||
container: "mkv" as string | null,
|
||||
orig_lang_source: null as OrigLangSource,
|
||||
// Default to a clean movie with matching canonical container title so
|
||||
// existing noop tests stay noop. Tests that care about container title
|
||||
// detection override these explicitly.
|
||||
type: "Movie" as "Movie" | "Episode",
|
||||
name: "Test" as string,
|
||||
year: null as number | null,
|
||||
series_name: null as string | null,
|
||||
season_number: null as number | null,
|
||||
episode_number: null as number | null,
|
||||
container_title: "Test" as string | null,
|
||||
container_comment: null as string | null,
|
||||
};
|
||||
|
||||
describe("analyzeItem — audio keep rules", () => {
|
||||
test("keeps only OG + configured languages, drops others", () => {
|
||||
@@ -73,6 +91,33 @@ describe("analyzeItem — audio keep rules", () => {
|
||||
});
|
||||
expect(result.decisions[0].action).toBe("keep");
|
||||
});
|
||||
|
||||
test("custom_language override wins over stream.language for keep/remove", () => {
|
||||
// File says UND, user corrects it to Spanish. With OG=eng and no extra
|
||||
// keep languages, the track should be removed — the override flowed
|
||||
// into the decision just like a real "spa" tag would have.
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, language: null }),
|
||||
];
|
||||
const overrides = new Map<number, string>([[2, "spa"]]);
|
||||
const removing = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng" },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
overrides,
|
||||
);
|
||||
expect(removing.decisions.find((d) => d.stream_id === 2)?.action).toBe("remove");
|
||||
|
||||
// Same file, but Spanish is now in the keep list → kept.
|
||||
const keeping = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng" },
|
||||
streams,
|
||||
{ audioLanguages: ["spa"] },
|
||||
overrides,
|
||||
);
|
||||
expect(keeping.decisions.find((d) => d.stream_id === 2)?.action).toBe("keep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeItem — audio ordering", () => {
|
||||
@@ -106,8 +151,8 @@ describe("analyzeItem — audio ordering", () => {
|
||||
test("audioOrderChanged is_noop=true when OG audio is already first and default", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1 }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu", title: "DEU - AAC" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
audioLanguages: ["deu"],
|
||||
@@ -144,7 +189,7 @@ describe("analyzeItem — subtitles & is_noop", () => {
|
||||
test("no audio change, no subs, OG already default+canonical → is_noop true", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
audioLanguages: [],
|
||||
@@ -178,9 +223,11 @@ describe("analyzeItem — subtitles & is_noop", () => {
|
||||
describe("analyzeItem — transcode targets", () => {
|
||||
test("DTS on mp4 → transcode to eac3", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng" })];
|
||||
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
||||
audioLanguages: [],
|
||||
});
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", needs_review: 0, container: "mp4" },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.decisions[0].transcode_codec).toBe("eac3");
|
||||
expect(result.job_type).toBe("transcode");
|
||||
expect(result.is_noop).toBe(false);
|
||||
@@ -188,9 +235,11 @@ describe("analyzeItem — transcode targets", () => {
|
||||
|
||||
test("AAC passes through without transcode", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })];
|
||||
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
||||
audioLanguages: [],
|
||||
});
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", needs_review: 0, container: "mp4" },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.decisions[0].transcode_codec).toBe(null);
|
||||
expect(result.job_type).toBe("copy");
|
||||
});
|
||||
@@ -338,11 +387,357 @@ describe("analyzeItem — one audio track per language", () => {
|
||||
test("single-stream file stays a noop", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mp4" }, streams, {
|
||||
audioLanguages: [],
|
||||
});
|
||||
expect(result.is_noop).toBe(true);
|
||||
});
|
||||
|
||||
test("wrong audio title (e.g. 'Chinese - Dolby Digital - 5.1') → not noop", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({
|
||||
id: 2,
|
||||
type: "Audio",
|
||||
stream_index: 1,
|
||||
codec: "eac3",
|
||||
language: "zho",
|
||||
channels: 6,
|
||||
is_default: 1,
|
||||
title: "Chinese - Dolby Digital Plus - 5.1 - Default",
|
||||
}),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "zho" }, streams, { audioLanguages: [] });
|
||||
expect(result.is_noop).toBe(false);
|
||||
expect(result.reasons).toContain("Fix audio title");
|
||||
});
|
||||
|
||||
test("correct harmonized title → noop", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({
|
||||
id: 2,
|
||||
type: "Audio",
|
||||
stream_index: 1,
|
||||
codec: "eac3",
|
||||
language: "zho",
|
||||
channels: 6,
|
||||
is_default: 1,
|
||||
title: "ZHO - EAC3 5.1",
|
||||
}),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "zho" }, streams, { audioLanguages: [] });
|
||||
expect(result.is_noop).toBe(true);
|
||||
});
|
||||
|
||||
test("null title → not noop (title needs to be set)", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: null }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mp4" }, streams, {
|
||||
audioLanguages: [],
|
||||
});
|
||||
expect(result.is_noop).toBe(false);
|
||||
});
|
||||
|
||||
test("wrong video title with release/ad noise → not noop", () => {
|
||||
const streams = [
|
||||
stream({
|
||||
id: 1,
|
||||
type: "Video",
|
||||
stream_index: 0,
|
||||
codec: "h264",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
title: "Movie.Name.1080p.WEB-DL.ADS-GRP",
|
||||
}),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mp4" }, streams, {
|
||||
audioLanguages: [],
|
||||
});
|
||||
expect(result.is_noop).toBe(false);
|
||||
expect(result.reasons).toContain("Fix video title");
|
||||
});
|
||||
|
||||
test("correct video and audio titles → noop", () => {
|
||||
const streams = [
|
||||
stream({
|
||||
id: 1,
|
||||
type: "Video",
|
||||
stream_index: 0,
|
||||
codec: "h264",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
title: "1080p - H.264",
|
||||
}),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mp4" }, streams, {
|
||||
audioLanguages: [],
|
||||
});
|
||||
expect(result.is_noop).toBe(true);
|
||||
});
|
||||
|
||||
test("dirty container title with release-group junk → not noop", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080, title: "1080p - H.264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{
|
||||
...ITEM_DEFAULTS,
|
||||
type: "Movie",
|
||||
name: "101 Dalmatians",
|
||||
year: 1961,
|
||||
container_title: "101.Dalmatians.1961.1080p.BluRay.x264-RARBG",
|
||||
original_language: "eng",
|
||||
},
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.is_noop).toBe(false);
|
||||
expect(result.reasons).toContain("Fix container title");
|
||||
});
|
||||
|
||||
test("container title matching canonical form → noop", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080, title: "1080p - H.264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{
|
||||
...ITEM_DEFAULTS,
|
||||
type: "Movie",
|
||||
name: "101 Dalmatians",
|
||||
year: 1961,
|
||||
container_title: "101 Dalmatians (1961)",
|
||||
original_language: "eng",
|
||||
},
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.is_noop).toBe(true);
|
||||
});
|
||||
|
||||
test("non-empty container comment → not noop with reason Clear comment", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080, title: "1080p - H.264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1, title: "ENG - AAC" }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{
|
||||
...ITEM_DEFAULTS,
|
||||
type: "Movie",
|
||||
name: "101 Dalmatians",
|
||||
year: 1961,
|
||||
container_title: "101 Dalmatians (1961)",
|
||||
container_comment: "rarbg",
|
||||
original_language: "eng",
|
||||
},
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.is_noop).toBe(false);
|
||||
expect(result.reasons).toContain("Clear comment");
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeItem — auto_class classification", () => {
|
||||
const AUTHORITATIVE = {
|
||||
...ITEM_DEFAULTS,
|
||||
original_language: "eng" as string | null,
|
||||
orig_lang_source: "radarr" as OrigLangSource,
|
||||
needs_review: 0,
|
||||
};
|
||||
|
||||
test("one OG language track → auto", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }),
|
||||
];
|
||||
const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] });
|
||||
expect(result.auto_class).toBe("auto");
|
||||
});
|
||||
|
||||
test("OG + additional configured language, both kept → auto", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, codec: "eac3", language: "deu", channels: 6 }),
|
||||
];
|
||||
const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: ["deu"] });
|
||||
expect(result.auto_class).toBe("auto");
|
||||
});
|
||||
|
||||
test("two English tracks resolved by channel count → auto", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6, title: "English 5.1" }),
|
||||
stream({
|
||||
id: 3,
|
||||
type: "Audio",
|
||||
stream_index: 2,
|
||||
codec: "ac3",
|
||||
language: "eng",
|
||||
channels: 2,
|
||||
title: "English Stereo",
|
||||
}),
|
||||
];
|
||||
const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] });
|
||||
expect(result.auto_class).toBe("auto");
|
||||
});
|
||||
|
||||
test("commentary track dropped by title heuristic → auto_heuristic", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6, title: "English 5.1" }),
|
||||
stream({
|
||||
id: 3,
|
||||
type: "Audio",
|
||||
stream_index: 2,
|
||||
codec: "ac3",
|
||||
language: "eng",
|
||||
channels: 2,
|
||||
title: "Director's Commentary",
|
||||
}),
|
||||
];
|
||||
const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] });
|
||||
expect(result.auto_class).toBe("auto_heuristic");
|
||||
});
|
||||
|
||||
test("OG language unknown → manual", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: null, orig_lang_source: null, needs_review: 1 },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.auto_class).toBe("manual");
|
||||
});
|
||||
|
||||
test("OG known but not present in any audio track → manual", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "deu" })];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "radarr", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.auto_class).toBe("manual");
|
||||
});
|
||||
|
||||
test("kept audio track with null language tag → manual", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: null }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "radarr", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.auto_class).toBe("manual");
|
||||
});
|
||||
|
||||
test("needs_review=1 → manual even with known OG", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "radarr", needs_review: 1 },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.auto_class).toBe("manual");
|
||||
});
|
||||
|
||||
test("non-OG track with coincidental commentary-ish title → auto (not auto_heuristic)", () => {
|
||||
// OG is English. German track is removed for LANGUAGE reasons, not title.
|
||||
// Its title coincidentally contains 'commentary', but that should not
|
||||
// upgrade the classification to auto_heuristic.
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }),
|
||||
stream({
|
||||
id: 3,
|
||||
type: "Audio",
|
||||
stream_index: 2,
|
||||
codec: "ac3",
|
||||
language: "deu",
|
||||
channels: 2,
|
||||
title: "German Commentary Audio",
|
||||
}),
|
||||
];
|
||||
const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] });
|
||||
expect(result.auto_class).toBe("auto");
|
||||
});
|
||||
|
||||
test("orig_lang_source=jellyfin is not authoritative → manual", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "probe", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
expect(result.auto_class).toBe("manual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto_class — OG quality inferior to non-OG", () => {
|
||||
test("OG mono + non-OG 5.1 → auto_heuristic, not auto", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "jpn", channels: 1 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "jpn", orig_lang_source: "sonarr", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: ["eng"] },
|
||||
);
|
||||
expect(result.auto_class).toBe("auto_heuristic");
|
||||
});
|
||||
|
||||
test("OG 5.1 + non-OG stereo → auto (OG is better)", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "jpn", channels: 6 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", channels: 2 }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "jpn", orig_lang_source: "sonarr", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: ["eng"] },
|
||||
);
|
||||
expect(result.auto_class).toBe("auto");
|
||||
});
|
||||
|
||||
test("OG and non-OG same channels → auto (no quality mismatch)", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "jpn", channels: 6 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "jpn", orig_lang_source: "sonarr", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: ["eng"] },
|
||||
);
|
||||
expect(result.auto_class).toBe("auto");
|
||||
});
|
||||
|
||||
test("non-OG removed by config but still triggers heuristic (Dead Zone case)", () => {
|
||||
// Japanese mono OG, English 5.1 available but not in audio_languages.
|
||||
// The analyzer removes English, but the quality gap should still flag
|
||||
// for review — the user might want to keep the superior dub.
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng", channels: 6 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", channels: 6 }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "jpn", channels: 1 }),
|
||||
];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "jpn", orig_lang_source: "sonarr", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: [] }, // English NOT in config
|
||||
);
|
||||
expect(result.auto_class).toBe("auto_heuristic");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { discoverVideoFiles } from "../discover";
|
||||
|
||||
const TMP = "/tmp/discover-test-" + Date.now();
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(`${TMP}/movies/Movie A (2020)`, { recursive: true });
|
||||
mkdirSync(`${TMP}/tv/Show B (2019)/Season 01`, { recursive: true });
|
||||
writeFileSync(`${TMP}/movies/Movie A (2020)/Movie A (2020).mkv`, "");
|
||||
writeFileSync(`${TMP}/movies/Movie A (2020)/Movie A (2020).nfo`, "");
|
||||
writeFileSync(`${TMP}/tv/Show B (2019)/Season 01/Show B (2019) - S01E01 - Pilot.mkv`, "");
|
||||
writeFileSync(`${TMP}/tv/Show B (2019)/Season 01/Show B (2019) - S01E01 - Pilot.srt`, "");
|
||||
// Leftover intermediate outputs from crashed ffmpeg runs — must be skipped.
|
||||
writeFileSync(`${TMP}/movies/Movie A (2020)/Movie A (2020).tmp.mkv`, "");
|
||||
writeFileSync(`${TMP}/tv/Show B (2019)/Season 01/Show B (2019) - S01E01 - Pilot.tmp.mp4`, "");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("discoverVideoFiles", () => {
|
||||
it("finds video files in both roots", async () => {
|
||||
const files = await discoverVideoFiles([`${TMP}/movies`, `${TMP}/tv`]);
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files.some((f) => f.endsWith("Movie A (2020).mkv"))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith("Show B (2019) - S01E01 - Pilot.mkv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-video files (.nfo, .srt)", async () => {
|
||||
const files = await discoverVideoFiles([`${TMP}/movies`, `${TMP}/tv`]);
|
||||
expect(files.every((f) => !f.endsWith(".nfo") && !f.endsWith(".srt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles missing directory gracefully", async () => {
|
||||
const files = await discoverVideoFiles([`${TMP}/does-not-exist`]);
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips .tmp.<ext> intermediate outputs from interrupted ffmpeg runs", async () => {
|
||||
const files = await discoverVideoFiles([`${TMP}/movies`, `${TMP}/tv`]);
|
||||
expect(files.every((f) => !f.includes(".tmp."))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { MediaItem, MediaStream, StreamDecision } from "../../types";
|
||||
import { buildCommand, buildPipelineCommand, predictExtractedFiles, shellQuote, sortKeptStreams } from "../ffmpeg";
|
||||
import { buildCommand, buildPipelineCommand, containerTitle, shellQuote, sortKeptStreams } from "../ffmpeg";
|
||||
|
||||
function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">): MediaStream {
|
||||
return {
|
||||
@@ -8,7 +8,6 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "str
|
||||
codec: null,
|
||||
profile: null,
|
||||
language: null,
|
||||
language_display: null,
|
||||
title: null,
|
||||
is_default: 0,
|
||||
is_forced: 0,
|
||||
@@ -18,6 +17,8 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "str
|
||||
bit_rate: null,
|
||||
sample_rate: null,
|
||||
bit_depth: null,
|
||||
width: null,
|
||||
height: null,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +29,7 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id"
|
||||
plan_id: 1,
|
||||
target_index: null,
|
||||
custom_title: null,
|
||||
custom_language: null,
|
||||
transcode_codec: null,
|
||||
...o,
|
||||
};
|
||||
@@ -35,28 +37,25 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id"
|
||||
|
||||
const ITEM: MediaItem = {
|
||||
id: 1,
|
||||
jellyfin_id: "x",
|
||||
type: "Movie",
|
||||
name: "Test",
|
||||
original_title: null,
|
||||
series_name: null,
|
||||
series_jellyfin_id: null,
|
||||
series_key: null,
|
||||
season_number: null,
|
||||
episode_number: null,
|
||||
year: null,
|
||||
file_path: "/movies/Test.mkv",
|
||||
file_size: null,
|
||||
container: "mkv",
|
||||
runtime_ticks: null,
|
||||
date_last_refreshed: null,
|
||||
duration_seconds: null,
|
||||
original_language: "eng",
|
||||
orig_lang_source: "jellyfin",
|
||||
orig_lang_source: "probe",
|
||||
needs_review: 0,
|
||||
imdb_id: null,
|
||||
tmdb_id: null,
|
||||
tvdb_id: null,
|
||||
jellyfin_raw: null,
|
||||
external_raw: null,
|
||||
container_title: "Test",
|
||||
container_comment: null,
|
||||
scan_status: "scanned",
|
||||
scan_error: null,
|
||||
last_scanned_at: null,
|
||||
@@ -104,7 +103,7 @@ describe("sortKeptStreams", () => {
|
||||
describe("buildCommand", () => {
|
||||
test("produces ffmpeg remux with tmp-rename pattern", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
];
|
||||
const decisions = [
|
||||
@@ -116,6 +115,9 @@ describe("buildCommand", () => {
|
||||
expect(cmd).toContain("-map 0:v:0");
|
||||
expect(cmd).toContain("-map 0:a:0");
|
||||
expect(cmd).toContain("-c copy");
|
||||
expect(cmd).toContain("-metadata:s:v:0 title='1080p - H.264'");
|
||||
expect(cmd).toContain("-metadata title='Test'");
|
||||
expect(cmd).toContain("-metadata comment=");
|
||||
expect(cmd).toContain("'/movies/Test.tmp.mkv'");
|
||||
expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.mkv'");
|
||||
});
|
||||
@@ -182,7 +184,7 @@ describe("buildCommand", () => {
|
||||
expect(cmd).toContain("-metadata:s:a:2 language=und");
|
||||
});
|
||||
|
||||
test("writes canonical 'ENG - CODEC · CHANNELS' title on every kept audio stream", () => {
|
||||
test("writes canonical 'ENG - CODEC CHANNELS' title on every kept audio stream", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||
stream({
|
||||
@@ -205,11 +207,11 @@ describe("buildCommand", () => {
|
||||
];
|
||||
const cmd = buildCommand(ITEM, streams, decisions);
|
||||
// Original "Audio Description" title is replaced with the harmonized form.
|
||||
expect(cmd).toContain("-metadata:s:a:0 title='ENG - AC3 · 5.1'");
|
||||
expect(cmd).toContain("-metadata:s:a:0 title='ENG - AC3 5.1'");
|
||||
// Mono renders as 1.0 (not the legacy "mono" string).
|
||||
expect(cmd).toContain("-metadata:s:a:1 title='DEU - DTS · 1.0'");
|
||||
expect(cmd).toContain("-metadata:s:a:1 title='DEU - DTS 1.0'");
|
||||
// Stereo renders as 2.0.
|
||||
expect(cmd).toContain("-metadata:s:a:2 title='AAC · 2.0'");
|
||||
expect(cmd).toContain("-metadata:s:a:2 title='AAC 2.0'");
|
||||
});
|
||||
|
||||
test("custom_title still overrides the auto-generated audio title", () => {
|
||||
@@ -241,6 +243,29 @@ describe("buildCommand", () => {
|
||||
expect(cmd).toContain("-disposition:a:0 default");
|
||||
expect(cmd).toContain("-disposition:a:1 0");
|
||||
});
|
||||
|
||||
test("writes canonical video titles without release-group noise", () => {
|
||||
const streams = [
|
||||
stream({
|
||||
id: 1,
|
||||
type: "Video",
|
||||
stream_index: 0,
|
||||
codec: "hevc",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
title: "Movie Name - 2160p WEB-DL HDR10 - ADS - GRP",
|
||||
}),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", channels: 6, language: "eng" }),
|
||||
];
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||
];
|
||||
const cmd = buildCommand(ITEM, streams, decisions);
|
||||
expect(cmd).toContain("-metadata:s:v:0 title='2160p - HEVC'");
|
||||
expect(cmd).not.toContain("ADS");
|
||||
expect(cmd).not.toContain("GRP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPipelineCommand", () => {
|
||||
@@ -279,28 +304,106 @@ describe("buildPipelineCommand", () => {
|
||||
expect(command).toContain("-c:a:0 eac3");
|
||||
expect(command).toContain("-b:a:0 640k"); // 6 channels → 640k
|
||||
});
|
||||
|
||||
test("writes canonical container title for movies", () => {
|
||||
const movieItem = { ...ITEM, name: "101 Dalmatians", year: 1961, file_path: "/movies/101.mkv" };
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
];
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||
];
|
||||
const cmd = buildCommand(movieItem, streams, decisions);
|
||||
expect(cmd).toContain("-metadata title='101 Dalmatians (1961)'");
|
||||
expect(cmd).toContain("-metadata comment=");
|
||||
});
|
||||
|
||||
describe("predictExtractedFiles", () => {
|
||||
test("predicts sidecar paths matching extraction output", () => {
|
||||
test("writes canonical container title for episodes", () => {
|
||||
const epItem: MediaItem = {
|
||||
...ITEM,
|
||||
type: "Episode",
|
||||
name: "Pilot",
|
||||
series_name: "Test Show",
|
||||
year: 2020,
|
||||
season_number: 1,
|
||||
episode_number: 2,
|
||||
file_path: "/tv/Test.Show/S01E02.mkv",
|
||||
};
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }),
|
||||
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "deu", is_forced: 1 }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
];
|
||||
const files = predictExtractedFiles(ITEM, streams);
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files[0].file_path).toBe("/movies/Test.en.srt");
|
||||
expect(files[1].file_path).toBe("/movies/Test.de.forced.srt");
|
||||
expect(files[1].is_forced).toBe(true);
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||
];
|
||||
const cmd = buildCommand(epItem, streams, decisions);
|
||||
expect(cmd).toContain("-metadata title='Test Show (2020) - S01E02 - Pilot'");
|
||||
});
|
||||
});
|
||||
|
||||
test("deduplicates paths with a numeric suffix", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }),
|
||||
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
|
||||
];
|
||||
const files = predictExtractedFiles(ITEM, streams);
|
||||
expect(files[0].file_path).toBe("/movies/Test.en.srt");
|
||||
expect(files[1].file_path).toBe("/movies/Test.en.2.srt");
|
||||
describe("containerTitle", () => {
|
||||
test("movie with year", () => {
|
||||
expect(containerTitle({ type: "Movie", name: "101 Dalmatians", year: 1961 } as MediaItem)).toBe(
|
||||
"101 Dalmatians (1961)",
|
||||
);
|
||||
});
|
||||
|
||||
test("movie without year", () => {
|
||||
expect(containerTitle({ type: "Movie", name: "101 Dalmatians", year: null } as MediaItem)).toBe("101 Dalmatians");
|
||||
});
|
||||
|
||||
test("episode with full metadata", () => {
|
||||
expect(
|
||||
containerTitle({
|
||||
type: "Episode",
|
||||
name: "Pilot",
|
||||
series_name: "Test Show",
|
||||
year: 2020,
|
||||
season_number: 1,
|
||||
episode_number: 2,
|
||||
} as MediaItem),
|
||||
).toBe("Test Show (2020) - S01E02 - Pilot");
|
||||
});
|
||||
|
||||
test("episode without year", () => {
|
||||
expect(
|
||||
containerTitle({
|
||||
type: "Episode",
|
||||
name: "Pilot",
|
||||
series_name: "Test Show",
|
||||
year: null,
|
||||
season_number: 1,
|
||||
episode_number: 2,
|
||||
} as MediaItem),
|
||||
).toBe("Test Show - S01E02 - Pilot");
|
||||
});
|
||||
|
||||
test("episode where name equals series drops duplicated tail", () => {
|
||||
expect(
|
||||
containerTitle({
|
||||
type: "Episode",
|
||||
name: "Test Show",
|
||||
series_name: "Test Show",
|
||||
year: 2020,
|
||||
season_number: 1,
|
||||
episode_number: 2,
|
||||
} as MediaItem),
|
||||
).toBe("Test Show (2020) - S01E02");
|
||||
});
|
||||
|
||||
test("pads single-digit season and episode", () => {
|
||||
expect(
|
||||
containerTitle({
|
||||
type: "Episode",
|
||||
name: "x",
|
||||
series_name: "S",
|
||||
year: null,
|
||||
season_number: 1,
|
||||
episode_number: 2,
|
||||
} as MediaItem),
|
||||
).toBe("S - S01E02 - x");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { JellyfinItem, JellyfinMediaStream } from "../../types";
|
||||
import { extractOriginalLanguage } from "../jellyfin";
|
||||
|
||||
function audio(o: Partial<JellyfinMediaStream>): JellyfinMediaStream {
|
||||
return { Type: "Audio", Index: 0, ...o };
|
||||
}
|
||||
|
||||
function item(streams: JellyfinMediaStream[]): JellyfinItem {
|
||||
return { Id: "x", Type: "Movie", Name: "Test", MediaStreams: streams };
|
||||
}
|
||||
|
||||
describe("extractOriginalLanguage — Jellyfin heuristic", () => {
|
||||
test("returns null when there are no audio streams", () => {
|
||||
expect(extractOriginalLanguage(item([{ Type: "Video", Index: 0 }]))).toBe(null);
|
||||
});
|
||||
|
||||
test("uses the only audio track when there is just one", () => {
|
||||
expect(extractOriginalLanguage(item([audio({ Language: "eng" })]))).toBe("eng");
|
||||
});
|
||||
|
||||
test("prefers the IsDefault audio track over position", () => {
|
||||
// 8 Mile regression: Turkish dub first, English default further down.
|
||||
// Old heuristic took the first track and labelled the movie Turkish.
|
||||
const streams = [audio({ Index: 0, Language: "tur" }), audio({ Index: 1, Language: "eng", IsDefault: true })];
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("eng");
|
||||
});
|
||||
|
||||
test("skips a dub even when it is the default", () => {
|
||||
const streams = [
|
||||
audio({ Index: 0, Language: "tur", IsDefault: true, Title: "Turkish Dub" }),
|
||||
audio({ Index: 1, Language: "eng" }),
|
||||
];
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("eng");
|
||||
});
|
||||
|
||||
test("falls back to first audio track when every track looks like a dub", () => {
|
||||
const streams = [
|
||||
audio({ Index: 0, Language: "tur", Title: "Turkish Dub" }),
|
||||
audio({ Index: 1, Language: "deu", Title: "German Dub" }),
|
||||
];
|
||||
// No good candidate — returns the first audio so there's *some* guess,
|
||||
// but scan.ts is responsible for marking this needs_review.
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("tur");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test, beforeEach } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import { resolveLanguage, type LanguageResolverConfig } from "../language-resolver";
|
||||
import type { RadarrLibrary } from "../radarr";
|
||||
import type { SonarrLibrary } from "../sonarr";
|
||||
|
||||
let db: Database;
|
||||
|
||||
function initDb(): Database {
|
||||
const d = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";").filter((s) => s.trim())) {
|
||||
d.run(`${stmt};`);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function insertItem(db: Database, overrides: Record<string, unknown> = {}): number {
|
||||
const defaults = {
|
||||
type: "Movie",
|
||||
name: "Test Movie",
|
||||
file_path: "/movies/test.mkv",
|
||||
original_language: "deu",
|
||||
orig_lang_source: "probe",
|
||||
needs_review: 0,
|
||||
tmdb_id: "12345",
|
||||
imdb_id: null,
|
||||
tvdb_id: null,
|
||||
series_name: null,
|
||||
series_key: null,
|
||||
};
|
||||
const row = { ...defaults, ...overrides };
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO media_items (type, name, file_path, original_language, orig_lang_source, needs_review, tmdb_id, imdb_id, tvdb_id, series_name, series_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
row.type,
|
||||
row.name,
|
||||
row.file_path,
|
||||
row.original_language,
|
||||
row.orig_lang_source,
|
||||
row.needs_review,
|
||||
row.tmdb_id,
|
||||
row.imdb_id,
|
||||
row.tvdb_id,
|
||||
row.series_name,
|
||||
row.series_key,
|
||||
);
|
||||
return (db.prepare("SELECT id FROM media_items WHERE file_path = ?").get(row.file_path) as { id: number }).id;
|
||||
}
|
||||
|
||||
function makeRadarrLibrary(entries: Array<{ tmdbId?: string; imdbId?: string; lang: string }>): RadarrLibrary {
|
||||
const byTmdbId = new Map<string, { originalLanguage?: { name: string } }>();
|
||||
const byImdbId = new Map<string, { originalLanguage?: { name: string } }>();
|
||||
for (const e of entries) {
|
||||
const movie = { originalLanguage: { name: e.lang } };
|
||||
if (e.tmdbId) byTmdbId.set(e.tmdbId, movie);
|
||||
if (e.imdbId) byImdbId.set(e.imdbId, movie);
|
||||
}
|
||||
return { byTmdbId, byImdbId };
|
||||
}
|
||||
|
||||
function makeSonarrLibrary(entries: Array<{ tvdbId: string; lang: string; title?: string }>): SonarrLibrary {
|
||||
const byTvdbId = new Map<string, { originalLanguage?: { name: string }; title?: string }>();
|
||||
for (const e of entries) {
|
||||
byTvdbId.set(e.tvdbId, { originalLanguage: { name: e.lang }, title: e.title });
|
||||
}
|
||||
const byTitle = new Map<string, { originalLanguage?: { name: string }; title?: string }>();
|
||||
for (const e of entries) {
|
||||
if (e.title) byTitle.set(e.title.toLowerCase(), { originalLanguage: { name: e.lang }, title: e.title });
|
||||
}
|
||||
return { byTvdbId, byTitle };
|
||||
}
|
||||
|
||||
function baseCfg(overrides: Partial<LanguageResolverConfig> = {}): LanguageResolverConfig {
|
||||
return {
|
||||
radarr: null,
|
||||
sonarr: null,
|
||||
radarrLibrary: null,
|
||||
sonarrLibrary: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = initDb();
|
||||
});
|
||||
|
||||
describe("resolveLanguage", () => {
|
||||
test("radarr hit overrides probe guess for movies", async () => {
|
||||
const id = insertItem(db, {
|
||||
type: "Movie",
|
||||
tmdb_id: "12345",
|
||||
original_language: "deu",
|
||||
orig_lang_source: "probe",
|
||||
});
|
||||
const cfg = baseCfg({
|
||||
radarr: { url: "http://radarr:7878", apiKey: "key" },
|
||||
radarrLibrary: makeRadarrLibrary([{ tmdbId: "12345", lang: "English" }]),
|
||||
});
|
||||
|
||||
const result = await resolveLanguage(db, id, cfg);
|
||||
|
||||
expect(result.origLang).toBe("eng");
|
||||
expect(result.origLangSource).toBe("radarr");
|
||||
expect(result.needsReview).toBe(0);
|
||||
expect(result.externalRaw).toBeDefined();
|
||||
});
|
||||
|
||||
test("sonarr hit overrides probe guess for episodes via tvdb_id", async () => {
|
||||
const id = insertItem(db, {
|
||||
type: "Episode",
|
||||
file_path: "/series/ep1.mkv",
|
||||
name: "Test Episode",
|
||||
tmdb_id: null,
|
||||
tvdb_id: "99999",
|
||||
original_language: "deu",
|
||||
orig_lang_source: "probe",
|
||||
});
|
||||
const cfg = baseCfg({
|
||||
sonarr: { url: "http://sonarr:8989", apiKey: "key" },
|
||||
sonarrLibrary: makeSonarrLibrary([{ tvdbId: "99999", lang: "Japanese" }]),
|
||||
});
|
||||
|
||||
const result = await resolveLanguage(db, id, cfg);
|
||||
|
||||
expect(result.origLang).toBe("jpn");
|
||||
expect(result.origLangSource).toBe("sonarr");
|
||||
expect(result.needsReview).toBe(0);
|
||||
});
|
||||
|
||||
test("sonarr hit via series_name when tvdb_id not in library", async () => {
|
||||
const id = insertItem(db, {
|
||||
type: "Episode",
|
||||
file_path: "/series/arrow-ep1.mkv",
|
||||
name: "Arrow S01E01",
|
||||
tmdb_id: null,
|
||||
tvdb_id: null,
|
||||
series_name: "Arrow",
|
||||
original_language: "deu",
|
||||
orig_lang_source: "probe",
|
||||
});
|
||||
// Sonarr library has the series under a title match
|
||||
const cfg = baseCfg({
|
||||
sonarr: { url: "http://sonarr:8989", apiKey: "key" },
|
||||
sonarrLibrary: makeSonarrLibrary([{ tvdbId: "series-tvdb-456", lang: "English", title: "Arrow" }]),
|
||||
});
|
||||
|
||||
const result = await resolveLanguage(db, id, cfg);
|
||||
|
||||
expect(result.origLang).toBe("eng");
|
||||
expect(result.origLangSource).toBe("sonarr");
|
||||
expect(result.needsReview).toBe(0);
|
||||
});
|
||||
|
||||
test("skips resolution when orig_lang_source is manual", async () => {
|
||||
const id = insertItem(db, {
|
||||
type: "Movie",
|
||||
tmdb_id: "12345",
|
||||
original_language: "fra",
|
||||
orig_lang_source: "manual",
|
||||
});
|
||||
const cfg = baseCfg({
|
||||
radarr: { url: "http://radarr:7878", apiKey: "key" },
|
||||
radarrLibrary: makeRadarrLibrary([{ tmdbId: "12345", lang: "English" }]),
|
||||
});
|
||||
|
||||
const result = await resolveLanguage(db, id, cfg);
|
||||
|
||||
expect(result.origLang).toBe("fra");
|
||||
expect(result.origLangSource).toBe("manual");
|
||||
expect(result.needsReview).toBe(0);
|
||||
});
|
||||
|
||||
test("returns probe guess unchanged when no external source matches", async () => {
|
||||
const id = insertItem(db, {
|
||||
type: "Movie",
|
||||
tmdb_id: "99999",
|
||||
original_language: "deu",
|
||||
orig_lang_source: "probe",
|
||||
needs_review: 0,
|
||||
});
|
||||
// Radarr library has no entry for tmdb 99999
|
||||
const cfg = baseCfg({
|
||||
radarr: { url: "http://radarr:7878", apiKey: "key" },
|
||||
radarrLibrary: makeRadarrLibrary([{ tmdbId: "11111", lang: "English" }]),
|
||||
});
|
||||
|
||||
const result = await resolveLanguage(db, id, cfg);
|
||||
|
||||
expect(result.origLang).toBe("deu");
|
||||
expect(result.origLangSource).toBe("probe");
|
||||
expect(result.needsReview).toBe(1);
|
||||
expect(result.externalRaw).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parsePath } from "../path-parser";
|
||||
|
||||
const MOVIES = "/movies";
|
||||
const TV = "/tv";
|
||||
|
||||
describe("parsePath", () => {
|
||||
test("movie with imdb id", () => {
|
||||
const result = parsePath(
|
||||
"/movies/Hot Fuzz (2007)/Hot Fuzz (2007) [imdbid-tt0425112] - [Bluray-1080p][DTS 5.1][x264]-CtrlHD.mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Movie");
|
||||
expect(result!.name).toBe("Hot Fuzz");
|
||||
expect(result!.year).toBe(2007);
|
||||
expect(result!.imdbId).toBe("tt0425112");
|
||||
expect(result!.tmdbId).toBeNull();
|
||||
expect(result!.tvdbId).toBeNull();
|
||||
expect(result!.container).toBe("mkv");
|
||||
expect(result!.seriesName).toBeNull();
|
||||
expect(result!.seasonNumber).toBeNull();
|
||||
expect(result!.episodeNumber).toBeNull();
|
||||
});
|
||||
|
||||
test("movie with tmdb id", () => {
|
||||
const result = parsePath("/movies/Alien (1979)/Alien (1979) [tmdbid-348] - [Bluray-1080p].mkv", MOVIES, TV);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Movie");
|
||||
expect(result!.name).toBe("Alien");
|
||||
expect(result!.year).toBe(1979);
|
||||
expect(result!.tmdbId).toBe("348");
|
||||
expect(result!.imdbId).toBeNull();
|
||||
});
|
||||
|
||||
test("movie with both imdb and tmdb ids", () => {
|
||||
const result = parsePath(
|
||||
"/movies/The Matrix (1999)/The Matrix (1999) [imdbid-tt0133093][tmdbid-603] - [Bluray-1080p].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.imdbId).toBe("tt0133093");
|
||||
expect(result!.tmdbId).toBe("603");
|
||||
});
|
||||
|
||||
test("episode standard format", () => {
|
||||
const result = parsePath(
|
||||
"/tv/Breaking Bad (2008)/Season 05/Breaking Bad (2008) - S05E03 - Hazard Pay [WEBDL-1080p][AC3 5.1][h264]-BS.mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Episode");
|
||||
expect(result!.seriesName).toBe("Breaking Bad");
|
||||
expect(result!.year).toBe(2008);
|
||||
expect(result!.seasonNumber).toBe(5);
|
||||
expect(result!.episodeNumber).toBe(3);
|
||||
expect(result!.name).toBe("Hazard Pay");
|
||||
expect(result!.tvdbId).toBeNull();
|
||||
expect(result!.container).toBe("mkv");
|
||||
});
|
||||
|
||||
test("episode with tvdb id in series folder", () => {
|
||||
const result = parsePath(
|
||||
"/tv/Arrow (2012) [tvdbid-257655]/Season 01/Arrow (2012) - S01E01 - Pilot [Bluray-1080p].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Episode");
|
||||
expect(result!.seriesName).toBe("Arrow");
|
||||
expect(result!.year).toBe(2012);
|
||||
expect(result!.seasonNumber).toBe(1);
|
||||
expect(result!.episodeNumber).toBe(1);
|
||||
expect(result!.name).toBe("Pilot");
|
||||
expect(result!.tvdbId).toBe("257655");
|
||||
});
|
||||
|
||||
test("multi-episode file uses first episode number", () => {
|
||||
const result = parsePath(
|
||||
"/tv/Breaking Bad (2008)/Season 02/Breaking Bad (2008) - S02E01-E13 - Seven Thirty-Seven [info].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.seasonNumber).toBe(2);
|
||||
expect(result!.episodeNumber).toBe(1);
|
||||
expect(result!.name).toBe("Seven Thirty-Seven");
|
||||
});
|
||||
|
||||
test("mp4 container", () => {
|
||||
const result = parsePath("/movies/Jaws (1975)/Jaws (1975) [imdbid-tt0073195] - [Bluray-1080p].mp4", MOVIES, TV);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.container).toBe("mp4");
|
||||
});
|
||||
|
||||
test("non-video file returns null", () => {
|
||||
expect(parsePath("/movies/Hot Fuzz (2007)/Hot Fuzz (2007).nfo", MOVIES, TV)).toBeNull();
|
||||
expect(parsePath("/movies/Hot Fuzz (2007)/Hot Fuzz (2007).srt", MOVIES, TV)).toBeNull();
|
||||
expect(parsePath("/movies/Hot Fuzz (2007)/poster.jpg", MOVIES, TV)).toBeNull();
|
||||
});
|
||||
|
||||
test("file not under moviesRoot or tvRoot returns null", () => {
|
||||
expect(parsePath("/other/Hot Fuzz (2007)/Hot Fuzz (2007) [imdbid-tt0425112].mkv", MOVIES, TV)).toBeNull();
|
||||
});
|
||||
|
||||
test("episode without episode title falls back to SxxExx", () => {
|
||||
const result = parsePath(
|
||||
"/tv/Lost (2004)/Season 02/Lost (2004) - S02E21 - [DSNP WEBDL-1080p][EAC3 5.1][h264]-FLUX.mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Episode");
|
||||
expect(result!.seriesName).toBe("Lost");
|
||||
expect(result!.seasonNumber).toBe(2);
|
||||
expect(result!.episodeNumber).toBe(21);
|
||||
expect(result!.name).toBe("S02E21");
|
||||
});
|
||||
|
||||
test("movie without provider ids (bare folder)", () => {
|
||||
const result = parsePath(
|
||||
"/movies/No Country for Old Men (2007)/No Country for Old Men (2007) - [Bluray-1080p].mkv",
|
||||
MOVIES,
|
||||
TV,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("Movie");
|
||||
expect(result!.name).toBe("No Country for Old Men");
|
||||
expect(result!.year).toBe(2007);
|
||||
expect(result!.imdbId).toBeNull();
|
||||
expect(result!.tmdbId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseProbeOutput } from "../probe";
|
||||
|
||||
describe("parseProbeOutput — format metadata", () => {
|
||||
test("parses size, duration, and container from format", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {
|
||||
size: "1500000000",
|
||||
duration: "7200.000000",
|
||||
format_name: "matroska,webm",
|
||||
},
|
||||
streams: [],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.fileSize).toBe(1500000000);
|
||||
expect(result.durationSeconds).toBe(7200.0);
|
||||
expect(result.container).toBe("matroska");
|
||||
});
|
||||
|
||||
test("returns null for missing format fields", () => {
|
||||
const json = JSON.stringify({ format: {}, streams: [] });
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.fileSize).toBeNull();
|
||||
expect(result.durationSeconds).toBeNull();
|
||||
expect(result.container).toBeNull();
|
||||
expect(result.containerTitle).toBeNull();
|
||||
expect(result.containerComment).toBeNull();
|
||||
});
|
||||
|
||||
test("parses container-level title and comment tags", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {
|
||||
format_name: "matroska",
|
||||
tags: { title: "101 Dalmatians (1961)", comment: "rarbg" },
|
||||
},
|
||||
streams: [],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.containerTitle).toBe("101 Dalmatians (1961)");
|
||||
expect(result.containerComment).toBe("rarbg");
|
||||
});
|
||||
|
||||
test("accepts uppercase TITLE and COMMENT container tags", () => {
|
||||
const json = JSON.stringify({
|
||||
format: { format_name: "matroska", tags: { TITLE: "Movie", COMMENT: "ads" } },
|
||||
streams: [],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.containerTitle).toBe("Movie");
|
||||
expect(result.containerComment).toBe("ads");
|
||||
});
|
||||
|
||||
test("takes first part of comma-separated format_name", () => {
|
||||
const json = JSON.stringify({
|
||||
format: { format_name: "mov,mp4,m4a,3gp,3g2,mj2" },
|
||||
streams: [],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.container).toBe("mov");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProbeOutput — video stream", () => {
|
||||
test("parses a video stream correctly", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
codec_type: "video",
|
||||
codec_name: "h264",
|
||||
profile: "High",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
disposition: { default: 1, forced: 0, hearing_impaired: 0 },
|
||||
tags: { language: "eng", title: "Main Video" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.streams).toHaveLength(1);
|
||||
const s = result.streams[0];
|
||||
expect(s.streamIndex).toBe(0);
|
||||
expect(s.type).toBe("Video");
|
||||
expect(s.codec).toBe("h264");
|
||||
expect(s.profile).toBe("High");
|
||||
expect(s.language).toBe("eng");
|
||||
expect(s.title).toBe("Main Video");
|
||||
expect(s.isDefault).toBe(1);
|
||||
expect(s.isForced).toBe(0);
|
||||
expect(s.isHearingImpaired).toBe(0);
|
||||
expect(s.channels).toBeNull();
|
||||
expect(s.channelLayout).toBeNull();
|
||||
expect(s.bitRate).toBeNull();
|
||||
expect(s.sampleRate).toBeNull();
|
||||
expect(s.bitDepth).toBeNull();
|
||||
expect(s.width).toBe(1920);
|
||||
expect(s.height).toBe(1080);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProbeOutput — audio stream", () => {
|
||||
test("parses audio stream with full metadata", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
index: 1,
|
||||
codec_type: "audio",
|
||||
codec_name: "eac3",
|
||||
profile: "DDP",
|
||||
channels: 6,
|
||||
channel_layout: "5.1(side)",
|
||||
bit_rate: "640000",
|
||||
sample_rate: "48000",
|
||||
bits_per_raw_sample: "24",
|
||||
disposition: { default: 1, forced: 0, hearing_impaired: 0 },
|
||||
tags: { language: "eng", title: "English Surround" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.streams).toHaveLength(1);
|
||||
const s = result.streams[0];
|
||||
expect(s.streamIndex).toBe(1);
|
||||
expect(s.type).toBe("Audio");
|
||||
expect(s.codec).toBe("eac3");
|
||||
expect(s.profile).toBe("DDP");
|
||||
expect(s.language).toBe("eng");
|
||||
expect(s.title).toBe("English Surround");
|
||||
expect(s.channels).toBe(6);
|
||||
expect(s.channelLayout).toBe("5.1(side)");
|
||||
expect(s.bitRate).toBe(640000);
|
||||
expect(s.sampleRate).toBe(48000);
|
||||
expect(s.bitDepth).toBe(24);
|
||||
expect(s.width).toBeNull();
|
||||
expect(s.height).toBeNull();
|
||||
expect(s.isDefault).toBe(1);
|
||||
expect(s.isForced).toBe(0);
|
||||
expect(s.isHearingImpaired).toBe(0);
|
||||
});
|
||||
|
||||
test("accepts uppercase LANGUAGE and TITLE tags", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
index: 2,
|
||||
codec_type: "audio",
|
||||
codec_name: "aac",
|
||||
disposition: { default: 0, forced: 0, hearing_impaired: 0 },
|
||||
tags: { LANGUAGE: "deu", TITLE: "Deutsch" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
const s = result.streams[0];
|
||||
expect(s.language).toBe("deu");
|
||||
expect(s.title).toBe("Deutsch");
|
||||
});
|
||||
|
||||
test("handles null/missing optional audio fields", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
codec_type: "audio",
|
||||
codec_name: "aac",
|
||||
disposition: { default: 0, forced: 0, hearing_impaired: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
const s = result.streams[0];
|
||||
expect(s.channels).toBeNull();
|
||||
expect(s.channelLayout).toBeNull();
|
||||
expect(s.bitRate).toBeNull();
|
||||
expect(s.sampleRate).toBeNull();
|
||||
expect(s.bitDepth).toBeNull();
|
||||
expect(s.language).toBeNull();
|
||||
expect(s.title).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProbeOutput — subtitle stream", () => {
|
||||
test("parses subtitle stream with hearing_impaired flag", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
index: 3,
|
||||
codec_type: "subtitle",
|
||||
codec_name: "subrip",
|
||||
disposition: { default: 0, forced: 0, hearing_impaired: 1 },
|
||||
tags: { language: "eng", title: "SDH" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.streams).toHaveLength(1);
|
||||
const s = result.streams[0];
|
||||
expect(s.streamIndex).toBe(3);
|
||||
expect(s.type).toBe("Subtitle");
|
||||
expect(s.codec).toBe("subrip");
|
||||
expect(s.language).toBe("eng");
|
||||
expect(s.title).toBe("SDH");
|
||||
expect(s.isDefault).toBe(0);
|
||||
expect(s.isForced).toBe(0);
|
||||
expect(s.isHearingImpaired).toBe(1);
|
||||
});
|
||||
|
||||
test("parses forced subtitle stream", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
index: 4,
|
||||
codec_type: "subtitle",
|
||||
codec_name: "hdmv_pgs_subtitle",
|
||||
disposition: { default: 0, forced: 1, hearing_impaired: 0 },
|
||||
tags: { language: "deu" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
const s = result.streams[0];
|
||||
expect(s.type).toBe("Subtitle");
|
||||
expect(s.isForced).toBe(1);
|
||||
expect(s.language).toBe("deu");
|
||||
expect(s.title).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseProbeOutput — codec_type mapping", () => {
|
||||
test("maps data → Data and attachment → EmbeddedImage", () => {
|
||||
const json = JSON.stringify({
|
||||
format: {},
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
codec_type: "data",
|
||||
disposition: { default: 0, forced: 0, hearing_impaired: 0 },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
codec_type: "attachment",
|
||||
disposition: { default: 0, forced: 0, hearing_impaired: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseProbeOutput(json);
|
||||
expect(result.streams[0].type).toBe("Data");
|
||||
expect(result.streams[1].type).toBe("EmbeddedImage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import type { ParsedPath } from "../path-parser";
|
||||
import type { ProbeResult } from "../probe";
|
||||
import { upsertScannedItem } from "../rescan";
|
||||
|
||||
function freshDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
const PARSED: ParsedPath = {
|
||||
type: "Movie",
|
||||
name: "Hot Fuzz",
|
||||
year: 2007,
|
||||
seriesName: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
imdbId: "tt0425112",
|
||||
tmdbId: null,
|
||||
tvdbId: null,
|
||||
container: "mkv",
|
||||
};
|
||||
|
||||
const PROBE: ProbeResult = {
|
||||
fileSize: 5_000_000_000,
|
||||
durationSeconds: 7200,
|
||||
container: "matroska",
|
||||
containerTitle: "Hot.Fuzz.2007.1080p.BluRay.x264-RARBG",
|
||||
containerComment: "rarbg",
|
||||
streams: [
|
||||
{
|
||||
streamIndex: 0,
|
||||
type: "Video",
|
||||
codec: "h264",
|
||||
profile: "High",
|
||||
language: null,
|
||||
title: null,
|
||||
isDefault: 1,
|
||||
isForced: 0,
|
||||
isHearingImpaired: 0,
|
||||
channels: null,
|
||||
channelLayout: null,
|
||||
bitRate: null,
|
||||
sampleRate: null,
|
||||
bitDepth: null,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
streamIndex: 1,
|
||||
type: "Audio",
|
||||
codec: "dts",
|
||||
profile: "DTS-HD MA",
|
||||
language: "eng",
|
||||
title: "English 5.1",
|
||||
isDefault: 1,
|
||||
isForced: 0,
|
||||
isHearingImpaired: 0,
|
||||
channels: 6,
|
||||
channelLayout: "5.1(side)",
|
||||
bitRate: 1509000,
|
||||
sampleRate: 48000,
|
||||
bitDepth: 24,
|
||||
width: null,
|
||||
height: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("upsertScannedItem", () => {
|
||||
test("inserts new item with streams", () => {
|
||||
const db = freshDb();
|
||||
const result = upsertScannedItem(db, "/movies/Hot Fuzz (2007)/Hot Fuzz (2007).mkv", PARSED, PROBE);
|
||||
expect(result.itemId).toBeGreaterThan(0);
|
||||
expect(result.isNew).toBe(true);
|
||||
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(result.itemId) as any;
|
||||
expect(item.name).toBe("Hot Fuzz");
|
||||
expect(item.type).toBe("Movie");
|
||||
expect(item.imdb_id).toBe("tt0425112");
|
||||
expect(item.file_size).toBe(5_000_000_000);
|
||||
expect(item.duration_seconds).toBe(7200);
|
||||
expect(item.scan_status).toBe("scanned");
|
||||
expect(item.container_title).toBe("Hot.Fuzz.2007.1080p.BluRay.x264-RARBG");
|
||||
expect(item.container_comment).toBe("rarbg");
|
||||
|
||||
const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(result.itemId);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect((streams[0] as any).width).toBe(1920);
|
||||
expect((streams[0] as any).height).toBe(1080);
|
||||
});
|
||||
|
||||
test("upserts on same file_path", () => {
|
||||
const db = freshDb();
|
||||
upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
const updated = { ...PROBE, fileSize: 6_000_000_000 };
|
||||
const result = upsertScannedItem(db, "/movies/test.mkv", PARSED, updated);
|
||||
expect(result.isNew).toBe(false);
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(result.itemId) as any;
|
||||
expect(item.file_size).toBe(6_000_000_000);
|
||||
});
|
||||
|
||||
test("preserves manual language override on rescan", () => {
|
||||
const db = freshDb();
|
||||
const result = upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
db
|
||||
.prepare("UPDATE media_items SET original_language = 'fra', orig_lang_source = 'manual' WHERE id = ?")
|
||||
.run(result.itemId);
|
||||
|
||||
const result2 = upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
const item = db
|
||||
.prepare("SELECT original_language, orig_lang_source FROM media_items WHERE id = ?")
|
||||
.get(result2.itemId) as any;
|
||||
expect(item.original_language).toBe("fra");
|
||||
expect(item.orig_lang_source).toBe("manual");
|
||||
});
|
||||
|
||||
test("creates review_plan stub", () => {
|
||||
const db = freshDb();
|
||||
const result = upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(result.itemId) as any;
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan.status).toBe("pending");
|
||||
});
|
||||
|
||||
test("does not reset approved plan on rescan", () => {
|
||||
const db = freshDb();
|
||||
const result = upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
db.prepare("UPDATE review_plans SET status = 'approved' WHERE item_id = ?").run(result.itemId);
|
||||
|
||||
upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
const plan = db.prepare("SELECT status FROM review_plans WHERE item_id = ?").get(result.itemId) as any;
|
||||
expect(plan.status).toBe("approved");
|
||||
});
|
||||
|
||||
test("resets error plan to pending on rescan", () => {
|
||||
const db = freshDb();
|
||||
const result = upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(result.itemId);
|
||||
|
||||
upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
const plan = db.prepare("SELECT status FROM review_plans WHERE item_id = ?").get(result.itemId) as any;
|
||||
expect(plan.status).toBe("pending");
|
||||
});
|
||||
|
||||
test("guesses original language from default audio track", () => {
|
||||
const db = freshDb();
|
||||
const result = upsertScannedItem(db, "/movies/test.mkv", PARSED, PROBE);
|
||||
expect(result.origLang).toBe("eng");
|
||||
expect(result.origLangSource).toBe("probe");
|
||||
});
|
||||
|
||||
test("sets series_key for episodes", () => {
|
||||
const db = freshDb();
|
||||
const episodeParsed: ParsedPath = {
|
||||
...PARSED,
|
||||
type: "Episode",
|
||||
name: "Pilot",
|
||||
seriesName: "Breaking Bad",
|
||||
year: 2008,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
};
|
||||
const result = upsertScannedItem(db, "/tv/Breaking Bad (2008)/Season 01/file.mkv", episodeParsed, PROBE);
|
||||
const item = db.prepare("SELECT series_key FROM media_items WHERE id = ?").get(result.itemId) as any;
|
||||
expect(item.series_key).toBe("Breaking Bad|2008");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type ProbedStream, verifyStreamMetadata } from "../verify";
|
||||
|
||||
type ExpectedStream = Parameters<typeof verifyStreamMetadata>[0][number];
|
||||
|
||||
function expected(
|
||||
o: Partial<ExpectedStream> & Pick<ExpectedStream, "id" | "stream_id" | "type" | "stream_index">,
|
||||
): ExpectedStream {
|
||||
return {
|
||||
id: o.id,
|
||||
item_id: 1,
|
||||
stream_id: o.stream_id,
|
||||
plan_id: 1,
|
||||
action: "keep",
|
||||
target_index: 0,
|
||||
custom_title: null,
|
||||
custom_language: null,
|
||||
transcode_codec: null,
|
||||
codec: null,
|
||||
profile: null,
|
||||
language: null,
|
||||
title: null,
|
||||
is_default: 0,
|
||||
is_forced: 0,
|
||||
is_hearing_impaired: 0,
|
||||
channels: null,
|
||||
channel_layout: null,
|
||||
bit_rate: null,
|
||||
sample_rate: null,
|
||||
bit_depth: null,
|
||||
width: null,
|
||||
height: null,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
function probed(o: Partial<ProbedStream> & Pick<ProbedStream, "type">): ProbedStream {
|
||||
return {
|
||||
codec: null,
|
||||
language: null,
|
||||
title: null,
|
||||
isDefault: 0,
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
describe("verifyStreamMetadata", () => {
|
||||
test("detects dirty video title metadata", () => {
|
||||
const mismatch = verifyStreamMetadata(
|
||||
[expected({ id: 1, stream_id: 1, type: "Video", stream_index: 0, codec: "h264", width: 1920, height: 1080 })],
|
||||
[probed({ type: "Video", codec: "h264", title: "Movie.Name.1080p.ADS-GRP" })],
|
||||
);
|
||||
expect(mismatch?.reason).toContain("video track 0: title");
|
||||
expect(mismatch?.reason).toContain("1080p - H.264");
|
||||
});
|
||||
|
||||
test("detects dirty audio title metadata", () => {
|
||||
const mismatch = verifyStreamMetadata(
|
||||
[
|
||||
expected({
|
||||
id: 1,
|
||||
stream_id: 1,
|
||||
type: "Audio",
|
||||
stream_index: 0,
|
||||
codec: "dts",
|
||||
language: "eng",
|
||||
channels: 6,
|
||||
}),
|
||||
],
|
||||
[probed({ type: "Audio", codec: "dts", language: "eng", title: "English DTS ads", isDefault: 1 })],
|
||||
);
|
||||
expect(mismatch?.reason).toContain("audio track 0: title");
|
||||
expect(mismatch?.reason).toContain("ENG - DTS 5.1");
|
||||
});
|
||||
|
||||
test("detects non-canonical language tags and wrong default disposition", () => {
|
||||
const languageMismatch = verifyStreamMetadata(
|
||||
[expected({ id: 1, stream_id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })],
|
||||
[probed({ type: "Audio", codec: "aac", language: "en", title: "ENG - AAC", isDefault: 1 })],
|
||||
);
|
||||
expect(languageMismatch?.reason).toContain("language en ≠ expected eng");
|
||||
|
||||
const defaultMismatch = verifyStreamMetadata(
|
||||
[expected({ id: 1, stream_id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })],
|
||||
[probed({ type: "Audio", codec: "aac", language: "eng", title: "ENG - AAC", isDefault: 0 })],
|
||||
);
|
||||
expect(defaultMismatch?.reason).toContain("default disposition 0 ≠ expected 1");
|
||||
});
|
||||
|
||||
test("returns null when video and audio metadata already match", () => {
|
||||
const mismatch = verifyStreamMetadata(
|
||||
[
|
||||
expected({ id: 1, stream_id: 1, type: "Video", stream_index: 0, codec: "hevc", width: 3840, height: 2160 }),
|
||||
expected({ id: 2, stream_id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }),
|
||||
],
|
||||
[
|
||||
probed({ type: "Video", codec: "hevc", title: "2160p - HEVC" }),
|
||||
probed({ type: "Audio", codec: "eac3", language: "eng", title: "ENG - EAC3 5.1", isDefault: 1 }),
|
||||
],
|
||||
);
|
||||
expect(mismatch).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import type { JellyfinItem } from "../../types";
|
||||
import type { JellyfinConfig } from "../jellyfin";
|
||||
import type { RescanConfig } from "../rescan";
|
||||
import { _resetDedupe, processWebhookEvent } from "../webhook";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
const JF: JellyfinConfig = { url: "http://jf", apiKey: "k" };
|
||||
const RESCAN_CFG: RescanConfig = {
|
||||
audioLanguages: [],
|
||||
radarr: null,
|
||||
sonarr: null,
|
||||
radarrLibrary: null,
|
||||
sonarrLibrary: null,
|
||||
};
|
||||
|
||||
function fakeItem(over: Partial<JellyfinItem> = {}): JellyfinItem {
|
||||
return {
|
||||
Id: "jf-1",
|
||||
Type: "Movie",
|
||||
Name: "Test Movie",
|
||||
Path: "/movies/Test.mkv",
|
||||
Container: "mkv",
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0, Codec: "h264" },
|
||||
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processWebhookEvent — acceptance", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
afterEach(() => _resetDedupe());
|
||||
|
||||
test("rejects playback-related NotificationTypes (the plugin publishes many, we only want ItemAdded)", async () => {
|
||||
const db = makeDb();
|
||||
for (const nt of ["PlaybackStart", "PlaybackProgress", "UserDataSaved", "ItemUpdated"]) {
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: nt, ItemId: "jf-1", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("NotificationType");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-Movie/Episode types", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Trailer" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem({ Type: "Trailer" }) },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("ItemType");
|
||||
});
|
||||
|
||||
test("rejects missing ItemId", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("ItemId");
|
||||
});
|
||||
|
||||
test("dedupes bursts within 5s and accepts again after", async () => {
|
||||
const db = makeDb();
|
||||
let fakeNow = 1_000_000;
|
||||
const getItemFn = async () => fakeItem();
|
||||
const payload = { NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Movie" };
|
||||
|
||||
const first = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(first.accepted).toBe(true);
|
||||
|
||||
fakeNow += 1000;
|
||||
const second = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(second.accepted).toBe(false);
|
||||
expect(second.reason).toBe("deduped");
|
||||
|
||||
fakeNow += 5001;
|
||||
const third = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(third.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test("drops when Jellyfin returns no item", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: "jf-missing", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("no item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processWebhookEvent — done-status override", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
|
||||
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
|
||||
return processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
|
||||
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
|
||||
);
|
||||
}
|
||||
|
||||
function planStatusFor(db: Database, jellyfinId: string): string {
|
||||
return (
|
||||
db
|
||||
.prepare("SELECT rp.status FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?")
|
||||
.get(jellyfinId) as { status: string }
|
||||
).status;
|
||||
}
|
||||
|
||||
test("a webhook that analyzes to is_noop=1 leaves a done plan as done", async () => {
|
||||
const db = makeDb();
|
||||
const fresh = fakeItem();
|
||||
await runWebhook(db, fresh);
|
||||
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
_resetDedupe();
|
||||
await runWebhook(db, fresh);
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("done");
|
||||
});
|
||||
|
||||
test("a webhook that analyzes to is_noop=0 flips a done plan back to pending", async () => {
|
||||
const db = makeDb();
|
||||
// audio_languages=['deu'] means a file with english OG + french extra
|
||||
// audio should remove french → is_noop=0.
|
||||
const cfg: RescanConfig = { ...RESCAN_CFG, audioLanguages: ["deu"] };
|
||||
const fresh = fakeItem({
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0, Codec: "h264" },
|
||||
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
||||
{ Type: "Audio", Index: 2, Codec: "aac", Language: "fra" },
|
||||
],
|
||||
});
|
||||
|
||||
await runWebhook(db, fresh, cfg);
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
_resetDedupe();
|
||||
await runWebhook(db, fresh, cfg);
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("pending");
|
||||
});
|
||||
});
|
||||
+161
-31
@@ -1,12 +1,25 @@
|
||||
import type { MediaItem, MediaStream, PlanResult } from "../types";
|
||||
import { computeAppleCompat, isAppleCompatible, transcodeTarget } from "./apple-compat";
|
||||
import { isExtractableSubtitle } from "./ffmpeg";
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
import { containerTitle, isExtractableSubtitle, trackTitle } from "./ffmpeg";
|
||||
import { normalizeLanguage } from "./language-utils";
|
||||
|
||||
const AUTHORITATIVE_ORIG_SOURCES = new Set<string>(["radarr", "sonarr", "manual"]);
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
audioLanguages: string[]; // additional languages to keep (after OG)
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective-language lookup — prefer the user's per-stream override, fall
|
||||
* back to whatever the file reports. Returned as raw; callers still need
|
||||
* to normalizeLanguage() for comparison.
|
||||
*/
|
||||
function effectiveLanguage(stream: MediaStream, overrides: Map<number, string> | undefined): string | null {
|
||||
const override = overrides?.get(stream.id);
|
||||
if (override) return override;
|
||||
return stream.language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an item and its streams, compute what action to take for each stream
|
||||
* and whether the file needs audio remuxing.
|
||||
@@ -15,29 +28,54 @@ export interface AnalyzerConfig {
|
||||
* sidecar files). is_noop considers audio removal/reorder, subtitle
|
||||
* extraction, and transcode — a "noop" is a file that needs no changes
|
||||
* at all.
|
||||
*
|
||||
* `languageOverrides` maps stream_id → ISO code and lets the user correct a
|
||||
* mislabeled track ("und" → "spa") before the analyzer groups and filters.
|
||||
* When present, the override wins over `MediaStream.language` for every
|
||||
* language-aware decision (keep/remove, dedup, ordering, is_noop).
|
||||
*/
|
||||
export function analyzeItem(
|
||||
item: Pick<MediaItem, "original_language" | "needs_review" | "container">,
|
||||
item: Pick<
|
||||
MediaItem,
|
||||
| "original_language"
|
||||
| "orig_lang_source"
|
||||
| "needs_review"
|
||||
| "container"
|
||||
| "container_title"
|
||||
| "container_comment"
|
||||
| "type"
|
||||
| "name"
|
||||
| "year"
|
||||
| "series_name"
|
||||
| "season_number"
|
||||
| "episode_number"
|
||||
>,
|
||||
streams: MediaStream[],
|
||||
config: AnalyzerConfig,
|
||||
languageOverrides?: Map<number, string>,
|
||||
): PlanResult {
|
||||
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
||||
const notes: string[] = [];
|
||||
|
||||
const decisions: PlanResult["decisions"] = streams.map((s) => {
|
||||
const action = decideAction(s, origLang, config.audioLanguages);
|
||||
const lang = effectiveLanguage(s, languageOverrides);
|
||||
const action = decideAction(s, lang, origLang, config.audioLanguages);
|
||||
return { stream_id: s.id, action, target_index: null, transcode_codec: null };
|
||||
});
|
||||
|
||||
// Snapshot actions before dedup so we can distinguish language-driven removes
|
||||
// from commentary-title-driven removes when computing commentaryHeuristicFired.
|
||||
const decisionsBeforeDedup = new Map<number, "keep" | "remove">(decisions.map((d) => [d.stream_id, d.action]));
|
||||
|
||||
// Second pass: within each kept-language group, drop commentary/AD tracks
|
||||
// and alternate formats so we end up with exactly one audio stream per
|
||||
// language. The user doesn't need 2× English (main + director's
|
||||
// commentary) — one well-chosen track is enough.
|
||||
deduplicateAudioByLanguage(streams, decisions, origLang);
|
||||
deduplicateAudioByLanguage(streams, decisions, origLang, languageOverrides);
|
||||
|
||||
const anyAudioRemoved = streams.some((s, i) => s.type === "Audio" && decisions[i].action === "remove");
|
||||
|
||||
assignTargetOrder(streams, decisions, origLang, config.audioLanguages);
|
||||
assignTargetOrder(streams, decisions, origLang, config.audioLanguages, languageOverrides);
|
||||
|
||||
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
|
||||
|
||||
@@ -76,18 +114,51 @@ export function analyzeItem(
|
||||
const firstKeptAudio = keptAudioSorted[0];
|
||||
const defaultMismatch = !!firstKeptAudio && firstKeptAudio.is_default !== 1;
|
||||
const nonDefaultHasDefault = keptAudioSorted.slice(1).some((s) => s.is_default === 1);
|
||||
const languageMismatch = keptAudioStreams.some(
|
||||
(s) => s.language != null && s.language !== normalizeLanguage(s.language),
|
||||
);
|
||||
// Non-canonical language tag in the file (e.g. "ger" instead of "deu", or
|
||||
// "en" instead of "eng") or a user-provided custom_language that differs
|
||||
// from the stream's raw tag — either one means ffmpeg would rewrite the
|
||||
// metadata, so the file isn't already in the desired state.
|
||||
const languageMismatch = keptAudioStreams.some((s) => {
|
||||
const override = languageOverrides?.get(s.id);
|
||||
if (override) {
|
||||
const canonical = normalizeLanguage(override);
|
||||
return s.language !== canonical;
|
||||
}
|
||||
return s.language != null && s.language !== normalizeLanguage(s.language);
|
||||
});
|
||||
|
||||
const is_noop =
|
||||
!anyAudioRemoved &&
|
||||
!audioOrderChanged &&
|
||||
!hasSubs &&
|
||||
!needsTranscode &&
|
||||
!defaultMismatch &&
|
||||
!nonDefaultHasDefault &&
|
||||
!languageMismatch;
|
||||
// Title mismatch: every kept video/audio track must have the correct
|
||||
// canonical title for the file to be in its desired state. This removes
|
||||
// release-group/ad noise from stream metadata while keeping filename info.
|
||||
const keptTitleStreams = (type: "Video" | "Audio") =>
|
||||
streams.filter((s) => s.type === type && decisions.find((d) => d.stream_id === s.id)?.action === "keep");
|
||||
const audioTitleMismatch = keptTitleStreams("Audio").some((s) => {
|
||||
const override = languageOverrides?.get(s.id);
|
||||
const expected = trackTitle(s, override ?? null);
|
||||
return expected != null && s.title !== expected;
|
||||
});
|
||||
const videoTitleMismatch = keptTitleStreams("Video").some((s) => {
|
||||
const expected = trackTitle(s);
|
||||
return expected != null && s.title !== expected;
|
||||
});
|
||||
|
||||
const expectedContainerTitle = containerTitle(item);
|
||||
const containerTitleMismatch = (item.container_title ?? null) !== (expectedContainerTitle ?? null);
|
||||
const containerCommentDirty = !!item.container_comment && item.container_comment.length > 0;
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (anyAudioRemoved) reasons.push("Remove tracks");
|
||||
if (audioOrderChanged) reasons.push("Reorder");
|
||||
if (hasSubs) reasons.push("Extract subs");
|
||||
if (needsTranscode) reasons.push("Transcode");
|
||||
if (defaultMismatch || nonDefaultHasDefault) reasons.push("Fix default");
|
||||
if (languageMismatch) reasons.push("Fix language tag");
|
||||
if (audioTitleMismatch) reasons.push("Fix audio title");
|
||||
if (videoTitleMismatch) reasons.push("Fix video title");
|
||||
if (containerTitleMismatch) reasons.push("Fix container title");
|
||||
if (containerCommentDirty) reasons.push("Clear comment");
|
||||
|
||||
const is_noop = reasons.length === 0;
|
||||
|
||||
if (!origLang && item.needs_review) {
|
||||
notes.push("Original language unknown — audio tracks not filtered; manual review required");
|
||||
@@ -109,7 +180,57 @@ export function analyzeItem(
|
||||
notes.push(`${nonExtractable.length} subtitle(s) dropped: ${summary} — not extractable to sidecar`);
|
||||
}
|
||||
|
||||
return { is_noop, has_subs: hasSubs, confidence: "low", apple_compat, job_type, decisions, notes };
|
||||
const origLangSource = item.orig_lang_source ?? null;
|
||||
const authoritativeOg =
|
||||
!!origLang && !!origLangSource && AUTHORITATIVE_ORIG_SOURCES.has(origLangSource) && item.needs_review === 0;
|
||||
|
||||
const keptAudioLanguages = keptAudioStreams.map((s) => {
|
||||
const lang = effectiveLanguage(s, languageOverrides);
|
||||
return lang ? normalizeLanguage(lang) : null;
|
||||
});
|
||||
const ogPresent = !!origLang && keptAudioLanguages.includes(origLang);
|
||||
const everyKeptHasLanguage = keptAudioStreams.length > 0 && keptAudioLanguages.every((l) => l != null);
|
||||
|
||||
// Only count as heuristic-fired when the commentary regex itself CAUSED the
|
||||
// removal: track was "keep" after language-based decideAction, then flipped
|
||||
// to "remove" by deduplicateAudioByLanguage because of its title/flag.
|
||||
// A track removed for LANGUAGE reasons (keep→remove never happened) should
|
||||
// not upgrade the classification even if its title coincidentally matches.
|
||||
const commentaryHeuristicFired = decisions.some((d) => {
|
||||
const before = decisionsBeforeDedup.get(d.stream_id);
|
||||
if (before !== "keep" || d.action !== "remove") return false;
|
||||
const s = streams.find((str) => str.id === d.stream_id);
|
||||
return !!s && isCommentaryOrAuxiliary(s);
|
||||
});
|
||||
|
||||
// Quality mismatch: OG track exists but has fewer channels than a non-OG
|
||||
// track available in the file. E.g. Japanese mono vs English 5.1 — user
|
||||
// should decide whether to keep the higher-quality dub. Compares against
|
||||
// ALL non-OG audio streams (not just kept ones) because the user might
|
||||
// want to keep a track that the config currently removes.
|
||||
const audioStreams = streams.filter((s) => s.type === "Audio");
|
||||
const ogStreams = audioStreams.filter((s) => {
|
||||
const lang = s.language ? normalizeLanguage(s.language) : null;
|
||||
return lang && lang === origLang;
|
||||
});
|
||||
const nonOgStreams = audioStreams.filter((s) => {
|
||||
const lang = s.language ? normalizeLanguage(s.language) : null;
|
||||
return lang && lang !== origLang;
|
||||
});
|
||||
const ogMaxChannels = Math.max(0, ...ogStreams.map((s) => s.channels ?? 0));
|
||||
const nonOgMaxChannels = Math.max(0, ...nonOgStreams.map((s) => s.channels ?? 0));
|
||||
const ogQualityInferior = ogMaxChannels > 0 && nonOgMaxChannels > ogMaxChannels;
|
||||
|
||||
let auto_class: PlanResult["auto_class"];
|
||||
if (!authoritativeOg || !ogPresent || !everyKeptHasLanguage) {
|
||||
auto_class = "manual";
|
||||
} else if (commentaryHeuristicFired || ogQualityInferior) {
|
||||
auto_class = "auto_heuristic";
|
||||
} else {
|
||||
auto_class = "auto";
|
||||
}
|
||||
|
||||
return { is_noop, has_subs: hasSubs, auto_class, apple_compat, job_type, decisions, notes, reasons };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +277,7 @@ function deduplicateAudioByLanguage(
|
||||
streams: MediaStream[],
|
||||
decisions: PlanResult["decisions"],
|
||||
origLang: string | null,
|
||||
languageOverrides: Map<number, string> | undefined,
|
||||
): void {
|
||||
const decisionById = new Map(decisions.map((d) => [d.stream_id, d]));
|
||||
const keptAudio = streams.filter((s) => s.type === "Audio" && decisionById.get(s.id)?.action === "keep");
|
||||
@@ -168,21 +290,23 @@ function deduplicateAudioByLanguage(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Group remaining kept-audio streams by normalized language and keep
|
||||
// one winner per group. Streams without a language tag are handled
|
||||
// specially: when OG language is unknown we keep them all (ambiguity
|
||||
// means we can't safely drop anything); when OG is known they've
|
||||
// already been kept by decideAction's "unknown language falls
|
||||
// through" clause, so still dedupe within them.
|
||||
// 2. Group remaining kept-audio streams by normalized effective language
|
||||
// and keep one winner per group. Streams without a language (no raw
|
||||
// tag and no override) are handled specially: when OG language is
|
||||
// unknown we keep them all (ambiguity means we can't safely drop
|
||||
// anything); when OG is known they've already been kept by
|
||||
// decideAction's "unknown language falls through" clause, so still
|
||||
// dedupe within them.
|
||||
const stillKept = keptAudio.filter((s) => decisionById.get(s.id)?.action === "keep");
|
||||
const byLang = new Map<string, MediaStream[]>();
|
||||
const noLang: MediaStream[] = [];
|
||||
for (const s of stillKept) {
|
||||
if (!s.language) {
|
||||
const lang = effectiveLanguage(s, languageOverrides);
|
||||
if (!lang) {
|
||||
noLang.push(s);
|
||||
continue;
|
||||
}
|
||||
const key = normalizeLanguage(s.language);
|
||||
const key = normalizeLanguage(lang);
|
||||
if (!byLang.has(key)) byLang.set(key, []);
|
||||
byLang.get(key)!.push(s);
|
||||
}
|
||||
@@ -210,7 +334,12 @@ function deduplicateAudioByLanguage(
|
||||
}
|
||||
}
|
||||
|
||||
function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" {
|
||||
function decideAction(
|
||||
stream: MediaStream,
|
||||
effectiveLang: string | null,
|
||||
origLang: string | null,
|
||||
audioLanguages: string[],
|
||||
): "keep" | "remove" {
|
||||
switch (stream.type) {
|
||||
case "Video":
|
||||
case "Data":
|
||||
@@ -219,8 +348,8 @@ function decideAction(stream: MediaStream, origLang: string | null, audioLanguag
|
||||
|
||||
case "Audio": {
|
||||
if (!origLang) return "keep";
|
||||
if (!stream.language) return "keep";
|
||||
const normalized = normalizeLanguage(stream.language);
|
||||
if (!effectiveLang) return "keep";
|
||||
const normalized = normalizeLanguage(effectiveLang);
|
||||
if (normalized === origLang) return "keep";
|
||||
if (audioLanguages.includes(normalized)) return "keep";
|
||||
return "remove";
|
||||
@@ -245,6 +374,7 @@ export function assignTargetOrder(
|
||||
decisions: PlanResult["decisions"],
|
||||
origLang: string | null,
|
||||
audioLanguages: string[],
|
||||
languageOverrides?: Map<number, string>,
|
||||
): void {
|
||||
const keptByType = new Map<string, MediaStream[]>();
|
||||
for (const s of allStreams) {
|
||||
@@ -257,8 +387,8 @@ export function assignTargetOrder(
|
||||
const audio = keptByType.get("Audio");
|
||||
if (audio) {
|
||||
audio.sort((a, b) => {
|
||||
const aRank = langRank(a.language, origLang, audioLanguages);
|
||||
const bRank = langRank(b.language, origLang, audioLanguages);
|
||||
const aRank = langRank(effectiveLanguage(a, languageOverrides), origLang, audioLanguages);
|
||||
const bRank = langRank(effectiveLanguage(b, languageOverrides), origLang, audioLanguages);
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
return a.stream_index - b.stream_index;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Recursively discover all video files under the given root directories.
|
||||
* Returns absolute paths sorted alphabetically.
|
||||
*
|
||||
* Skips `.tmp.<ext>` files — these are intermediate outputs from ffmpeg
|
||||
* copy/transcode runs that are renamed to the final path on success. If one
|
||||
* is still on disk, a previous run crashed and the file is likely truncated.
|
||||
*/
|
||||
export async function discoverVideoFiles(roots: string[]): Promise<string[]> {
|
||||
const glob = new Bun.Glob("**/*.{mkv,mp4,avi,m4v,ts,wmv}");
|
||||
const results: string[] = [];
|
||||
|
||||
for (const root of roots) {
|
||||
if (!existsSync(root)) continue;
|
||||
for await (const file of glob.scan({ cwd: root, absolute: true })) {
|
||||
if (/\.tmp\.[^./]+$/.test(file)) continue;
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort();
|
||||
}
|
||||
+113
-50
@@ -1,5 +1,5 @@
|
||||
import type { MediaItem, MediaStream, StreamDecision } from "../types";
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
import { normalizeLanguage } from "./language-utils";
|
||||
|
||||
// ─── Subtitle extraction helpers ──────────────────────────────────────────────
|
||||
|
||||
@@ -103,7 +103,7 @@ interface ExtractionEntry {
|
||||
codecArg: string;
|
||||
}
|
||||
|
||||
/** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */
|
||||
/** Compute extraction metadata for all subtitle streams. */
|
||||
function computeExtractionEntries(allStreams: MediaStream[], basePath: string): ExtractionEntry[] {
|
||||
const subTypeIdx = new Map<number, number>();
|
||||
let subCount = 0;
|
||||
@@ -148,34 +148,9 @@ function computeExtractionEntries(allStreams: MediaStream[], basePath: string):
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict the sidecar files that subtitle extraction will create.
|
||||
* Used to populate the subtitle_files table after a successful job.
|
||||
*/
|
||||
export function predictExtractedFiles(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
): Array<{
|
||||
file_path: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
is_forced: boolean;
|
||||
is_hearing_impaired: boolean;
|
||||
}> {
|
||||
const basePath = item.file_path.replace(/\.[^.]+$/, "");
|
||||
const entries = computeExtractionEntries(streams, basePath);
|
||||
return entries.map((e) => ({
|
||||
file_path: e.outPath,
|
||||
language: e.stream.language,
|
||||
codec: e.stream.codec,
|
||||
is_forced: !!e.stream.is_forced,
|
||||
is_hearing_impaired: !!e.stream.is_hearing_impaired,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
export const LANG_NAMES: Record<string, string> = {
|
||||
eng: "English",
|
||||
deu: "German",
|
||||
spa: "Spanish",
|
||||
@@ -232,12 +207,29 @@ function formatChannels(n: number | null): string | null {
|
||||
return `${n}ch`;
|
||||
}
|
||||
|
||||
function trackTitle(stream: MediaStream): string | null {
|
||||
if (stream.type === "Subtitle") {
|
||||
// Subtitles always get a clean language-based title so Jellyfin displays
|
||||
// "German", "English (Forced)", etc. regardless of the original file title.
|
||||
// The review UI shows a ⚠ badge when the original title looks like a
|
||||
// different language, so users can spot and remove mislabeled tracks.
|
||||
function formatCodec(codec: string | null): string | null {
|
||||
if (!codec) return null;
|
||||
const normalized = codec.toLowerCase();
|
||||
const labels: Record<string, string> = {
|
||||
h264: "H.264",
|
||||
avc: "H.264",
|
||||
hevc: "HEVC",
|
||||
h265: "HEVC",
|
||||
av1: "AV1",
|
||||
vp9: "VP9",
|
||||
vp8: "VP8",
|
||||
mpeg2video: "MPEG-2",
|
||||
vc1: "VC-1",
|
||||
};
|
||||
return labels[normalized] ?? codec.toUpperCase();
|
||||
}
|
||||
|
||||
function formatResolution(height: number | null): string | null {
|
||||
if (!height || height <= 0) return null;
|
||||
return `${height}p`;
|
||||
}
|
||||
|
||||
export function subtitleTitle(stream: MediaStream): string | null {
|
||||
if (!stream.language) return null;
|
||||
const lang = normalizeLanguage(stream.language);
|
||||
const base = LANG_NAMES[lang] ?? lang.toUpperCase();
|
||||
@@ -245,20 +237,75 @@ function trackTitle(stream: MediaStream): string | null {
|
||||
if (stream.is_hearing_impaired) return `${base} (CC)`;
|
||||
return base;
|
||||
}
|
||||
// Audio: harmonize to "ENG - AC3 · 5.1". Overrides whatever the file had
|
||||
|
||||
export function videoTitle(stream: MediaStream): string | null {
|
||||
const resolutionPart = formatResolution(stream.height);
|
||||
const codecPart = formatCodec(stream.codec);
|
||||
if (!resolutionPart) return null;
|
||||
const parts = [resolutionPart, codecPart].filter((v): v is string => !!v);
|
||||
return parts.length > 0 ? parts.join(" - ") : null;
|
||||
}
|
||||
|
||||
export function audioTitle(stream: MediaStream, customLanguage: string | null = null): string | null {
|
||||
const rawLang = customLanguage ?? stream.language;
|
||||
const lang = rawLang ? normalizeLanguage(rawLang) : null;
|
||||
const langPart = lang ? lang.toUpperCase() : null;
|
||||
const codecPart = formatCodec(stream.codec);
|
||||
const channelsPart = formatChannels(stream.channels);
|
||||
const tail = [codecPart, channelsPart].filter((v): v is string => !!v).join(" ");
|
||||
if (langPart && tail) return `${langPart} - ${tail}`;
|
||||
if (langPart) return langPart;
|
||||
if (tail) return tail;
|
||||
return null;
|
||||
}
|
||||
|
||||
type ContainerTitleItem = Pick<
|
||||
MediaItem,
|
||||
"type" | "name" | "year" | "series_name" | "season_number" | "episode_number"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Canonical container-level title for the media file.
|
||||
* Movie: "Name (Year)" — or "Name" when year is unknown.
|
||||
* Episode: "Series (Year) - S01E02 - Episode Title" — each segment drops
|
||||
* when its source is missing (year absent → "Series - S01E02 - Title",
|
||||
* episode name absent → "Series (Year) - S01E02").
|
||||
* Returns null when there's nothing to build a title from at all.
|
||||
*/
|
||||
export function containerTitle(item: ContainerTitleItem): string | null {
|
||||
if (item.type === "Episode") {
|
||||
const series = item.series_name ?? item.name ?? null;
|
||||
if (!series) return null;
|
||||
const head = item.year ? `${series} (${item.year})` : series;
|
||||
const hasSeason = item.season_number != null;
|
||||
const hasEpisode = item.episode_number != null;
|
||||
const code = hasSeason && hasEpisode
|
||||
? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")}`
|
||||
: null;
|
||||
const epTitle = item.name && item.name !== series ? item.name : null;
|
||||
const parts = [head, code, epTitle].filter((v): v is string => !!v);
|
||||
return parts.join(" - ");
|
||||
}
|
||||
if (!item.name) return null;
|
||||
return item.year ? `${item.name} (${item.year})` : item.name;
|
||||
}
|
||||
|
||||
export function trackTitle(stream: MediaStream, customLanguage: string | null = null): string | null {
|
||||
if (stream.type === "Subtitle") {
|
||||
// Subtitles always get a clean language-based title so Jellyfin displays
|
||||
// "German", "English (Forced)", etc. regardless of the original file title.
|
||||
return subtitleTitle(stream);
|
||||
}
|
||||
if (stream.type === "Video") return videoTitle(stream);
|
||||
// Audio: harmonize to "ENG - AC3 5.1". Overrides whatever the file had
|
||||
// (e.g. "Audio Description", "Director's Commentary") — the user uses
|
||||
// the review UI to drop unwanted tracks before we get here, so by this
|
||||
// point every kept audio track is a primary track that deserves a clean
|
||||
// canonical label. If a user wants a different title, custom_title on
|
||||
// the decision still wins (see buildStreamFlags).
|
||||
const lang = stream.language ? normalizeLanguage(stream.language) : null;
|
||||
const langPart = lang ? lang.toUpperCase() : null;
|
||||
const codecPart = stream.codec ? stream.codec.toUpperCase() : null;
|
||||
const channelsPart = formatChannels(stream.channels);
|
||||
const tail = [codecPart, channelsPart].filter((v): v is string => !!v).join(" · ");
|
||||
if (langPart && tail) return `${langPart} - ${tail}`;
|
||||
if (langPart) return langPart;
|
||||
if (tail) return tail;
|
||||
// the decision still wins (see buildStreamFlags). A per-stream language
|
||||
// override comes through as customLanguage so "UND → Spanish" renames
|
||||
// flow through to the harmonized title too.
|
||||
if (stream.type === "Audio") return audioTitle(stream, customLanguage);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -296,20 +343,36 @@ function buildMaps(allStreams: MediaStream[], kept: { stream: MediaStream; dec:
|
||||
* - Writes canonical ISO 639-2/B 3-letter language tags (e.g. "en" → "eng",
|
||||
* "ger" → "deu"). Streams with no language get "und" (ffmpeg convention).
|
||||
*/
|
||||
function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
|
||||
function buildStreamFlags(
|
||||
item: ContainerTitleItem,
|
||||
kept: { stream: MediaStream; dec: StreamDecision }[],
|
||||
): string[] {
|
||||
const videoKept = kept.filter((k) => k.stream.type === "Video");
|
||||
const audioKept = kept.filter((k) => k.stream.type === "Audio");
|
||||
const args: string[] = [];
|
||||
|
||||
videoKept.forEach((k, i) => {
|
||||
const title = k.dec.custom_title ?? videoTitle(k.stream);
|
||||
if (title) args.push(`-metadata:s:v:${i}`, `title=${shellQuote(title)}`);
|
||||
});
|
||||
|
||||
audioKept.forEach((k, i) => {
|
||||
args.push(`-disposition:a:${i}`, i === 0 ? "default" : "0");
|
||||
|
||||
const title = k.dec.custom_title ?? trackTitle(k.stream);
|
||||
const title = k.dec.custom_title ?? audioTitle(k.stream, k.dec.custom_language);
|
||||
if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`);
|
||||
|
||||
const lang = k.stream.language ? normalizeLanguage(k.stream.language) : "und";
|
||||
// Per-stream language override wins over the raw file tag so the
|
||||
// ffmpeg output carries the corrected language (e.g. "und" → "spa").
|
||||
const rawLang = k.dec.custom_language ?? k.stream.language;
|
||||
const lang = rawLang ? normalizeLanguage(rawLang) : "und";
|
||||
args.push(`-metadata:s:a:${i}`, `language=${lang}`);
|
||||
});
|
||||
|
||||
const fileTitle = containerTitle(item);
|
||||
args.push("-metadata", fileTitle ? `title=${shellQuote(fileTitle)}` : "title=");
|
||||
args.push("-metadata", "comment=");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -365,7 +428,7 @@ export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[],
|
||||
const kept = sortKeptStreams(streams, decisions);
|
||||
|
||||
const maps = buildMaps(streams, kept);
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
const streamFlags = buildStreamFlags(item, kept);
|
||||
|
||||
return [
|
||||
"ffmpeg",
|
||||
@@ -447,7 +510,7 @@ export function buildPipelineCommand(
|
||||
const finalCodecFlags = hasTranscode ? codecFlags : ["-c copy"];
|
||||
|
||||
// Disposition + metadata flags for audio
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
const streamFlags = buildStreamFlags(item, kept);
|
||||
|
||||
// Assemble command
|
||||
const parts: string[] = ["ffmpeg", "-y", "-i", shellQuote(inputPath)];
|
||||
@@ -505,7 +568,7 @@ export function summarizeChanges(
|
||||
export function streamLabel(s: MediaStream): string {
|
||||
const parts: string[] = [s.type];
|
||||
if (s.codec) parts.push(s.codec);
|
||||
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
|
||||
if (s.language) parts.push(s.language);
|
||||
if (s.title) parts.push(`"${s.title}"`);
|
||||
if (s.type === "Audio" && s.channels) parts.push(`${s.channels}ch`);
|
||||
if (s.is_forced) parts.push("forced");
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types";
|
||||
|
||||
export interface JellyfinConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
/** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/** Build the base items URL: user-scoped when userId is set, server-level otherwise. */
|
||||
function itemsBaseUrl(cfg: JellyfinConfig): string {
|
||||
return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
"X-Emby-Token": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${cfg.url}/Users`, {
|
||||
headers: headers(cfg.apiKey),
|
||||
});
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsers(cfg: Pick<JellyfinConfig, "url" | "apiKey">): Promise<JellyfinUser[]> {
|
||||
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
||||
return res.json() as Promise<JellyfinUser[]>;
|
||||
}
|
||||
|
||||
const ITEM_FIELDS = [
|
||||
"MediaStreams",
|
||||
"Path",
|
||||
"ProviderIds",
|
||||
"OriginalTitle",
|
||||
"ProductionYear",
|
||||
"Size",
|
||||
"Container",
|
||||
"RunTimeTicks",
|
||||
"DateLastRefreshed",
|
||||
].join(",");
|
||||
|
||||
export async function* getAllItems(
|
||||
cfg: JellyfinConfig,
|
||||
onProgress?: (count: number, total: number) => void,
|
||||
): AsyncGenerator<JellyfinItem> {
|
||||
let startIndex = 0;
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
const url = new URL(itemsBaseUrl(cfg));
|
||||
url.searchParams.set("Recursive", "true");
|
||||
url.searchParams.set("IncludeItemTypes", "Movie,Episode");
|
||||
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||
url.searchParams.set("Limit", String(PAGE_SIZE));
|
||||
url.searchParams.set("StartIndex", String(startIndex));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
|
||||
total = body.TotalRecordCount;
|
||||
|
||||
for (const item of body.Items) {
|
||||
yield item;
|
||||
}
|
||||
|
||||
startIndex += body.Items.length;
|
||||
onProgress?.(startIndex, total);
|
||||
} while (startIndex < total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev mode: yields 50 random movies + all episodes from 10 random series.
|
||||
* Used instead of getAllItems() when NODE_ENV=development.
|
||||
*/
|
||||
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
|
||||
// 50 random movies
|
||||
const movieUrl = new URL(itemsBaseUrl(cfg));
|
||||
movieUrl.searchParams.set("Recursive", "true");
|
||||
movieUrl.searchParams.set("IncludeItemTypes", "Movie");
|
||||
movieUrl.searchParams.set("SortBy", "Random");
|
||||
movieUrl.searchParams.set("Limit", "50");
|
||||
movieUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||
|
||||
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!movieRes.ok)
|
||||
throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
|
||||
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const item of movieBody.Items) yield item;
|
||||
|
||||
// 10 random series → yield all their episodes
|
||||
const seriesUrl = new URL(itemsBaseUrl(cfg));
|
||||
seriesUrl.searchParams.set("Recursive", "true");
|
||||
seriesUrl.searchParams.set("IncludeItemTypes", "Series");
|
||||
seriesUrl.searchParams.set("SortBy", "Random");
|
||||
seriesUrl.searchParams.set("Limit", "10");
|
||||
|
||||
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
|
||||
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
|
||||
for (const series of seriesBody.Items) {
|
||||
const epUrl = new URL(itemsBaseUrl(cfg));
|
||||
epUrl.searchParams.set("ParentId", series.Id);
|
||||
epUrl.searchParams.set("Recursive", "true");
|
||||
epUrl.searchParams.set("IncludeItemTypes", "Episode");
|
||||
epUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||
|
||||
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (epRes.ok) {
|
||||
const epBody = (await epRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const ep of epBody.Items) yield ep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a single Jellyfin item by its ID (for per-file rescan). */
|
||||
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
|
||||
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<JellyfinItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a Jellyfin metadata refresh for a single item and wait until it completes.
|
||||
* Polls DateLastRefreshed until it changes (or timeout is reached).
|
||||
*/
|
||||
/**
|
||||
* Trigger a Jellyfin metadata refresh and poll until the item's
|
||||
* `DateLastRefreshed` advances. Returns true when the probe actually ran;
|
||||
* false on timeout (caller decides whether to trust the item's current
|
||||
* metadata or treat it as unverified — verification paths should NOT
|
||||
* proceed on false, since a stale snapshot would give a bogus verdict).
|
||||
*/
|
||||
export async function refreshItem(
|
||||
cfg: JellyfinConfig,
|
||||
jellyfinId: string,
|
||||
timeoutMs = 15000,
|
||||
): Promise<{ refreshed: boolean }> {
|
||||
const itemUrl = `${cfg.url}/Items/${jellyfinId}`;
|
||||
|
||||
// 1. Snapshot current DateLastRefreshed
|
||||
const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`);
|
||||
const before = (await beforeRes.json()) as { DateLastRefreshed?: string };
|
||||
const beforeDate = before.DateLastRefreshed;
|
||||
|
||||
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
|
||||
const refreshUrl = new URL(`${itemUrl}/Refresh`);
|
||||
refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh");
|
||||
refreshUrl.searchParams.set("ImageRefreshMode", "None");
|
||||
refreshUrl.searchParams.set("ReplaceAllMetadata", "false");
|
||||
refreshUrl.searchParams.set("ReplaceAllImages", "false");
|
||||
const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) });
|
||||
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
|
||||
|
||||
// 3. Poll until DateLastRefreshed changes
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!checkRes.ok) continue;
|
||||
const check = (await checkRes.json()) as { DateLastRefreshed?: string };
|
||||
if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) {
|
||||
return { refreshed: true };
|
||||
}
|
||||
}
|
||||
return { refreshed: false };
|
||||
}
|
||||
|
||||
/** Case-insensitive hints that a track is a dub / commentary, not the original. */
|
||||
const DUB_TITLE_HINTS = /(dub|dubb|synchro|commentary|director)/i;
|
||||
|
||||
/**
|
||||
* Jellyfin has no real original_language field, so we guess from audio streams.
|
||||
* This is the notorious "8 Mile got labelled Turkish" heuristic — guard it:
|
||||
* 1. Prefer IsDefault audio when available (Jellyfin sets this from the file's
|
||||
* default disposition flag; uploaders usually set it to the original).
|
||||
* 2. Skip tracks whose title screams "dub" / "commentary".
|
||||
* 3. Fall back to the first non-dub audio track, then first audio track.
|
||||
* The caller must still treat any jellyfin-sourced value as unverified — this
|
||||
* just makes the guess less wrong. The trustworthy answer comes from Radarr/Sonarr.
|
||||
*/
|
||||
export function extractOriginalLanguage(item: JellyfinItem): string | null {
|
||||
if (!item.MediaStreams) return null;
|
||||
const audio = item.MediaStreams.filter((s) => s.Type === "Audio");
|
||||
if (audio.length === 0) return null;
|
||||
const notDub = (s: JellyfinMediaStream) => !s.Title || !DUB_TITLE_HINTS.test(s.Title);
|
||||
const pick = audio.find((s) => s.IsDefault && notDub(s)) ?? audio.find(notDub) ?? audio[0];
|
||||
return pick.Language ? normalizeLanguage(pick.Language) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id).
|
||||
*
|
||||
* NOTE: stores the raw `Language` value from Jellyfin (e.g. "en", "eng", "ger",
|
||||
* null). We intentionally do NOT normalize here because `is_noop` compares
|
||||
* raw → normalized to decide whether the pipeline should rewrite the tag to
|
||||
* canonical iso3. Callers that compare languages must use normalizeLanguage().
|
||||
*/
|
||||
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, "id" | "item_id"> {
|
||||
return {
|
||||
stream_index: s.Index,
|
||||
type: s.Type as MediaStream["type"],
|
||||
codec: s.Codec ?? null,
|
||||
profile: s.Profile ?? null,
|
||||
language: s.Language ?? null,
|
||||
language_display: s.DisplayLanguage ?? null,
|
||||
title: s.Title ?? null,
|
||||
is_default: s.IsDefault ? 1 : 0,
|
||||
is_forced: s.IsForced ? 1 : 0,
|
||||
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
|
||||
channels: s.Channels ?? null,
|
||||
channel_layout: s.ChannelLayout ?? null,
|
||||
bit_rate: s.BitRate ?? null,
|
||||
sample_rate: s.SampleRate ?? null,
|
||||
bit_depth: s.BitDepth ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ISO 639-1 (2-letter) → ISO 639-2/B (3-letter) canonical form.
|
||||
// Used by normalizeLanguage so "en" and "eng" both resolve to "eng" and
|
||||
// the canonical-language check can flag files whose tags are still 2-letter.
|
||||
const ISO_1_TO_2: Record<string, string> = {
|
||||
en: "eng",
|
||||
de: "deu",
|
||||
es: "spa",
|
||||
fr: "fra",
|
||||
it: "ita",
|
||||
pt: "por",
|
||||
ja: "jpn",
|
||||
ko: "kor",
|
||||
zh: "zho",
|
||||
ar: "ara",
|
||||
ru: "rus",
|
||||
nl: "nld",
|
||||
sv: "swe",
|
||||
no: "nor",
|
||||
da: "dan",
|
||||
fi: "fin",
|
||||
pl: "pol",
|
||||
tr: "tur",
|
||||
th: "tha",
|
||||
hi: "hin",
|
||||
hu: "hun",
|
||||
cs: "ces",
|
||||
ro: "ron",
|
||||
el: "ell",
|
||||
he: "heb",
|
||||
fa: "fas",
|
||||
uk: "ukr",
|
||||
id: "ind",
|
||||
ca: "cat",
|
||||
nb: "nob",
|
||||
nn: "nno",
|
||||
is: "isl",
|
||||
hr: "hrv",
|
||||
sk: "slk",
|
||||
bg: "bul",
|
||||
sr: "srp",
|
||||
sl: "slv",
|
||||
lv: "lav",
|
||||
lt: "lit",
|
||||
et: "est",
|
||||
vi: "vie",
|
||||
ms: "msa",
|
||||
ta: "tam",
|
||||
te: "tel",
|
||||
};
|
||||
|
||||
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
// German: both /T (deu) and /B (ger) → deu
|
||||
ger: "deu",
|
||||
// Chinese
|
||||
chi: "zho",
|
||||
// French
|
||||
fre: "fra",
|
||||
// Dutch
|
||||
dut: "nld",
|
||||
// Modern Greek
|
||||
gre: "ell",
|
||||
// Hebrew
|
||||
heb: "heb",
|
||||
// Farsi
|
||||
per: "fas",
|
||||
// Romanian
|
||||
rum: "ron",
|
||||
// Malay
|
||||
may: "msa",
|
||||
// Tibetan
|
||||
tib: "bod",
|
||||
// Burmese
|
||||
bur: "mya",
|
||||
// Czech
|
||||
cze: "ces",
|
||||
// Slovak
|
||||
slo: "slk",
|
||||
// Georgian
|
||||
geo: "kat",
|
||||
// Icelandic
|
||||
ice: "isl",
|
||||
// Armenian
|
||||
arm: "hye",
|
||||
// Basque
|
||||
baq: "eus",
|
||||
// Albanian
|
||||
alb: "sqi",
|
||||
// Macedonian
|
||||
mac: "mkd",
|
||||
// Welsh
|
||||
wel: "cym",
|
||||
};
|
||||
|
||||
export function normalizeLanguage(lang: string): string {
|
||||
const lower = lang.toLowerCase().trim();
|
||||
if (ISO_1_TO_2[lower]) return ISO_1_TO_2[lower];
|
||||
return LANG_ALIASES[lower] ?? lower;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import type { MediaItem } from "../types";
|
||||
import { getOriginalLanguage as radarrLang, type RadarrLibrary } from "./radarr";
|
||||
import { getOriginalLanguage as sonarrLang, type SonarrLibrary } from "./sonarr";
|
||||
|
||||
export interface LanguageResolverConfig {
|
||||
radarr: { url: string; apiKey: string } | null;
|
||||
sonarr: { url: string; apiKey: string } | null;
|
||||
radarrLibrary: RadarrLibrary | null;
|
||||
sonarrLibrary: SonarrLibrary | null;
|
||||
}
|
||||
|
||||
export interface LanguageResult {
|
||||
origLang: string | null;
|
||||
origLangSource: string | null;
|
||||
needsReview: number;
|
||||
externalRaw: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original language of a media item using Radarr (movies) or
|
||||
* Sonarr (episodes). Pure lookup — does NOT write to the database; the
|
||||
* caller decides what to persist.
|
||||
*/
|
||||
export async function resolveLanguage(
|
||||
db: Database,
|
||||
itemId: number,
|
||||
cfg: LanguageResolverConfig,
|
||||
): Promise<LanguageResult> {
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | null;
|
||||
if (!item) throw new Error(`media_items row ${itemId} not found`);
|
||||
|
||||
// Manual override is sacred — never touch it
|
||||
if (item.orig_lang_source === "manual") {
|
||||
return {
|
||||
origLang: item.original_language,
|
||||
origLangSource: "manual",
|
||||
needsReview: item.needs_review,
|
||||
externalRaw: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === "Movie") {
|
||||
return resolveMovie(item, cfg);
|
||||
}
|
||||
return resolveEpisode(item, cfg);
|
||||
}
|
||||
|
||||
async function resolveMovie(item: MediaItem, cfg: LanguageResolverConfig): Promise<LanguageResult> {
|
||||
if (!cfg.radarr || !cfg.radarrLibrary) {
|
||||
return probeFallback(item);
|
||||
}
|
||||
|
||||
const lang = await radarrLang(
|
||||
cfg.radarr,
|
||||
{ tmdbId: item.tmdb_id ?? undefined, imdbId: item.imdb_id ?? undefined },
|
||||
cfg.radarrLibrary,
|
||||
);
|
||||
if (lang) {
|
||||
// Capture the library entry for external_raw
|
||||
const entry =
|
||||
(item.tmdb_id ? cfg.radarrLibrary.byTmdbId.get(item.tmdb_id) : null) ??
|
||||
(item.imdb_id ? cfg.radarrLibrary.byImdbId.get(item.imdb_id) : null) ??
|
||||
null;
|
||||
return {
|
||||
origLang: lang,
|
||||
origLangSource: "radarr",
|
||||
needsReview: 0,
|
||||
externalRaw: entry,
|
||||
};
|
||||
}
|
||||
|
||||
return probeFallback(item);
|
||||
}
|
||||
|
||||
async function resolveEpisode(item: MediaItem, cfg: LanguageResolverConfig): Promise<LanguageResult> {
|
||||
if (!cfg.sonarr || !cfg.sonarrLibrary) return probeFallback(item);
|
||||
|
||||
// Try direct TVDB ID lookup (from path parser's [tvdbid-X] in folder name)
|
||||
if (item.tvdb_id) {
|
||||
const lang = await sonarrLang(cfg.sonarr, item.tvdb_id, cfg.sonarrLibrary);
|
||||
if (lang) {
|
||||
const entry = cfg.sonarrLibrary.byTvdbId.get(item.tvdb_id) ?? null;
|
||||
return { origLang: lang, origLangSource: "sonarr", needsReview: 0, externalRaw: entry };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: search Sonarr library by series name
|
||||
if (item.series_name) {
|
||||
const lowerName = item.series_name.toLowerCase();
|
||||
for (const [tvdbId, series] of cfg.sonarrLibrary.byTvdbId) {
|
||||
if ((series as any).title?.toLowerCase() === lowerName) {
|
||||
const lang = await sonarrLang(cfg.sonarr, tvdbId, cfg.sonarrLibrary);
|
||||
if (lang) return { origLang: lang, origLangSource: "sonarr", needsReview: 0, externalRaw: series };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return probeFallback(item);
|
||||
}
|
||||
|
||||
function probeFallback(item: MediaItem): LanguageResult {
|
||||
return {
|
||||
origLang: item.original_language,
|
||||
origLangSource: item.orig_lang_source,
|
||||
needsReview: 1,
|
||||
externalRaw: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// ISO 639-1 (2-letter) → ISO 639-2/B (3-letter)
|
||||
const ISO_1_TO_2: Record<string, string> = {
|
||||
en: "eng",
|
||||
de: "deu",
|
||||
es: "spa",
|
||||
fr: "fra",
|
||||
it: "ita",
|
||||
pt: "por",
|
||||
ja: "jpn",
|
||||
ko: "kor",
|
||||
zh: "zho",
|
||||
ar: "ara",
|
||||
ru: "rus",
|
||||
nl: "nld",
|
||||
sv: "swe",
|
||||
no: "nor",
|
||||
da: "dan",
|
||||
fi: "fin",
|
||||
pl: "pol",
|
||||
tr: "tur",
|
||||
th: "tha",
|
||||
hi: "hin",
|
||||
hu: "hun",
|
||||
cs: "ces",
|
||||
ro: "ron",
|
||||
el: "ell",
|
||||
he: "heb",
|
||||
fa: "fas",
|
||||
uk: "ukr",
|
||||
id: "ind",
|
||||
ca: "cat",
|
||||
nb: "nob",
|
||||
nn: "nno",
|
||||
is: "isl",
|
||||
hr: "hrv",
|
||||
sk: "slk",
|
||||
bg: "bul",
|
||||
sr: "srp",
|
||||
sl: "slv",
|
||||
lv: "lav",
|
||||
lt: "lit",
|
||||
et: "est",
|
||||
vi: "vie",
|
||||
ms: "msa",
|
||||
ta: "tam",
|
||||
te: "tel",
|
||||
};
|
||||
|
||||
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
ger: "deu",
|
||||
chi: "zho",
|
||||
fre: "fra",
|
||||
dut: "nld",
|
||||
gre: "ell",
|
||||
heb: "heb",
|
||||
per: "fas",
|
||||
rum: "ron",
|
||||
may: "msa",
|
||||
tib: "bod",
|
||||
bur: "mya",
|
||||
cze: "ces",
|
||||
slo: "slk",
|
||||
geo: "kat",
|
||||
ice: "isl",
|
||||
arm: "hye",
|
||||
baq: "eus",
|
||||
alb: "sqi",
|
||||
mac: "mkd",
|
||||
wel: "cym",
|
||||
};
|
||||
|
||||
export function normalizeLanguage(lang: string): string {
|
||||
const lower = lang.toLowerCase().trim();
|
||||
if (ISO_1_TO_2[lower]) return ISO_1_TO_2[lower];
|
||||
return LANG_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
const DUB_TITLE_HINTS = /(dub|dubb|synchro|commentary|director)/i;
|
||||
|
||||
/**
|
||||
* Guess original language from audio streams by looking at the default track.
|
||||
* Heuristic: prefer the default audio track, skip dubs/commentary, fall back to first.
|
||||
*/
|
||||
export function guessOriginalLanguage(
|
||||
audioStreams: { language: string | null; title: string | null; isDefault: number }[],
|
||||
): string | null {
|
||||
if (audioStreams.length === 0) return null;
|
||||
const notDub = (s: { title: string | null }) => !s.title || !DUB_TITLE_HINTS.test(s.title);
|
||||
const pick = audioStreams.find((s) => s.isDefault && notDub(s)) ?? audioStreams.find(notDub) ?? audioStreams[0];
|
||||
return pick.language ? normalizeLanguage(pick.language) : null;
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import mqtt, { type MqttClient } from "mqtt";
|
||||
import { getConfig } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { handleWebhookMessage } from "./webhook";
|
||||
|
||||
export type MqttStatus = "connected" | "disconnected" | "error" | "not_configured";
|
||||
|
||||
interface MqttConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
let client: MqttClient | null = null;
|
||||
let currentStatus: MqttStatus = "not_configured";
|
||||
let currentError: string | null = null;
|
||||
const statusListeners = new Set<(status: MqttStatus, error: string | null) => void>();
|
||||
|
||||
export function getMqttStatus(): { status: MqttStatus; error: string | null } {
|
||||
return { status: currentStatus, error: currentError };
|
||||
}
|
||||
|
||||
export function onMqttStatus(fn: (status: MqttStatus, error: string | null) => void): () => void {
|
||||
statusListeners.add(fn);
|
||||
return () => {
|
||||
statusListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(next: MqttStatus, err: string | null = null): void {
|
||||
currentStatus = next;
|
||||
currentError = err;
|
||||
for (const l of statusListeners) l(next, err);
|
||||
}
|
||||
|
||||
function readConfig(): MqttConfig | null {
|
||||
if (getConfig("mqtt_enabled") !== "1") return null;
|
||||
const url = getConfig("mqtt_url") ?? "";
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
topic: getConfig("mqtt_topic") ?? "jellyfin/events",
|
||||
username: getConfig("mqtt_username") ?? "",
|
||||
password: getConfig("mqtt_password") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the configured MQTT broker and subscribe to the webhook topic.
|
||||
* Safe to call repeatedly: an existing client is torn down first. When no
|
||||
* broker is configured, status is set to 'not_configured' and the call is
|
||||
* a no-op.
|
||||
*/
|
||||
export async function startMqttClient(): Promise<void> {
|
||||
await stopMqttClient();
|
||||
const cfg = readConfig();
|
||||
if (!cfg) {
|
||||
setStatus("not_configured");
|
||||
return;
|
||||
}
|
||||
|
||||
log(`MQTT: connecting to ${cfg.url} (topic=${cfg.topic})`);
|
||||
const c = mqtt.connect(cfg.url, {
|
||||
username: cfg.username || undefined,
|
||||
password: cfg.password || undefined,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 15_000,
|
||||
clientId: `netfelix-audio-fix-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
client = c;
|
||||
|
||||
c.on("connect", () => {
|
||||
c.subscribe(cfg.topic, { qos: 0 }, (err) => {
|
||||
if (err) {
|
||||
logError(`MQTT subscribe to ${cfg.topic} failed:`, err);
|
||||
setStatus("error", String(err));
|
||||
return;
|
||||
}
|
||||
log(`MQTT: connected, subscribed to ${cfg.topic}`);
|
||||
setStatus("connected");
|
||||
});
|
||||
});
|
||||
|
||||
c.on("reconnect", () => {
|
||||
setStatus("disconnected", "reconnecting");
|
||||
});
|
||||
|
||||
c.on("close", () => {
|
||||
setStatus("disconnected", null);
|
||||
});
|
||||
|
||||
c.on("error", (err) => {
|
||||
warn(`MQTT error: ${String(err)}`);
|
||||
setStatus("error", String(err));
|
||||
});
|
||||
|
||||
c.on("message", (_topic, payload) => {
|
||||
const text = payload.toString("utf8");
|
||||
// Best-effort: the handler owns its own error handling. Don't let a
|
||||
// single malformed message tear the subscriber down.
|
||||
handleWebhookMessage(text).catch((err) => logError("webhook handler threw:", err));
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopMqttClient(): Promise<void> {
|
||||
if (!client) return;
|
||||
const c = client;
|
||||
client = null;
|
||||
await new Promise<void>((resolve) => {
|
||||
c.end(false, {}, () => resolve());
|
||||
});
|
||||
setStatus("not_configured");
|
||||
}
|
||||
|
||||
export interface MqttTestResult {
|
||||
brokerConnected: boolean;
|
||||
jellyfinTriggered: boolean;
|
||||
receivedMessage: boolean;
|
||||
itemName?: string;
|
||||
expectedItemId?: string;
|
||||
samplePayload?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end test of the MQTT loop: connect to the broker, subscribe to the
|
||||
* topic, ask Jellyfin to refresh a known item, and wait for the plugin to
|
||||
* publish a matching event. A pass proves the whole chain is wired up —
|
||||
* broker creds, Jellyfin webhook plugin config, and network reachability
|
||||
* between Jellyfin and broker.
|
||||
*
|
||||
* `triggerRefresh` is async and returns the Jellyfin item id we're waiting
|
||||
* for (so we can match only messages about that item and ignore unrelated
|
||||
* traffic). When null, we fall back to "any message on the topic" mode —
|
||||
* useful before the library is scanned.
|
||||
*/
|
||||
export async function testMqttConnection(
|
||||
cfg: MqttConfig,
|
||||
triggerRefresh: () => Promise<{ itemId: string; itemName: string } | null>,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<MqttTestResult> {
|
||||
return new Promise((resolve) => {
|
||||
const c = mqtt.connect(cfg.url, {
|
||||
username: cfg.username || undefined,
|
||||
password: cfg.password || undefined,
|
||||
reconnectPeriod: 0,
|
||||
connectTimeout: 10_000,
|
||||
clientId: `netfelix-audio-fix-test-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let expectedItemId: string | null = null;
|
||||
let itemName: string | undefined;
|
||||
let jellyfinTriggered = false;
|
||||
let brokerConnected = false;
|
||||
const done = (result: Omit<MqttTestResult, "expectedItemId" | "jellyfinTriggered" | "brokerConnected">) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
c.end(true);
|
||||
resolve({
|
||||
brokerConnected,
|
||||
jellyfinTriggered,
|
||||
expectedItemId: expectedItemId ?? undefined,
|
||||
itemName,
|
||||
...result,
|
||||
});
|
||||
};
|
||||
|
||||
c.on("connect", () => {
|
||||
brokerConnected = true;
|
||||
c.subscribe(cfg.topic, { qos: 0 }, async (err) => {
|
||||
if (err) {
|
||||
done({ receivedMessage: false, error: `subscribe: ${String(err)}` });
|
||||
return;
|
||||
}
|
||||
// Subscribed. Trigger the Jellyfin refresh so the webhook has
|
||||
// something concrete to publish.
|
||||
try {
|
||||
const trigger = await triggerRefresh();
|
||||
if (trigger) {
|
||||
expectedItemId = trigger.itemId;
|
||||
itemName = trigger.itemName;
|
||||
jellyfinTriggered = true;
|
||||
}
|
||||
} catch (triggerErr) {
|
||||
done({ receivedMessage: false, error: `jellyfin trigger: ${String(triggerErr)}` });
|
||||
return;
|
||||
}
|
||||
});
|
||||
setTimeout(() => done({ receivedMessage: false }), timeoutMs);
|
||||
});
|
||||
|
||||
c.on("message", (_topic, payload) => {
|
||||
// Any message on the configured topic is enough — a rescan of an
|
||||
// unchanged item won't fire Item Added, so the "itemId matches"
|
||||
// filter would cause false failures. The user triggers real
|
||||
// activity in Jellyfin if the auto-rescan doesn't wake anything.
|
||||
done({ receivedMessage: true, samplePayload: payload.toString("utf8").slice(0, 400) });
|
||||
});
|
||||
|
||||
c.on("error", (err) => {
|
||||
done({ receivedMessage: false, error: String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from "node:path";
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(["mkv", "mp4", "avi", "m4v", "ts", "wmv"]);
|
||||
|
||||
export interface ParsedPath {
|
||||
type: "Movie" | "Episode";
|
||||
name: string;
|
||||
year: number | null;
|
||||
seriesName: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
imdbId: string | null;
|
||||
tmdbId: string | null;
|
||||
tvdbId: string | null;
|
||||
container: string;
|
||||
}
|
||||
|
||||
function extractYear(str: string): number | null {
|
||||
const m = str.match(/\((\d{4})\)/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
|
||||
function extractId(str: string, prefix: string): string | null {
|
||||
const re = new RegExp(`\\[${prefix}-([^\\]]+)\\]`, "i");
|
||||
const m = str.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Strip trailing `(YYYY)` and any bracketed provider-id tokens from a folder name, return bare title. */
|
||||
function titleFromFolder(folderName: string): string {
|
||||
return folderName.replace(/\s*\(\d{4}\).*$/, "").trim();
|
||||
}
|
||||
|
||||
export function parsePath(filePath: string, moviesRoot: string, tvRoot: string): ParsedPath | null {
|
||||
const ext = path.extname(filePath).replace(/^\./, "").toLowerCase();
|
||||
if (!VIDEO_EXTENSIONS.has(ext)) return null;
|
||||
|
||||
// Normalise roots so comparisons work regardless of trailing slash.
|
||||
const moviesPrefix = moviesRoot.replace(/\/+$/, "") + "/";
|
||||
const tvPrefix = tvRoot.replace(/\/+$/, "") + "/";
|
||||
|
||||
const fileName = path.basename(filePath, `.${ext}`);
|
||||
|
||||
if (filePath.startsWith(moviesPrefix)) {
|
||||
return parseMovie(filePath, moviesPrefix, fileName, ext);
|
||||
}
|
||||
|
||||
if (filePath.startsWith(tvPrefix)) {
|
||||
return parseEpisode(filePath, tvPrefix, fileName, ext);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMovie(filePath: string, moviesPrefix: string, fileName: string, ext: string): ParsedPath | null {
|
||||
// Path: /movies/{Folder}/{filename}.ext — folder is the immediate child of moviesRoot.
|
||||
const relative = filePath.slice(moviesPrefix.length); // e.g. "Hot Fuzz (2007)/Hot Fuzz (2007) [imdbid-...].mkv"
|
||||
const folderName = relative.split("/")[0];
|
||||
|
||||
const name = titleFromFolder(folderName);
|
||||
const year = extractYear(folderName);
|
||||
|
||||
// IDs can appear in filename or folder name.
|
||||
const searchStr = `${folderName} ${fileName}`;
|
||||
const imdbId = extractId(searchStr, "imdbid");
|
||||
const tmdbId = extractId(searchStr, "tmdbid");
|
||||
|
||||
return {
|
||||
type: "Movie",
|
||||
name,
|
||||
year,
|
||||
seriesName: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tvdbId: null,
|
||||
container: ext,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEpisode(filePath: string, tvPrefix: string, fileName: string, ext: string): ParsedPath | null {
|
||||
// Path: /tv/{Series Folder}/Season NN/{filename}.ext
|
||||
const relative = filePath.slice(tvPrefix.length);
|
||||
const parts = relative.split("/");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const seriesFolder = parts[0]; // e.g. "Arrow (2012) [tvdbid-257655]"
|
||||
|
||||
const seriesName = titleFromFolder(seriesFolder);
|
||||
const year = extractYear(seriesFolder);
|
||||
const tvdbId = extractId(seriesFolder, "tvdbid");
|
||||
|
||||
// Season/episode from S01E01 (or S02E01-E13 for multi-episode).
|
||||
const seMatch = fileName.match(/S(\d{2})E(\d{2})/i);
|
||||
if (!seMatch) return null;
|
||||
|
||||
const seasonNumber = parseInt(seMatch[1], 10);
|
||||
const episodeNumber = parseInt(seMatch[2], 10);
|
||||
|
||||
// Episode title: everything after "- S01E01... -" up to the first "[".
|
||||
// e.g. "Breaking Bad (2008) - S05E03 - Hazard Pay [WEBDL-...]"
|
||||
// Some files have no episode title: "Lost (2004) - S02E21 - [DSNP WEBDL-1080p]..."
|
||||
const epTitleMatch = fileName.match(/S\d{2}E\d{2}(?:-E\d{2})?\s*-\s*([^\[]+?)(?:\s*\[|$)/i);
|
||||
const episodeTitle = epTitleMatch ? epTitleMatch[1].trim() || `S${seMatch[1]}E${seMatch[2]}` : `S${seMatch[1]}E${seMatch[2]}`;
|
||||
|
||||
return {
|
||||
type: "Episode",
|
||||
name: episodeTitle,
|
||||
year,
|
||||
seriesName,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
imdbId: null,
|
||||
tmdbId: null,
|
||||
tvdbId,
|
||||
container: ext,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export interface ProbeStream {
|
||||
streamIndex: number;
|
||||
type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage";
|
||||
codec: string | null;
|
||||
profile: string | null;
|
||||
language: string | null;
|
||||
title: string | null;
|
||||
isDefault: number;
|
||||
isForced: number;
|
||||
isHearingImpaired: number;
|
||||
channels: number | null;
|
||||
channelLayout: string | null;
|
||||
bitRate: number | null;
|
||||
sampleRate: number | null;
|
||||
bitDepth: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
fileSize: number | null;
|
||||
durationSeconds: number | null;
|
||||
container: string | null;
|
||||
containerTitle: string | null;
|
||||
containerComment: string | null;
|
||||
streams: ProbeStream[];
|
||||
}
|
||||
|
||||
function mapCodecType(codecType: string): ProbeStream["type"] {
|
||||
switch (codecType) {
|
||||
case "video":
|
||||
return "Video";
|
||||
case "audio":
|
||||
return "Audio";
|
||||
case "subtitle":
|
||||
return "Subtitle";
|
||||
case "data":
|
||||
return "Data";
|
||||
case "attachment":
|
||||
return "EmbeddedImage";
|
||||
default:
|
||||
return "Data";
|
||||
}
|
||||
}
|
||||
|
||||
function parseIntOrNull(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const n = typeof value === "number" ? value : parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseFloatOrNull(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const n = typeof value === "number" ? value : parseFloat(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Parse ffprobe JSON output. Exported for unit testing. */
|
||||
export function parseProbeOutput(json: string): ProbeResult {
|
||||
const raw = JSON.parse(json);
|
||||
const fmt = raw.format ?? {};
|
||||
|
||||
const fileSize = parseIntOrNull(fmt.size ?? null);
|
||||
const durationSeconds = parseFloatOrNull(fmt.duration ?? null);
|
||||
const container = fmt.format_name ? (fmt.format_name as string).split(",")[0] || null : null;
|
||||
const fmtTags = fmt.tags ?? {};
|
||||
const containerTitle = (fmtTags.title ?? fmtTags.TITLE ?? null) as string | null;
|
||||
const containerComment = (fmtTags.comment ?? fmtTags.COMMENT ?? null) as string | null;
|
||||
|
||||
const streams: ProbeStream[] = (raw.streams ?? []).map((s: any): ProbeStream => {
|
||||
const tags = s.tags ?? {};
|
||||
const disp = s.disposition ?? {};
|
||||
return {
|
||||
streamIndex: s.index as number,
|
||||
type: mapCodecType(s.codec_type ?? ""),
|
||||
codec: (s.codec_name as string | undefined) ?? null,
|
||||
profile: (s.profile as string | undefined) ?? null,
|
||||
language: (tags.language ?? tags.LANGUAGE ?? null) as string | null,
|
||||
title: (tags.title ?? tags.TITLE ?? null) as string | null,
|
||||
isDefault: (disp.default as number | undefined) ?? 0,
|
||||
isForced: (disp.forced as number | undefined) ?? 0,
|
||||
isHearingImpaired: (disp.hearing_impaired as number | undefined) ?? 0,
|
||||
channels: parseIntOrNull(s.channels ?? null),
|
||||
channelLayout: (s.channel_layout as string | undefined) ?? null,
|
||||
bitRate: parseIntOrNull(s.bit_rate ?? null),
|
||||
sampleRate: parseIntOrNull(s.sample_rate ?? null),
|
||||
bitDepth: parseIntOrNull(s.bits_per_raw_sample ?? null),
|
||||
width: parseIntOrNull(s.width ?? null),
|
||||
height: parseIntOrNull(s.height ?? null),
|
||||
};
|
||||
});
|
||||
|
||||
return { fileSize, durationSeconds, container, containerTitle, containerComment, streams };
|
||||
}
|
||||
|
||||
/** Run ffprobe on a local file. */
|
||||
export async function probeFile(filePath: string): Promise<ProbeResult> {
|
||||
const proc = Bun.spawn(
|
||||
["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
|
||||
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new Error(`ffprobe exited with code ${exitCode}: ${stderr.trim()}`);
|
||||
}
|
||||
|
||||
return parseProbeOutput(stdout);
|
||||
}
|
||||
+107
-1
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { error as logError, warn } from "../lib/log";
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
import { normalizeLanguage } from "./language-utils";
|
||||
|
||||
export interface RadarrConfig {
|
||||
url: string;
|
||||
@@ -35,6 +36,7 @@ export async function testConnection(cfg: RadarrConfig): Promise<{ ok: boolean;
|
||||
}
|
||||
|
||||
interface RadarrMovie {
|
||||
id?: number;
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
originalLanguage?: { name: string; nameTranslated?: string };
|
||||
@@ -174,6 +176,110 @@ const NAME_TO_639_2: Record<string, string> = {
|
||||
"portuguese (brazil)": "por",
|
||||
};
|
||||
|
||||
/**
|
||||
* Kick Radarr to rescan a movie (pick up that we deleted the file) and then
|
||||
* trigger an indexer search so it grabs a replacement. Best-effort: returns
|
||||
* a short status so the caller can surface "refetch queued" vs "not found
|
||||
* in Radarr" in the API response. No throws.
|
||||
*/
|
||||
export async function triggerMovieRefetch(
|
||||
cfg: RadarrConfig,
|
||||
ids: { tmdbId?: string | null; imdbId?: string | null },
|
||||
): Promise<{ ok: boolean; movieId?: number; error?: string }> {
|
||||
if (!isUsable(cfg)) return { ok: false, error: "Radarr not configured" };
|
||||
|
||||
// GET /api/v3/movie?tmdbId=X (or imdbId=Y) returns only the matching
|
||||
// movie — we need its internal id for the command endpoints below.
|
||||
const query = ids.tmdbId
|
||||
? `tmdbId=${encodeURIComponent(ids.tmdbId)}`
|
||||
: ids.imdbId
|
||||
? `imdbId=${encodeURIComponent(ids.imdbId)}`
|
||||
: null;
|
||||
if (!query) return { ok: false, error: "movie has no tmdb/imdb id" };
|
||||
|
||||
const matches = await fetchJson<RadarrMovie[]>(`${cfg.url}/api/v3/movie?${query}`, cfg, `movie?${query}`);
|
||||
if (!matches || matches.length === 0) return { ok: false, error: "movie not tracked by Radarr" };
|
||||
const movieId = matches[0]?.id;
|
||||
if (movieId == null) return { ok: false, error: "Radarr movie missing id field" };
|
||||
|
||||
try {
|
||||
await postCommand(cfg, { name: "RescanMovie", movieId });
|
||||
await postCommand(cfg, { name: "MoviesSearch", movieIds: [movieId] });
|
||||
return { ok: true, movieId };
|
||||
} catch (e) {
|
||||
return { ok: false, movieId, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell Radarr to rescan the movie's folder and rename any files whose names
|
||||
* don't match its naming convention. Returns a basename → new-basename map
|
||||
* so the caller can update its own path records (Radarr's absolute paths
|
||||
* may differ from ours due to Docker volume mappings — basenames don't).
|
||||
*/
|
||||
export async function triggerMovieRename(
|
||||
cfg: RadarrConfig,
|
||||
ids: { tmdbId?: string | null; imdbId?: string | null },
|
||||
): Promise<{ ok: boolean; renames: Map<string, string>; error?: string }> {
|
||||
const empty = new Map<string, string>();
|
||||
if (!isUsable(cfg)) return { ok: false, renames: empty, error: "Radarr not configured" };
|
||||
|
||||
const query = ids.tmdbId
|
||||
? `tmdbId=${encodeURIComponent(ids.tmdbId)}`
|
||||
: ids.imdbId
|
||||
? `imdbId=${encodeURIComponent(ids.imdbId)}`
|
||||
: null;
|
||||
if (!query) return { ok: false, renames: empty, error: "movie has no tmdb/imdb id" };
|
||||
|
||||
const matches = await fetchJson<RadarrMovie[]>(`${cfg.url}/api/v3/movie?${query}`, cfg, `movie?${query}`);
|
||||
if (!matches || matches.length === 0) return { ok: false, renames: empty, error: "movie not tracked by Radarr" };
|
||||
const movieId = matches[0]?.id;
|
||||
if (movieId == null) return { ok: false, renames: empty, error: "Radarr movie missing id field" };
|
||||
|
||||
try {
|
||||
await postCommandAndWait(cfg, { name: "RescanMovie", movieId });
|
||||
|
||||
const previews = await fetchJson<{ movieFileId: number; existingPath: string; newPath: string }[]>(
|
||||
`${cfg.url}/api/v3/rename?movieId=${movieId}`,
|
||||
cfg,
|
||||
`rename?movieId=${movieId}`,
|
||||
);
|
||||
if (!previews || previews.length === 0) return { ok: true, renames: empty };
|
||||
|
||||
const fileIds = previews.map((r) => r.movieFileId);
|
||||
await postCommandAndWait(cfg, { name: "RenameFiles", movieId, files: fileIds });
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const r of previews) map.set(path.basename(r.existingPath), path.basename(r.newPath));
|
||||
return { ok: true, renames: map };
|
||||
} catch (e) {
|
||||
return { ok: false, renames: empty, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function postCommand(cfg: RadarrConfig, body: Record<string, unknown>): Promise<number> {
|
||||
const res = await fetch(`${cfg.url}/api/v3/command`, {
|
||||
method: "POST",
|
||||
headers: { ...headers(cfg.apiKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { id: number };
|
||||
return data.id;
|
||||
}
|
||||
|
||||
async function postCommandAndWait(cfg: RadarrConfig, body: Record<string, unknown>, timeoutMs = 30_000): Promise<void> {
|
||||
const cmdId = await postCommand(cfg, body);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await Bun.sleep(1000);
|
||||
const cmd = await fetchJson<{ status: string }>(`${cfg.url}/api/v3/command/${cmdId}`, cfg, `command/${cmdId}`);
|
||||
if (!cmd) break;
|
||||
if (cmd.status === "completed") return;
|
||||
if (cmd.status === "failed" || cmd.status === "aborted") throw new Error(`Command ${body.name} ${cmd.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function nameToIso(name: string): string | null {
|
||||
const key = name.toLowerCase().trim();
|
||||
if (NAME_TO_639_2[key]) return NAME_TO_639_2[key];
|
||||
|
||||
+92
-228
@@ -1,280 +1,144 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import type { JellyfinItem, MediaStream } from "../types";
|
||||
import { analyzeItem } from "./analyzer";
|
||||
import { extractOriginalLanguage, mapStream, normalizeLanguage } from "./jellyfin";
|
||||
import { type RadarrLibrary, getOriginalLanguage as radarrLang } from "./radarr";
|
||||
import { type SonarrLibrary, getOriginalLanguage as sonarrLang } from "./sonarr";
|
||||
import { guessOriginalLanguage } from "./language-utils";
|
||||
import type { ParsedPath } from "./path-parser";
|
||||
import type { ProbeResult } from "./probe";
|
||||
|
||||
export interface RescanConfig {
|
||||
audioLanguages: string[];
|
||||
radarr: { url: string; apiKey: string } | null;
|
||||
sonarr: { url: string; apiKey: string } | null;
|
||||
radarrLibrary: RadarrLibrary | null;
|
||||
sonarrLibrary: SonarrLibrary | null;
|
||||
}
|
||||
|
||||
export interface RescanResult {
|
||||
export interface UpsertResult {
|
||||
itemId: number;
|
||||
origLang: string | null;
|
||||
origLangSource: string | null;
|
||||
needsReview: number;
|
||||
confidence: "high" | "low";
|
||||
isNoop: boolean;
|
||||
radarrHit: boolean;
|
||||
radarrMiss: boolean;
|
||||
sonarrHit: boolean;
|
||||
sonarrMiss: boolean;
|
||||
missingProviderId: boolean;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single Jellyfin item (metadata + streams + review_plan + decisions)
|
||||
* in one transaction. Shared by the full scan loop and the post-execute refresh.
|
||||
*
|
||||
* Returns the internal item id and a summary of what happened so callers can
|
||||
* aggregate counters or emit SSE events.
|
||||
* Upsert a scanned item (metadata + streams + review_plan) in one transaction.
|
||||
* Driven by filesystem path + ffprobe output.
|
||||
*/
|
||||
export async function upsertJellyfinItem(
|
||||
export function upsertScannedItem(
|
||||
db: Database,
|
||||
jellyfinItem: JellyfinItem,
|
||||
cfg: RescanConfig,
|
||||
opts: { executed?: boolean; source?: "scan" | "webhook" } = {},
|
||||
): Promise<RescanResult> {
|
||||
const source = opts.source ?? "scan";
|
||||
if (!jellyfinItem.Name || !jellyfinItem.Path) {
|
||||
throw new Error(`Jellyfin item ${jellyfinItem.Id} missing Name or Path`);
|
||||
}
|
||||
const itemName: string = jellyfinItem.Name;
|
||||
const itemPath: string = jellyfinItem.Path;
|
||||
filePath: string,
|
||||
parsed: ParsedPath,
|
||||
probe: ProbeResult,
|
||||
): UpsertResult {
|
||||
// Derive series_key for episode grouping
|
||||
const seriesKey = parsed.seriesName ? `${parsed.seriesName}|${parsed.year ?? ""}` : null;
|
||||
|
||||
const providerIds = jellyfinItem.ProviderIds ?? {};
|
||||
const imdbId = providerIds.Imdb ?? null;
|
||||
const tmdbId = providerIds.Tmdb ?? null;
|
||||
const tvdbId = providerIds.Tvdb ?? null;
|
||||
// Preserve manual language overrides
|
||||
const existing = db
|
||||
.prepare("SELECT id, original_language, orig_lang_source FROM media_items WHERE file_path = ?")
|
||||
.get(filePath) as { id: number; original_language: string | null; orig_lang_source: string | null } | undefined;
|
||||
const hasManualOverride = existing?.orig_lang_source === "manual";
|
||||
|
||||
// See scan.ts for the "8 Mile got labelled Turkish" rationale. Jellyfin's
|
||||
// first-audio-track guess is an unverified starting point.
|
||||
const jellyfinGuess = extractOriginalLanguage(jellyfinItem);
|
||||
let origLang: string | null = jellyfinGuess;
|
||||
let origLangSource: string | null = jellyfinGuess ? "jellyfin" : null;
|
||||
let needsReview = origLang ? 0 : 1;
|
||||
let authoritative = false;
|
||||
let externalRaw: unknown = null;
|
||||
// Guess language from default audio track
|
||||
const audioStreams = probe.streams
|
||||
.filter((s) => s.type === "Audio")
|
||||
.map((s) => ({ language: s.language, title: s.title, isDefault: s.isDefault }));
|
||||
const probeGuess = guessOriginalLanguage(audioStreams);
|
||||
|
||||
const result: RescanResult = {
|
||||
itemId: -1,
|
||||
origLang: null,
|
||||
origLangSource: null,
|
||||
needsReview: 1,
|
||||
confidence: "low",
|
||||
isNoop: false,
|
||||
radarrHit: false,
|
||||
radarrMiss: false,
|
||||
sonarrHit: false,
|
||||
sonarrMiss: false,
|
||||
missingProviderId: false,
|
||||
};
|
||||
const origLang = hasManualOverride ? existing!.original_language : probeGuess;
|
||||
const origLangSource: string | null = hasManualOverride ? "manual" : probeGuess ? "probe" : null;
|
||||
const needsReview = origLang ? (hasManualOverride ? 0 : 1) : 1;
|
||||
|
||||
if (jellyfinItem.Type === "Movie" && cfg.radarr && cfg.radarrLibrary) {
|
||||
if (!tmdbId && !imdbId) {
|
||||
result.missingProviderId = true;
|
||||
} else {
|
||||
const movie = tmdbId ? cfg.radarrLibrary.byTmdbId.get(tmdbId) : undefined;
|
||||
const movieByImdb = !movie && imdbId ? cfg.radarrLibrary.byImdbId.get(imdbId) : undefined;
|
||||
externalRaw = movie ?? movieByImdb ?? null;
|
||||
const lang = await radarrLang(
|
||||
cfg.radarr,
|
||||
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined },
|
||||
cfg.radarrLibrary,
|
||||
);
|
||||
if (lang) {
|
||||
result.radarrHit = true;
|
||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
||||
origLang = lang;
|
||||
origLangSource = "radarr";
|
||||
authoritative = true;
|
||||
} else {
|
||||
result.radarrMiss = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result: UpsertResult = { itemId: -1, origLang, origLangSource, needsReview, isNew: !existing };
|
||||
|
||||
if (jellyfinItem.Type === "Episode" && cfg.sonarr && cfg.sonarrLibrary) {
|
||||
if (!tvdbId) {
|
||||
result.missingProviderId = true;
|
||||
} else {
|
||||
externalRaw = cfg.sonarrLibrary.byTvdbId.get(tvdbId) ?? null;
|
||||
const lang = await sonarrLang(cfg.sonarr, tvdbId, cfg.sonarrLibrary);
|
||||
if (lang) {
|
||||
result.sonarrHit = true;
|
||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
||||
origLang = lang;
|
||||
origLangSource = "sonarr";
|
||||
authoritative = true;
|
||||
} else {
|
||||
result.sonarrMiss = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let confidence: "high" | "low" = "low";
|
||||
if (origLang && authoritative && !needsReview) confidence = "high";
|
||||
else if (origLang && !authoritative) needsReview = 1;
|
||||
|
||||
const jellyfinRaw = JSON.stringify(jellyfinItem);
|
||||
const externalRawJson = externalRaw ? JSON.stringify(externalRaw) : null;
|
||||
|
||||
// One transaction per item keeps scan throughput high on SQLite — every
|
||||
// INSERT/UPDATE would otherwise hit WAL independently.
|
||||
db.transaction(() => {
|
||||
const upsertItem = db.prepare(`
|
||||
db
|
||||
.prepare(`
|
||||
INSERT INTO media_items (
|
||||
jellyfin_id, type, name, original_title, series_name, series_jellyfin_id,
|
||||
season_number, episode_number, year, file_path, file_size, container,
|
||||
runtime_ticks, date_last_refreshed,
|
||||
original_language, orig_lang_source, needs_review,
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
jellyfin_raw, external_raw,
|
||||
scan_status, last_scanned_at${opts.executed ? ", last_executed_at" : ""}
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now')${opts.executed ? ", datetime('now')" : ""})
|
||||
ON CONFLICT(jellyfin_id) DO UPDATE SET
|
||||
type = excluded.type, name = excluded.name, original_title = excluded.original_title,
|
||||
series_name = excluded.series_name, series_jellyfin_id = excluded.series_jellyfin_id,
|
||||
file_path, type, name, series_name, series_key,
|
||||
season_number, episode_number, year, file_size, container,
|
||||
duration_seconds, original_language, orig_lang_source, needs_review,
|
||||
imdb_id, tmdb_id, tvdb_id, container_title, container_comment,
|
||||
scan_status, last_scanned_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now'))
|
||||
ON CONFLICT(file_path) DO UPDATE SET
|
||||
type = excluded.type, name = excluded.name,
|
||||
series_name = excluded.series_name, series_key = excluded.series_key,
|
||||
season_number = excluded.season_number, episode_number = excluded.episode_number,
|
||||
year = excluded.year, file_path = excluded.file_path,
|
||||
file_size = excluded.file_size, container = excluded.container,
|
||||
runtime_ticks = excluded.runtime_ticks, date_last_refreshed = excluded.date_last_refreshed,
|
||||
year = excluded.year, file_size = excluded.file_size, container = excluded.container,
|
||||
duration_seconds = excluded.duration_seconds,
|
||||
original_language = excluded.original_language, orig_lang_source = excluded.orig_lang_source,
|
||||
needs_review = excluded.needs_review, imdb_id = excluded.imdb_id,
|
||||
tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id,
|
||||
jellyfin_raw = excluded.jellyfin_raw, external_raw = excluded.external_raw,
|
||||
needs_review = excluded.needs_review,
|
||||
imdb_id = excluded.imdb_id, tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id,
|
||||
container_title = excluded.container_title,
|
||||
container_comment = excluded.container_comment,
|
||||
scan_status = 'scanned', last_scanned_at = datetime('now')
|
||||
${opts.executed ? ", last_executed_at = datetime('now')" : ""}
|
||||
`);
|
||||
upsertItem.run(
|
||||
jellyfinItem.Id,
|
||||
jellyfinItem.Type === "Episode" ? "Episode" : "Movie",
|
||||
itemName,
|
||||
jellyfinItem.OriginalTitle ?? null,
|
||||
jellyfinItem.SeriesName ?? null,
|
||||
jellyfinItem.SeriesId ?? null,
|
||||
jellyfinItem.ParentIndexNumber ?? null,
|
||||
jellyfinItem.IndexNumber ?? null,
|
||||
jellyfinItem.ProductionYear ?? null,
|
||||
itemPath,
|
||||
jellyfinItem.Size ?? null,
|
||||
jellyfinItem.Container ?? null,
|
||||
jellyfinItem.RunTimeTicks ?? null,
|
||||
jellyfinItem.DateLastRefreshed ?? null,
|
||||
`)
|
||||
.run(
|
||||
filePath,
|
||||
parsed.type,
|
||||
parsed.name,
|
||||
parsed.seriesName,
|
||||
seriesKey,
|
||||
parsed.seasonNumber,
|
||||
parsed.episodeNumber,
|
||||
parsed.year,
|
||||
probe.fileSize,
|
||||
parsed.container,
|
||||
probe.durationSeconds,
|
||||
origLang,
|
||||
origLangSource,
|
||||
needsReview,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
jellyfinRaw,
|
||||
externalRawJson,
|
||||
parsed.imdbId,
|
||||
parsed.tmdbId,
|
||||
parsed.tvdbId,
|
||||
probe.containerTitle,
|
||||
probe.containerComment,
|
||||
);
|
||||
|
||||
const itemRow = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?").get(jellyfinItem.Id) as {
|
||||
id: number;
|
||||
};
|
||||
const itemId = itemRow.id;
|
||||
const row = db.prepare("SELECT id FROM media_items WHERE file_path = ?").get(filePath) as { id: number };
|
||||
const itemId = row.id;
|
||||
result.itemId = itemId;
|
||||
|
||||
// Replace streams
|
||||
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(itemId);
|
||||
const insertStream = db.prepare(`
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO media_streams (
|
||||
item_id, stream_index, type, codec, profile, language, language_display,
|
||||
title, is_default, is_forced, is_hearing_impaired,
|
||||
channels, channel_layout, bit_rate, sample_rate, bit_depth
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
item_id, stream_index, type, codec, profile, language, title,
|
||||
is_default, is_forced, is_hearing_impaired,
|
||||
channels, channel_layout, bit_rate, sample_rate, bit_depth,
|
||||
width, height
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue;
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(
|
||||
for (const s of probe.streams) {
|
||||
ins.run(
|
||||
itemId,
|
||||
s.stream_index,
|
||||
s.streamIndex,
|
||||
s.type,
|
||||
s.codec,
|
||||
s.profile,
|
||||
s.language,
|
||||
s.language_display,
|
||||
s.title,
|
||||
s.is_default,
|
||||
s.is_forced,
|
||||
s.is_hearing_impaired,
|
||||
s.isDefault,
|
||||
s.isForced,
|
||||
s.isHearingImpaired,
|
||||
s.channels,
|
||||
s.channel_layout,
|
||||
s.bit_rate,
|
||||
s.sample_rate,
|
||||
s.bit_depth,
|
||||
s.channelLayout,
|
||||
s.bitRate,
|
||||
s.sampleRate,
|
||||
s.bitDepth,
|
||||
s.width,
|
||||
s.height,
|
||||
);
|
||||
}
|
||||
|
||||
const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(itemId) as MediaStream[];
|
||||
const analysis = analyzeItem(
|
||||
{ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null },
|
||||
streams,
|
||||
{ audioLanguages: cfg.audioLanguages },
|
||||
);
|
||||
|
||||
// Status transition rules:
|
||||
// is_noop=1 → done (all sources)
|
||||
// done + is_noop=0 + source='webhook' → pending (Jellyfin says the
|
||||
// on-disk file doesn't match the plan, re-open for review)
|
||||
// done + is_noop=0 + source='scan' → done (safety net: a plain
|
||||
// rescan must not reopen plans and risk duplicate jobs; see the
|
||||
// commit that made done terminal)
|
||||
// error → pending (retry loop)
|
||||
// else keep current status
|
||||
// Stub review_plan (don't disturb existing non-error plans)
|
||||
db
|
||||
.prepare(`
|
||||
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
||||
INSERT INTO review_plans (item_id, status, is_noop, sorted)
|
||||
VALUES (?, 'pending', 0, 0)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
status = CASE
|
||||
WHEN excluded.is_noop = 1 THEN 'done'
|
||||
WHEN review_plans.status = 'done' AND ? = 'webhook' THEN 'pending'
|
||||
WHEN review_plans.status = 'done' THEN 'done'
|
||||
WHEN review_plans.status = 'error' THEN 'pending'
|
||||
ELSE review_plans.status
|
||||
END,
|
||||
is_noop = excluded.is_noop,
|
||||
confidence = excluded.confidence,
|
||||
apple_compat = excluded.apple_compat,
|
||||
job_type = excluded.job_type,
|
||||
notes = excluded.notes
|
||||
sorted = CASE
|
||||
WHEN review_plans.status = 'error' THEN 0
|
||||
ELSE review_plans.sorted
|
||||
END
|
||||
`)
|
||||
.run(
|
||||
itemId,
|
||||
analysis.is_noop ? 1 : 0,
|
||||
confidence,
|
||||
analysis.apple_compat,
|
||||
analysis.job_type,
|
||||
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
||||
source, // for the CASE WHEN ? = 'webhook' branch
|
||||
);
|
||||
|
||||
const planRow = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };
|
||||
const upsertDecision = db.prepare(`
|
||||
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, transcode_codec)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(plan_id, stream_id) DO UPDATE SET
|
||||
action = excluded.action,
|
||||
target_index = excluded.target_index,
|
||||
transcode_codec = excluded.transcode_codec
|
||||
`);
|
||||
for (const dec of analysis.decisions) {
|
||||
upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec);
|
||||
}
|
||||
|
||||
result.origLang = origLang;
|
||||
result.origLangSource = origLangSource;
|
||||
result.needsReview = needsReview;
|
||||
result.confidence = confidence;
|
||||
result.isNoop = analysis.is_noop;
|
||||
.run(itemId);
|
||||
})();
|
||||
|
||||
return result;
|
||||
|
||||
+138
-4
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { error as logError, warn } from "../lib/log";
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
import { normalizeLanguage } from "./language-utils";
|
||||
|
||||
export interface SonarrConfig {
|
||||
url: string;
|
||||
@@ -35,13 +36,23 @@ export async function testConnection(cfg: SonarrConfig): Promise<{ ok: boolean;
|
||||
}
|
||||
|
||||
interface SonarrSeries {
|
||||
id?: number;
|
||||
tvdbId?: number;
|
||||
title?: string;
|
||||
originalLanguage?: { name: string };
|
||||
}
|
||||
|
||||
interface SonarrEpisode {
|
||||
id?: number;
|
||||
seriesId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
/** Pre-loaded Sonarr library indexed for O(1) per-series lookups during a scan. */
|
||||
export interface SonarrLibrary {
|
||||
byTvdbId: Map<string, SonarrSeries>;
|
||||
byTitle: Map<string, SonarrSeries>;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, cfg: SonarrConfig, context: string): Promise<T | null> {
|
||||
@@ -59,15 +70,18 @@ async function fetchJson<T>(url: string, cfg: SonarrConfig, context: string): Pr
|
||||
}
|
||||
|
||||
export async function loadLibrary(cfg: SonarrConfig): Promise<SonarrLibrary> {
|
||||
const empty: SonarrLibrary = { byTvdbId: new Map() };
|
||||
const empty: SonarrLibrary = { byTvdbId: new Map(), byTitle: new Map() };
|
||||
if (!isUsable(cfg)) return empty;
|
||||
const list = await fetchJson<SonarrSeries[]>(`${cfg.url}/api/v3/series`, cfg, "library/all");
|
||||
if (!list) return empty;
|
||||
const byTvdbId = new Map<string, SonarrSeries>();
|
||||
const byTitle = new Map<string, SonarrSeries>();
|
||||
for (const s of list) {
|
||||
if (s.tvdbId != null) byTvdbId.set(String(s.tvdbId), s);
|
||||
const title = normalizeSeriesTitle(s.title);
|
||||
if (title) byTitle.set(title, s);
|
||||
}
|
||||
return { byTvdbId };
|
||||
return { byTvdbId, byTitle };
|
||||
}
|
||||
|
||||
/** Returns ISO 639-2 original language for a series or null. */
|
||||
@@ -85,7 +99,10 @@ export async function getOriginalLanguage(
|
||||
cfg,
|
||||
"lookup/tvdb",
|
||||
);
|
||||
const fromLookup = lookup?.find((s) => String(s.tvdbId ?? "") === String(tvdbId)) ?? lookup?.[0];
|
||||
// Only trust an exact tvdbId match. Falling back to lookup[0] silently
|
||||
// attaches whatever Sonarr returned first (often a fuzzy title match) and
|
||||
// caused shows to be labelled with completely unrelated languages.
|
||||
const fromLookup = lookup?.find((s) => String(s.tvdbId ?? "") === String(tvdbId));
|
||||
if (fromLookup?.originalLanguage) return nameToIso(fromLookup.originalLanguage.name);
|
||||
|
||||
return null;
|
||||
@@ -140,6 +157,118 @@ const NAME_TO_639_2: Record<string, string> = {
|
||||
estonian: "est",
|
||||
};
|
||||
|
||||
/**
|
||||
* Kick Sonarr to rescan a series (pick up that we deleted one of its files)
|
||||
* and then trigger an indexer search for the specific episode so it grabs a
|
||||
* replacement. Best-effort with a short status so the caller can surface
|
||||
* "refetch queued" vs "not found in Sonarr" on the API response. No throws.
|
||||
*/
|
||||
export async function triggerEpisodeRefetch(
|
||||
cfg: SonarrConfig,
|
||||
args: { tvdbId?: string | null; seasonNumber?: number | null; episodeNumber?: number | null },
|
||||
): Promise<{ ok: boolean; seriesId?: number; episodeId?: number; error?: string }> {
|
||||
if (!isUsable(cfg)) return { ok: false, error: "Sonarr not configured" };
|
||||
if (!args.tvdbId) return { ok: false, error: "episode has no tvdb id" };
|
||||
if (args.seasonNumber == null || args.episodeNumber == null)
|
||||
return { ok: false, error: "missing season/episode number" };
|
||||
|
||||
const series = await fetchJson<SonarrSeries[]>(
|
||||
`${cfg.url}/api/v3/series?tvdbId=${encodeURIComponent(args.tvdbId)}`,
|
||||
cfg,
|
||||
`series?tvdbId=${args.tvdbId}`,
|
||||
);
|
||||
if (!series || series.length === 0) return { ok: false, error: "series not tracked by Sonarr" };
|
||||
const seriesId = series[0]?.id;
|
||||
if (seriesId == null) return { ok: false, error: "Sonarr series missing id field" };
|
||||
|
||||
// Need the Sonarr episode.id to pass to EpisodeSearch. Pull every episode
|
||||
// for the series and filter — cheaper than `/api/v3/episode?seriesId=X`
|
||||
// would be for the caller; for our use (one episode, one search) this
|
||||
// single call + linear scan is fine.
|
||||
const episodes = await fetchJson<SonarrEpisode[]>(
|
||||
`${cfg.url}/api/v3/episode?seriesId=${seriesId}`,
|
||||
cfg,
|
||||
`episode?seriesId=${seriesId}`,
|
||||
);
|
||||
const episode = episodes?.find((e) => e.seasonNumber === args.seasonNumber && e.episodeNumber === args.episodeNumber);
|
||||
if (!episode?.id) return { ok: false, seriesId, error: "matching episode not found in Sonarr" };
|
||||
|
||||
try {
|
||||
await postCommand(cfg, { name: "RescanSeries", seriesId });
|
||||
await postCommand(cfg, { name: "EpisodeSearch", episodeIds: [episode.id] });
|
||||
return { ok: true, seriesId, episodeId: episode.id };
|
||||
} catch (e) {
|
||||
return { ok: false, seriesId, episodeId: episode.id, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell Sonarr to rescan the series and rename any files whose names don't
|
||||
* match its naming convention. Returns a basename → new-basename map so the
|
||||
* caller can update its own path records (Sonarr's absolute paths may differ
|
||||
* from ours due to Docker volume mappings — basenames don't).
|
||||
*/
|
||||
export async function triggerSeriesRename(
|
||||
cfg: SonarrConfig,
|
||||
args: { tvdbId?: string | null },
|
||||
): Promise<{ ok: boolean; renames: Map<string, string>; error?: string }> {
|
||||
const empty = new Map<string, string>();
|
||||
if (!isUsable(cfg)) return { ok: false, renames: empty, error: "Sonarr not configured" };
|
||||
if (!args.tvdbId) return { ok: false, renames: empty, error: "episode has no tvdb id" };
|
||||
|
||||
const series = await fetchJson<SonarrSeries[]>(
|
||||
`${cfg.url}/api/v3/series?tvdbId=${encodeURIComponent(args.tvdbId)}`,
|
||||
cfg,
|
||||
`series?tvdbId=${args.tvdbId}`,
|
||||
);
|
||||
if (!series || series.length === 0) return { ok: false, renames: empty, error: "series not tracked by Sonarr" };
|
||||
const seriesId = series[0]?.id;
|
||||
if (seriesId == null) return { ok: false, renames: empty, error: "Sonarr series missing id field" };
|
||||
|
||||
try {
|
||||
await postCommandAndWait(cfg, { name: "RescanSeries", seriesId });
|
||||
|
||||
const previews = await fetchJson<{ episodeFileId: number; existingPath: string; newPath: string }[]>(
|
||||
`${cfg.url}/api/v3/rename?seriesId=${seriesId}`,
|
||||
cfg,
|
||||
`rename?seriesId=${seriesId}`,
|
||||
);
|
||||
if (!previews || previews.length === 0) return { ok: true, renames: empty };
|
||||
|
||||
const fileIds = previews.map((r) => r.episodeFileId);
|
||||
await postCommandAndWait(cfg, { name: "RenameFiles", seriesId, files: fileIds });
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const r of previews) map.set(path.basename(r.existingPath), path.basename(r.newPath));
|
||||
return { ok: true, renames: map };
|
||||
} catch (e) {
|
||||
return { ok: false, renames: empty, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function postCommand(cfg: SonarrConfig, body: Record<string, unknown>): Promise<number> {
|
||||
const res = await fetch(`${cfg.url}/api/v3/command`, {
|
||||
method: "POST",
|
||||
headers: { ...headers(cfg.apiKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { id: number };
|
||||
return data.id;
|
||||
}
|
||||
|
||||
async function postCommandAndWait(cfg: SonarrConfig, body: Record<string, unknown>, timeoutMs = 30_000): Promise<void> {
|
||||
const cmdId = await postCommand(cfg, body);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await Bun.sleep(1000);
|
||||
const cmd = await fetchJson<{ status: string }>(`${cfg.url}/api/v3/command/${cmdId}`, cfg, `command/${cmdId}`);
|
||||
if (!cmd) break;
|
||||
if (cmd.status === "completed") return;
|
||||
if (cmd.status === "failed" || cmd.status === "aborted") throw new Error(`Command ${body.name} ${cmd.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function nameToIso(name: string): string | null {
|
||||
const key = name.toLowerCase().trim();
|
||||
if (NAME_TO_639_2[key]) return NAME_TO_639_2[key];
|
||||
@@ -147,3 +276,8 @@ function nameToIso(name: string): string | null {
|
||||
warn(`Sonarr language name not recognised: '${name}'.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSeriesTitle(title: string | undefined): string | null {
|
||||
const normalized = title?.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
+142
-58
@@ -1,28 +1,52 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import type { MediaItem, MediaStream, StreamDecision } from "../types";
|
||||
import { containerTitle, trackTitle } from "./ffmpeg";
|
||||
import { normalizeLanguage } from "./language-utils";
|
||||
|
||||
interface ProbedStream {
|
||||
export interface ProbedStream {
|
||||
type: "Audio" | "Video" | "Subtitle" | "Data" | "Attachment" | "Unknown";
|
||||
codec: string | null;
|
||||
language: string | null;
|
||||
title: string | null;
|
||||
isDefault: number;
|
||||
}
|
||||
|
||||
async function ffprobeStreams(filePath: string): Promise<ProbedStream[]> {
|
||||
const proc = Bun.spawn(["ffprobe", "-v", "error", "-print_format", "json", "-show_streams", filePath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
export interface ProbedFile {
|
||||
streams: ProbedStream[];
|
||||
containerTitle: string | null;
|
||||
containerComment: string | null;
|
||||
}
|
||||
|
||||
async function ffprobeFile(filePath: string): Promise<ProbedFile> {
|
||||
const proc = Bun.spawn(
|
||||
["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) throw new Error(`ffprobe exited ${exitCode}: ${stderr.trim() || "<no stderr>"}`);
|
||||
|
||||
const data = JSON.parse(stdout) as {
|
||||
streams?: Array<{ codec_type?: string; codec_name?: string; tags?: { language?: string } }>;
|
||||
format?: { tags?: { title?: string; TITLE?: string; comment?: string; COMMENT?: string } };
|
||||
streams?: Array<{
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
tags?: { language?: string; LANGUAGE?: string; title?: string; TITLE?: string };
|
||||
disposition?: { default?: number };
|
||||
}>;
|
||||
};
|
||||
return (data.streams ?? []).map((s) => ({
|
||||
const fmtTags = data.format?.tags ?? {};
|
||||
return {
|
||||
streams: (data.streams ?? []).map((s) => ({
|
||||
type: codecTypeToType(s.codec_type),
|
||||
codec: s.codec_name ?? null,
|
||||
language: s.tags?.language ?? null,
|
||||
}));
|
||||
language: s.tags?.language ?? s.tags?.LANGUAGE ?? null,
|
||||
title: s.tags?.title ?? s.tags?.TITLE ?? null,
|
||||
isDefault: s.disposition?.default ?? 0,
|
||||
})),
|
||||
containerTitle: fmtTags.title ?? fmtTags.TITLE ?? null,
|
||||
containerComment: fmtTags.comment ?? fmtTags.COMMENT ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function codecTypeToType(t: string | undefined): ProbedStream["type"] {
|
||||
@@ -47,65 +71,52 @@ export interface VerifyResult {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the on-disk file already matches the plan's desired state.
|
||||
* Comparison is conservative: any uncertainty falls back to "run the job".
|
||||
*
|
||||
* Matches when:
|
||||
* - audio stream count, language order, and codec match the `keep` decisions
|
||||
* - no subtitle streams remain in the container
|
||||
* - either subs_extracted=1 or the plan has no subtitle decisions to extract
|
||||
*/
|
||||
export async function verifyDesiredState(db: Database, itemId: number, filePath: string): Promise<VerifyResult> {
|
||||
const plan = db.prepare("SELECT id, subs_extracted FROM review_plans WHERE item_id = ?").get(itemId) as
|
||||
| { id: number; subs_extracted: number }
|
||||
| undefined;
|
||||
if (!plan) return { matches: false, reason: "no review plan found" };
|
||||
|
||||
const expected = db
|
||||
.prepare(`
|
||||
SELECT sd.target_index, sd.transcode_codec, ms.language, ms.codec
|
||||
FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
WHERE sd.plan_id = ? AND sd.action = 'keep' AND ms.type = 'Audio'
|
||||
ORDER BY sd.target_index
|
||||
`)
|
||||
.all(plan.id) as {
|
||||
target_index: number;
|
||||
transcode_codec: string | null;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
}[];
|
||||
|
||||
let probed: ProbedStream[];
|
||||
try {
|
||||
probed = await ffprobeStreams(filePath);
|
||||
} catch (err) {
|
||||
return { matches: false, reason: `ffprobe failed: ${(err as Error).message}` };
|
||||
}
|
||||
type ExpectedKeptStream = MediaStream &
|
||||
StreamDecision & {
|
||||
decision_id?: number;
|
||||
};
|
||||
|
||||
export function verifyStreamMetadata(expected: ExpectedKeptStream[], probed: ProbedStream[]): VerifyResult | null {
|
||||
const probedAudio = probed.filter((s) => s.type === "Audio");
|
||||
const probedSubs = probed.filter((s) => s.type === "Subtitle");
|
||||
const probedVideo = probed.filter((s) => s.type === "Video");
|
||||
const expectedAudio = expected.filter((s) => s.type === "Audio");
|
||||
const expectedVideo = expected.filter((s) => s.type === "Video");
|
||||
|
||||
if (probedSubs.length > 0) {
|
||||
return { matches: false, reason: `file still contains ${probedSubs.length} subtitle stream(s) in the container` };
|
||||
}
|
||||
|
||||
if (probedAudio.length !== expected.length) {
|
||||
if (probedVideo.length !== expectedVideo.length) {
|
||||
return {
|
||||
matches: false,
|
||||
reason: `audio stream count mismatch (file: ${probedAudio.length}, expected: ${expected.length})`,
|
||||
reason: `video stream count mismatch (file: ${probedVideo.length}, expected: ${expectedVideo.length})`,
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
const want = expected[i];
|
||||
if (probedAudio.length !== expectedAudio.length) {
|
||||
return {
|
||||
matches: false,
|
||||
reason: `audio stream count mismatch (file: ${probedAudio.length}, expected: ${expectedAudio.length})`,
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < expectedVideo.length; i++) {
|
||||
const want = expectedVideo[i];
|
||||
const got = probedVideo[i];
|
||||
const expectedTitle = want.custom_title ?? trackTitle(want, null);
|
||||
if (expectedTitle != null && got.title !== expectedTitle) {
|
||||
return {
|
||||
matches: false,
|
||||
reason: `video track ${i}: title ${got.title || "<none>"} ≠ expected ${expectedTitle}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < expectedAudio.length; i++) {
|
||||
const want = expectedAudio[i];
|
||||
const got = probedAudio[i];
|
||||
const wantCodec = (want.transcode_codec ?? want.codec ?? "").toLowerCase();
|
||||
const gotCodec = (got.codec ?? "").toLowerCase();
|
||||
const wantLang = (want.language ?? "").toLowerCase();
|
||||
const expectedLanguage = want.custom_language ?? want.language;
|
||||
const wantLang = expectedLanguage ? normalizeLanguage(expectedLanguage) : "und";
|
||||
const gotLang = (got.language ?? "").toLowerCase();
|
||||
if (wantLang && wantLang !== gotLang) {
|
||||
if (wantLang !== gotLang) {
|
||||
return {
|
||||
matches: false,
|
||||
reason: `audio track ${i}: language ${gotLang || "<none>"} ≠ expected ${wantLang}`,
|
||||
@@ -117,6 +128,79 @@ export async function verifyDesiredState(db: Database, itemId: number, filePath:
|
||||
reason: `audio track ${i}: codec ${gotCodec} ≠ expected ${wantCodec}`,
|
||||
};
|
||||
}
|
||||
const expectedTitle = want.custom_title ?? trackTitle(want, want.custom_language);
|
||||
if (expectedTitle != null && got.title !== expectedTitle) {
|
||||
return {
|
||||
matches: false,
|
||||
reason: `audio track ${i}: title ${got.title || "<none>"} ≠ expected ${expectedTitle}`,
|
||||
};
|
||||
}
|
||||
const expectedDefault = i === 0 ? 1 : 0;
|
||||
if (got.isDefault !== expectedDefault) {
|
||||
return {
|
||||
matches: false,
|
||||
reason: `audio track ${i}: default disposition ${got.isDefault} ≠ expected ${expectedDefault}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the on-disk file already matches the plan's desired state.
|
||||
* Comparison is conservative: any uncertainty falls back to "run the job".
|
||||
*
|
||||
* Matches when:
|
||||
* - video/audio stream count, metadata, order, and codec match the `keep` decisions
|
||||
* - no subtitle streams remain in the container
|
||||
* - either subs_extracted=1 or the plan has no subtitle decisions to extract
|
||||
*/
|
||||
export async function verifyDesiredState(db: Database, itemId: number, filePath: string): Promise<VerifyResult> {
|
||||
const plan = db.prepare("SELECT id, subs_extracted FROM review_plans WHERE item_id = ?").get(itemId) as
|
||||
| { id: number; subs_extracted: number }
|
||||
| undefined;
|
||||
if (!plan) return { matches: false, reason: "no review plan found" };
|
||||
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
|
||||
if (!item) return { matches: false, reason: "no media item found" };
|
||||
|
||||
const expected = db
|
||||
.prepare(`
|
||||
SELECT ms.*, sd.id as decision_id, sd.plan_id, sd.stream_id, sd.action,
|
||||
sd.target_index, sd.custom_title, sd.custom_language, sd.transcode_codec
|
||||
FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
WHERE sd.plan_id = ? AND sd.action = 'keep' AND ms.type IN ('Video', 'Audio')
|
||||
ORDER BY CASE ms.type WHEN 'Video' THEN 0 WHEN 'Audio' THEN 1 ELSE 2 END, sd.target_index
|
||||
`)
|
||||
.all(plan.id) as ExpectedKeptStream[];
|
||||
|
||||
let probed: ProbedFile;
|
||||
try {
|
||||
probed = await ffprobeFile(filePath);
|
||||
} catch (err) {
|
||||
return { matches: false, reason: `ffprobe failed: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
const probedSubs = probed.streams.filter((s) => s.type === "Subtitle");
|
||||
|
||||
if (probedSubs.length > 0) {
|
||||
return { matches: false, reason: `file still contains ${probedSubs.length} subtitle stream(s) in the container` };
|
||||
}
|
||||
|
||||
const mismatch = verifyStreamMetadata(expected, probed.streams);
|
||||
if (mismatch) return mismatch;
|
||||
|
||||
const expectedContainerTitle = containerTitle(item);
|
||||
if ((probed.containerTitle ?? null) !== (expectedContainerTitle ?? null)) {
|
||||
return {
|
||||
matches: false,
|
||||
reason: `container title ${probed.containerTitle || "<none>"} ≠ expected ${expectedContainerTitle ?? "<none>"}`,
|
||||
};
|
||||
}
|
||||
if (probed.containerComment && probed.containerComment.length > 0) {
|
||||
return { matches: false, reason: `container comment not empty: ${probed.containerComment}` };
|
||||
}
|
||||
|
||||
if (plan.subs_extracted === 0) {
|
||||
@@ -134,6 +218,6 @@ export async function verifyDesiredState(db: Database, itemId: number, filePath:
|
||||
|
||||
return {
|
||||
matches: true,
|
||||
reason: `file already matches desired layout (${probedAudio.length} audio track(s), no embedded subtitles)`,
|
||||
reason: `file already matches desired layout (${expected.filter((s) => s.type === "Audio").length} audio track(s), no embedded subtitles)`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { getAllConfig, getDb } from "../db/index";
|
||||
import { log, warn } from "../lib/log";
|
||||
import { getItem, type JellyfinConfig } from "./jellyfin";
|
||||
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "./radarr";
|
||||
import { type RescanConfig, type RescanResult, upsertJellyfinItem } from "./rescan";
|
||||
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "./sonarr";
|
||||
|
||||
/**
|
||||
* Events we care about. Jellyfin's Webhook plugin (jellyfin-plugin-webhook)
|
||||
* only exposes ItemAdded as a library-side notification — there is no
|
||||
* ItemUpdated or Library.ItemUpdated. File-rewrites on existing items
|
||||
* produce zero MQTT traffic, so we can't observe them here; the UI's
|
||||
* post-job verification runs off our own ffprobe instead.
|
||||
*
|
||||
* Payload fields are PascalCase (NotificationType, ItemId, ItemType) — the
|
||||
* earlier camelCase in this handler matched nothing the plugin ever sends.
|
||||
*/
|
||||
const ACCEPTED_EVENTS = new Set(["ItemAdded"]);
|
||||
const ACCEPTED_TYPES = new Set(["Movie", "Episode"]);
|
||||
|
||||
/** 5-second dedupe window: Jellyfin can fire the same ItemAdded twice when multiple libraries share a path. */
|
||||
const DEDUPE_WINDOW_MS = 5000;
|
||||
const dedupe = new Map<string, number>();
|
||||
|
||||
function parseLanguageList(raw: string | null | undefined, fallback: string[]): string[] {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebhookPayload {
|
||||
NotificationType?: string;
|
||||
ItemId?: string;
|
||||
ItemType?: string;
|
||||
}
|
||||
|
||||
export interface WebhookHandlerDeps {
|
||||
db: Database;
|
||||
jellyfin: JellyfinConfig;
|
||||
rescanCfg: RescanConfig;
|
||||
getItemFn?: typeof getItem;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
accepted: boolean;
|
||||
reason?: string;
|
||||
result?: RescanResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an incoming webhook payload and, if it describes a relevant Jellyfin
|
||||
* library event for a Movie/Episode, re-analyze the item and let rescan's
|
||||
* webhook-override flip stale 'done' plans back to 'pending'.
|
||||
*
|
||||
* Errors from Jellyfin are logged and swallowed: one bad message must not
|
||||
* take down the MQTT subscriber.
|
||||
*/
|
||||
export async function processWebhookEvent(payload: WebhookPayload, deps: WebhookHandlerDeps): Promise<WebhookResult> {
|
||||
const { db, jellyfin, rescanCfg, getItemFn = getItem, now = Date.now } = deps;
|
||||
|
||||
if (!payload.NotificationType || !ACCEPTED_EVENTS.has(payload.NotificationType)) {
|
||||
return { accepted: false, reason: `NotificationType '${payload.NotificationType}' not accepted` };
|
||||
}
|
||||
if (!payload.ItemType || !ACCEPTED_TYPES.has(payload.ItemType)) {
|
||||
return { accepted: false, reason: `ItemType '${payload.ItemType}' not accepted` };
|
||||
}
|
||||
if (!payload.ItemId) {
|
||||
return { accepted: false, reason: "missing ItemId" };
|
||||
}
|
||||
|
||||
// Debounce: drop bursts within the window, always evict stale entries.
|
||||
const ts = now();
|
||||
for (const [id, seen] of dedupe) {
|
||||
if (ts - seen > DEDUPE_WINDOW_MS) dedupe.delete(id);
|
||||
}
|
||||
const last = dedupe.get(payload.ItemId);
|
||||
if (last != null && ts - last <= DEDUPE_WINDOW_MS) {
|
||||
return { accepted: false, reason: "deduped" };
|
||||
}
|
||||
dedupe.set(payload.ItemId, ts);
|
||||
|
||||
const fresh = await getItemFn(jellyfin, payload.ItemId);
|
||||
if (!fresh) {
|
||||
warn(`Webhook: Jellyfin returned no item for ${payload.ItemId}`);
|
||||
return { accepted: false, reason: "jellyfin returned no item" };
|
||||
}
|
||||
|
||||
const result = await upsertJellyfinItem(db, fresh, rescanCfg, { source: "webhook" });
|
||||
log(`Webhook: ingested ${payload.ItemType} ${payload.ItemId} is_noop=${result.isNoop}`);
|
||||
return { accepted: true, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT-facing adapter: parses the raw payload text, pulls config, calls
|
||||
* processWebhookEvent. Exposed so server/services/mqtt.ts can stay purely
|
||||
* about transport, and tests can drive the logic without spinning up MQTT.
|
||||
*/
|
||||
export async function handleWebhookMessage(rawPayload: string): Promise<WebhookResult> {
|
||||
let payload: WebhookPayload;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch (err) {
|
||||
warn(`Webhook: malformed JSON payload: ${String(err)}`);
|
||||
return { accepted: false, reason: "malformed JSON" };
|
||||
}
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jellyfin: JellyfinConfig = {
|
||||
url: cfg.jellyfin_url,
|
||||
apiKey: cfg.jellyfin_api_key,
|
||||
userId: cfg.jellyfin_user_id,
|
||||
};
|
||||
if (!jellyfin.url || !jellyfin.apiKey) {
|
||||
return { accepted: false, reason: "jellyfin not configured" };
|
||||
}
|
||||
|
||||
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
|
||||
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
|
||||
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
|
||||
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
|
||||
const [radarrLibrary, sonarrLibrary] = await Promise.all([
|
||||
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
|
||||
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const rescanCfg: RescanConfig = {
|
||||
audioLanguages: parseLanguageList(cfg.audio_languages, []),
|
||||
radarr: radarrEnabled ? radarrCfg : null,
|
||||
sonarr: sonarrEnabled ? sonarrCfg : null,
|
||||
radarrLibrary,
|
||||
sonarrLibrary,
|
||||
};
|
||||
|
||||
return processWebhookEvent(payload, { db: getDb(), jellyfin, rescanCfg });
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export function _resetDedupe(): void {
|
||||
dedupe.clear();
|
||||
}
|
||||
+18
-72
@@ -2,28 +2,25 @@
|
||||
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
file_path: string;
|
||||
type: "Movie" | "Episode";
|
||||
name: string;
|
||||
original_title: string | null;
|
||||
series_name: string | null;
|
||||
series_jellyfin_id: string | null;
|
||||
series_key: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
year: number | null;
|
||||
file_path: string;
|
||||
file_size: number | null;
|
||||
container: string | null;
|
||||
runtime_ticks: number | null;
|
||||
date_last_refreshed: string | null;
|
||||
duration_seconds: number | null;
|
||||
original_language: string | null;
|
||||
orig_lang_source: "jellyfin" | "radarr" | "sonarr" | "manual" | null;
|
||||
orig_lang_source: "probe" | "radarr" | "sonarr" | "manual" | null;
|
||||
needs_review: number;
|
||||
imdb_id: string | null;
|
||||
tmdb_id: string | null;
|
||||
tvdb_id: string | null;
|
||||
jellyfin_raw: string | null;
|
||||
external_raw: string | null;
|
||||
container_title: string | null;
|
||||
container_comment: string | null;
|
||||
scan_status: "pending" | "scanned" | "error";
|
||||
scan_error: string | null;
|
||||
last_scanned_at: string | null;
|
||||
@@ -38,11 +35,7 @@ export interface MediaStream {
|
||||
type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage";
|
||||
codec: string | null;
|
||||
profile: string | null;
|
||||
/** Raw language tag as reported by Jellyfin (e.g. "en", "eng", "ger", null).
|
||||
* Not normalized on ingest — callers use normalizeLanguage() for comparison
|
||||
* so we can detect non-canonical tags that the pipeline should rewrite. */
|
||||
language: string | null;
|
||||
language_display: string | null;
|
||||
title: string | null;
|
||||
is_default: number;
|
||||
is_forced: number;
|
||||
@@ -52,6 +45,8 @@ export interface MediaStream {
|
||||
bit_rate: number | null;
|
||||
sample_rate: number | null;
|
||||
bit_depth: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewPlan {
|
||||
@@ -59,7 +54,8 @@ export interface ReviewPlan {
|
||||
item_id: number;
|
||||
status: "pending" | "approved" | "skipped" | "done" | "error";
|
||||
is_noop: number;
|
||||
confidence: "high" | "low";
|
||||
auto_class: "auto" | "auto_heuristic" | "manual" | null;
|
||||
sorted: number;
|
||||
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||
job_type: "copy" | "transcode";
|
||||
subs_extracted: number;
|
||||
@@ -68,18 +64,6 @@ export interface ReviewPlan {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SubtitleFile {
|
||||
id: number;
|
||||
item_id: number;
|
||||
file_path: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
is_forced: number;
|
||||
is_hearing_impaired: number;
|
||||
file_size: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StreamDecision {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
@@ -87,6 +71,11 @@ export interface StreamDecision {
|
||||
action: "keep" | "remove";
|
||||
target_index: number | null;
|
||||
custom_title: string | null;
|
||||
/** Per-stream language override. When set, the analyzer and ffmpeg
|
||||
* command builder both read this in preference to the raw
|
||||
* media_streams.language. Lets the user correct an "und" or
|
||||
* mislabeled audio track without going through Jellyfin. */
|
||||
custom_language: string | null;
|
||||
transcode_codec: string | null;
|
||||
}
|
||||
|
||||
@@ -113,7 +102,7 @@ export interface StreamWithDecision extends MediaStream {
|
||||
export interface PlanResult {
|
||||
is_noop: boolean;
|
||||
has_subs: boolean;
|
||||
confidence: "high" | "low";
|
||||
auto_class: "auto" | "auto_heuristic" | "manual";
|
||||
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||
job_type: "copy" | "transcode";
|
||||
decisions: Array<{
|
||||
@@ -123,51 +112,8 @@ export interface PlanResult {
|
||||
transcode_codec: string | null;
|
||||
}>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
// ─── Jellyfin API types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface JellyfinMediaStream {
|
||||
Type: string;
|
||||
Index: number;
|
||||
Codec?: string;
|
||||
Profile?: string;
|
||||
Language?: string;
|
||||
DisplayLanguage?: string;
|
||||
Title?: string;
|
||||
IsDefault?: boolean;
|
||||
IsForced?: boolean;
|
||||
IsHearingImpaired?: boolean;
|
||||
IsExternal?: boolean;
|
||||
Channels?: number;
|
||||
ChannelLayout?: string;
|
||||
BitRate?: number;
|
||||
SampleRate?: number;
|
||||
BitDepth?: number;
|
||||
}
|
||||
|
||||
export interface JellyfinItem {
|
||||
Id: string;
|
||||
Type: string;
|
||||
Name: string;
|
||||
OriginalTitle?: string;
|
||||
SeriesName?: string;
|
||||
SeriesId?: string;
|
||||
ParentIndexNumber?: number;
|
||||
IndexNumber?: number;
|
||||
ProductionYear?: number;
|
||||
Path?: string;
|
||||
Size?: number;
|
||||
Container?: string;
|
||||
RunTimeTicks?: number;
|
||||
DateLastRefreshed?: string;
|
||||
MediaStreams?: JellyfinMediaStream[];
|
||||
ProviderIds?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface JellyfinUser {
|
||||
Id: string;
|
||||
Name: string;
|
||||
/** Short reason tags explaining why the file needs processing. */
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
// ─── Scan state ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface PathInfo {
|
||||
prefix: string;
|
||||
itemCount: number;
|
||||
accessible: boolean;
|
||||
}
|
||||
|
||||
let cache: PathInfo[] | null = null;
|
||||
|
||||
export function PathsPage() {
|
||||
const [paths, setPaths] = useState<PathInfo[]>(cache ?? []);
|
||||
const [loading, setLoading] = useState(cache === null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.get<{ paths: PathInfo[] }>("/api/paths")
|
||||
.then((d) => {
|
||||
cache = d.paths;
|
||||
setPaths(d.paths);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cache === null) load();
|
||||
}, [load]);
|
||||
|
||||
const allGood = paths.length > 0 && paths.every((p) => p.accessible);
|
||||
const hasBroken = paths.some((p) => !p.accessible);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-4">Paths</h1>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
|
||||
{paths.length === 0 && !loading && (
|
||||
<span className="text-sm text-gray-500">No media items scanned yet. Run a scan first.</span>
|
||||
)}
|
||||
{paths.length === 0 && loading && <span className="text-sm text-gray-400">Checking paths...</span>}
|
||||
{allGood && <span className="text-sm font-medium">All {paths.length} paths accessible</span>}
|
||||
{hasBroken && (
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
{paths.filter((p) => !p.accessible).length} path{paths.filter((p) => !p.accessible).length !== 1 ? "s" : ""} not
|
||||
mounted
|
||||
</span>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? "Checking..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paths.length > 0 && (
|
||||
<>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||
<th className="py-2 pr-4">Path</th>
|
||||
<th className="py-2 pr-4 text-right">Items</th>
|
||||
<th className="py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paths.map((p) => (
|
||||
<tr key={p.prefix} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-4 font-mono text-sm">{p.prefix}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{p.itemCount}</td>
|
||||
<td className="py-2">
|
||||
{p.accessible ? <Badge variant="keep">Accessible</Badge> : <Badge variant="error">Not mounted</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{paths.some((p) => !p.accessible) && (
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
Paths marked "Not mounted" are not reachable from the container. Mount each path into the Docker container
|
||||
exactly as Jellyfin reports it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,49 +6,109 @@ export interface ColumnAction {
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
primary?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SortOption<T extends string = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnShellProps {
|
||||
title: string;
|
||||
count: ReactNode;
|
||||
actions?: ColumnAction[];
|
||||
subtitle?: ReactNode;
|
||||
backward?: ColumnAction;
|
||||
middle?: ColumnAction | ReactNode;
|
||||
forward?: ColumnAction;
|
||||
sortOptions?: SortOption[];
|
||||
sortValue?: string;
|
||||
onSortChange?: (value: string) => void;
|
||||
search?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equal-width pipeline column with a header (title + count + optional action buttons)
|
||||
* and a scrolling body. All four pipeline columns share this shell so widths and
|
||||
* header layout stay consistent.
|
||||
*/
|
||||
export function ColumnShell({ title, count, actions, children }: ColumnShellProps) {
|
||||
function actionClass(a: ColumnAction): string {
|
||||
const base = "text-xs px-2 py-0.5 rounded border whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
if (a.danger) return `${base} border-red-600 bg-red-600 text-white hover:bg-red-700`;
|
||||
if (a.primary) return `${base} border-blue-600 bg-blue-600 text-white hover:bg-blue-700`;
|
||||
return `${base} border-gray-300 text-gray-600 hover:bg-gray-100`;
|
||||
}
|
||||
|
||||
function ActionButton({ action }: { action: ColumnAction }) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 basis-0 min-w-64 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
title={action.title}
|
||||
className={actionClass(action)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function isColumnAction(v: unknown): v is ColumnAction {
|
||||
return typeof v === "object" && v !== null && "label" in v && "onClick" in v;
|
||||
}
|
||||
|
||||
export function ColumnShell({
|
||||
title,
|
||||
count,
|
||||
subtitle,
|
||||
backward,
|
||||
middle,
|
||||
forward,
|
||||
sortOptions,
|
||||
sortValue,
|
||||
onSortChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
children,
|
||||
}: ColumnShellProps) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 basis-0 min-w-80 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2 border-b">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{title} <span className="text-gray-400 font-normal">({count})</span>
|
||||
</span>
|
||||
{actions && actions.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{actions.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
onClick={a.onClick}
|
||||
disabled={a.disabled}
|
||||
className={
|
||||
a.danger
|
||||
? "text-xs px-2 py-0.5 rounded border border-red-200 text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
: a.primary
|
||||
? "text-xs px-2 py-0.5 rounded border border-blue-600 bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
: "text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="text-xs text-gray-500 min-w-0 h-8 flex items-center">{subtitle}</div>
|
||||
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-1 min-h-[1.375rem]">
|
||||
<div className="flex justify-start">{backward && <ActionButton action={backward} />}</div>
|
||||
<div className="flex justify-center">
|
||||
{middle && (isColumnAction(middle) ? <ActionButton action={middle} /> : middle)}
|
||||
</div>
|
||||
<div className="flex justify-end">{forward && <ActionButton action={forward} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(sortOptions?.length || onSearchChange) && (
|
||||
<div className="flex flex-col gap-1 px-3 py-1">
|
||||
{sortOptions && sortOptions.length > 0 && (
|
||||
<select
|
||||
className="h-5 text-[11px] border border-gray-300 rounded px-1 bg-white w-full text-gray-500"
|
||||
value={sortValue}
|
||||
onChange={(e) => onSortChange?.(e.target.value)}
|
||||
>
|
||||
{sortOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{onSearchChange && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={search ?? ""}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-5 text-[11px] border border-gray-300 rounded px-1.5 bg-white w-full text-gray-700 placeholder:text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,49 +1,96 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import type { PipelineJobItem } from "~/shared/lib/types";
|
||||
import { ColumnShell } from "./ColumnShell";
|
||||
import { ColumnShell, type SortOption } from "./ColumnShell";
|
||||
import type { JobSort } from "./PipelinePage";
|
||||
|
||||
const DONE_SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "added_desc", label: "↑ Completed" },
|
||||
{ value: "added_asc", label: "↓ Completed" },
|
||||
{ value: "name_asc", label: "↓ Name" },
|
||||
{ value: "name_desc", label: "↑ Name" },
|
||||
];
|
||||
|
||||
interface DoneColumnProps {
|
||||
items: PipelineJobItem[];
|
||||
onMutate: () => void;
|
||||
sort: JobSort;
|
||||
onChangeSort: (next: JobSort) => void;
|
||||
}
|
||||
|
||||
export function DoneColumn({ items, onMutate }: DoneColumnProps) {
|
||||
function statusVariant(status: string): "done" | "error" | "noop" {
|
||||
if (status === "error") return "error";
|
||||
if (status === "noop") return "noop";
|
||||
return "done";
|
||||
}
|
||||
|
||||
export function DoneColumn({ items, onMutate, sort, onChangeSort }: DoneColumnProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const clear = async () => {
|
||||
await api.post("/api/execute/clear-completed");
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const reopenAll = async () => {
|
||||
if (!confirm(`Send all ${items.length} completed items back to the Inbox for re-sorting?`)) return;
|
||||
await api.post("/api/review/reopen-all");
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const reopen = async (itemId: number) => {
|
||||
await api.post(`/api/review/${itemId}/reopen`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const actions = items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined;
|
||||
const backward = {
|
||||
label: "← Back to inbox",
|
||||
onClick: reopenAll,
|
||||
disabled: items.length === 0,
|
||||
title: "Reopen every completed item so Process Inbox can reclassify and re-queue them",
|
||||
};
|
||||
const forward = {
|
||||
label: "Clear",
|
||||
onClick: clear,
|
||||
disabled: items.length === 0,
|
||||
title: "Dismiss completed items from this column",
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnShell title="Done" count={items.length} actions={actions}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="group rounded border bg-white p-2">
|
||||
<ColumnShell
|
||||
title="Done"
|
||||
count={items.length}
|
||||
backward={backward}
|
||||
forward={forward}
|
||||
sortOptions={DONE_SORT_OPTIONS}
|
||||
sortValue={sort}
|
||||
onSortChange={(v) => onChangeSort(v as JobSort)}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
>
|
||||
{items.filter((i) => !search || i.name.toLowerCase().includes(search.toLowerCase())).map((item) => (
|
||||
<div key={item.id ?? `noop-${item.item_id}`} className="group rounded border bg-white p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reopen(item.item_id)}
|
||||
title="Back to Inbox"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(item.item_id) }}
|
||||
className="text-xs font-medium truncate block hover:text-blue-600 hover:underline"
|
||||
className="text-xs font-medium truncate block hover:text-blue-600 hover:underline mt-1"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reopen(item.item_id)}
|
||||
title="Send this item back to the Review column to redecide and re-queue"
|
||||
className="text-[0.68rem] px-1.5 py-0.5 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
>
|
||||
← Back to review
|
||||
</button>
|
||||
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types";
|
||||
import { ColumnShell, type SortOption } from "./ColumnShell";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
import { SeriesCard } from "./SeriesCard";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
|
||||
|
||||
export const INBOX_SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "scan_asc", label: "↓ Scan time" },
|
||||
{ value: "scan_desc", label: "↑ Scan time" },
|
||||
{ value: "name_asc", label: "↓ Name" },
|
||||
{ value: "name_desc", label: "↑ Name" },
|
||||
];
|
||||
|
||||
interface InboxColumnProps {
|
||||
initialResponse: ReviewGroupsResponse;
|
||||
totalItems: number;
|
||||
autoProcessing: boolean;
|
||||
onToggleAutoProcessing: (enabled: boolean) => void;
|
||||
onMutate: () => void;
|
||||
sortProgress: { processed: number; total: number } | null;
|
||||
sort: InboxSort;
|
||||
onChangeSort: (next: InboxSort) => void;
|
||||
}
|
||||
|
||||
export function InboxColumn({
|
||||
initialResponse,
|
||||
totalItems,
|
||||
autoProcessing,
|
||||
onToggleAutoProcessing,
|
||||
onMutate,
|
||||
sortProgress,
|
||||
sort,
|
||||
onChangeSort,
|
||||
}: InboxColumnProps) {
|
||||
const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
|
||||
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [localAutoProcessing, setLocalAutoProcessing] = useState(autoProcessing);
|
||||
useEffect(() => {
|
||||
setLocalAutoProcessing(autoProcessing);
|
||||
}, [autoProcessing]);
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(initialResponse.groups);
|
||||
setHasMore(initialResponse.hasMore);
|
||||
}, [initialResponse]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const res = await api.get<ReviewGroupsResponse>(
|
||||
`/api/review/groups?bucket=inbox&offset=${groups.length}&limit=${PAGE_SIZE}&sort=${sort}`,
|
||||
);
|
||||
setGroups((prev) => [...prev, ...res.groups]);
|
||||
setHasMore(res.hasMore);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [groups.length, hasMore, loadingMore, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) loadMore();
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore]);
|
||||
|
||||
const runProcess = async () => {
|
||||
await api.post("/api/review/process-inbox");
|
||||
};
|
||||
|
||||
const stopProcess = async () => {
|
||||
await api.post("/api/review/process-inbox/stop");
|
||||
};
|
||||
|
||||
const processItem = async (itemId: number) => {
|
||||
await api.post(`/api/review/${itemId}/process`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const sorting = sortProgress !== null;
|
||||
const pct =
|
||||
sortProgress && sortProgress.total > 0 ? Math.round((sortProgress.processed / sortProgress.total) * 100) : 0;
|
||||
|
||||
// Checkbox always visible. Progress bar shows below it when sorting.
|
||||
const subtitle = (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={localAutoProcessing}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked;
|
||||
setLocalAutoProcessing(next);
|
||||
onToggleAutoProcessing(next);
|
||||
}}
|
||||
/>
|
||||
<span>Auto-process Inbox</span>
|
||||
</label>
|
||||
{sorting && (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className="text-gray-600 tabular-nums shrink-0">
|
||||
{sortProgress.processed}/{sortProgress.total}
|
||||
</span>
|
||||
<div className="h-1.5 rounded bg-gray-200 overflow-hidden flex-1">
|
||||
<div className="h-full bg-blue-600 transition-[width]" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const backward = sorting ? { label: "■ Stop", onClick: stopProcess, danger: true } : undefined;
|
||||
const forward = sorting
|
||||
? undefined
|
||||
: {
|
||||
label: "Process →",
|
||||
onClick: runProcess,
|
||||
primary: true,
|
||||
disabled: totalItems === 0,
|
||||
title: "Process inbox to Queue / Review",
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnShell
|
||||
title="Inbox"
|
||||
count={totalItems}
|
||||
subtitle={subtitle}
|
||||
backward={backward}
|
||||
forward={forward}
|
||||
sortOptions={INBOX_SORT_OPTIONS}
|
||||
sortValue={sort}
|
||||
onSortChange={(v) => onChangeSort(v as InboxSort)}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{groups.filter((g) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
if (g.kind === "movie") return g.item.name.toLowerCase().includes(q);
|
||||
return g.seriesName.toLowerCase().includes(q);
|
||||
}).map((group) => {
|
||||
if (group.kind === "movie") {
|
||||
return (
|
||||
<PipelineCard
|
||||
key={group.item.id}
|
||||
item={group.item}
|
||||
onProcess={() => processItem(group.item.item_id ?? (group.item as { id: number }).id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeriesCard
|
||||
key={group.seriesKey}
|
||||
seriesKey={group.seriesKey}
|
||||
seriesName={group.seriesName}
|
||||
seasons={group.seasons}
|
||||
episodeCount={group.episodeCount}
|
||||
readyCount={group.readyCount}
|
||||
originalLanguage={group.originalLanguage}
|
||||
onMutate={onMutate}
|
||||
onProcess={() => {
|
||||
const ids = group.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id));
|
||||
Promise.all(ids.map((id) => api.post(`/api/review/${id}/process`))).then(() => onMutate());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{groups.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items in the inbox</p>}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-4 text-center text-xs text-gray-400">
|
||||
{loadingMore ? "Loading more…" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ColumnShell>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { langName, normalizeLanguageClient } from "~/shared/lib/lang";
|
||||
import type { PipelineAudioStream } from "~/shared/lib/types";
|
||||
import type { PipelineAudioStream, PipelineReviewItem } from "~/shared/lib/types";
|
||||
|
||||
// Shared shape across review items, raw media_item rows, and queued jobs.
|
||||
// Only name/type are strictly required; the rest is optional so the card
|
||||
@@ -14,17 +14,17 @@ interface PipelineCardItem {
|
||||
series_name?: string | null;
|
||||
season_number?: number | null;
|
||||
episode_number?: number | null;
|
||||
jellyfin_id?: string;
|
||||
confidence?: "high" | "low";
|
||||
auto_class?: PipelineReviewItem["auto_class"];
|
||||
job_type?: "copy" | "transcode";
|
||||
original_language?: string | null;
|
||||
transcode_reasons?: string[];
|
||||
reasons?: string[];
|
||||
audio_streams?: PipelineAudioStream[];
|
||||
}
|
||||
|
||||
interface PipelineCardProps {
|
||||
item: PipelineCardItem;
|
||||
jellyfinUrl: string;
|
||||
/** Render title only — no badges, streams, or action buttons. Used in the inbox. */
|
||||
minimal?: boolean;
|
||||
onToggleStream?: (streamId: number, nextAction: "keep" | "remove") => void;
|
||||
onApprove?: () => void;
|
||||
onSkip?: () => void;
|
||||
@@ -37,6 +37,12 @@ interface PipelineCardProps {
|
||||
// expanded series episodes don't get this (the series' "Approve all"
|
||||
// covers the prior-episodes-in-series case).
|
||||
onApproveUpToHere?: () => void;
|
||||
// Inbox: process this single item (resolve language + classify → Review/Queue).
|
||||
onProcess?: () => void;
|
||||
// Queue: run this single job (move to Processing).
|
||||
onRun?: () => void;
|
||||
// Review: send this item back to the Inbox.
|
||||
onBackToInbox?: () => void;
|
||||
}
|
||||
|
||||
function formatChannels(n: number | null | undefined): string | null {
|
||||
@@ -59,32 +65,123 @@ function describeStream(s: PipelineAudioStream): string {
|
||||
|
||||
export function PipelineCard({
|
||||
item,
|
||||
jellyfinUrl,
|
||||
minimal,
|
||||
onToggleStream,
|
||||
onApprove,
|
||||
onSkip,
|
||||
onUnapprove,
|
||||
onApproveUpToHere,
|
||||
onProcess,
|
||||
onRun,
|
||||
onBackToInbox,
|
||||
}: PipelineCardProps) {
|
||||
const title =
|
||||
item.type === "Episode"
|
||||
? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")} — ${item.name}`
|
||||
: item.name;
|
||||
|
||||
const confidenceColor = item.confidence === "high" ? "bg-green-50 border-green-200" : "bg-amber-50 border-amber-200";
|
||||
|
||||
const jellyfinLink =
|
||||
jellyfinUrl && item.jellyfin_id ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` : null;
|
||||
const cardColor = minimal
|
||||
? "bg-white border-gray-200"
|
||||
: item.auto_class === "auto_heuristic"
|
||||
? "bg-amber-50 border-amber-200"
|
||||
: item.auto_class === "manual"
|
||||
? "bg-gray-50 border-gray-200"
|
||||
: "bg-white border-gray-200";
|
||||
|
||||
// item.item_id is present in pipeline payloads; card can also be fed raw
|
||||
// media_item rows (no plan) in which case we fall back to item.id.
|
||||
const mediaItemId: number = item.item_id ?? (item as { id: number }).id;
|
||||
|
||||
const hasActionRow = !!(onSkip || onApprove || onUnapprove || onApproveUpToHere || onProcess || onRun || onBackToInbox);
|
||||
const hasReasons = !!item.reasons && item.reasons.length > 0;
|
||||
const hasInfoRow = hasReasons || !!item.job_type;
|
||||
|
||||
return (
|
||||
<div className={`group rounded-lg border p-3 ${confidenceColor}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<div className={`group rounded-lg border p-3 ${cardColor}`}>
|
||||
{/* Action row — anchored at the top so buttons stay in the same
|
||||
place regardless of card body height. */}
|
||||
{hasActionRow && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* ← backward actions (left side) */}
|
||||
{onBackToInbox && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToInbox}
|
||||
title="Back to Inbox"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
{onUnapprove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUnapprove}
|
||||
title="Back to Review"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* × middle action */}
|
||||
{onSkip && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
title="Skip"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* → forward actions (right side) */}
|
||||
{onApproveUpToHere && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApproveUpToHere}
|
||||
title="Queue every card above this one"
|
||||
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
{onApprove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApprove}
|
||||
title="Queue"
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
{onProcess && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProcess}
|
||||
title="Process"
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
{onRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
title="Process now"
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title row */}
|
||||
<div className={`flex items-center gap-1 min-w-0 ${hasActionRow ? "mt-2" : ""}`}>
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(mediaItemId) }}
|
||||
@@ -92,34 +189,26 @@ export function PipelineCard({
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
{jellyfinLink && (
|
||||
<a
|
||||
href={jellyfinLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open in Jellyfin"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-gray-400 hover:text-blue-600 shrink-0"
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
{item.transcode_reasons && item.transcode_reasons.length > 0
|
||||
? item.transcode_reasons.map((r) => (
|
||||
|
||||
{/* Info row: file info (transcode / copy) on the left. */}
|
||||
{!minimal && hasInfoRow && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0 mt-1">
|
||||
{item.job_type && <Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>}
|
||||
{hasReasons &&
|
||||
item.reasons!.map((r) => (
|
||||
<Badge key={r} variant="manual">
|
||||
{r}
|
||||
</Badge>
|
||||
))
|
||||
: item.job_type === "copy" && <Badge variant="noop">copy</Badge>}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio streams: checkboxes over the actual tracks on this file,
|
||||
{/* Audio streams (hidden in minimal/inbox mode): checkboxes over the actual tracks on this file,
|
||||
pre-checked per analyzer decisions. The track whose language
|
||||
matches the item's OG (set from radarr/sonarr/jellyfin) is
|
||||
matches the item's OG (set from radarr/sonarr) is
|
||||
marked "(Original Language)". */}
|
||||
{item.audio_streams && item.audio_streams.length > 0 && (
|
||||
{!minimal && item.audio_streams && item.audio_streams.length > 0 && (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{item.audio_streams.map((s) => {
|
||||
const ogLang = item.original_language ? normalizeLanguageClient(item.original_language) : null;
|
||||
@@ -158,48 +247,5 @@ export function PipelineCard({
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{onSkip && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{onApproveUpToHere && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApproveUpToHere}
|
||||
title="Approve every card listed above this one"
|
||||
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
↑ Approve above
|
||||
</button>
|
||||
)}
|
||||
{onApprove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApprove}
|
||||
className="text-xs px-3 py-1 rounded bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
)}
|
||||
{onUnapprove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUnapprove}
|
||||
className="text-xs px-3 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
← Back to review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { formatThousands } from "~/shared/lib/utils";
|
||||
|
||||
interface ScanError {
|
||||
name: string;
|
||||
file_path: string;
|
||||
scan_error: string | null;
|
||||
}
|
||||
|
||||
interface ScanStatus {
|
||||
running: boolean;
|
||||
progress: { scanned: number; total: number; errors: number };
|
||||
scanLimit: number | null;
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalItems: number;
|
||||
needsAction: number;
|
||||
queued: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
// Mutable SSE buffer flushed to React state on a timer so a blast of
|
||||
// "progress" events from the backend doesn't cause one React re-render per
|
||||
// scanned item. See the original scan page for the full reasoning; kept
|
||||
// verbatim here because the scan-progress wiring hasn't changed.
|
||||
interface SseBuf {
|
||||
scanned: number;
|
||||
total: number;
|
||||
errors: number;
|
||||
currentItem: string;
|
||||
dirty: boolean;
|
||||
complete: { scanned?: number; errors?: number } | null;
|
||||
lost: boolean;
|
||||
}
|
||||
|
||||
function freshBuf(): SseBuf {
|
||||
return { scanned: 0, total: 0, errors: 0, currentItem: "", dirty: false, complete: null, lost: false };
|
||||
}
|
||||
|
||||
const FLUSH_MS = 200;
|
||||
|
||||
function StatPill({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className={`font-semibold tabular-nums ${danger ? "text-red-600" : "text-gray-900"}`}>
|
||||
{formatThousands(value)}
|
||||
</span>
|
||||
<span className="text-gray-500">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact header for the pipeline page. One row of stats (total / scanned /
|
||||
* needs-action / done / errors) and one row with the scan control bar
|
||||
* (status label, progress, start/stop). Replaces the separate Library page
|
||||
* — everything the user needs about library-wide state lives here so the
|
||||
* pipeline columns below don't have to carry it too.
|
||||
*/
|
||||
export function PipelineHeader() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||
const [limit, setLimit] = useState("");
|
||||
const [statusLabel, setStatusLabel] = useState("");
|
||||
const [currentItem, setCurrentItem] = useState("");
|
||||
const [progressScanned, setProgressScanned] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
const [errors, setErrors] = useState(0);
|
||||
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [scanErrors, setScanErrors] = useState<ScanError[]>([]);
|
||||
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const bufRef = useRef<SseBuf>(freshBuf());
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const loadStats = useCallback(() => {
|
||||
api
|
||||
.get<{ stats: DashboardStats }>("/api/dashboard")
|
||||
.then((d) => setStats(d.stats))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadScan = useCallback(async () => {
|
||||
const s = await api.get<ScanStatus>("/api/scan");
|
||||
setScanStatus(s);
|
||||
setProgressScanned(s.progress.scanned);
|
||||
setProgressTotal(s.progress.total);
|
||||
setErrors(s.progress.errors);
|
||||
setStatusLabel(s.running ? "Scan in progress…" : "Scan idle");
|
||||
if (s.scanLimit != null) setLimit(String(s.scanLimit));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadScan();
|
||||
}, [loadStats, loadScan]);
|
||||
|
||||
const clearFlushTimer = () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flush = useCallback(() => {
|
||||
const b = bufRef.current;
|
||||
if (!b.dirty && !b.complete && !b.lost) return;
|
||||
|
||||
if (b.dirty) {
|
||||
setProgressScanned(b.scanned);
|
||||
setProgressTotal(b.total);
|
||||
setErrors(b.errors);
|
||||
setCurrentItem(b.currentItem);
|
||||
b.dirty = false;
|
||||
// Refresh dashboard stats periodically so the stat pills update
|
||||
// during a scan (every ~5s to avoid hammering the server).
|
||||
if (b.scanned % 25 === 0) loadStats();
|
||||
}
|
||||
|
||||
if (b.complete) {
|
||||
const d = b.complete;
|
||||
b.complete = null;
|
||||
setStatusLabel(`Scan complete — ${d.scanned ?? "?"} items, ${d.errors ?? 0} errors`);
|
||||
setScanStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
clearFlushTimer();
|
||||
loadStats();
|
||||
}
|
||||
|
||||
if (b.lost) {
|
||||
b.lost = false;
|
||||
setStatusLabel("Scan connection lost — refresh for status");
|
||||
setScanStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
clearFlushTimer();
|
||||
}
|
||||
}, [loadStats]);
|
||||
|
||||
const startFlushing = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
timerRef.current = setInterval(flush, FLUSH_MS);
|
||||
}, [flush]);
|
||||
|
||||
const stopFlushing = useCallback(() => {
|
||||
if (!timerRef.current) return;
|
||||
clearFlushTimer();
|
||||
flush();
|
||||
}, [flush]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const connectSse = useCallback(() => {
|
||||
esRef.current?.close();
|
||||
const buf = bufRef.current;
|
||||
const es = new EventSource("/api/scan/events");
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener("progress", (e) => {
|
||||
const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
|
||||
buf.scanned = d.scanned;
|
||||
buf.total = d.total;
|
||||
buf.errors = d.errors;
|
||||
buf.currentItem = d.current_item ?? "";
|
||||
buf.dirty = true;
|
||||
});
|
||||
es.addEventListener("complete", (e) => {
|
||||
const d = JSON.parse(e.data || "{}") as { scanned?: number; errors?: number };
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
buf.complete = d;
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
buf.lost = true;
|
||||
});
|
||||
|
||||
startFlushing();
|
||||
return es;
|
||||
}, [startFlushing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scanStatus?.running || esRef.current) return;
|
||||
connectSse();
|
||||
return () => {
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
stopFlushing();
|
||||
};
|
||||
}, [scanStatus?.running, connectSse, stopFlushing]);
|
||||
|
||||
const startScan = async () => {
|
||||
setProgressScanned(0);
|
||||
setProgressTotal(0);
|
||||
setErrors(0);
|
||||
setCurrentItem("");
|
||||
setStatusLabel("Scan in progress…");
|
||||
setScanStatus((prev) => (prev ? { ...prev, running: true } : prev));
|
||||
bufRef.current = freshBuf();
|
||||
connectSse();
|
||||
const limitNum = limit ? Number(limit) : undefined;
|
||||
await api.post("/api/scan/start", limitNum !== undefined ? { limit: limitNum } : {});
|
||||
};
|
||||
|
||||
const stopScan = async () => {
|
||||
await api.post("/api/scan/stop", {});
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
stopFlushing();
|
||||
setScanStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
setStatusLabel("Scan stopped");
|
||||
};
|
||||
|
||||
const running = scanStatus?.running ?? false;
|
||||
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 border-b shrink-0 flex flex-col gap-1.5 text-xs">
|
||||
{/* Stats row — inline pills, left-aligned after the page title. */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<h1 className="text-base font-semibold m-0">Pipeline</h1>
|
||||
{stats && (
|
||||
<>
|
||||
<StatPill label="total" value={stats.totalItems} />
|
||||
<StatPill label="needs action" value={stats.needsAction} />
|
||||
<StatPill label="queued" value={stats.queued} />
|
||||
{stats.errors > 0 && <StatPill label="errors" value={stats.errors} danger />}
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Scan bar — one row. Progress bar renders only when total > 0. */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-gray-700">{statusLabel || (running ? "Scan in progress…" : "Scan idle")}</span>
|
||||
{progressTotal > 0 && (
|
||||
<>
|
||||
<div className="bg-gray-200 rounded-full h-1.5 w-40 overflow-hidden">
|
||||
<div className="h-full bg-blue-600 rounded-full transition-all duration-300" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="tabular-nums text-gray-500">
|
||||
{formatThousands(progressScanned)}/{formatThousands(progressTotal)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{errors > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-red-600 underline decoration-dotted cursor-pointer hover:text-red-800"
|
||||
onClick={async () => {
|
||||
if (showErrors) {
|
||||
setShowErrors(false);
|
||||
return;
|
||||
}
|
||||
const res = await api.get<{ errors: ScanError[] }>("/api/scan/errors");
|
||||
setScanErrors(res.errors);
|
||||
setShowErrors(true);
|
||||
}}
|
||||
>
|
||||
{errors} error(s)
|
||||
</button>
|
||||
)}
|
||||
{currentItem && <span className="truncate max-w-md text-gray-400 min-w-0">{currentItem}</span>}
|
||||
<div className="flex-1" />
|
||||
{running ? (
|
||||
<Button variant="danger" size="sm" onClick={stopScan}>
|
||||
■ Stop Scan
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex items-center gap-1 text-gray-500">
|
||||
limit
|
||||
<input
|
||||
type="number"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
placeholder="all"
|
||||
min="1"
|
||||
className="border border-gray-300 rounded px-1.5 py-0.5 w-16"
|
||||
/>
|
||||
</label>
|
||||
<Button size="sm" onClick={startScan}>
|
||||
Scan
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showErrors && scanErrors.length > 0 && (
|
||||
<div className="border-t pt-1.5 mt-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-red-700">Scan errors</span>
|
||||
<button type="button" className="text-gray-400 hover:text-gray-600" onClick={() => setShowErrors(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{scanErrors.map((err) => (
|
||||
<div key={err.file_path} className="bg-red-50 rounded px-2 py-1">
|
||||
<span className="font-medium text-gray-800">{err.name}</span>
|
||||
{err.scan_error && <p className="text-red-600 mt-0.5 break-all">{err.scan_error}</p>}
|
||||
<p className="text-gray-400 truncate" title={err.file_path}>
|
||||
{err.file_path}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import type { PipelineData } from "~/shared/lib/types";
|
||||
import type { PipelineData, PipelineJobItem, ReviewGroupsResponse } from "~/shared/lib/types";
|
||||
import { DoneColumn } from "./DoneColumn";
|
||||
import { type InboxSort, InboxColumn } from "./InboxColumn";
|
||||
import { PipelineHeader } from "./PipelineHeader";
|
||||
import { ProcessingColumn } from "./ProcessingColumn";
|
||||
import { QueueColumn } from "./QueueColumn";
|
||||
import { ReviewColumn } from "./ReviewColumn";
|
||||
import { type ReviewSort, ReviewColumn } from "./ReviewColumn";
|
||||
|
||||
interface Progress {
|
||||
id: number;
|
||||
@@ -18,45 +20,81 @@ interface QueueStatus {
|
||||
seconds?: number;
|
||||
}
|
||||
|
||||
interface SortProgress {
|
||||
processed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type JobSort = "name_asc" | "name_desc" | "added_asc" | "added_desc";
|
||||
|
||||
function sortJobs(items: PipelineJobItem[], sort: JobSort): PipelineJobItem[] {
|
||||
const sorted = [...items];
|
||||
if (sort === "name_asc") return sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (sort === "name_desc") return sorted.sort((a, b) => b.name.localeCompare(a.name));
|
||||
if (sort === "added_desc") return sorted.reverse();
|
||||
return sorted; // added_asc = default backend order
|
||||
}
|
||||
|
||||
export function PipelinePage() {
|
||||
const [data, setData] = useState<PipelineData | null>(null);
|
||||
const [inboxInitial, setInboxInitial] = useState<ReviewGroupsResponse | null>(null);
|
||||
const [reviewInitial, setReviewInitial] = useState<ReviewGroupsResponse | null>(null);
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
|
||||
const [sortProgress, setSortProgress] = useState<SortProgress | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const pipelineRes = await api.get<PipelineData>("/api/review/pipeline");
|
||||
setData(pipelineRes);
|
||||
setLoading(false);
|
||||
// Sort state for all columns
|
||||
const [inboxSort, setInboxSort] = useState<InboxSort>("scan_asc");
|
||||
const inboxSortRef = useRef<InboxSort>("scan_asc");
|
||||
const [reviewSort, setReviewSort] = useState<ReviewSort>("class");
|
||||
const reviewSortRef = useRef<ReviewSort>("class");
|
||||
const [queueSort, setQueueSort] = useState<JobSort>("added_asc");
|
||||
const [doneSort, setDoneSort] = useState<JobSort>("added_desc");
|
||||
|
||||
const loadPipeline = useCallback(async () => {
|
||||
const res = await api.get<PipelineData>("/api/review/pipeline");
|
||||
setData(res);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
const loadGroups = useCallback(async () => {
|
||||
const iSort = inboxSortRef.current;
|
||||
const rSort = reviewSortRef.current;
|
||||
const [inbox, review] = await Promise.all([
|
||||
api.get<ReviewGroupsResponse>(`/api/review/groups?bucket=inbox&offset=0&limit=25&sort=${iSort}`),
|
||||
api.get<ReviewGroupsResponse>(`/api/review/groups?bucket=review&offset=0&limit=25&sort=${rSort}`),
|
||||
]);
|
||||
setInboxInitial(inbox);
|
||||
setReviewInitial(review);
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
await Promise.all([loadPipeline(), loadGroups()]);
|
||||
setLoading(false);
|
||||
}, [loadPipeline, loadGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
// SSE for live updates. job_update fires on every status change and per-line
|
||||
// stdout flush of the running job — without coalescing, the pipeline endpoint
|
||||
// (a 500-row review query + counts) would re-run several times per second.
|
||||
const reloadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const scheduleReload = () => {
|
||||
const schedulePipelineReload = () => {
|
||||
if (reloadTimer.current) return;
|
||||
reloadTimer.current = setTimeout(() => {
|
||||
reloadTimer.current = null;
|
||||
load();
|
||||
loadAll();
|
||||
}, 1000);
|
||||
};
|
||||
const es = new EventSource("/api/execute/events");
|
||||
es.addEventListener("job_update", (e) => {
|
||||
// When a job leaves 'running' (done / error / cancelled), drop any
|
||||
// stale progress so the bar doesn't linger on the next job's card.
|
||||
try {
|
||||
const upd = JSON.parse((e as MessageEvent).data) as { id: number; status: string };
|
||||
if (upd.status !== "running") setProgress(null);
|
||||
} catch {
|
||||
/* ignore malformed events */
|
||||
}
|
||||
scheduleReload();
|
||||
schedulePipelineReload();
|
||||
});
|
||||
es.addEventListener("job_progress", (e) => {
|
||||
setProgress(JSON.parse((e as MessageEvent).data));
|
||||
@@ -64,25 +102,93 @@ export function PipelinePage() {
|
||||
es.addEventListener("queue_status", (e) => {
|
||||
setQueueStatus(JSON.parse((e as MessageEvent).data));
|
||||
});
|
||||
es.addEventListener("pipeline_changed", () => {
|
||||
schedulePipelineReload();
|
||||
});
|
||||
es.addEventListener("inbox_sort_start", (e) => {
|
||||
try {
|
||||
const { total } = JSON.parse((e as MessageEvent).data) as { total: number };
|
||||
setSortProgress({ processed: 0, total });
|
||||
} catch {
|
||||
/* ignore malformed events */
|
||||
}
|
||||
});
|
||||
es.addEventListener("inbox_sort_progress", (e) => {
|
||||
try {
|
||||
setSortProgress(JSON.parse((e as MessageEvent).data) as SortProgress);
|
||||
} catch {
|
||||
/* ignore malformed events */
|
||||
}
|
||||
schedulePipelineReload();
|
||||
});
|
||||
es.addEventListener("inbox_sorted", () => {
|
||||
setSortProgress(null);
|
||||
loadAll();
|
||||
});
|
||||
return () => {
|
||||
es.close();
|
||||
if (reloadTimer.current) clearTimeout(reloadTimer.current);
|
||||
};
|
||||
}, [load]);
|
||||
}, [loadPipeline, loadAll]);
|
||||
|
||||
if (loading || !data) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
|
||||
const toggleAutoProcessing = async (enabled: boolean) => {
|
||||
await api.post<{ ok: boolean }>("/api/settings/auto-processing", { enabled });
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const toggleAutoProcessQueue = async (enabled: boolean) => {
|
||||
await api.post<{ ok: boolean }>("/api/settings/auto-process-queue", { enabled });
|
||||
loadAll();
|
||||
};
|
||||
|
||||
if (loading || !data || !inboxInitial || !reviewInitial)
|
||||
return <div className="p-6 text-gray-500">Loading pipeline...</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col -mx-3 sm:-mx-5 -mt-4 -mb-12 h-[calc(100vh-3rem)] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
|
||||
<h1 className="text-lg font-semibold">Pipeline</h1>
|
||||
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
|
||||
</div>
|
||||
<PipelineHeader />
|
||||
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
|
||||
<ReviewColumn items={data.review} total={data.reviewTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
||||
<QueueColumn items={data.queued} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
||||
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={load} />
|
||||
<DoneColumn items={data.done} onMutate={load} />
|
||||
<InboxColumn
|
||||
initialResponse={inboxInitial}
|
||||
totalItems={data.inboxTotal}
|
||||
autoProcessing={data.autoProcessing}
|
||||
onToggleAutoProcessing={toggleAutoProcessing}
|
||||
onMutate={loadAll}
|
||||
sortProgress={sortProgress}
|
||||
sort={inboxSort}
|
||||
onChangeSort={(next) => {
|
||||
inboxSortRef.current = next;
|
||||
setInboxSort(next);
|
||||
loadGroups();
|
||||
}}
|
||||
/>
|
||||
<ReviewColumn
|
||||
initialResponse={reviewInitial}
|
||||
totalItems={data.reviewItemsTotal}
|
||||
readyCount={data.reviewReadyCount}
|
||||
onMutate={loadAll}
|
||||
sort={reviewSort}
|
||||
onChangeSort={(next) => {
|
||||
reviewSortRef.current = next;
|
||||
setReviewSort(next);
|
||||
loadGroups();
|
||||
}}
|
||||
/>
|
||||
<QueueColumn
|
||||
items={sortJobs(data.queued, queueSort)}
|
||||
autoProcessQueue={data.autoProcessQueue}
|
||||
onToggleAutoProcessQueue={toggleAutoProcessQueue}
|
||||
onMutate={loadAll}
|
||||
sort={queueSort}
|
||||
onChangeSort={setQueueSort}
|
||||
/>
|
||||
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={loadAll} />
|
||||
<DoneColumn
|
||||
items={sortJobs(data.done, doneSort)}
|
||||
onMutate={loadAll}
|
||||
sort={doneSort}
|
||||
onChangeSort={setDoneSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,25 @@ export function ProcessingColumn({ items, progress, queueStatus, onMutate }: Pro
|
||||
return () => clearInterval(t);
|
||||
}, [job]);
|
||||
|
||||
// Local sleep countdown. Server emits the sleep duration once when the
|
||||
// pause begins; the client anchors "deadline = receivedAt + seconds*1000"
|
||||
// and ticks a 1s timer so the UI shows a live countdown, not a static number.
|
||||
const [sleepDeadline, setSleepDeadline] = useState<number | null>(null);
|
||||
const [sleepNow, setSleepNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (queueStatus?.status === "sleeping" && typeof queueStatus.seconds === "number") {
|
||||
setSleepDeadline(Date.now() + queueStatus.seconds * 1000);
|
||||
} else {
|
||||
setSleepDeadline(null);
|
||||
}
|
||||
}, [queueStatus?.status, queueStatus?.seconds]);
|
||||
useEffect(() => {
|
||||
if (sleepDeadline == null) return;
|
||||
const t = setInterval(() => setSleepNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [sleepDeadline]);
|
||||
const sleepRemaining = sleepDeadline != null ? Math.max(0, Math.ceil((sleepDeadline - sleepNow) / 1000)) : null;
|
||||
|
||||
// Only trust progress if it belongs to the current job — stale events from
|
||||
// a previous job would otherwise show wrong numbers until the new job emits.
|
||||
const liveProgress = job && progress && progress.id === job.id ? progress : null;
|
||||
@@ -52,34 +71,42 @@ export function ProcessingColumn({ items, progress, queueStatus, onMutate }: Pro
|
||||
<ColumnShell
|
||||
title="Processing"
|
||||
count={job ? 1 : 0}
|
||||
actions={job ? [{ label: "Stop", onClick: stop, danger: true }] : undefined}
|
||||
backward={{
|
||||
label: "■ Stop",
|
||||
onClick: stop,
|
||||
danger: true,
|
||||
disabled: !job,
|
||||
title: "Stop the running job",
|
||||
}}
|
||||
>
|
||||
{queueStatus && queueStatus.status !== "running" && (
|
||||
<div className="mb-2 text-xs text-gray-500 bg-white rounded border p-2">
|
||||
{queueStatus && queueStatus.status !== "running" && queueStatus.status !== "idle" && (
|
||||
<div className="mb-2 text-xs text-gray-500 bg-white rounded border p-2 tabular-nums">
|
||||
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
|
||||
{queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
||||
{queueStatus.status === "idle" && <>Idle</>}
|
||||
{queueStatus.status === "sleeping" && <>Next job in {sleepRemaining ?? queueStatus.seconds ?? 0}s</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job ? (
|
||||
<div className="rounded border bg-white p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(job.item_id) }}
|
||||
className="text-sm font-medium truncate flex-1 hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{job.name}
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
className="text-xs px-2 py-0.5 rounded border border-red-200 text-red-700 hover:bg-red-50 shrink-0"
|
||||
title="Stop job"
|
||||
className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700 shrink-0"
|
||||
>
|
||||
Stop
|
||||
■
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(job.item_id) }}
|
||||
className="text-sm font-medium truncate block hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{job.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="running">running</Badge>
|
||||
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import type { PipelineJobItem } from "~/shared/lib/types";
|
||||
import { ColumnShell } from "./ColumnShell";
|
||||
import { ColumnShell, type SortOption } from "./ColumnShell";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
import type { JobSort } from "./PipelinePage";
|
||||
|
||||
const QUEUE_SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "added_asc", label: "↓ Added" },
|
||||
{ value: "added_desc", label: "↑ Added" },
|
||||
{ value: "name_asc", label: "↓ Name" },
|
||||
{ value: "name_desc", label: "↑ Name" },
|
||||
];
|
||||
|
||||
interface QueueColumnProps {
|
||||
items: PipelineJobItem[];
|
||||
jellyfinUrl: string;
|
||||
autoProcessQueue: boolean;
|
||||
onToggleAutoProcessQueue: (enabled: boolean) => void;
|
||||
onMutate: () => void;
|
||||
sort: JobSort;
|
||||
onChangeSort: (next: JobSort) => void;
|
||||
}
|
||||
|
||||
export function QueueColumn({ items, jellyfinUrl, onMutate }: QueueColumnProps) {
|
||||
export function QueueColumn({
|
||||
items,
|
||||
autoProcessQueue,
|
||||
onToggleAutoProcessQueue,
|
||||
onMutate,
|
||||
sort,
|
||||
onChangeSort,
|
||||
}: QueueColumnProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [localEnabled, setLocalEnabled] = useState(autoProcessQueue);
|
||||
useEffect(() => {
|
||||
setLocalEnabled(autoProcessQueue);
|
||||
}, [autoProcessQueue]);
|
||||
const runAll = async () => {
|
||||
await api.post("/api/execute/start");
|
||||
onMutate();
|
||||
};
|
||||
const clear = async () => {
|
||||
if (!confirm(`Cancel all ${items.length} pending jobs?`)) return;
|
||||
const backToInbox = async () => {
|
||||
if (!confirm(`Cancel all ${items.length} pending jobs and send them back to the Inbox?`)) return;
|
||||
await api.post("/api/execute/clear");
|
||||
onMutate();
|
||||
};
|
||||
@@ -23,20 +47,57 @@ export function QueueColumn({ items, jellyfinUrl, onMutate }: QueueColumnProps)
|
||||
await api.post(`/api/review/${itemId}/unapprove`);
|
||||
onMutate();
|
||||
};
|
||||
const runSingle = async (jobId: number) => {
|
||||
await api.post(`/api/execute/job/${jobId}/run`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const actions =
|
||||
items.length > 0
|
||||
? [
|
||||
{ label: "Run all", onClick: runAll, primary: true },
|
||||
{ label: "Clear", onClick: clear },
|
||||
]
|
||||
: undefined;
|
||||
const backward = {
|
||||
label: "← Back to inbox",
|
||||
onClick: backToInbox,
|
||||
disabled: items.length === 0,
|
||||
title: "Cancel every pending job and send its plan back to the Inbox",
|
||||
};
|
||||
const forward = {
|
||||
label: "Process →",
|
||||
onClick: runAll,
|
||||
primary: true,
|
||||
disabled: items.length === 0,
|
||||
title: "Start processing all queued jobs",
|
||||
};
|
||||
|
||||
const subtitle = (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={localEnabled}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked;
|
||||
setLocalEnabled(next);
|
||||
onToggleAutoProcessQueue(next);
|
||||
}}
|
||||
/>
|
||||
<span>Auto-process Queue</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<ColumnShell title="Queued" count={items.length} actions={actions}>
|
||||
<ColumnShell
|
||||
title="Queued"
|
||||
count={items.length}
|
||||
subtitle={subtitle}
|
||||
backward={backward}
|
||||
forward={forward}
|
||||
sortOptions={QUEUE_SORT_OPTIONS}
|
||||
sortValue={sort}
|
||||
onSortChange={(v) => onChangeSort(v as JobSort)}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<PipelineCard key={item.id} item={item} jellyfinUrl={jellyfinUrl} onUnapprove={() => unapprove(item.item_id)} />
|
||||
{items.filter((i) => !search || i.name.toLowerCase().includes(search.toLowerCase())).map((item) => (
|
||||
<PipelineCard key={item.id} item={item} onUnapprove={() => unapprove(item.item_id)} onRun={() => runSingle(item.id!)} />
|
||||
))}
|
||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import type { PipelineReviewItem } from "~/shared/lib/types";
|
||||
import { ColumnShell } from "./ColumnShell";
|
||||
import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types";
|
||||
import { ColumnShell, type SortOption } from "./ColumnShell";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
import { SeriesCard } from "./SeriesCard";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export type ReviewSort = "class" | "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
|
||||
|
||||
export const REVIEW_SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "class", label: "Classification" },
|
||||
{ value: "scan_asc", label: "↓ Scan time" },
|
||||
{ value: "scan_desc", label: "↑ Scan time" },
|
||||
{ value: "name_asc", label: "↓ Name" },
|
||||
{ value: "name_desc", label: "↑ Name" },
|
||||
];
|
||||
|
||||
interface ReviewColumnProps {
|
||||
items: PipelineReviewItem[];
|
||||
total: number;
|
||||
jellyfinUrl: string;
|
||||
initialResponse: ReviewGroupsResponse;
|
||||
totalItems: number;
|
||||
readyCount: number;
|
||||
onMutate: () => void;
|
||||
sort: ReviewSort;
|
||||
onChangeSort: (next: ReviewSort) => void;
|
||||
}
|
||||
|
||||
interface SeriesGroup {
|
||||
name: string;
|
||||
key: string;
|
||||
jellyfinId: string | null;
|
||||
episodes: PipelineReviewItem[];
|
||||
}
|
||||
export function ReviewColumn({
|
||||
initialResponse,
|
||||
totalItems,
|
||||
readyCount,
|
||||
onMutate,
|
||||
sort,
|
||||
onChangeSort,
|
||||
}: ReviewColumnProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
|
||||
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||
const truncated = total > items.length;
|
||||
useEffect(() => {
|
||||
setGroups(initialResponse.groups);
|
||||
setHasMore(initialResponse.hasMore);
|
||||
}, [initialResponse]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const res = await api.get<ReviewGroupsResponse>(
|
||||
`/api/review/groups?bucket=review&offset=${groups.length}&limit=${PAGE_SIZE}&sort=${sort}`,
|
||||
);
|
||||
setGroups((prev) => [...prev, ...res.groups]);
|
||||
setHasMore(res.hasMore);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [groups.length, hasMore, loadingMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) loadMore();
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore]);
|
||||
|
||||
const skipAll = async () => {
|
||||
if (!confirm(`Skip all ${total} pending items? They won't be processed unless you unskip them.`)) return;
|
||||
if (!confirm(`Skip all ${totalItems} pending items? They won't be processed unless you unskip them.`)) return;
|
||||
await api.post("/api/review/skip-all");
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const autoApprove = async () => {
|
||||
const res = await api.post<{ ok: boolean; count: number }>("/api/review/auto-approve");
|
||||
const backToInbox = async () => {
|
||||
if (
|
||||
!confirm(`Move all ${totalItems} review items back to the Inbox? You'll need to run Process Inbox to re-sort them.`)
|
||||
)
|
||||
return;
|
||||
await api.post("/api/review/unsort-all");
|
||||
onMutate();
|
||||
if (res.count === 0) alert("No high-confidence items to auto-approve.");
|
||||
};
|
||||
|
||||
const approveAllReady = async () => {
|
||||
const res = await api.post<{ ok: boolean; count: number }>("/api/review/approve-ready");
|
||||
onMutate();
|
||||
if (res.count === 0) alert("No auto-approvable items found.");
|
||||
};
|
||||
|
||||
const approveItem = async (itemId: number) => {
|
||||
@@ -41,95 +100,93 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
||||
await api.post(`/api/review/${itemId}/skip`);
|
||||
onMutate();
|
||||
};
|
||||
const unsortItem = async (itemId: number) => {
|
||||
await api.post(`/api/review/${itemId}/unsort`);
|
||||
onMutate();
|
||||
};
|
||||
const approveBatch = async (itemIds: number[]) => {
|
||||
if (itemIds.length === 0) return;
|
||||
await api.post<{ ok: boolean; count: number }>("/api/review/approve-batch", { itemIds });
|
||||
onMutate();
|
||||
};
|
||||
|
||||
// Group by series (movies are standalone)
|
||||
const movies = items.filter((i) => i.type === "Movie");
|
||||
const seriesMap = new Map<string, SeriesGroup>();
|
||||
|
||||
for (const item of items.filter((i) => i.type === "Episode")) {
|
||||
const key = item.series_jellyfin_id ?? item.series_name ?? String(item.item_id);
|
||||
if (!seriesMap.has(key)) {
|
||||
seriesMap.set(key, { name: item.series_name ?? "", key, jellyfinId: item.series_jellyfin_id, episodes: [] });
|
||||
}
|
||||
seriesMap.get(key)!.episodes.push(item);
|
||||
}
|
||||
|
||||
// Interleave movies and series, sorted by confidence (high first)
|
||||
const allItems = [
|
||||
...movies.map((m) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })),
|
||||
...[...seriesMap.values()].map((s) => ({
|
||||
type: "series" as const,
|
||||
item: s,
|
||||
sortKey: s.episodes.every((e) => e.confidence === "high") ? 0 : 1,
|
||||
})),
|
||||
].sort((a, b) => a.sortKey - b.sortKey);
|
||||
|
||||
// Flatten each visible entry to its list of item_ids. "Approve up to here"
|
||||
// on index i approves everything in the union of idsByEntry[0..i-1] — one
|
||||
// id for a movie, N ids for a series (one per episode).
|
||||
const idsByEntry: number[][] = allItems.map((entry) =>
|
||||
entry.type === "movie" ? [entry.item.item_id] : entry.item.episodes.map((e) => e.item_id),
|
||||
const idsByGroup: number[][] = groups.map((g) =>
|
||||
g.kind === "movie" ? [g.item.item_id] : g.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id)),
|
||||
);
|
||||
const priorIds = (index: number): number[] => idsByEntry.slice(0, index).flat();
|
||||
const priorIds = (index: number): number[] => idsByGroup.slice(0, index).flat();
|
||||
|
||||
const backward = {
|
||||
label: "← Back to inbox",
|
||||
onClick: backToInbox,
|
||||
disabled: totalItems === 0,
|
||||
title: "Move everything back to the Inbox",
|
||||
};
|
||||
const skip = { label: "Skip all", onClick: skipAll, disabled: totalItems === 0 };
|
||||
const forward = {
|
||||
label: "Queue →",
|
||||
onClick: approveAllReady,
|
||||
primary: true,
|
||||
disabled: readyCount === 0,
|
||||
title: "Queue every auto-approvable item (no manual decision needed)",
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnShell
|
||||
title="Review"
|
||||
count={truncated ? `${items.length} of ${total}` : total}
|
||||
actions={
|
||||
total > 0
|
||||
? [
|
||||
{ label: "Auto Review", onClick: autoApprove, primary: true },
|
||||
{ label: "Skip all", onClick: skipAll },
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
count={totalItems}
|
||||
backward={backward}
|
||||
middle={skip}
|
||||
forward={forward}
|
||||
sortOptions={REVIEW_SORT_OPTIONS}
|
||||
sortValue={sort}
|
||||
onSortChange={(v) => onChangeSort(v as ReviewSort)}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{allItems.map((entry, index) => {
|
||||
// The button approves everything visually above this card. First
|
||||
// card has nothing before it → undefined suppresses the affordance.
|
||||
{groups.filter((g) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
if (g.kind === "movie") return g.item.name.toLowerCase().includes(q);
|
||||
return g.seriesName.toLowerCase().includes(q);
|
||||
}).map((group, index) => {
|
||||
const prior = index > 0 ? priorIds(index) : null;
|
||||
const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
|
||||
if (entry.type === "movie") {
|
||||
if (group.kind === "movie") {
|
||||
return (
|
||||
<PipelineCard
|
||||
key={entry.item.id}
|
||||
item={entry.item}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
key={group.item.id}
|
||||
item={group.item}
|
||||
onToggleStream={async (streamId, action) => {
|
||||
await api.patch(`/api/review/${entry.item.item_id}/stream/${streamId}`, { action });
|
||||
await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action });
|
||||
onMutate();
|
||||
}}
|
||||
onApprove={() => approveItem(entry.item.item_id)}
|
||||
onSkip={() => skipItem(entry.item.item_id)}
|
||||
onApprove={() => approveItem(group.item.item_id)}
|
||||
onSkip={() => skipItem(group.item.item_id)}
|
||||
onBackToInbox={() => unsortItem(group.item.item_id)}
|
||||
onApproveUpToHere={onApproveUpToHere}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeriesCard
|
||||
key={entry.item.key}
|
||||
seriesKey={entry.item.key}
|
||||
seriesName={entry.item.name}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
seriesJellyfinId={entry.item.jellyfinId}
|
||||
episodes={entry.item.episodes}
|
||||
key={group.seriesKey}
|
||||
seriesKey={group.seriesKey}
|
||||
seriesName={group.seriesName}
|
||||
seasons={group.seasons}
|
||||
episodeCount={group.episodeCount}
|
||||
readyCount={group.readyCount}
|
||||
originalLanguage={group.originalLanguage}
|
||||
onMutate={onMutate}
|
||||
onApproveUpToHere={onApproveUpToHere}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
{truncated && (
|
||||
<p className="text-xs text-gray-400 text-center py-3 border-t mt-2">
|
||||
Showing first {items.length} of {total}. Approve some to see the rest.
|
||||
</p>
|
||||
{groups.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-4 text-center text-xs text-gray-400">
|
||||
{loadingMore ? "Loading more…" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ColumnShell>
|
||||
|
||||
@@ -7,27 +7,43 @@ import { PipelineCard } from "./PipelineCard";
|
||||
interface SeriesCardProps {
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
jellyfinUrl: string;
|
||||
seriesJellyfinId: string | null;
|
||||
episodes: PipelineReviewItem[];
|
||||
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||
episodeCount: number;
|
||||
readyCount?: number;
|
||||
originalLanguage: string | null;
|
||||
onMutate: () => void;
|
||||
// Review-column affordance: approve every card visually above this
|
||||
// series in one round-trip. See ReviewColumn for the id computation.
|
||||
onApproveUpToHere?: () => void;
|
||||
// Inbox: process entire series (resolve language + classify → Review/Queue).
|
||||
onProcess?: () => void;
|
||||
}
|
||||
|
||||
export function SeriesCard({
|
||||
seriesKey,
|
||||
seriesName,
|
||||
jellyfinUrl,
|
||||
seriesJellyfinId,
|
||||
episodes,
|
||||
seasons,
|
||||
episodeCount,
|
||||
readyCount = 0,
|
||||
originalLanguage,
|
||||
onMutate,
|
||||
onApproveUpToHere,
|
||||
onProcess,
|
||||
}: SeriesCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
|
||||
const seriesLang = episodes[0]?.original_language ?? "";
|
||||
const multipleSeasons = seasons.length > 1;
|
||||
|
||||
const rescanSeries = async () => {
|
||||
setRescanning(true);
|
||||
try {
|
||||
await api.post("/api/review/rescan-series", { seriesKey });
|
||||
onMutate();
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setSeriesLanguage = async (lang: string) => {
|
||||
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
||||
@@ -39,44 +55,88 @@ export function SeriesCard({
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const highCount = episodes.filter((e) => e.confidence === "high").length;
|
||||
const lowCount = episodes.filter((e) => e.confidence === "low").length;
|
||||
|
||||
const jellyfinLink =
|
||||
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
|
||||
const approveSeason = async (season: number | null) => {
|
||||
if (season == null) return;
|
||||
await api.post(`/api/review/season/${encodeURIComponent(seriesKey)}/${season}/approve-all`);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group/series rounded-lg border bg-white overflow-hidden">
|
||||
{/* Title row */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
{/* Action row — icon buttons to save horizontal space. */}
|
||||
<div className="flex items-center gap-1 px-3 pt-3">
|
||||
<div className="flex-1" />
|
||||
{onApproveUpToHere && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApproveUpToHere();
|
||||
}}
|
||||
title="Approve every card above this one"
|
||||
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer shrink-0 opacity-0 group-hover/series:opacity-100 transition-opacity"
|
||||
>
|
||||
<span className="text-xs text-gray-400 shrink-0">{expanded ? "▼" : "▶"}</span>
|
||||
{jellyfinLink ? (
|
||||
<a
|
||||
href={jellyfinLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium truncate hover:text-blue-600 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
rescanSeries();
|
||||
}}
|
||||
disabled={rescanning}
|
||||
title="Rescan series"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 cursor-pointer shrink-0"
|
||||
>
|
||||
{seriesName}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm font-medium truncate">{seriesName}</p>
|
||||
↻
|
||||
</button>
|
||||
{onProcess && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProcess();
|
||||
}}
|
||||
title="Process series"
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer shrink-0"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
{!onProcess && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
approveSeries();
|
||||
}}
|
||||
title="Queue entire series"
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer shrink-0"
|
||||
>
|
||||
→→
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
{/* Title row */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 pt-2 pb-1 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 shrink-0">{expanded ? "▼" : "▶"}</span>
|
||||
<p className="text-sm font-medium truncate">{seriesName}</p>
|
||||
</div>
|
||||
|
||||
{/* Meta row: badges + language select */}
|
||||
<div className="flex items-center gap-2 px-3 pb-3 pt-1">
|
||||
<span className="text-xs text-gray-500 shrink-0">{episodes.length} eps</span>
|
||||
{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
|
||||
{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
|
||||
<span className="text-xs text-gray-500 shrink-0">{episodeCount} eps</span>
|
||||
{multipleSeasons && <span className="text-xs text-gray-500 shrink-0">· {seasons.length} seasons</span>}
|
||||
{readyCount > 0 && <span className="text-xs text-amber-700 shrink-0">⚡ {readyCount} auto</span>}
|
||||
<div className="flex-1" />
|
||||
<select
|
||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
|
||||
value={seriesLang}
|
||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0 min-w-0"
|
||||
value={originalLanguage ?? ""}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSeriesLanguage(e.target.value);
|
||||
@@ -89,36 +149,114 @@ export function SeriesCard({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{onApproveUpToHere && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApproveUpToHere();
|
||||
}}
|
||||
title="Approve every card listed above this one"
|
||||
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer whitespace-nowrap shrink-0 opacity-0 group-hover/series:opacity-100 transition-opacity"
|
||||
>
|
||||
↑ Approve above
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
approveSeries();
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
|
||||
>
|
||||
Approve all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t px-3 pb-3 space-y-2 pt-2">
|
||||
<div className="border-t">
|
||||
{multipleSeasons
|
||||
? seasons.map((s) => (
|
||||
<SeasonGroup
|
||||
key={s.season ?? "unknown"}
|
||||
season={s.season}
|
||||
episodes={s.episodes}
|
||||
seriesKey={seriesKey}
|
||||
onApproveSeason={() => approveSeason(s.season)}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
))
|
||||
: (seasons[0]?.episodes ?? []).map((ep) => <EpisodeRow key={ep.id} ep={ep} onMutate={onMutate} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonGroup({
|
||||
season,
|
||||
episodes,
|
||||
seriesKey,
|
||||
onApproveSeason,
|
||||
onMutate,
|
||||
}: {
|
||||
season: number | null;
|
||||
episodes: PipelineReviewItem[];
|
||||
seriesKey: string;
|
||||
onApproveSeason: () => void;
|
||||
onMutate: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
const readyCount = episodes.filter((e) => e.auto_class === "auto_heuristic").length;
|
||||
const label = season == null ? "No season" : `Season ${String(season).padStart(2, "0")}`;
|
||||
|
||||
const rescanSeason = async () => {
|
||||
setRescanning(true);
|
||||
try {
|
||||
await api.post("/api/review/rescan-series", {
|
||||
seriesKey,
|
||||
seasonNumber: season,
|
||||
});
|
||||
onMutate();
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t first:border-t-0">
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 shrink-0">{open ? "▼" : "▶"}</span>
|
||||
<span className="text-xs font-medium shrink-0">{label}</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">· {episodes.length} eps</span>
|
||||
{readyCount > 0 && <span className="text-xs text-amber-700 shrink-0">⚡ {readyCount} ready</span>}
|
||||
{season != null && (
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
rescanSeason();
|
||||
}}
|
||||
disabled={rescanning}
|
||||
title="Rescan season"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 cursor-pointer shrink-0"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApproveSeason();
|
||||
}}
|
||||
title="Queue entire season"
|
||||
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer shrink-0"
|
||||
>
|
||||
→→
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 space-y-2 pt-2">
|
||||
{episodes.map((ep) => (
|
||||
<EpisodeRow key={ep.id} ep={ep} onMutate={onMutate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EpisodeRow({ ep, onMutate }: { ep: PipelineReviewItem; onMutate: () => void }) {
|
||||
return (
|
||||
<div className="px-3 py-1">
|
||||
<PipelineCard
|
||||
key={ep.id}
|
||||
item={ep}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
onToggleStream={async (streamId, action) => {
|
||||
await api.patch(`/api/review/${ep.item_id}/stream/${streamId}`, { action });
|
||||
onMutate();
|
||||
@@ -131,10 +269,11 @@ export function SeriesCard({
|
||||
await api.post(`/api/review/${ep.item_id}/skip`);
|
||||
onMutate();
|
||||
}}
|
||||
onBackToInbox={async () => {
|
||||
await api.post(`/api/review/${ep.item_id}/unsort`);
|
||||
onMutate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
@@ -92,6 +92,11 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
onUpdate(d);
|
||||
};
|
||||
|
||||
const updateLanguage = async (streamId: number, language: string | null) => {
|
||||
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}/language`, { language });
|
||||
onUpdate(d);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
@@ -128,7 +133,6 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
const outputNum = outIdx.get(s.id);
|
||||
const lbl = effectiveLabel(s, dec);
|
||||
const origTitle = s.title;
|
||||
const lang = langName(s.language);
|
||||
const isEditable = plan?.status === "pending" && isAudio;
|
||||
const rowBg = action === "keep" ? "bg-green-50" : "bg-red-50";
|
||||
|
||||
@@ -140,9 +144,20 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? "—"}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{isAudio ? (
|
||||
isEditable ? (
|
||||
<LanguageSelect
|
||||
rawLanguage={s.language}
|
||||
customLanguage={dec?.custom_language ?? null}
|
||||
onCommit={(v) => updateLanguage(s.id, v)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{lang} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||
{langName(dec?.custom_language ?? s.language)}{" "}
|
||||
{(dec?.custom_language ?? s.language) && (
|
||||
<span className="text-gray-400 font-mono text-xs">({dec?.custom_language ?? s.language})</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
@@ -184,6 +199,53 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Per-stream language override. Shows the effective language (override if
|
||||
// set, otherwise the file's own tag) and lets the user pick any ISO code the
|
||||
// app knows about, including "—" to clear the override. Commits on change so
|
||||
// the backend can reanalyze and the table redraws with the new keep/remove
|
||||
// decision, target order, and track title. A tiny ✎ hint marks tracks whose
|
||||
// language has been user-overridden vs. inherited from the file.
|
||||
function LanguageSelect({
|
||||
rawLanguage,
|
||||
customLanguage,
|
||||
onCommit,
|
||||
}: {
|
||||
rawLanguage: string | null;
|
||||
customLanguage: string | null;
|
||||
onCommit: (v: string | null) => void;
|
||||
}) {
|
||||
const effective = customLanguage ?? rawLanguage ?? "";
|
||||
const overridden = !!customLanguage && customLanguage !== rawLanguage;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<select
|
||||
value={effective}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value || null;
|
||||
// Don't round-trip a "set it to the file's value" — that's the same
|
||||
// as clearing the override, and clearing is the form the backend
|
||||
// normalizes to via `null`.
|
||||
if (next === rawLanguage) onCommit(null);
|
||||
else onCommit(next);
|
||||
}}
|
||||
className="text-[0.79rem] py-0.5 px-1 rounded border border-transparent bg-transparent hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white outline-none"
|
||||
>
|
||||
<option value="">— Unknown —</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>
|
||||
{name} ({code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{overridden && (
|
||||
<span className="text-amber-600 text-[0.7rem]" title={`File says "${rawLanguage ?? "und"}" — you overrode it`}>
|
||||
✎
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [localVal, setLocalVal] = useState(value);
|
||||
useEffect(() => {
|
||||
@@ -316,11 +378,20 @@ function JobSection({ job, onMutate }: JobSectionProps) {
|
||||
|
||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface DeleteResponse {
|
||||
ok: boolean;
|
||||
file: { deleted: boolean; path: string; error: string | null };
|
||||
db: { deleted: boolean };
|
||||
refetch: { triggered: boolean; service: "radarr" | "sonarr" | null; error?: string };
|
||||
}
|
||||
|
||||
export function AudioDetailPage() {
|
||||
const { id } = useParams({ from: "/review/audio/$id" });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<DetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
@@ -361,13 +432,44 @@ export function AudioDetailPage() {
|
||||
const rescan = async () => {
|
||||
setRescanning(true);
|
||||
try {
|
||||
const d = await api.post<DetailData>(`/api/review/${id}/rescan`);
|
||||
setData(d);
|
||||
await api.post(`/api/review/${id}/rescan`);
|
||||
navigate({ to: "/review" });
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (refetch: boolean) => {
|
||||
if (!data) return;
|
||||
const service = data.item.type === "Movie" ? "Radarr" : "Sonarr";
|
||||
const action = refetch
|
||||
? `Delete the file and ask ${service} to find a replacement release?`
|
||||
: "Delete the file from disk? This removes it from netfelix-audio-fix and the filesystem.";
|
||||
if (!confirm(`${action}\n\n${data.item.file_path}`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await api.post<DeleteResponse>(`/api/review/${id}/delete`, { refetch });
|
||||
// Compose a compact result message so the user sees whether the
|
||||
// file, db, and (optional) refetch all succeeded. Refetch failures
|
||||
// are common (movie not in Radarr, no tmdb id, etc.) and we don't
|
||||
// want them to block navigating away from an item that's already
|
||||
// been cleaned up locally.
|
||||
const lines: string[] = [];
|
||||
lines.push(res.file.deleted ? "File deleted." : `File: ${res.file.error ?? "not removed"}`);
|
||||
if (refetch) {
|
||||
lines.push(
|
||||
res.refetch.triggered
|
||||
? `${service} queued a replacement search.`
|
||||
: `${service} refetch skipped: ${res.refetch.error ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
alert(lines.join("\n"));
|
||||
navigate({ to: "/" });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <Alert variant="error">Item not found.</Alert>;
|
||||
|
||||
@@ -378,7 +480,7 @@ export function AudioDetailPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">
|
||||
<Link to="/pipeline" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
|
||||
<Link to="/" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
|
||||
← Pipeline
|
||||
</Link>
|
||||
{item.name}
|
||||
@@ -488,16 +590,28 @@ export function AudioDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Refresh */}
|
||||
{/* Refresh + destructive actions */}
|
||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
||||
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
|
||||
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning || deleting}>
|
||||
{rescanning ? "↻ Refreshing…" : "↻ Refresh metadata"}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-[0.75rem]">
|
||||
{rescanning
|
||||
? "Triggering Jellyfin metadata probe and waiting for completion…"
|
||||
: "Triggers a metadata re-probe in Jellyfin, then re-fetches stream data"}
|
||||
{rescanning ? "Re-probing metadata and refreshing stream data…" : "Re-probes metadata and re-fetches stream data"}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{/*
|
||||
Escape hatch for files nothing in the audio pipeline can
|
||||
usefully fix — e.g. a release whose only audio track is
|
||||
commentary. "Delete" removes the file + our rows; "Delete &
|
||||
refetch" additionally asks Radarr/Sonarr to go find a
|
||||
replacement release.
|
||||
*/}
|
||||
<Button variant="danger" size="sm" onClick={() => deleteItem(false)} disabled={deleting || rescanning}>
|
||||
{deleting ? "Deleting…" : "🗑 Delete file"}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteItem(true)} disabled={deleting || rescanning}>
|
||||
{deleting ? "Deleting…" : `🗑 Delete & refetch via ${item.type === "Movie" ? "Radarr" : "Sonarr"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MqttBadge } from "~/shared/components/MqttBadge";
|
||||
import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { formatThousands } from "~/shared/lib/utils";
|
||||
|
||||
interface ScanStatus {
|
||||
running: boolean;
|
||||
progress: { scanned: number; total: number; errors: number };
|
||||
recentItems: { name: string; type: string; scan_status: string; file_path: string }[];
|
||||
scanLimit: number | null;
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalItems: number;
|
||||
scanned: number;
|
||||
needsAction: number;
|
||||
approved: number;
|
||||
done: number;
|
||||
errors: number;
|
||||
noChange: number;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
stats: DashboardStats;
|
||||
scanRunning: boolean;
|
||||
setupComplete: boolean;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
|
||||
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? "text-red-600" : ""}`}>
|
||||
{formatThousands(value)}
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface LogEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
// Mutable buffer for SSE data — flushed to React state on an interval
|
||||
interface SseBuf {
|
||||
scanned: number;
|
||||
total: number;
|
||||
errors: number;
|
||||
currentItem: string;
|
||||
newLogs: LogEntry[];
|
||||
dirty: boolean;
|
||||
complete: { scanned?: number; errors?: number } | null;
|
||||
lost: boolean;
|
||||
}
|
||||
|
||||
function freshBuf(): SseBuf {
|
||||
return { scanned: 0, total: 0, errors: 0, currentItem: "", newLogs: [], dirty: false, complete: null, lost: false };
|
||||
}
|
||||
|
||||
const FLUSH_MS = 200;
|
||||
|
||||
export function ScanPage() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [configChecked, setConfigChecked] = useState(false);
|
||||
const [limit, setLimit] = useState("");
|
||||
const [log, setLog] = useState<LogEntry[]>([]);
|
||||
const [statusLabel, setStatusLabel] = useState("");
|
||||
const [scanComplete, setScanComplete] = useState(false);
|
||||
const [currentItem, setCurrentItem] = useState("");
|
||||
const [progressScanned, setProgressScanned] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
const [errors, setErrors] = useState(0);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const bufRef = useRef<SseBuf>(freshBuf());
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Pull dashboard stats + redirect-on-unconfigured. Re-fetch on scan completion
|
||||
// so the counts above the scan controls reflect the new totals.
|
||||
const loadStats = useCallback(() => {
|
||||
api
|
||||
.get<DashboardData>("/api/dashboard")
|
||||
.then((d) => {
|
||||
setStats(d.stats);
|
||||
if (!configChecked) {
|
||||
setConfigChecked(true);
|
||||
if (!d.setupComplete) navigate({ to: "/settings" });
|
||||
}
|
||||
})
|
||||
.catch(() => setConfigChecked(true));
|
||||
}, [navigate, configChecked]);
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// Stop the periodic flush interval. Inlined into flush() to avoid a
|
||||
// circular useCallback dep (flush → stopFlushing → flush) that tripped
|
||||
// TDZ in prod builds: "can't access lexical declaration 'o' before initialization".
|
||||
const clearFlushTimer = () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Flush buffered SSE data to React state
|
||||
const flush = useCallback(() => {
|
||||
const b = bufRef.current;
|
||||
if (!b.dirty && !b.complete && !b.lost) return;
|
||||
|
||||
if (b.dirty) {
|
||||
setProgressScanned(b.scanned);
|
||||
setProgressTotal(b.total);
|
||||
setErrors(b.errors);
|
||||
setCurrentItem(b.currentItem);
|
||||
if (b.newLogs.length > 0) {
|
||||
const batch = b.newLogs.splice(0);
|
||||
setLog((prev) => [...batch.reverse(), ...prev].slice(0, 100));
|
||||
}
|
||||
b.dirty = false;
|
||||
}
|
||||
|
||||
if (b.complete) {
|
||||
const d = b.complete;
|
||||
b.complete = null;
|
||||
setStatusLabel(`Scan complete — ${d.scanned ?? "?"} items, ${d.errors ?? 0} errors`);
|
||||
setScanComplete(true);
|
||||
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
clearFlushTimer();
|
||||
loadStats(); // refresh the totals above the controls
|
||||
}
|
||||
|
||||
if (b.lost) {
|
||||
b.lost = false;
|
||||
setStatusLabel("Scan connection lost — refresh to see current status");
|
||||
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
clearFlushTimer();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startFlushing = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
timerRef.current = setInterval(flush, FLUSH_MS);
|
||||
}, [flush]);
|
||||
|
||||
const stopFlushing = useCallback(() => {
|
||||
if (!timerRef.current) return;
|
||||
clearFlushTimer();
|
||||
flush(); // final flush
|
||||
}, [flush]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const s = await api.get<ScanStatus>("/api/scan");
|
||||
setStatus(s);
|
||||
setProgressScanned(s.progress.scanned);
|
||||
setProgressTotal(s.progress.total);
|
||||
setErrors(s.progress.errors);
|
||||
setStatusLabel(s.running ? "Scan in progress…" : "Scan idle");
|
||||
if (s.scanLimit != null) setLimit(String(s.scanLimit));
|
||||
setLog(s.recentItems.map((i) => ({ name: i.name, type: i.type, status: i.scan_status, file: i.file_path })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const connectSse = useCallback(() => {
|
||||
esRef.current?.close();
|
||||
const buf = bufRef.current;
|
||||
const es = new EventSource("/api/scan/events");
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener("progress", (e) => {
|
||||
const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
|
||||
buf.scanned = d.scanned;
|
||||
buf.total = d.total;
|
||||
buf.errors = d.errors;
|
||||
buf.currentItem = d.current_item ?? "";
|
||||
buf.dirty = true;
|
||||
});
|
||||
|
||||
es.addEventListener("log", (e) => {
|
||||
const d = JSON.parse(e.data) as LogEntry;
|
||||
buf.newLogs.push(d);
|
||||
buf.dirty = true;
|
||||
});
|
||||
|
||||
es.addEventListener("complete", (e) => {
|
||||
const d = JSON.parse(e.data || "{}") as { scanned?: number; errors?: number };
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
buf.complete = d;
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
buf.lost = true;
|
||||
});
|
||||
|
||||
startFlushing();
|
||||
return es;
|
||||
}, [startFlushing]);
|
||||
|
||||
// Reconnect SSE on page load if scan is already running (skip if already connected)
|
||||
useEffect(() => {
|
||||
if (!status?.running || esRef.current) return;
|
||||
connectSse();
|
||||
return () => {
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
stopFlushing();
|
||||
};
|
||||
}, [status?.running, connectSse, stopFlushing]);
|
||||
|
||||
const startScan = async () => {
|
||||
setLog([]);
|
||||
setProgressScanned(0);
|
||||
setProgressTotal(0);
|
||||
setErrors(0);
|
||||
setCurrentItem("");
|
||||
setStatusLabel("Scan in progress…");
|
||||
setScanComplete(false);
|
||||
setStatus((prev) => (prev ? { ...prev, running: true } : prev));
|
||||
bufRef.current = freshBuf();
|
||||
|
||||
// Connect SSE before starting the scan so no events are missed
|
||||
connectSse();
|
||||
|
||||
const limitNum = limit ? Number(limit) : undefined;
|
||||
await api.post("/api/scan/start", limitNum !== undefined ? { limit: limitNum } : {});
|
||||
};
|
||||
|
||||
const stopScan = async () => {
|
||||
await api.post("/api/scan/stop", {});
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
stopFlushing();
|
||||
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
setStatusLabel("Scan stopped");
|
||||
};
|
||||
|
||||
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
|
||||
const running = status?.running ?? false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Scan</h1>
|
||||
<MqttBadge />
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2.5 mb-5">
|
||||
<StatCard label="Total items" value={stats.totalItems} />
|
||||
<StatCard label="Scanned" value={stats.scanned} />
|
||||
<StatCard label="Needs action" value={stats.needsAction} />
|
||||
<StatCard label="No change needed" value={stats.noChange} />
|
||||
<StatCard label="Approved / queued" value={stats.approved} />
|
||||
<StatCard label="Done" value={stats.done} />
|
||||
{stats.errors > 0 && <StatCard label="Errors" value={stats.errors} danger />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.scanned === 0 && (
|
||||
<Alert variant="info" className="mb-5">
|
||||
Library not scanned yet. Click <strong>Start Scan</strong> below to begin.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||
<span className="text-sm font-medium">{statusLabel || (running ? "Scan in progress…" : "Scan idle")}</span>
|
||||
{scanComplete && (
|
||||
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
|
||||
Review in Pipeline →
|
||||
</Link>
|
||||
)}
|
||||
{running ? (
|
||||
<Button variant="secondary" size="sm" onClick={stopScan}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs m-0">
|
||||
Limit
|
||||
<input
|
||||
type="number"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
placeholder="all"
|
||||
min="1"
|
||||
className="border border-gray-300 rounded px-1.5 py-0.5 text-xs w-16"
|
||||
/>
|
||||
items
|
||||
</label>
|
||||
<Button size="sm" onClick={startScan}>
|
||||
Start Scan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
|
||||
</div>
|
||||
|
||||
{(running || progressScanned > 0) && (
|
||||
<>
|
||||
{progressTotal > 0 && (
|
||||
<div className="bg-gray-200 rounded-full h-1.5 overflow-hidden my-2">
|
||||
<div className="h-full bg-blue-600 rounded-full transition-all duration-300" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs">
|
||||
<span>
|
||||
{progressScanned}
|
||||
{progressTotal > 0 ? ` / ${progressTotal}` : ""} scanned
|
||||
</span>
|
||||
{currentItem && <span className="truncate max-w-xs text-gray-400">{currentItem}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log */}
|
||||
<h3 className="font-semibold text-sm mb-2">Recent items</h3>
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{["Type", "File", "Status"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log.map((item, i) => {
|
||||
const fileName = item.file ? (item.file.split("/").pop() ?? item.name) : item.name;
|
||||
return (
|
||||
<tr key={i} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">{item.type}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100" title={item.file ?? item.name}>
|
||||
{fileName}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<Badge variant={item.status as "error" | "done" | "pending"}>{item.status}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Input } from "~/shared/components/ui/input";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface MqttStatus {
|
||||
status: "connected" | "disconnected" | "error" | "not_configured";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
brokerConnected: boolean;
|
||||
jellyfinTriggered: boolean;
|
||||
receivedMessage: boolean;
|
||||
expectedItemId?: string;
|
||||
itemName?: string;
|
||||
samplePayload?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WebhookPluginInfo {
|
||||
ok: boolean;
|
||||
installed?: boolean;
|
||||
plugin?: { Name?: string; Version?: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const HANDLEBARS_TEMPLATE = `{
|
||||
"event": "{{NotificationType}}",
|
||||
"itemId": "{{ItemId}}",
|
||||
"itemType": "{{ItemType}}"
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Pull host / port / TLS out of the mqtt_url the user saved. The Jellyfin
|
||||
* plugin's MQTT destination asks for host and port as separate fields
|
||||
* plus an explicit TLS checkbox, whereas we store a single URL.
|
||||
*/
|
||||
function parseBroker(raw: string): { host: string; port: string; useTls: boolean } {
|
||||
if (!raw) return { host: "", port: "1883", useTls: false };
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
const useTls = u.protocol === "mqtts:" || u.protocol === "wss:";
|
||||
const port = u.port || (useTls ? "8883" : "1883");
|
||||
return { host: u.hostname, port, useTls };
|
||||
} catch {
|
||||
return { host: "", port: "1883", useTls: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
// Secure-context fast path
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
/* fall through to legacy */
|
||||
}
|
||||
}
|
||||
// HTTP LAN fallback: temporary textarea + execCommand.
|
||||
try {
|
||||
const el = document.createElement("textarea");
|
||||
el.value = text;
|
||||
el.setAttribute("readonly", "");
|
||||
el.style.position = "fixed";
|
||||
el.style.top = "0";
|
||||
el.style.left = "0";
|
||||
el.style.opacity = "0";
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the plugin-setup checklist. `copyable=false` is for fields the
|
||||
* user selects from a dropdown / toggles (Status, Notification Type, Use TLS,
|
||||
* etc.) — copying their value doesn't help, so we just display it.
|
||||
*/
|
||||
function SetupValue({
|
||||
label,
|
||||
value,
|
||||
mono = true,
|
||||
copyable = true,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
copyable?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState<"ok" | "fail" | null>(null);
|
||||
const copy = async () => {
|
||||
const ok = await copyText(value);
|
||||
setCopied(ok ? "ok" : "fail");
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className="text-xs text-gray-500 w-28 flex-shrink-0 pt-1.5">{label}</div>
|
||||
<pre className={`flex-1 text-xs bg-gray-50 border rounded px-2 py-1.5 overflow-x-auto ${mono ? "font-mono" : ""}`}>
|
||||
{value}
|
||||
</pre>
|
||||
{copyable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 flex-shrink-0"
|
||||
>
|
||||
{copied === "ok" ? "Copied" : copied === "fail" ? "Select & ⌘C" : "Copy"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px] text-gray-400 italic w-[72px] flex-shrink-0 pt-1.5 text-center">select</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; locked: Set<string> }) {
|
||||
const [enabled, setEnabled] = useState(cfg.mqtt_enabled === "1");
|
||||
const [url, setUrl] = useState(cfg.mqtt_url ?? "");
|
||||
const [topic, setTopic] = useState(cfg.mqtt_topic || "jellyfin/events");
|
||||
const [username, setUsername] = useState(cfg.mqtt_username ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedMsg, setSavedMsg] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [status, setStatus] = useState<MqttStatus>({ status: "not_configured", error: null });
|
||||
const [plugin, setPlugin] = useState<WebhookPluginInfo | null>(null);
|
||||
|
||||
const allLocked =
|
||||
locked.has("mqtt_url") && locked.has("mqtt_topic") && locked.has("mqtt_username") && locked.has("mqtt_password");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const s = await api.get<MqttStatus>("/api/settings/mqtt/status");
|
||||
if (!cancelled) setStatus(s);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<WebhookPluginInfo>("/api/settings/jellyfin/webhook-plugin")
|
||||
.then(setPlugin)
|
||||
.catch((err) => setPlugin({ ok: false, error: String(err) }));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setSavedMsg("");
|
||||
try {
|
||||
await api.post("/api/settings/mqtt", { enabled, url, topic, username, password });
|
||||
setSavedMsg(password ? "Saved." : "Saved (password unchanged).");
|
||||
setPassword("");
|
||||
setTimeout(() => setSavedMsg(""), 2500);
|
||||
} catch (e) {
|
||||
setSavedMsg(`Failed: ${String(e)}`);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const r = await api.post<TestResult>("/api/settings/mqtt/test", { url, topic, username, password });
|
||||
setTestResult(r);
|
||||
} catch (e) {
|
||||
setTestResult({
|
||||
brokerConnected: false,
|
||||
jellyfinTriggered: false,
|
||||
receivedMessage: false,
|
||||
error: String(e),
|
||||
});
|
||||
}
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
const statusColor =
|
||||
status.status === "connected"
|
||||
? "text-green-700 bg-green-50 border-green-300"
|
||||
: status.status === "error"
|
||||
? "text-red-700 bg-red-50 border-red-300"
|
||||
: status.status === "disconnected"
|
||||
? "text-amber-700 bg-amber-50 border-amber-300"
|
||||
: "text-gray-600 bg-gray-50 border-gray-300";
|
||||
|
||||
const broker = parseBroker(url);
|
||||
const useCredentials = !!(username || cfg.mqtt_password);
|
||||
const jellyfinBase = (cfg.jellyfin_url ?? "").replace(/\/$/, "") || "http://jellyfin.lan:8096";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-semibold text-sm">Jellyfin → MQTT webhook</div>
|
||||
{enabled && <span className={`text-xs px-2 py-0.5 rounded border ${statusColor}`}>MQTT: {status.status}</span>}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mb-3 mt-0">
|
||||
Two jobs over one channel: when Jellyfin's library picks up a brand-new or modified file, we analyze it immediately
|
||||
and drop it into the Review column — no manual Scan needed. And when we finish an ffmpeg job, Jellyfin's post-rescan
|
||||
event confirms the plan as done (or flips it back to pending if the on-disk streams don't actually match).
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
disabled={locked.has("mqtt_enabled")}
|
||||
/>
|
||||
Enable MQTT integration
|
||||
</label>
|
||||
|
||||
{!enabled && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
{savedMsg && <span className="text-sm text-green-700">✓ {savedMsg}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Broker URL
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="mqtt://192.168.1.10:1883"
|
||||
disabled={locked.has("mqtt_url")}
|
||||
className="mt-0.5 max-w-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||
Topic
|
||||
<Input
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="jellyfin/events"
|
||||
disabled={locked.has("mqtt_topic")}
|
||||
className="mt-0.5 max-w-xs"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3 max-w-md">
|
||||
<label className="block text-sm text-gray-700">
|
||||
Username
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="(optional)"
|
||||
disabled={locked.has("mqtt_username")}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700">
|
||||
Password
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={cfg.mqtt_password ? "(unchanged)" : "(optional)"}
|
||||
disabled={locked.has("mqtt_password")}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={save} disabled={saving || allLocked}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
{savedMsg && <span className="text-sm text-green-700">✓ {savedMsg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Plugin + setup instructions */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="text-sm font-medium mb-2">Jellyfin Webhook plugin setup</div>
|
||||
{plugin?.ok === false && <p className="text-xs text-red-600">Couldn't reach Jellyfin: {plugin.error}</p>}
|
||||
{plugin?.ok && !plugin.installed && (
|
||||
<p className="text-xs text-amber-700">
|
||||
⚠ The Webhook plugin is not installed on Jellyfin. Install it from{" "}
|
||||
<span className="font-mono">Dashboard → Plugins → Catalog → Webhook</span>, restart Jellyfin, then configure an
|
||||
MQTT destination with the values below.
|
||||
</p>
|
||||
)}
|
||||
{plugin?.ok && plugin.installed && (
|
||||
<p className="text-xs text-green-700 mb-2">
|
||||
✓ Plugin detected{plugin.plugin?.Version ? ` (v${plugin.plugin.Version})` : ""}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1 mb-3">
|
||||
In Jellyfin → <span className="font-mono">Dashboard → Plugins → Webhook</span>, set the plugin-wide
|
||||
<span className="font-mono"> Server Url</span> at the top of the page, then
|
||||
<span className="font-mono"> Add Generic Destination</span> and fill in:
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<div className="text-[11px] font-semibold text-gray-500 uppercase mb-1">Top of plugin page</div>
|
||||
<SetupValue label="Server Url" value={jellyfinBase} />
|
||||
|
||||
<div className="text-[11px] font-semibold text-gray-500 uppercase mt-3 mb-1">Generic destination</div>
|
||||
<SetupValue label="Webhook Name" value="Audio Fix" mono={false} />
|
||||
<SetupValue label="Webhook Url" value={url || "(your broker URL)"} />
|
||||
<SetupValue label="Status" value="Enabled" mono={false} copyable={false} />
|
||||
<SetupValue label="Notification Type" value="Item Added" mono={false} copyable={false} />
|
||||
<SetupValue label="Item Type" value="Movies, Episodes" mono={false} copyable={false} />
|
||||
|
||||
<div className="text-[11px] font-semibold text-gray-500 uppercase mt-3 mb-1">MQTT settings</div>
|
||||
<SetupValue label="MQTT Server" value={broker.host || "broker.lan"} />
|
||||
<SetupValue label="MQTT Port" value={broker.port} />
|
||||
<SetupValue label="Use TLS" value={broker.useTls ? "Enabled" : "Disabled"} mono={false} copyable={false} />
|
||||
<SetupValue
|
||||
label="Use Credentials"
|
||||
value={useCredentials ? "Enabled" : "Disabled"}
|
||||
mono={false}
|
||||
copyable={false}
|
||||
/>
|
||||
{useCredentials && (
|
||||
<>
|
||||
<SetupValue label="Username" value={username || "(same as above)"} />
|
||||
<SetupValue label="Password" value={cfg.mqtt_password ? "(same as above)" : ""} />
|
||||
</>
|
||||
)}
|
||||
<SetupValue label="Topic" value={topic || "jellyfin/events"} />
|
||||
<SetupValue label="Quality of Service" value="At most once (QoS 0)" mono={false} copyable={false} />
|
||||
|
||||
<div className="text-[11px] font-semibold text-gray-500 uppercase mt-3 mb-1">Template</div>
|
||||
<SetupValue label="Template" value={HANDLEBARS_TEMPLATE} />
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 mt-3">
|
||||
Notes: "Server Url" is Jellyfin's own base URL (used for rendered links). "Webhook Url" is required by the form
|
||||
even for MQTT destinations; paste your broker URL so validation passes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* End-to-end test */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="text-sm font-medium mb-2">End-to-end test</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Optional. Verifies the full loop — broker reachable, plugin publishing, our subscriber receiving. Not required
|
||||
for day-to-day operation.
|
||||
</p>
|
||||
<ol className="text-xs text-gray-600 list-decimal list-inside space-y-1 mb-3">
|
||||
<li>
|
||||
In the Jellyfin Webhook destination, temporarily add <span className="font-mono">Playback Start</span> to the
|
||||
Notification Types (a library <span className="font-mono">Item Added</span> on demand is hard; starting playback
|
||||
is reliable).
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Start test</strong> below — it opens a 30-second listening window on your topic.
|
||||
</li>
|
||||
<li>While the timer runs, hit play on any movie or episode in Jellyfin, then stop.</li>
|
||||
<li>
|
||||
Result appears below. Afterwards you can remove <span className="font-mono">Playback Start</span> from the
|
||||
webhook — only <span className="font-mono">Item Added</span> is needed in production.
|
||||
</li>
|
||||
</ol>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={runTest} disabled={testing || !url}>
|
||||
{testing ? "Listening (30s)…" : "Start test"}
|
||||
</Button>
|
||||
{testing && <span className="text-xs text-gray-500">play something in Jellyfin now</span>}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="mt-3 text-sm space-y-1">
|
||||
<div className={testResult.brokerConnected ? "text-green-700" : "text-red-700"}>
|
||||
{testResult.brokerConnected ? "✓" : "✗"} Broker reachable & credentials accepted
|
||||
</div>
|
||||
{testResult.brokerConnected && (
|
||||
<div className={testResult.receivedMessage ? "text-green-700" : "text-amber-700"}>
|
||||
{testResult.receivedMessage
|
||||
? `✓ Received webhook on topic "${topic || "jellyfin/events"}" — the loop is closed.`
|
||||
: "⚠ No webhook in 30s. Check that the destination is Enabled, Item Type covers Movies/Episodes, the MQTT server/topic match, the Jellyfin host can reach this broker, and Playback Start is in the Notification Types while testing."}
|
||||
</div>
|
||||
)}
|
||||
{testResult.samplePayload && (
|
||||
<pre className="text-xs bg-gray-50 border rounded px-2 py-1 font-mono overflow-x-auto">
|
||||
{testResult.samplePayload}
|
||||
</pre>
|
||||
)}
|
||||
{testResult.error && <div className="text-red-700">✗ {testResult.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Input } from "~/shared/components/ui/input";
|
||||
import { Select } from "~/shared/components/ui/select";
|
||||
import { TimeInput } from "~/shared/components/ui/time-input";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||
import { MqttSection } from "./MqttSection";
|
||||
|
||||
interface ScheduleWindow {
|
||||
enabled: boolean;
|
||||
@@ -24,7 +24,7 @@ interface SettingsData {
|
||||
envLocked: string[];
|
||||
}
|
||||
|
||||
/** Server response from /api/settings/{jellyfin,radarr,sonarr}. */
|
||||
/** Server response from /api/settings/{radarr,sonarr}. */
|
||||
interface SaveResult {
|
||||
ok: boolean; // connection test passed
|
||||
saved?: boolean; // values were persisted (true even when test failed)
|
||||
@@ -58,6 +58,224 @@ function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTML
|
||||
// (LockedInput) already signals when a value is env-controlled, the badge
|
||||
// was duplicate noise.
|
||||
|
||||
// ─── Secret input (password-masked with eye-icon reveal) ──────────────────────
|
||||
|
||||
function EyeIcon({ open }: { open: boolean }) {
|
||||
// GNOME-style eye / crossed-eye glyphs as inline SVG so they inherit
|
||||
// currentColor instead of fighting emoji rendering across OSes.
|
||||
if (open) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for API keys / passwords. Shows "***" masked when the server returns
|
||||
* a secret value (the raw key never reaches this component by default). Eye
|
||||
* icon fetches the real value via /api/settings/reveal and shows it. Users
|
||||
* can also type a new value directly — any edit clears the masked state.
|
||||
*/
|
||||
function SecretInput({
|
||||
configKey,
|
||||
locked,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: {
|
||||
configKey: string;
|
||||
locked: boolean;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const isMasked = value === "***";
|
||||
|
||||
const toggle = async () => {
|
||||
if (revealed) {
|
||||
setRevealed(false);
|
||||
return;
|
||||
}
|
||||
if (isMasked) {
|
||||
try {
|
||||
const res = await api.get<{ value: string }>(`/api/settings/reveal?key=${encodeURIComponent(configKey)}`);
|
||||
onChange(res.value);
|
||||
} catch {
|
||||
/* ignore — keep masked */
|
||||
}
|
||||
}
|
||||
setRevealed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className ?? ""}`}>
|
||||
<Input
|
||||
type={revealed ? "text" : "password"}
|
||||
value={value}
|
||||
disabled={locked}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pr-9"
|
||||
/>
|
||||
{locked ? (
|
||||
<span
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-[0.9rem] opacity-40 pointer-events-none select-none"
|
||||
title="Set via environment variable — edit your .env file to change this value"
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-700 focus:outline-none focus-visible:text-gray-700"
|
||||
title={revealed ? "Hide" : "Reveal"}
|
||||
aria-label={revealed ? "Hide secret" : "Reveal secret"}
|
||||
>
|
||||
<EyeIcon open={revealed} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── About section ────────────────────────────────────────────────────────────
|
||||
|
||||
function AboutSection() {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div className="font-semibold text-sm mb-2">About netfelix</div>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
For people who watch movies in their original language. Default behavior: strip every audio track except the
|
||||
original language; remove embedded subtitles. Press play, hear the OG.
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 mb-2">While the file is being rewritten, three side jobs happen:</p>
|
||||
<ul className="text-sm text-gray-700 list-disc pl-5 space-y-1 mb-2">
|
||||
<li>
|
||||
Subtitles are extracted to sidecar files (<span className="font-mono text-xs">.lang.srt</span>) so a subtitle
|
||||
manager can edit them without touching the video.
|
||||
</li>
|
||||
<li>
|
||||
Surviving audio tracks are renamed to <span className="font-mono text-xs"><ISO> - <codec></span> (e.g.{" "}
|
||||
<span className="font-mono text-xs">ENG - AC3</span>), stripping release-group junk and ad-style noise from the
|
||||
original titles.
|
||||
</li>
|
||||
<li>After every change, Sonarr/Radarr is asked to rename the file according to its naming convention.</li>
|
||||
</ul>
|
||||
<p className="text-sm text-gray-700">
|
||||
To keep dubs alongside the original language, add languages under <em>Audio Languages</em>. Order determines stream
|
||||
priority in the output.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Paths section ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PathInfo {
|
||||
prefix: string;
|
||||
itemCount: number;
|
||||
accessible: boolean;
|
||||
}
|
||||
|
||||
function PathsSection() {
|
||||
const [paths, setPaths] = useState<PathInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.get<{ paths: PathInfo[] }>("/api/paths")
|
||||
.then((d) => setPaths(d.paths))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const broken = paths.filter((p) => !p.accessible).length;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Paths"
|
||||
subtitle="Library roots derived from your scanned media. Each path must be reachable from the netfelix container, mounted at the same path Jellyfin reports."
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3 text-sm">
|
||||
{paths.length === 0 && !loading && <span className="text-gray-500">No items scanned yet.</span>}
|
||||
{paths.length === 0 && loading && <span className="text-gray-400">Checking…</span>}
|
||||
{paths.length > 0 && broken === 0 && <span>All {paths.length} paths accessible.</span>}
|
||||
{broken > 0 && (
|
||||
<span className="text-red-700">
|
||||
{broken} path{broken !== 1 ? "s" : ""} not mounted.
|
||||
</span>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? "Checking…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
{paths.length > 0 && (
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-[0.7rem] text-gray-500 uppercase tracking-wide">
|
||||
<th className="py-1.5 pr-3">Path</th>
|
||||
<th className="py-1.5 pr-3 text-right">Items</th>
|
||||
<th className="py-1.5">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paths.map((p) => (
|
||||
<tr key={p.prefix} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-3 font-mono text-xs">{p.prefix}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{p.itemCount}</td>
|
||||
<td className="py-1.5">
|
||||
{p.accessible ? <Badge variant="keep">Accessible</Badge> : <Badge variant="error">Not mounted</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({
|
||||
@@ -227,17 +445,18 @@ function ConnSection({
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={urlPlaceholder}
|
||||
className="mt-0.5 max-w-sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||
API Key
|
||||
<LockedInput
|
||||
<SecretInput
|
||||
configKey={apiKeyProp}
|
||||
locked={locked.has(apiKeyProp)}
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onChange={setKey}
|
||||
placeholder="your-api-key"
|
||||
className="mt-0.5 max-w-xs"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
@@ -312,7 +531,7 @@ function ScheduleSection() {
|
||||
return (
|
||||
<SectionCard
|
||||
title="Schedule"
|
||||
subtitle="Restrict when the app is allowed to scan Jellyfin and process files. Useful when this container runs as an always-on service but you only want it to do real work at night."
|
||||
subtitle="Restrict when the app is allowed to scan and process files. Useful when this container runs as an always-on service but you only want it to do real work at night."
|
||||
>
|
||||
<WindowEditor label="Scan window" window={cfg.scan} onChange={(scan) => setCfg({ ...cfg, scan })} />
|
||||
<WindowEditor label="Processing window" window={cfg.process} onChange={(process) => setCfg({ ...cfg, process })} />
|
||||
@@ -374,8 +593,6 @@ export function SettingsPage() {
|
||||
|
||||
const { config: cfg, envLocked: envLockedArr } = data;
|
||||
const locked = new Set(envLockedArr);
|
||||
const saveJellyfin = (url: string, apiKey: string) =>
|
||||
api.post<SaveResult>("/api/settings/jellyfin", { url, api_key: apiKey });
|
||||
const saveRadarr = (url: string, apiKey: string) =>
|
||||
api.post<SaveResult>("/api/settings/radarr", { url, api_key: apiKey });
|
||||
const saveSonarr = (url: string, apiKey: string) =>
|
||||
@@ -401,7 +618,7 @@ export function SettingsPage() {
|
||||
const factoryReset = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Reset to first-run state? This wipes EVERYTHING — scan data, settings, languages, schedule, Jellyfin/Radarr/Sonarr credentials. You'll land back on the setup wizard. This cannot be undone.",
|
||||
"Reset to first-run state? This wipes EVERYTHING — scan data, settings, languages, schedule, Radarr/Sonarr credentials. You'll land back on the setup wizard. This cannot be undone.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -418,55 +635,11 @@ export function SettingsPage() {
|
||||
<h1 className="text-xl font-bold m-0">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Jellyfin (with nested MQTT webhook config) */}
|
||||
<ConnSection
|
||||
title="Jellyfin"
|
||||
urlKey="jellyfin_url"
|
||||
apiKey="jellyfin_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8096"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveJellyfin}
|
||||
>
|
||||
<MqttSection cfg={cfg} locked={locked} />
|
||||
</ConnSection>
|
||||
<AboutSection />
|
||||
|
||||
{/* Radarr */}
|
||||
<ConnSection
|
||||
title={
|
||||
<>
|
||||
Radarr <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Provides accurate original-language data for movies."
|
||||
urlKey="radarr_url"
|
||||
apiKey="radarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:7878"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveRadarr}
|
||||
/>
|
||||
|
||||
{/* Sonarr */}
|
||||
<ConnSection
|
||||
title={
|
||||
<>
|
||||
Sonarr <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Provides original-language data for TV series."
|
||||
urlKey="sonarr_url"
|
||||
apiKey="sonarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8989"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveSonarr}
|
||||
/>
|
||||
|
||||
{/* Schedule */}
|
||||
<ScheduleSection />
|
||||
|
||||
{/* Audio languages */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
|
||||
{/* Left column: Languages, Schedule, Paths */}
|
||||
<div>
|
||||
<SectionCard
|
||||
title="Audio Languages"
|
||||
subtitle="Additional audio languages to keep alongside the original language. Order determines stream priority in the output file. The original language is always kept first."
|
||||
@@ -480,8 +653,39 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="border border-red-400 rounded-lg p-4 mb-4">
|
||||
<ScheduleSection />
|
||||
|
||||
<PathsSection />
|
||||
</div>
|
||||
|
||||
{/* Right column: Radarr, Sonarr */}
|
||||
<div>
|
||||
<ConnSection
|
||||
title="Radarr"
|
||||
subtitle="Provides accurate original-language data for movies. Required for automatic classification."
|
||||
urlKey="radarr_url"
|
||||
apiKey="radarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:7878"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveRadarr}
|
||||
/>
|
||||
|
||||
<ConnSection
|
||||
title="Sonarr"
|
||||
subtitle="Provides original-language data for TV series. Required for automatic classification."
|
||||
urlKey="sonarr_url"
|
||||
apiKey="sonarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8989"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveSonarr}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone — full width */}
|
||||
<div className="border border-red-400 rounded-lg p-4 mt-4">
|
||||
<div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div>
|
||||
<p className="text-gray-500 text-sm mb-3">
|
||||
These actions are irreversible. Scan data can be regenerated by running a new scan.
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Select } from "~/shared/components/ui/select";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "~/shared/lib/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DetailData {
|
||||
item: MediaItem;
|
||||
subtitleStreams: MediaStream[];
|
||||
files: SubtitleFile[];
|
||||
plan: ReviewPlan | null;
|
||||
decisions: StreamDecision[];
|
||||
subs_extracted: number;
|
||||
}
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function fileName(filePath: string): string {
|
||||
return filePath.split("/").pop() ?? filePath;
|
||||
}
|
||||
|
||||
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
|
||||
if (dec?.custom_title) return dec.custom_title;
|
||||
if (!s.language) return "";
|
||||
const base = langName(s.language);
|
||||
if (s.is_forced) return `${base} (Forced)`;
|
||||
if (s.is_hearing_impaired) return `${base} (CC)`;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ─── Inline edit input ───────────────────────────────────────────────────────
|
||||
|
||||
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [localVal, setLocalVal] = useState(value);
|
||||
useEffect(() => {
|
||||
setLocalVal(value);
|
||||
}, [value]);
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={localVal}
|
||||
onChange={(e) => setLocalVal(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== value) onCommit(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
placeholder="—"
|
||||
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Container streams table ─────────────────────────────────────────────────
|
||||
|
||||
interface StreamTableProps {
|
||||
streams: MediaStream[];
|
||||
decisions: StreamDecision[];
|
||||
editable: boolean;
|
||||
onLanguageChange: (streamId: number, lang: string) => void;
|
||||
onTitleChange: (streamId: number, title: string) => void;
|
||||
}
|
||||
|
||||
function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleChange }: StreamTableProps) {
|
||||
if (streams.length === 0) return <p className="text-gray-500 text-sm">No subtitle streams in container.</p>;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
{["#", "Codec", "Language", "Title / Info", "Flags", "Action"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{streams.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
const title = effectiveTitle(s, dec);
|
||||
const origTitle = s.title;
|
||||
|
||||
return (
|
||||
<tr key={s.id} className="bg-sky-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.stream_index}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? "—"}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{editable ? (
|
||||
<Select
|
||||
value={s.language ?? ""}
|
||||
onChange={(e) => onLanguageChange(s.id, e.target.value)}
|
||||
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
|
||||
>
|
||||
<option value="">— Unknown —</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>
|
||||
{name} ({code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<>
|
||||
{langName(s.language)}{" "}
|
||||
{s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{editable ? (
|
||||
<TitleInput value={title} onCommit={(v) => onTitleChange(s.id, v)} />
|
||||
) : (
|
||||
<span>{title || "—"}</span>
|
||||
)}
|
||||
{editable && origTitle && origTitle !== title && (
|
||||
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-flex gap-1">
|
||||
{s.is_default ? <Badge>default</Badge> : null}
|
||||
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
|
||||
↑ Extract
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Extracted files table ────────────────────────────────────────────────────
|
||||
|
||||
function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDelete: (fileId: number) => void }) {
|
||||
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{["File", "Language", "Codec", "Flags", "Size", ""].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((f) => (
|
||||
<tr key={f.id} className="hover:bg-gray-50">
|
||||
<td
|
||||
className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs max-w-[200px] sm:max-w-[360px] truncate"
|
||||
title={f.file_path}
|
||||
>
|
||||
{fileName(f.file_path)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{f.language ? langName(f.language) : "—"}{" "}
|
||||
{f.language ? <span className="text-gray-400 font-mono text-xs">({f.language})</span> : null}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{f.codec ?? "—"}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-flex gap-1">
|
||||
{f.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||
{f.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
|
||||
{f.file_size ? formatBytes(f.file_size) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-right">
|
||||
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function SubtitleDetailPage() {
|
||||
const { id } = useParams({ from: "/review/subtitles/$id" });
|
||||
const [data, setData] = useState<DetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api
|
||||
.get<DetailData>(`/api/subtitles/${id}`)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false)),
|
||||
[id],
|
||||
);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const changeLanguage = async (streamId: number, lang: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
|
||||
setData(d);
|
||||
};
|
||||
|
||||
const changeTitle = async (streamId: number, title: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/title`, { title });
|
||||
setData(d);
|
||||
};
|
||||
|
||||
const deleteFile = async (fileId: number) => {
|
||||
const resp = await api.delete<{ ok: boolean; files: SubtitleFile[] }>(`/api/subtitles/${id}/files/${fileId}`);
|
||||
if (data) setData({ ...data, files: resp.files });
|
||||
};
|
||||
|
||||
const rescan = async () => {
|
||||
setRescanning(true);
|
||||
try {
|
||||
const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`);
|
||||
setData(d);
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <Alert variant="error">Item not found.</Alert>;
|
||||
|
||||
const { item, subtitleStreams, files, decisions, subs_extracted } = data;
|
||||
const hasContainerSubs = subtitleStreams.length > 0;
|
||||
const editable = !subs_extracted && hasContainerSubs;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">
|
||||
<Link to="/review/subtitles" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
|
||||
← Subtitles
|
||||
</Link>
|
||||
{item.name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-3">
|
||||
{/* Meta */}
|
||||
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
||||
{[
|
||||
{ label: "Type", value: item.type },
|
||||
...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
|
||||
...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
|
||||
{ label: "Container", value: item.container ?? "—" },
|
||||
{ label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" },
|
||||
{
|
||||
label: "Status",
|
||||
value: <Badge variant={subs_extracted ? "done" : "pending"}>{subs_extracted ? "extracted" : "pending"}</Badge>,
|
||||
},
|
||||
].map((entry, i) => (
|
||||
<div key={i}>
|
||||
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
|
||||
<dd className="m-0 font-medium">{entry.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
|
||||
|
||||
{/* Stream table */}
|
||||
{hasContainerSubs ? (
|
||||
<StreamTable
|
||||
streams={subtitleStreams}
|
||||
decisions={decisions}
|
||||
editable={editable}
|
||||
onLanguageChange={changeLanguage}
|
||||
onTitleChange={changeTitle}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="warning" className="mb-4">
|
||||
No subtitle streams found in this container.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Extracted files */}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-sm font-semibold mb-2">Extracted Sidecar Files</h2>
|
||||
<ExtractedFilesTable files={files} onDelete={deleteFile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasContainerSubs && !subs_extracted && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
Embedded subtitles present — they'll be extracted to sidecar files when this item is approved in the pipeline.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{subs_extracted ? (
|
||||
<Alert variant="success" className="mt-4">
|
||||
Subtitles have been extracted to sidecar files.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Refresh */}
|
||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
||||
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-[0.75rem]">
|
||||
{rescanning
|
||||
? "Triggering Jellyfin metadata probe and waiting for completion…"
|
||||
: "Triggers a metadata re-probe in Jellyfin, then re-fetches stream data"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { langName } from "~/shared/lib/lang";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SummaryCategory {
|
||||
language: string | null;
|
||||
variant: "standard" | "forced" | "cc";
|
||||
streamCount: number;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
interface SummaryTitle {
|
||||
language: string | null;
|
||||
title: string | null;
|
||||
count: number;
|
||||
isCanonical: boolean;
|
||||
}
|
||||
|
||||
interface SummaryData {
|
||||
embeddedCount: number;
|
||||
categories: SummaryCategory[];
|
||||
titles: SummaryTitle[];
|
||||
keepLanguages: string[];
|
||||
}
|
||||
|
||||
// ─── Table helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const Th = ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
|
||||
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
||||
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
|
||||
);
|
||||
|
||||
// ─── Language summary table ───────────────────────────────────────────────────
|
||||
|
||||
function variantLabel(v: string): string {
|
||||
if (v === "forced") return "Forced";
|
||||
if (v === "cc") return "CC";
|
||||
return "Standard";
|
||||
}
|
||||
|
||||
function LanguageSummary({
|
||||
categories,
|
||||
keepLanguages,
|
||||
onDelete,
|
||||
}: {
|
||||
categories: SummaryCategory[];
|
||||
keepLanguages: string[];
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const keepSet = new Set(keepLanguages);
|
||||
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>(() => {
|
||||
const init: Record<string, boolean> = {};
|
||||
for (const cat of categories) {
|
||||
const key = `${cat.language ?? "__null__"}|${cat.variant}`;
|
||||
init[key] = cat.language !== null && keepSet.has(cat.language);
|
||||
}
|
||||
return init;
|
||||
});
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [result, setResult] = useState("");
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const toggle = (key: string) => setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const uncheckedCategories = categories.filter((cat) => {
|
||||
const key = `${cat.language ?? "__null__"}|${cat.variant}`;
|
||||
return !checked[key] && cat.fileCount > 0;
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (uncheckedCategories.length === 0) return;
|
||||
const toDelete = uncheckedCategories.map((cat) => ({
|
||||
language: cat.language,
|
||||
variant: cat.variant,
|
||||
}));
|
||||
setDeleting(true);
|
||||
setResult("");
|
||||
try {
|
||||
const r = await api.post<{ ok: boolean; deleted: number }>("/api/subtitles/batch-delete", { categories: toDelete });
|
||||
setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? "s" : ""}.`);
|
||||
onDelete();
|
||||
} catch (e) {
|
||||
setResult(`Error: ${e}`);
|
||||
}
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wide text-gray-500 mb-2">Language Summary</h2>
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Keep</Th>
|
||||
<Th>Language</Th>
|
||||
<Th>Variant</Th>
|
||||
<Th>Streams</Th>
|
||||
<Th>Files</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((cat) => {
|
||||
const key = `${cat.language ?? "__null__"}|${cat.variant}`;
|
||||
return (
|
||||
<tr key={key} className="hover:bg-gray-50">
|
||||
<Td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked[key] ?? false}
|
||||
onChange={() => toggle(key)}
|
||||
className="w-4 h-4 accent-blue-600 cursor-pointer"
|
||||
/>
|
||||
</Td>
|
||||
<Td>{langName(cat.language)}</Td>
|
||||
<Td>{variantLabel(cat.variant)}</Td>
|
||||
<Td className="font-mono text-xs">{cat.streamCount}</Td>
|
||||
<Td className="font-mono text-xs">{cat.fileCount}</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Button size="sm" variant="danger" onClick={handleDelete} disabled={deleting || uncheckedCategories.length === 0}>
|
||||
{deleting ? "Deleting..." : "Delete Unchecked Files"}
|
||||
</Button>
|
||||
{uncheckedCategories.length > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0)} file
|
||||
{uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0) !== 1 ? "s" : ""} will be removed
|
||||
</span>
|
||||
)}
|
||||
{result && <span className="text-sm text-gray-600">{result}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Title harmonization ──────────────────────────────────────────────────────
|
||||
|
||||
function TitleHarmonization({ titles, onNormalize }: { titles: SummaryTitle[]; onNormalize: () => void }) {
|
||||
const [normalizing, setNormalizing] = useState(false);
|
||||
const [result, setResult] = useState("");
|
||||
|
||||
const nonCanonical = titles.filter((t) => !t.isCanonical);
|
||||
if (nonCanonical.length === 0) return null;
|
||||
|
||||
const handleNormalizeAll = async () => {
|
||||
setNormalizing(true);
|
||||
setResult("");
|
||||
try {
|
||||
const r = await api.post<{ ok: boolean; normalized: number }>("/api/subtitles/normalize-titles");
|
||||
setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? "s" : ""}.`);
|
||||
onNormalize();
|
||||
} catch (e) {
|
||||
setResult(`Error: ${e}`);
|
||||
}
|
||||
setNormalizing(false);
|
||||
};
|
||||
|
||||
// Group titles by language for display
|
||||
const byLang = new Map<string | null, SummaryTitle[]>();
|
||||
for (const t of titles) {
|
||||
if (!byLang.has(t.language)) byLang.set(t.language, []);
|
||||
byLang.get(t.language)!.push(t);
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="mb-6">
|
||||
<summary className="text-sm font-bold uppercase tracking-wide text-gray-500 mb-2 cursor-pointer select-none">
|
||||
Title Harmonization{" "}
|
||||
<span className="text-xs font-normal normal-case text-amber-600">({nonCanonical.length} non-canonical)</span>
|
||||
</summary>
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0 mt-2">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Language</Th>
|
||||
<Th>Current Title</Th>
|
||||
<Th>Count</Th>
|
||||
<Th></Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from(byLang.entries()).flatMap(([lang, langTitles]) =>
|
||||
langTitles.map((t) => (
|
||||
<tr key={`${lang}|${t.title}`} className="hover:bg-gray-50">
|
||||
<Td>{langName(lang)}</Td>
|
||||
<Td>
|
||||
<span className={`font-mono text-xs ${t.isCanonical ? "text-gray-900" : "text-amber-700"}`}>
|
||||
{t.title ? `"${t.title}"` : "(none)"}
|
||||
</span>
|
||||
{t.isCanonical && <span className="ml-2 text-[0.68rem] text-gray-400">(canonical)</span>}
|
||||
</Td>
|
||||
<Td className="font-mono text-xs">{t.count}</Td>
|
||||
<Td>{!t.isCanonical && <span className="text-[0.72rem] text-gray-400">will normalize</span>}</Td>
|
||||
</tr>
|
||||
)),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Button size="sm" onClick={handleNormalizeAll} disabled={normalizing}>
|
||||
{normalizing ? "Normalizing..." : "Normalize All"}
|
||||
</Button>
|
||||
{result && <span className="text-sm text-gray-600">{result}</span>}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let summaryCache: SummaryData | null = null;
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SubtitleListPage() {
|
||||
const [summary, setSummary] = useState<SummaryData | null>(summaryCache);
|
||||
const [loading, setLoading] = useState(summaryCache === null);
|
||||
|
||||
const loadSummary = useCallback(() => {
|
||||
if (!summaryCache) setLoading(true);
|
||||
api
|
||||
.get<SummaryData>("/api/subtitles/summary")
|
||||
.then((d) => {
|
||||
summaryCache = d;
|
||||
setSummary(d);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
const refresh = () => {
|
||||
summaryCache = null;
|
||||
loadSummary();
|
||||
};
|
||||
|
||||
if (loading && !summary) return <div className="text-gray-400 py-8 text-center">Loading...</div>;
|
||||
if (!summary) return <div className="text-red-600">Failed to load subtitle summary.</div>;
|
||||
|
||||
const totalFiles = summary.categories.reduce((sum, c) => sum + c.fileCount, 0);
|
||||
const langCount = new Set(summary.categories.map((c) => c.language)).size;
|
||||
const hasFiles = totalFiles > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-4">Subtitle Manager</h1>
|
||||
|
||||
<div
|
||||
className={`rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap ${hasFiles ? "border border-gray-200" : "border border-gray-200"}`}
|
||||
>
|
||||
{hasFiles ? (
|
||||
<span className="text-sm font-medium">
|
||||
{totalFiles} extracted file{totalFiles !== 1 ? "s" : ""} across {langCount} language{langCount !== 1 ? "s" : ""} —
|
||||
select which to keep below
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">No extracted subtitle files yet. Extract subtitles first.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LanguageSummary categories={summary.categories} keepLanguages={summary.keepLanguages} onDelete={refresh} />
|
||||
|
||||
<TitleHarmonization titles={summary.titles} onNormalize={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,20 +63,12 @@ function RootLayout() {
|
||||
🎬 netfelix
|
||||
</Link>
|
||||
<VersionBadge />
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
<NavLink to="/" exact>
|
||||
Scan
|
||||
</NavLink>
|
||||
<NavLink to="/pipeline">Pipeline</NavLink>
|
||||
<NavLink to="/review/subtitles">Subtitles</NavLink>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<NavLink to="/paths">Paths</NavLink>
|
||||
<NavLink to="/settings">Settings</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="max-w-[1600px] mx-auto px-3 sm:px-5 pt-4 pb-12">
|
||||
<main className="px-3 sm:px-5 pt-4 pb-12">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ScanPage } from "~/features/scan/ScanPage";
|
||||
import { PipelinePage } from "~/features/pipeline/PipelinePage";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ScanPage,
|
||||
component: PipelinePage,
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PathsPage } from "~/features/paths/PathsPage";
|
||||
|
||||
export const Route = createFileRoute("/paths")({
|
||||
component: PathsPage,
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PipelinePage } from "~/features/pipeline/PipelinePage";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/pipeline")({
|
||||
component: PipelinePage,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/review/")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/pipeline" });
|
||||
throw redirect({ to: "/" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SubtitleDetailPage } from "~/features/subtitles/SubtitleDetailPage";
|
||||
|
||||
export const Route = createFileRoute("/review/subtitles/$id")({
|
||||
component: SubtitleDetailPage,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SubtitleListPage } from "~/features/subtitles/SubtitleListPage";
|
||||
|
||||
export const Route = createFileRoute("/review/subtitles/")({
|
||||
component: SubtitleListPage,
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface MqttStatusPayload {
|
||||
status: "connected" | "disconnected" | "error" | "not_configured";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const LABEL: Record<MqttStatusPayload["status"], string> = {
|
||||
connected: "MQTT: connected",
|
||||
disconnected: "MQTT: disconnected",
|
||||
error: "MQTT: error",
|
||||
not_configured: "MQTT: not configured",
|
||||
};
|
||||
|
||||
const STYLES: Record<MqttStatusPayload["status"], string> = {
|
||||
connected: "text-green-700 bg-green-50 border-green-300",
|
||||
disconnected: "text-amber-700 bg-amber-50 border-amber-300",
|
||||
error: "text-red-700 bg-red-50 border-red-300",
|
||||
not_configured: "text-gray-500 bg-gray-50 border-gray-300",
|
||||
};
|
||||
|
||||
export function MqttBadge() {
|
||||
const [state, setState] = useState<MqttStatusPayload>({ status: "not_configured", error: null });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const s = await api.get<MqttStatusPayload>("/api/settings/mqtt/status");
|
||||
if (!cancelled) setState(s);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span title={state.error ?? undefined} className={`text-xs px-2 py-0.5 rounded border ${STYLES[state.status]}`}>
|
||||
{LABEL[state.status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function Button({ variant = "primary", size = "default", className, ...pr
|
||||
"inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0",
|
||||
variant === "primary" && "bg-blue-600 text-white hover:bg-blue-700",
|
||||
variant === "secondary" && "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50",
|
||||
variant === "danger" && "bg-white text-red-600 border border-red-400 hover:bg-red-50",
|
||||
variant === "danger" && "bg-red-600 text-white hover:bg-red-700",
|
||||
size === "default" && "px-3 py-1.5 text-sm",
|
||||
size === "sm" && "px-2.5 py-1 text-xs",
|
||||
size === "xs" && "px-2 py-0.5 text-xs",
|
||||
|
||||
+53
-33
@@ -2,16 +2,16 @@
|
||||
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
type: "Movie" | "Episode";
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
series_jellyfin_id: string | null;
|
||||
series_key: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
year: number | null;
|
||||
file_path: string;
|
||||
file_size: number | null;
|
||||
duration_seconds: number | null;
|
||||
container: string | null;
|
||||
original_language: string | null;
|
||||
orig_lang_source: string | null;
|
||||
@@ -19,6 +19,8 @@ export interface MediaItem {
|
||||
imdb_id: string | null;
|
||||
tmdb_id: string | null;
|
||||
tvdb_id: string | null;
|
||||
container_title: string | null;
|
||||
container_comment: string | null;
|
||||
scan_status: string;
|
||||
last_scanned_at: string | null;
|
||||
}
|
||||
@@ -30,7 +32,6 @@ export interface MediaStream {
|
||||
type: string;
|
||||
codec: string | null;
|
||||
language: string | null;
|
||||
language_display: string | null;
|
||||
title: string | null;
|
||||
is_default: number;
|
||||
is_forced: number;
|
||||
@@ -39,6 +40,9 @@ export interface MediaStream {
|
||||
channel_layout: string | null;
|
||||
bit_rate: number | null;
|
||||
sample_rate: number | null;
|
||||
bit_depth: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewPlan {
|
||||
@@ -46,7 +50,8 @@ export interface ReviewPlan {
|
||||
item_id: number;
|
||||
status: string;
|
||||
is_noop: number;
|
||||
confidence: "high" | "low";
|
||||
auto_class: "auto" | "auto_heuristic" | "manual" | null;
|
||||
sorted: number;
|
||||
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||
job_type: "copy" | "transcode";
|
||||
subs_extracted: number;
|
||||
@@ -55,18 +60,9 @@ export interface ReviewPlan {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SubtitleFile {
|
||||
id: number;
|
||||
item_id: number;
|
||||
file_path: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
is_forced: number;
|
||||
is_hearing_impaired: number;
|
||||
file_size: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Per-stream language override. When set, both the analyzer and the
|
||||
* ffmpeg command builder read `custom_language` in preference to
|
||||
* `MediaStream.language`. Corrects "und"/mislabeled audio tracks. */
|
||||
export interface StreamDecision {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
@@ -74,6 +70,7 @@ export interface StreamDecision {
|
||||
action: "keep" | "remove";
|
||||
target_index: number | null;
|
||||
custom_title: string | null;
|
||||
custom_language: string | null;
|
||||
transcode_codec: string | null;
|
||||
}
|
||||
|
||||
@@ -99,14 +96,13 @@ export interface PipelineReviewItem {
|
||||
item_id: number;
|
||||
status: string;
|
||||
is_noop: number;
|
||||
confidence: "high" | "low";
|
||||
auto_class: "auto" | "auto_heuristic" | "manual" | null;
|
||||
apple_compat: ReviewPlan["apple_compat"];
|
||||
job_type: "copy" | "transcode";
|
||||
// media_item fields
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
series_jellyfin_id: string | null;
|
||||
jellyfin_id: string;
|
||||
series_key: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
type: "Movie" | "Episode";
|
||||
@@ -115,7 +111,7 @@ export interface PipelineReviewItem {
|
||||
orig_lang_source: string | null;
|
||||
file_path: string;
|
||||
// computed
|
||||
transcode_reasons: string[];
|
||||
reasons: string[];
|
||||
audio_streams: PipelineAudioStream[];
|
||||
}
|
||||
|
||||
@@ -129,14 +125,16 @@ export interface PipelineAudioStream {
|
||||
action: "keep" | "remove";
|
||||
}
|
||||
|
||||
/** Row in the Queued / Processing / Done columns: job joined with media_item + review_plan. */
|
||||
/** Row in the Queued / Processing / Done columns: job joined with media_item + review_plan.
|
||||
* Noop items (already in desired state) appear in Done without a job, so
|
||||
* job-specific fields (id, started_at, completed_at) are optional. */
|
||||
export interface PipelineJobItem {
|
||||
id: number;
|
||||
id?: number;
|
||||
item_id: number;
|
||||
status: Job["status"];
|
||||
job_type: "copy" | "transcode";
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
status: Job["status"] | "noop";
|
||||
job_type?: "copy" | "transcode";
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
type: "Movie" | "Episode";
|
||||
@@ -145,26 +143,48 @@ export interface PipelineJobItem {
|
||||
// read-only with a "Back to review" action. Optional because the
|
||||
// Processing / Done columns don't enrich (and don't need to).
|
||||
plan_id?: number;
|
||||
series_jellyfin_id?: string | null;
|
||||
jellyfin_id?: string;
|
||||
series_key?: string | null;
|
||||
season_number?: number | null;
|
||||
episode_number?: number | null;
|
||||
container?: string | null;
|
||||
original_language?: string | null;
|
||||
orig_lang_source?: string | null;
|
||||
file_path?: string;
|
||||
confidence?: "high" | "low";
|
||||
auto_class?: "auto" | "auto_heuristic" | "manual" | null;
|
||||
is_noop?: number;
|
||||
transcode_reasons?: string[];
|
||||
reasons?: string[];
|
||||
audio_streams?: PipelineAudioStream[];
|
||||
}
|
||||
|
||||
export interface PipelineData {
|
||||
review: PipelineReviewItem[];
|
||||
reviewTotal: number;
|
||||
inboxTotal: number;
|
||||
reviewItemsTotal: number;
|
||||
reviewReadyCount: number;
|
||||
autoProcessing: boolean;
|
||||
autoProcessQueue: boolean;
|
||||
queued: PipelineJobItem[];
|
||||
processing: PipelineJobItem[];
|
||||
done: PipelineJobItem[];
|
||||
doneCount: number;
|
||||
jellyfinUrl: string;
|
||||
}
|
||||
|
||||
// ─── Review groups (GET /api/review/groups) ──────────────────────────────────
|
||||
|
||||
export type ReviewGroup =
|
||||
| { kind: "movie"; item: PipelineReviewItem }
|
||||
| {
|
||||
kind: "series";
|
||||
seriesKey: string;
|
||||
seriesName: string;
|
||||
episodeCount: number;
|
||||
readyCount: number;
|
||||
originalLanguage: string | null;
|
||||
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||
};
|
||||
|
||||
export interface ReviewGroupsResponse {
|
||||
groups: ReviewGroup[];
|
||||
totalGroups: number;
|
||||
totalItems: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user