Resolve audit findings + split db.rs into db/ module

- P1: Fix cancel race in pipeline, fix VideoToolbox quality mapping
- P2: SSRF protection, batch cancel N+1, archived filter fixes,
  metadata persistence, reverse proxy hardening, reprobe logging
- TD: Remove AlchemistEvent legacy bridge, fix silent .ok() on DB
  writes, optimize sort-by-size query, split db.rs (3400 LOC) into
  8 focused submodules under src/db/
- UX: Add queue position display for queued jobs
- Docs: Update API docs, engine modes, library doctor, config ref
- Plans: Add plans.md for remaining open items (UX-2/3, FG-4, RG-2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:02:11 -04:00
parent 5ca33835f1
commit e50ca64e80
45 changed files with 4796 additions and 1083 deletions

View File

@@ -27,7 +27,17 @@
"Bash(cargo fmt:*)", "Bash(cargo fmt:*)",
"Bash(cargo test:*)", "Bash(cargo test:*)",
"Bash(just check:*)", "Bash(just check:*)",
"Bash(just test:*)" "Bash(just test:*)",
"Bash(find /Users/brooklyn/data/alchemist -name *.sql -o -name *migration*)",
"Bash(grep -l \"DROP\\\\|RENAME\\\\|DELETE FROM\" /Users/brooklyn/data/alchemist/migrations/*.sql)",
"Bash(just test-filter:*)",
"Bash(npx tsc:*)",
"Bash(find /Users/brooklyn/data/alchemist -type f -name *.rs)",
"Bash(ls -la /Users/brooklyn/data/alchemist/src/*.rs)",
"Bash(grep -rn \"from_alchemist_event\\\\|AlchemistEvent.*JobEvent\\\\|JobEvent.*AlchemistEvent\" /Users/brooklyn/data/alchemist/src/ --include=*.rs)",
"Bash(grep -l AlchemistEvent /Users/brooklyn/data/alchemist/src/*.rs /Users/brooklyn/data/alchemist/src/**/*.rs)",
"Bash(/tmp/audit_report.txt:*)",
"Read(//tmp/**)"
] ]
} }
} }

View File

@@ -44,6 +44,8 @@ just test-e2e-headed # E2e with browser visible
Integration tests require FFmpeg and FFprobe installed locally. Integration tests require FFmpeg and FFprobe installed locally.
Integration tests live in `tests/` — notably `integration_db_upgrade.rs` tests schema migrations against a v0.2.5 baseline database. Every migration must pass this.
### Database ### Database
```bash ```bash
just db-reset # Wipe dev database (keeps config) just db-reset # Wipe dev database (keeps config)
@@ -53,6 +55,10 @@ just db-shell # SQLite shell
## Architecture ## Architecture
### Clippy Strictness
CI enforces `-D clippy::unwrap_used` and `-D clippy::expect_used`. Use `?` propagation or explicit match — no `.unwrap()` or `.expect()` in production code paths.
### Rust Backend (`src/`) ### Rust Backend (`src/`)
The backend is structured around a central `AppState` (holding SQLite pool, config, broadcast channels) passed to Axum handlers: The backend is structured around a central `AppState` (holding SQLite pool, config, broadcast channels) passed to Axum handlers:
@@ -77,15 +83,32 @@ The backend is structured around a central `AppState` (holding SQLite pool, conf
- `pipeline.rs` — Orchestrates scan → analyze → plan → execute - `pipeline.rs` — Orchestrates scan → analyze → plan → execute
- `processor.rs` — Job queue controller (concurrency, pausing, draining) - `processor.rs` — Job queue controller (concurrency, pausing, draining)
- `ffmpeg/` — FFmpeg command builder and progress parser, with platform-specific encoder modules - `ffmpeg/` — FFmpeg command builder and progress parser, with platform-specific encoder modules
- **`orchestrator.rs`** — Spawns and monitors FFmpeg processes, streams progress back via channels - **`orchestrator.rs`** — Spawns and monitors FFmpeg processes, streams progress back via channels. Uses `std::sync::Mutex` (not tokio) intentionally — critical sections never cross `.await` boundaries.
- **`system/`** — Hardware detection (`hardware.rs`), file watcher (`watcher.rs`), library scanner (`scanner.rs`) - **`system/`** — Hardware detection (`hardware.rs`), file watcher (`watcher.rs`), library scanner (`scanner.rs`)
- **`scheduler.rs`** — Off-peak cron scheduling - **`scheduler.rs`** — Off-peak cron scheduling
- **`notifications.rs`** — Discord, Gotify, Webhook integrations - **`notifications.rs`** — Discord, Gotify, Webhook integrations
- **`wizard.rs`** — First-run setup flow - **`wizard.rs`** — First-run setup flow
#### Event Channel Architecture
Three typed broadcast channels in `AppState` (defined in `db.rs`):
- `jobs` (capacity 1000) — high-frequency: progress, state changes, decisions, logs
- `config` (capacity 50) — watch folder changes, settings updates
- `system` (capacity 100) — scan lifecycle, hardware state changes
`sse.rs` merges all three via `futures::stream::select_all`. SSE is capped at 50 concurrent connections (`MAX_SSE_CONNECTIONS`), enforced with a RAII guard that decrements on stream drop.
`AlchemistEvent` still exists as a legacy bridge; `JobEvent` is the canonical type — new code uses `JobEvent`/`ConfigEvent`/`SystemEvent`.
#### FFmpeg Command Builder
`FFmpegCommandBuilder<'a>` in `src/media/ffmpeg/mod.rs` uses lifetime references to avoid cloning input/output paths. `.with_hardware(Option<&HardwareInfo>)` injects hardware flags; `.build_args()` returns `Vec<String>` for unit testing without spawning a process. Each hardware platform is a submodule (amf, cpu, nvenc, qsv, vaapi, videotoolbox). `EncoderCapabilities` is detected once via live ffmpeg invocation and cached in `OnceLock`.
### Frontend (`web/src/`) ### Frontend (`web/src/`)
Astro pages with React islands. UI reflects backend state via Server-Sent Events (SSE) — avoid optimistic UI unless reconciled with backend truth. Astro pages (`web/src/pages/`) with React islands. UI reflects backend state via SSE — avoid optimistic UI unless reconciled with backend truth.
Job management UI is split into focused subcomponents under `web/src/components/jobs/`: `JobsTable`, `JobDetailModal`, `JobsToolbar`, `JobExplanations`, `useJobSSE.ts` (SSE hook), and `types.ts` (shared types + pure data utilities). `JobManager.tsx` is the parent that owns state and wires them together.
### Database Schema ### Database Schema

470
audit.md
View File

@@ -1,136 +1,420 @@
# Audit Findings # Audit Findings
Date: 2026-04-11 Last updated: 2026-04-12 (second pass)
## Summary ---
This audit focused on the highest-risk paths in Alchemist: ## P1 Issues
- queue claiming and cancellation ---
- media planning and execution
- conversion validation
- setup/auth exposure
- job detail and failure UX
The current automated checks were green at audit time, but several real ### [P1-1] Cancel during analysis can be overwritten by the pipeline
correctness and behavior issues remain.
## Findings **Status: RESOLVED**
### [P1] Canceling a job during analysis can be overwritten **Files:**
- `src/server/jobs.rs:4163`
- `src/media/pipeline.rs:11781221`
- `src/orchestrator.rs:8490`
Relevant code: **Severity:** P1
- `src/server/jobs.rs:41` **Problem:**
- `src/media/pipeline.rs:927`
- `src/media/pipeline.rs:970`
- `src/orchestrator.rs:239`
`request_job_cancel()` marks `analyzing` and `resuming` jobs as `request_job_cancel()` in `jobs.rs` immediately writes `Cancelled` to the DB for jobs in `Analyzing` or `Resuming` state. The pipeline used to have race windows where it could overwrite this state with `Skipped`, `Encoding`, or `Remuxing` if it reached a checkpoint after the cancel was issued but before it could be processed.
`cancelled` immediately. But the analysis/planning path can still run to
completion and later overwrite that state to `skipped`,
`encoding`/`remuxing`, or another follow-on state.
The transcoder-side `pending_cancels` check only applies around FFmpeg **Fix:**
spawn, so a cancel issued during analysis is not guaranteed to stop the
pipeline before state transitions are persisted.
Impact: Implemented `cancel_requested: Arc<tokio::sync::RwLock<HashSet<i64>>>` in `Transcoder` (orchestrator). The `update_job_state` wrapper in `pipeline.rs` now checks this set before any DB write for `Encoding`, `Remuxing`, `Skipped`, and `Completed` states. Terminal states (Completed, Failed, Cancelled, Skipped) also trigger removal from the set.
- a user-visible cancel can be lost ---
- the UI can report a cancelled job that later resumes or becomes skipped
- queue state becomes harder to trust
### [P1] VideoToolbox quality controls are effectively a no-op ### [P1-2] VideoToolbox quality controls are effectively ignored
Relevant code: **Status: RESOLVED**
- `src/config.rs:85` **Files:**
- `src/media/planner.rs:633` - `src/media/planner.rs:630650`
- `src/media/ffmpeg/videotoolbox.rs:3` - `src/media/ffmpeg/videotoolbox.rs:2554`
- `src/conversion.rs:424` - `src/config.rs:8592`
The config still defines a VideoToolbox quality ladder, and the planner **Severity:** P1
still emits `RateControl::Cq` for VideoToolbox encoders. But the actual
VideoToolbox FFmpeg builder ignores rate-control input entirely.
The Convert workflow does the same thing by still generating `Cq` for **Problem:**
non-CPU/QSV encoders even though the VideoToolbox path does not consume
it.
Impact: The planner used to emit `RateControl::Cq` values that were incorrectly mapped for VideoToolbox, resulting in uncalibrated or inverted quality.
- quality profile does not meaningfully affect VideoToolbox jobs **Fix:**
- Convert quality values for VideoToolbox are misleading
- macOS throughput/quality tradeoffs are harder to reason about
### [P2] Convert does not reuse subtitle/container compatibility checks Fixed the mapping in `videotoolbox.rs` to use `-q:v` (1-100, lower is better) and clamped the input range to 1-51 to match user expectations from x264/x265. Updated `QualityProfile` in `config.rs` to provide sane default values (24, 28, 32) for VideoToolbox quality.
Relevant code: ---
- `src/media/planner.rs:863` ## P2 Issues
- `src/media/planner.rs:904`
- `src/conversion.rs:272`
- `src/conversion.rs:366`
The main library planner explicitly rejects unsafe subtitle-copy ---
combinations, especially for MP4/MOV targets. The Convert flow has its
own normalization/build path and does not reuse that validation.
Impact: ### [P2-1] Convert does not reuse subtitle/container compatibility checks
- the Convert UI can accept settings that are known to fail later in FFmpeg **Status: RESOLVED**
- conversion behavior diverges from library-job behavior
- users can hit avoidable execution-time errors instead of fast validation
### [P2] Completed job details omit metadata at the API layer **Files:**
- `src/conversion.rs:372380`
- `src/media/planner.rs`
Relevant code: **Severity:** P2
- `src/server/jobs.rs:344` **Problem:**
- `web/src/components/JobManager.tsx:1774`
The job detail endpoint explicitly returns `metadata = None` for The conversion path was not validating subtitle/container compatibility, leading to FFmpeg runtime failures instead of early validation errors.
`completed` jobs, even though the Jobs modal is structured to display
input metadata when available.
Impact: **Fix:**
- completed-job details are structurally incomplete Integrated `crate::media::planner::subtitle_copy_supported` into `src/conversion.rs:build_subtitle_plan`. The "copy" mode now returns an `AlchemistError::Config` if the combination is unsupported.
- the frontend needs special-case empty-state behavior
- operator confidence is lower when comparing completed jobs after the fact
### [P2] LAN-only setup is easy to misconfigure behind a local reverse proxy ---
Relevant code: ### [P2-2] Completed job metadata omitted at the API layer
- `src/server/middleware.rs:269` **Status: RESOLVED**
- `src/server/middleware.rs:300`
The setup gate uses `request_ip()` and trusts forwarded headers only when **Files:**
the direct peer is local/private. If Alchemist sits behind a loopback or - `src/db.rs:254263`
LAN reverse proxy that fails to forward the real client IP, the request - `src/media/pipeline.rs:599`
falls back to the proxy peer IP and is treated as LAN-local. - `src/server/jobs.rs:343`
Impact: **Severity:** P2
- public reverse-proxy deployments can accidentally expose setup **Problem:**
- behavior depends on correct proxy header forwarding
- the security model is sound in principle but fragile in deployment
## What To Fix First Job details required a live re-probe of the input file to show metadata, which failed if the file was moved or deleted after completion.
1. Fix the cancel-during-analysis race. **Fix:**
2. Fix or redesign VideoToolbox quality handling so the UI and planner do
not promise controls that the backend ignores.
3. Reuse planner validation in Convert for subtitle/container safety.
4. Decide whether completed jobs should persist and return metadata in the
detail API.
## What To Investigate Next Added `input_metadata_json` column to the `jobs` table (migration `20260412000000_store_job_metadata.sql`). The pipeline now stores the metadata string immediately after analysis. `get_job_detail_handler` reads this stored value, ensuring metadata is always available even if the source file is missing.
1. Use runtime diagnostics to confirm whether macOS slowness is true ---
hardware underperformance, silent fallback, or filter overhead.
2. Verify whether “only one job at a time” is caused by actual worker ### [P2-3] LAN-only setup exposed to reverse proxy misconfig
serialization or by planner eligibility/skips.
3. Review dominant skip reasons before relaxing planner heuristics. **Status: RESOLVED**
**Files:**
- `src/config.rs``SystemConfig.trusted_proxies`
- `src/server/mod.rs``AppState.trusted_proxies`, `AppState.setup_token`
- `src/server/middleware.rs``is_trusted_peer`, `request_ip`, `auth_middleware`
**Severity:** P2
**Problem:**
The setup wizard gate trusts all private/loopback IPs for header forwarding. When running behind a misconfigured proxy that doesn't set headers, it falls back to the proxy's own IP (e.g. 127.0.0.1), making the setup endpoint accessible to external traffic.
**Fix:**
Added two independent security layers:
1. `trusted_proxies: Vec<String>` to `SystemConfig`. When non-empty, only those exact IPs (plus loopback) are trusted for proxy header forwarding instead of all RFC-1918 ranges. Empty = previous behavior preserved.
2. `ALCHEMIST_SETUP_TOKEN` env var. When set, setup endpoints require `?token=<value>` query param regardless of client IP. Token mode takes precedence over IP-based LAN check.
---
### [P2-4] N+1 DB update in batch cancel
**Status: RESOLVED**
**Files:**
- `src/server/jobs.rs``batch_jobs_handler`
**Severity:** P2
**Problem:**
`batch_jobs_handler` for "cancel" action iterates over jobs and calls `request_job_cancel` which performs an individual `update_job_status` query per job. Cancelling a large number of jobs triggers N queries.
**Fix:**
Restructured the "cancel" branch in `batch_jobs_handler`. Orchestrator in-memory operations (add_cancel_request, cancel_job) still run per-job, but all DB status updates are batched into a single `db.batch_cancel_jobs(&ids)` call (which already existed at db.rs). Immediate-resolution jobs (Queued + successfully signalled Analyzing/Resuming) are collected and written in one UPDATE ... WHERE id IN (...) query.
---
### [P2-5] Missing archived filter in health and stats queries
**Status: RESOLVED**
**Files:**
- `src/db.rs``get_aggregated_stats`, `get_job_stats`, `get_health_summary`
**Severity:** P2
**Problem:**
`get_health_summary` and `get_aggregated_stats` (total_jobs) do not include `AND archived = 0`. Archived (deleted) jobs are incorrectly included in library health metrics and total job counts.
**Fix:**
Added `AND archived = 0` to all three affected queries: `total_jobs` and `completed_jobs` subqueries in `get_aggregated_stats`, the `GROUP BY status` query in `get_job_stats`, and both subqueries in `get_health_summary`. Updated tests that were asserting the old (incorrect) behavior.
---
### [P2-6] Daily summary notifications bypass SSRF protections
**Status: RESOLVED**
**Files:**
- `src/notifications.rs``build_safe_client()`, `send()`, `send_daily_summary_target()`
**Severity:** P2
**Problem:**
`send_daily_summary_target()` used `Client::new()` without any SSRF defences, while `send()` applied DNS timeout, private-IP blocking, no-redirect policy, and request timeout.
**Fix:**
Extracted all client-building logic into `build_safe_client(&self, target)` which applies the full SSRF defence stack. Both `send()` and `send_daily_summary_target()` now use this shared helper.
---
### [P2-7] Silent reprobe failure corrupts saved encode stats
**Status: RESOLVED**
**Files:**
- `src/media/pipeline.rs``finalize_job()` duration reprobe
**Severity:** P2
**Problem:**
When a completed encode's metadata has `duration_secs <= 0.0`, the pipeline reprobes the output file to get the actual duration. If reprobe fails, the error was silently swallowed via `.ok()` and duration defaulted to 0.0, poisoning downstream stats.
**Fix:**
Replaced `.ok().and_then().unwrap_or(0.0)` chain with explicit `match` that logs the error via `tracing::warn!` and falls through to 0.0. Existing guards at the stats computation lines already handle `duration <= 0.0` correctly — operators now see *why* stats are zeroed.
---
## Technical Debt
---
### [TD-1] `db.rs` is a 3481-line monolith
**Status: RESOLVED**
**File:** `src/db/` (was `src/db.rs`)
**Severity:** TD
**Problem:**
The database layer had grown to nearly 3500 lines. Every query, migration flag, and state enum was in one file, making navigation and maintenance difficult.
**Fix:**
Split into `src/db/` module with 8 submodules: `mod.rs` (Db struct, init, migrations, hash fns), `types.rs` (all type defs), `events.rs` (event enums + channels), `jobs.rs` (job CRUD/filtering/decisions), `stats.rs` (encode/aggregated/daily stats), `config.rs` (watch dirs/profiles/notifications/schedules/file settings/preferences), `conversion.rs` (ConversionJob CRUD), `system.rs` (auth/sessions/API tokens/logs/health). All tests moved alongside their methods. Public API unchanged — all types re-exported from `db/mod.rs`.
---
### [TD-2] `AlchemistEvent` legacy bridge is dead weight
**Status: RESOLVED**
**Files:**
- `src/db.rs` — enum and From impls removed
- `src/media/pipeline.rs`, `src/media/executor.rs`, `src/media/processor.rs` — legacy `tx` channel removed
- `src/notifications.rs` — migrated to typed `EventChannels` (jobs + system)
- `src/server/mod.rs`, `src/main.rs` — legacy channel removed from AppState/RunServerArgs
**Severity:** TD
**Problem:**
`AlchemistEvent` was a legacy event type duplicated by `JobEvent`, `ConfigEvent`, and `SystemEvent`. All senders were emitting events on both channels.
**Fix:**
Migrated the notification system (the sole consumer) to subscribe to `EventChannels.jobs` and `EventChannels.system` directly. Added `SystemEvent::EngineIdle` variant. Removed `AlchemistEvent` enum, its `From` impls, the legacy `tx` broadcast channel from all structs, and the `pub use` from `lib.rs`.
---
### [TD-3] `pipeline.rs` legacy `AlchemistEvent::Progress` stub
**Status: RESOLVED**
**Files:**
- `src/media/pipeline.rs:1228`
**Severity:** TD
**Problem:**
The pipeline used to emit zeroed progress events that could overwrite real stats from the executor.
**Fix:**
Emission removed. A comment at line 1228-1229 confirms that `AlchemistEvent::Progress` is no longer emitted from the pipeline wrapper.
---
### [TD-4] Silent `.ok()` on pipeline decision and attempt DB writes
**Status: RESOLVED**
**Files:**
- `src/media/pipeline.rs` — all `add_decision`, `insert_encode_attempt`, `upsert_job_failure_explanation`, and `add_log` call sites
**Severity:** TD
**Problem:**
Decision records, encode attempt records, failure explanations, and error logs were written with `.ok()` or `let _ =`, silently discarding DB write failures. These records are the only audit trail of *why* a job was skipped/transcoded/failed.
**Fix:**
Replaced all `.ok()` / `let _ =` patterns on `add_decision`, `insert_encode_attempt`, `upsert_job_failure_explanation`, and `add_log` calls with `if let Err(e) = ... { tracing::warn!(...) }`. Pipeline still continues on failure, but operators now see the error.
---
### [TD-5] Correlated subquery for sort-by-size in job listing
**Status: RESOLVED**
**Files:**
- `src/db.rs``get_jobs_filtered()` query
**Severity:** TD
**Problem:**
Sorting jobs by file size used a correlated subquery in ORDER BY, executing one subquery per row and producing NULL for jobs without encode_stats.
**Fix:**
Added `LEFT JOIN encode_stats es ON es.job_id = j.id` to the base query. Sort column changed to `COALESCE(es.input_size_bytes, 0)`, ensuring jobs without stats sort as 0 (smallest) instead of NULL.
---
## Reliability Gaps
---
### [RG-1] No encode resume after crash or restart
**Status: PARTIALLY RESOLVED**
**Files:**
- `src/main.rs:320`
- `src/media/processor.rs:255`
**Severity:** RG
**Problem:**
Interrupted encodes were left in `Encoding` state and orphaned temp files remained on disk.
**Fix:**
Implemented `db.reset_interrupted_jobs()` in `main.rs` which resets `Encoding`, `Remuxing`, `Resuming`, and `Analyzing` jobs to `Queued` on startup. Orphaned temp files are also detected and removed. Full bitstream-level resume (resuming from the middle of a file) is still missing.
---
### [RG-2] AMD VAAPI/AMF hardware paths unvalidated
**Files:**
- `src/media/ffmpeg/vaapi.rs`
- `src/media/ffmpeg/amf.rs`
**Severity:** RG
**Problem:**
Hardware paths for AMD (VAAPI on Linux, AMF on Windows) were implemented without real hardware validation.
**Fix:**
Verify exact flag compatibility on AMD hardware and add integration tests gated on GPU presence.
---
## UX Gaps
---
### [UX-1] Queued jobs show no position or estimated wait time
**Status: RESOLVED**
**Files:**
- `src/db.rs``get_queue_position`
- `src/server/jobs.rs``JobDetailResponse.queue_position`
- `web/src/components/jobs/JobDetailModal.tsx`
- `web/src/components/jobs/types.ts``JobDetail.queue_position`
**Severity:** UX
**Problem:**
Queued jobs only show "Waiting" without indicating their position in the priority queue.
**Fix:**
Implemented `db.get_queue_position(job_id)` which counts jobs with higher priority or earlier `created_at` (matching the `priority DESC, created_at ASC` dequeue order). Added `queue_position: Option<u32>` to `JobDetailResponse` — populated only when `status == Queued`. Frontend shows `Queue position: #N` in the empty state card in `JobDetailModal`.
---
### [UX-2] No way to add a single file to the queue via the UI
**Severity:** UX
**Problem:**
Jobs only enter the queue via full library scans. No manual "Enqueue path" exists in the UI.
**Fix:**
Add `POST /api/jobs/enqueue` and a "Add file" action in the `JobsToolbar`.
---
### [UX-3] Workers-blocked reason not surfaced for queued jobs
**Severity:** UX
**Problem:**
Users cannot see why a job is stuck in Queued (paused, scheduled, or slots full).
**Fix:**
Add `GET /api/processor/status` and show the reason in the job detail.
---
## Feature Gaps
---
### [FG-4] Intelligence page content not actionable
**Files:**
- `web/src/components/LibraryIntelligence.tsx`
**Severity:** FG
**Problem:**
Intelligence page is informational only; recommendations cannot be acted upon directly from the page.
**Fix:**
Add "Queue all" for remux opportunities and "Review" actions for duplicates.
---
## What To Fix Next
1. **[UX-2]** Single file enqueue — New feature.
2. **[UX-3]** Workers-blocked reason — New feature.
3. **[FG-4]** Intelligence page actions — New feature.
4. **[RG-2]** AMD VAAPI/AMF validation — Needs real hardware.

View File

@@ -1,54 +1,35 @@
--- ---
title: API title: API Reference
description: REST and SSE API reference for Alchemist. description: REST and SSE API reference for Alchemist.
--- ---
All API routes require the `alchemist_session` auth cookie
except:
- `/api/auth/*`
- `/api/health`
- `/api/ready`
- during first-time setup, the setup UI and setup-related
unauthenticated routes are only reachable from the local
network
Authentication is established by `POST /api/auth/login`.
The backend also accepts `Authorization: Bearer <token>`.
Bearer tokens now come in two classes:
- `read_only` — observability-only routes
- `full_access` — same route access as an authenticated session
The web UI still uses the session cookie.
Machine-readable contract:
- [OpenAPI spec](/openapi.yaml)
## Authentication ## Authentication
### API tokens All API routes require the `alchemist_session` auth cookie established via `/api/auth/login`, or an `Authorization: Bearer <token>` header.
API tokens are created in **Settings → API Tokens**. Machine-readable contract: [OpenAPI spec](/openapi.yaml)
- token values are only shown once at creation time ### `POST /api/auth/login`
- only hashed token material is stored server-side Establish a session. Returns a `Set-Cookie` header.
- revoked tokens stop working immediately
Read-only tokens are intentionally limited to observability **Request:**
routes such as stats, jobs, logs history, SSE, system info, ```json
hardware info, library intelligence, and health/readiness. {
"username": "admin",
"password": "..."
}
```
### `POST /api/auth/logout`
Invalidate current session and clear cookie.
### `GET /api/settings/api-tokens` ### `GET /api/settings/api-tokens`
List metadata for configured API tokens.
Lists token metadata only. Plaintext token values are never
returned after creation.
### `POST /api/settings/api-tokens` ### `POST /api/settings/api-tokens`
Create a new API token. The plaintext value is only returned once.
Request: **Request:**
```json ```json
{ {
"name": "Prometheus", "name": "Prometheus",
@@ -56,411 +37,114 @@ Request:
} }
``` ```
Response:
```json
{
"token": {
"id": 1,
"name": "Prometheus",
"access_level": "read_only"
},
"plaintext_token": "alc_tok_..."
}
```
### `DELETE /api/settings/api-tokens/:id` ### `DELETE /api/settings/api-tokens/:id`
Revoke a token.
Revokes a token in place. Existing automations using it will ---
begin receiving `401` or `403` depending on route class.
### `POST /api/auth/login`
Request:
```json
{
"username": "admin",
"password": "secret"
}
```
Response:
```http
HTTP/1.1 200 OK
Set-Cookie: alchemist_session=...; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000
```
```json
{
"status": "ok"
}
```
### `POST /api/auth/logout`
Clears the session cookie and deletes the server-side
session if one exists.
```json
{
"status": "ok"
}
```
## Jobs ## Jobs
### `GET /api/jobs` ### `GET /api/jobs`
List jobs with filtering and pagination.
Canonical job listing endpoint. Supports query params such **Params:** `limit`, `page`, `status`, `search`, `sort_by`, `sort_desc`, `archived`.
as `limit`, `page`, `status`, `search`, `sort_by`,
`sort_desc`, and `archived`.
Each returned job row still includes the legacy
`decision_reason` string when present, and now also includes
an optional `decision_explanation` object:
- `category`
- `code`
- `summary`
- `detail`
- `operator_guidance`
- `measured`
- `legacy_reason`
Example:
```bash
curl -b cookie.txt \
'http://localhost:3000/api/jobs?status=queued,failed&limit=50&page=1'
```
### `GET /api/jobs/:id/details` ### `GET /api/jobs/:id/details`
Fetch full job state, metadata, logs, and stats.
Returns the job row, any available analyzed metadata,
encode stats for completed jobs, recent job logs, and a
failure summary for failed jobs. Structured explanation
fields are included when available:
- `decision_explanation`
- `failure_explanation`
- `job_failure_summary` is retained as a compatibility field
Example response shape:
```json
{
"job": {
"id": 42,
"input_path": "/media/movies/example.mkv",
"status": "completed"
},
"metadata": {
"codec_name": "h264",
"width": 1920,
"height": 1080
},
"encode_stats": {
"input_size_bytes": 8011223344,
"output_size_bytes": 4112233445,
"compression_ratio": 1.95,
"encode_speed": 2.4,
"vmaf_score": 93.1
},
"job_logs": [],
"job_failure_summary": null,
"decision_explanation": {
"category": "decision",
"code": "transcode_recommended",
"summary": "Transcode recommended",
"detail": "Alchemist determined the file should be transcoded based on the current codec and measured efficiency.",
"operator_guidance": null,
"measured": {
"target_codec": "av1",
"current_codec": "h264",
"bpp": "0.1200"
},
"legacy_reason": "transcode_recommended|target_codec=av1,current_codec=h264,bpp=0.1200"
},
"failure_explanation": null
}
```
### `POST /api/jobs/:id/cancel` ### `POST /api/jobs/:id/cancel`
Cancel a queued or active job.
Cancels a queued or active job if the current state allows
it.
### `POST /api/jobs/:id/restart` ### `POST /api/jobs/:id/restart`
Restart a terminal job (failed/cancelled/completed).
Restarts a non-active job by sending it back to `queued`.
### `POST /api/jobs/:id/priority` ### `POST /api/jobs/:id/priority`
Update job priority.
Request: **Request:** `{"priority": 100}`
```json
{
"priority": 100
}
```
Response:
```json
{
"id": 42,
"priority": 100
}
```
### `POST /api/jobs/batch` ### `POST /api/jobs/batch`
Bulk action on multiple jobs.
Supported `action` values: `cancel`, `restart`, `delete`. **Request:**
```json ```json
{ {
"action": "restart", "action": "restart|cancel|delete",
"ids": [41, 42, 43] "ids": [1, 2, 3]
}
```
Response:
```json
{
"count": 3
} }
``` ```
### `POST /api/jobs/restart-failed` ### `POST /api/jobs/restart-failed`
Restart all failed or cancelled jobs.
Response:
```json
{
"count": 2,
"message": "Queued 2 failed or cancelled jobs for retry."
}
```
### `POST /api/jobs/clear-completed` ### `POST /api/jobs/clear-completed`
Archive all completed jobs from the active queue.
Archives completed jobs from the visible queue while ---
preserving historical encode stats.
```json
{
"count": 12,
"message": "Cleared 12 completed jobs from the queue. Historical stats were preserved."
}
```
## Engine ## Engine
### `POST /api/engine/pause` ### `GET /api/engine/status`
Get current operational status and limits.
```json ### `POST /api/engine/pause`
{ Pause the engine (suspend active jobs).
"status": "paused"
}
```
### `POST /api/engine/resume` ### `POST /api/engine/resume`
Resume the engine.
```json
{
"status": "running"
}
```
### `POST /api/engine/drain` ### `POST /api/engine/drain`
Enter drain mode (finish active jobs, don't start new ones).
```json
{
"status": "draining"
}
```
### `POST /api/engine/stop-drain`
```json
{
"status": "running"
}
```
### `GET /api/engine/status`
Response fields:
- `status`
- `mode`
- `concurrent_limit`
- `manual_paused`
- `scheduler_paused`
- `draining`
- `is_manual_override`
Example:
```json
{
"status": "paused",
"manual_paused": true,
"scheduler_paused": false,
"draining": false,
"mode": "balanced",
"concurrent_limit": 2,
"is_manual_override": false
}
```
### `GET /api/engine/mode`
Returns current mode, whether a manual override is active,
the current concurrent limit, CPU count, and computed mode
limits.
### `POST /api/engine/mode` ### `POST /api/engine/mode`
Switch engine mode or apply manual overrides.
Request: **Request:**
```json ```json
{ {
"mode": "balanced", "mode": "background|balanced|throughput",
"concurrent_jobs_override": 2, "concurrent_jobs_override": 2,
"threads_override": 0 "threads_override": 0
} }
``` ```
Response: ---
```json ## Statistics
{
"status": "ok",
"mode": "balanced",
"concurrent_limit": 2,
"is_manual_override": true
}
```
## Stats
### `GET /api/stats/aggregated` ### `GET /api/stats/aggregated`
Total savings, job counts, and global efficiency.
```json
{
"total_input_bytes": 1234567890,
"total_output_bytes": 678901234,
"total_savings_bytes": 555666656,
"total_time_seconds": 81234.5,
"total_jobs": 87,
"avg_vmaf": 92.4
}
```
### `GET /api/stats/daily` ### `GET /api/stats/daily`
Encode activity history for the last 30 days.
Returns the last 30 days of encode activity.
### `GET /api/stats/detailed`
Returns the most recent detailed encode stats rows.
### `GET /api/stats/savings` ### `GET /api/stats/savings`
Detailed breakdown of storage savings.
Returns the storage-savings summary used by the statistics ---
dashboard.
## Settings
### `GET /api/settings/transcode`
Returns the transcode settings payload currently loaded by
the backend.
### `POST /api/settings/transcode`
Request:
```json
{
"concurrent_jobs": 2,
"size_reduction_threshold": 0.3,
"min_bpp_threshold": 0.1,
"min_file_size_mb": 50,
"output_codec": "av1",
"quality_profile": "balanced",
"threads": 0,
"allow_fallback": true,
"hdr_mode": "preserve",
"tonemap_algorithm": "hable",
"tonemap_peak": 100.0,
"tonemap_desat": 0.2,
"subtitle_mode": "copy",
"stream_rules": {
"strip_audio_by_title": ["commentary"],
"keep_audio_languages": ["eng"],
"keep_only_default_audio": false
}
}
```
## System ## System
### `GET /api/system/hardware` ### `GET /api/system/hardware`
Detected hardware backend and codec support matrix.
Returns the current detected hardware backend, supported
codecs, backends, selection reason, probe summary, and any
detection notes.
### `GET /api/system/hardware/probe-log` ### `GET /api/system/hardware/probe-log`
Full logs from the startup hardware probe.
Returns the per-encoder probe log with success/failure
status, selected-flag metadata, summary text, and stderr
excerpts.
### `GET /api/system/resources` ### `GET /api/system/resources`
Live telemetry: CPU, Memory, GPU utilization, and uptime.
Returns live resource data: ---
- `cpu_percent` ## Events (SSE)
- `memory_used_mb`
- `memory_total_mb`
- `memory_percent`
- `uptime_seconds`
- `active_jobs`
- `concurrent_limit`
- `cpu_count`
- `gpu_utilization`
- `gpu_memory_percent`
## Server-Sent Events
### `GET /api/events` ### `GET /api/events`
Real-time event stream.
Internal event types are `JobStateChanged`, `Progress`, **Emitted Events:**
`Decision`, and `Log`. The SSE stream exposed to clients - `status`: Job state changes.
emits lower-case event names: - `progress`: Real-time encode statistics.
- `decision`: Skip/Transcode logic results.
- `status` - `log`: Engine and job logs.
- `progress` - `config_updated`: Configuration hot-reload notification.
- `decision` - `scan_started` / `scan_completed`: Library scan status.
- `log`
Additional config/system events may also appear, including
`config_updated`, `scan_started`, `scan_completed`,
`engine_status_changed`, and `hardware_state_changed`.
Example:
```text
event: progress
data: {"job_id":42,"percentage":61.4,"time":"00:11:32"}
```
`decision` events include the legacy `reason` plus an
optional structured `explanation` object with the same shape
used by the jobs API.

View File

@@ -70,7 +70,7 @@ Default config file location:
| `output_extension` | string | `"mkv"` | Output file extension | | `output_extension` | string | `"mkv"` | Output file extension |
| `output_suffix` | string | `"-alchemist"` | Suffix added to the output filename | | `output_suffix` | string | `"-alchemist"` | Suffix added to the output filename |
| `replace_strategy` | string | `"keep"` | Replace behavior for output collisions | | `replace_strategy` | string | `"keep"` | Replace behavior for output collisions |
| `output_root` | string | optional | Mirror outputs into another root path instead of writing beside the source | | `output_root` | string | optional | If set, Alchemist mirrors the source library directory structure under this root path instead of writing outputs alongside the source files |
## `[schedule]` ## `[schedule]`

View File

@@ -1,37 +1,39 @@
--- ---
title: Engine Modes title: Engine Modes & States
description: Background, Balanced, and Throughput — what they mean and when to use each. description: Background, Balanced, and Throughput — understanding concurrency and execution flow.
--- ---
Engine modes set the concurrent job limit. Alchemist uses **Modes** to dictate performance limits and **States** to control execution flow.
## Modes ## Engine Modes (Concurrency)
| Mode | Concurrent jobs | Use when | Modes define the maximum number of concurrent jobs the engine will attempt to run.
|------|----------------|----------|
| Background | 1 | Server in active use |
| Balanced (default) | floor(cpu_count / 2), min 1, max 4 | General shared server |
| Throughput | floor(cpu_count / 2), min 1, no cap | Dedicated server, clear a backlog |
## Manual override | Mode | Concurrent Jobs | Ideal For |
|------|----------------|-----------|
| **Background** | 1 | Server in active use by other applications. |
| **Balanced** | `floor(cpu_count / 2)` (min 1, max 4) | Default. Shared server usage. |
| **Throughput** | `floor(cpu_count / 2)` (min 1, no cap) | Dedicated server; clearing a large backlog. |
Override the computed limit in **Settings → Runtime**. Takes :::tip Manual Override
effect immediately. A "manual" badge appears in engine You can override the computed limit in **Settings → Runtime**. A "Manual" badge will appear in the engine status. Switching modes clears manual overrides.
status. Switching modes clears the override. :::
## States vs. modes ---
Modes determine *how many* jobs run. States determine ## Engine States (Execution)
*whether* they run.
States determine whether the engine is actively processing the queue.
| State | Behavior | | State | Behavior |
|-------|----------| |-------|----------|
| Running | Jobs start up to the mode's limit | | **Running** | Engine is active. Jobs start up to the current mode's limit. |
| Paused | No jobs start; active jobs freeze | | **Paused** | Engine is suspended. No new jobs start; active jobs are frozen. |
| Draining | Active jobs finish; no new jobs start | | **Draining** | Engine is stopping. Active jobs finish, but no new jobs start. |
| Scheduler paused | Paused by a schedule window | | **Scheduler Paused** | Engine is temporarily paused by a configured [Schedule Window](/scheduling). |
## Changing modes ---
**Settings → Runtime**. Takes effect immediately; in-progress ## Changing Engine Behavior
jobs are not cancelled.
Engine behavior can be adjusted in real-time via the **Runtime** dashboard or the [API](/api#engine). Changes take effect immediately without cancelling in-progress jobs.

View File

@@ -1,32 +1,35 @@
--- ---
title: Library Doctor title: Library Doctor
description: Scan for corrupt, truncated, and unreadable media files. description: Identifying corrupt, truncated, and unreadable media files in your library.
--- ---
Library Doctor scans your configured directories for files Library Doctor is a specialized diagnostic tool that scans your library for media files that are corrupt, truncated, or otherwise unreadable by the Alchemist analyzer.
that are corrupt, truncated, or unreadable by FFprobe.
Run from **Settings → Runtime → Library Doctor → Run Scan**. Run a scan manually from **Settings → Runtime → Library Doctor**.
## What it checks ## Core Checks
| Check | What it detects | Library Doctor runs an intensive probe on every file in your watch directories to identify the following issues:
|-------|-----------------|
| Probe failure | Files FFprobe cannot read at all |
| No video stream | Files with no detectable video track |
| Zero duration | Files reporting 0 seconds of content |
| Truncated file | Files that appear to end prematurely |
| Missing codec data | Files missing metadata needed to plan a transcode |
## What to do with results | Check | Technical Detection | Action Recommended |
|-------|-----------------|--------------------|
| **Probe Failure** | `ffprobe` returns a non-zero exit code or cannot parse headers. | Re-download or Re-rip. |
| **No Video Stream** | File container is valid but contains no detectable video tracks. | Verify source; delete if unintended. |
| **Zero Duration** | File metadata reports a duration of 0 seconds. | Check for interrupted transfers. |
| **Truncated File** | File size is significantly smaller than expected for the reported bitrate/duration. | Check filesystem integrity. |
| **Missing Metadata** | Missing critical codec data (e.g., pixel format, profile) needed for planning. | Possible unsupported codec variant. |
Library Doctor reports issues — it does not repair or delete ---
files automatically.
- **Re-download** — interrupted download ## Relationship to Jobs
- **Re-rip** — disc read errors
- **Delete** — duplicate or unrecoverable
- **Ignore** — player handles it despite FFprobe failing
Files that fail Library Doctor also fail the Analyzing Files that fail Library Doctor checks will also fail the **Analyzing** stage of a standard transcode job.
stage of a transcode job and appear as Failed in Jobs.
- **Pre-emptive detection**: Running Library Doctor helps you clear "broken" files from your library before they enter the processing queue.
- **Reporting**: Issues identified by the Doctor appear in the **Health** tab of the dashboard, separate from active transcode jobs.
## Handling Results
Library Doctor is read-only; it will **never delete or modify** your files automatically.
If a file is flagged, you should manually verify it using a media player. If the file is indeed unplayable, we recommend replacing it from the source. Flags can be cleared by deleting the file or moving it out of a watched directory.

View File

@@ -37,13 +37,9 @@ FFmpeg expert.
## Hardware support ## Hardware support
| Vendor | AV1 | HEVC | H.264 | Notes | Alchemist detects and selects the best available hardware encoder automatically (NVIDIA NVENC, Intel QSV, AMD VAAPI/AMF, Apple VideoToolbox, or CPU fallback).
|--------|-----|------|-------|-------|
| NVIDIA NVENC | RTX 30/40 | Maxwell+ | All | Best for speed | For detailed codec support matrices (AV1, HEVC, H.264) and vendor-specific setup guides, see the [Hardware Acceleration](/hardware) documentation.
| Intel QSV | 12th gen+ | 6th gen+ | All | Best for power efficiency |
| AMD VAAPI/AMF | RDNA 2+ on compatible driver/FFmpeg stacks | Polaris+ | All | Linux VAAPI / Windows AMF; HEVC/H.264 are the validated AMD paths for `0.3.0` |
| Apple VideoToolbox | M3+ | M1+ / T2 | All | Binary install recommended |
| CPU (SVT-AV1/x265/x264) | All | All | All | Always available |
## Where to start ## Where to start

View File

@@ -103,7 +103,6 @@ const config: Config = {
], ],
}, },
footer: { footer: {
style: 'dark',
links: [ links: [
{ {
title: 'Get Started', title: 'Get Started',

View File

@@ -94,8 +94,9 @@ html {
} }
.footer { .footer {
border-top: 1px solid rgba(200, 155, 90, 0.22); border-top: 1px solid var(--doc-border);
background: var(--ifm-footer-background-color); background-color: var(--ifm-footer-background-color) !important;
color: #ddd0be;
} }
.footer__links { .footer__links {
@@ -118,13 +119,22 @@ html {
} }
.footer__title { .footer__title {
color: #fdf6ee;
font-weight: 700; font-weight: 700;
} }
.footer__bottom, .footer__link-item {
color: #cfc0aa;
}
.footer__link-item:hover {
color: var(--ifm-link-hover-color);
text-decoration: none;
}
.footer__copyright { .footer__copyright {
text-align: center;
color: #b8a88e; color: #b8a88e;
text-align: center;
} }
.main-wrapper { .main-wrapper {

View File

@@ -0,0 +1,2 @@
-- Store input metadata as JSON to avoid live re-probing completed jobs
ALTER TABLE jobs ADD COLUMN input_metadata_json TEXT;

191
plans.md Normal file
View File

@@ -0,0 +1,191 @@
# Open Item Plans
---
## [UX-2] Single File Enqueue
### Goal
`POST /api/jobs/enqueue` + "Add file" button in JobsToolbar.
### Backend
**New handler in `src/server/jobs.rs`:**
```rust
#[derive(Deserialize)]
struct EnqueueFilePayload {
input_path: String,
source_root: Option<String>,
}
async fn enqueue_file_handler(State(state), Json(payload)) -> impl IntoResponse
```
Logic:
1. Validate `input_path` exists on disk, is a file
2. Read `mtime` from filesystem metadata
3. Build `DiscoveredMedia { path, mtime, source_root }`
4. Call `enqueue_discovered_with_db(&db, discovered)` — reuses all existing skip checks, output path computation, file settings
5. If `Ok(true)` → fetch job via `db.get_job_by_input_path()`, return it
6. If `Ok(false)` → 409 "already tracked / output exists"
7. If `Err` → 400 with error
**Route:** Add `.route("/api/jobs/enqueue", post(enqueue_file_handler))` in `src/server/mod.rs`
### Frontend
**`web/src/components/jobs/JobsToolbar.tsx`:**
- Add "Add File" button next to refresh
- Opens small modal/dialog with text input for path
- POST to `/api/jobs/enqueue`, toast result
- SSE handles job appearing in table automatically
### Files to modify
- `src/server/jobs.rs` — new handler + payload struct
- `src/server/mod.rs` — route registration
- `web/src/components/jobs/JobsToolbar.tsx` — button + dialog
- `web/src/components/jobs/` — optional: new `EnqueueDialog.tsx` component
### Verification
- `cargo check && cargo test && cargo clippy`
- Manual: POST valid path → job appears queued
- POST nonexistent path → 400
- POST already-tracked path → 409
- Frontend: click Add File, enter path, see job in table
---
## [UX-3] Workers-Blocked Reason
### Goal
Surface why queued jobs aren't being processed. Extend `/api/engine/status` → show reason in JobDetailModal.
### Backend
**Extend `engine_status_handler` response** (or create new endpoint) to include blocking state:
```rust
struct EngineStatusResponse {
// existing fields...
blocked_reason: Option<String>, // "paused", "scheduled", "draining", "boot_analysis", "slots_full", null
schedule_resume: Option<String>, // next window open time if scheduler_paused
}
```
Derive from `Agent` state:
- `agent.is_manual_paused()``"paused"`
- `agent.is_scheduler_paused()``"scheduled"`
- `agent.is_draining()``"draining"`
- `agent.is_boot_analyzing()``"boot_analysis"`
- `agent.in_flight_jobs >= agent.concurrent_jobs_limit()``"slots_full"`
- else → `null` (processing normally)
### Frontend
**`web/src/components/jobs/JobDetailModal.tsx`:**
- Below queue position display, show blocked reason if present
- Fetch from engine status (already available via SSE `EngineStatusChanged` events, or poll `/api/engine/status`)
- Color-coded: yellow for schedule/pause, blue for boot analysis, gray for slots full
### Files to modify
- `src/server/jobs.rs` or wherever `engine_status_handler` lives — extend response
- `web/src/components/jobs/JobDetailModal.tsx` — display blocked reason
- `web/src/components/jobs/useJobSSE.ts` — optionally track engine status via SSE
### Verification
- Pause engine → queued job detail shows "Engine paused"
- Set schedule window outside current time → shows "Outside schedule window"
- Fill all slots → shows "All worker slots occupied"
- Resume → reason disappears
---
## [FG-4] Intelligence Page Actions
### Goal
Add actionable buttons to `LibraryIntelligence.tsx`: delete duplicates, queue remux opportunities.
### Duplicate Group Actions
**"Keep Latest, Delete Rest" button per group:**
- Each duplicate group card gets a "Clean Up" button
- Selects all jobs except the one with latest `updated_at`
- Calls `POST /api/jobs/batch` with `{ action: "delete", ids: [...] }`
- Confirmation modal: "Archive N duplicate jobs?"
**"Clean All Duplicates" bulk button:**
- Top-level button in duplicates section header
- Same logic across all groups
- Shows total count in confirmation
### Recommendation Actions
**"Queue All Remux" button:**
- Gathers IDs of all remux opportunity jobs
- Calls `POST /api/jobs/batch` with `{ action: "restart", ids: [...] }`
- Jobs re-enter queue for remux processing
**Per-recommendation "Queue" button:**
- Individual restart for single recommendation items
### Backend
No new endpoints needed — existing `POST /api/jobs/batch` handles all actions (cancel/delete/restart).
### Frontend
**`web/src/components/LibraryIntelligence.tsx`:**
- Add "Clean Up" button to each duplicate group card
- Add "Clean All Duplicates" button to section header
- Add "Queue All" button to remux opportunities section
- Add confirmation modal component
- Add toast notifications for success/error
- Refresh data after action completes
### Files to modify
- `web/src/components/LibraryIntelligence.tsx` — buttons, modals, action handlers
### Verification
- Click "Clean Up" on duplicate group → archives all but latest
- Click "Queue All Remux" → remux jobs reset to queued
- Confirm counts in modal match actual
- Data refreshes after action
---
## [RG-2] AMD VAAPI/AMF Validation
### Goal
Verify AMD hardware encoder paths produce correct FFmpeg commands on real AMD hardware.
### Problem
`src/media/ffmpeg/vaapi.rs` and `src/media/ffmpeg/amf.rs` were implemented without real hardware validation. Flag mappings, device paths, and quality controls may be incorrect.
### Validation checklist
**VAAPI (Linux):**
- [ ] Device path `/dev/dri/renderD128` detection works
- [ ] `hevc_vaapi` / `h264_vaapi` encoder selection
- [ ] CRF/quality mapping → `-rc_mode CQP -qp N` or `-rc_mode ICQ -quality N`
- [ ] HDR passthrough flags (if applicable)
- [ ] Container compatibility (MKV/MP4)
**AMF (Windows):**
- [ ] `hevc_amf` / `h264_amf` encoder selection
- [ ] Quality mapping → `-quality quality -qp_i N -qp_p N`
- [ ] B-frame support detection
- [ ] HDR passthrough
### Approach
1. Write unit tests for `build_args()` output — verify flag strings without hardware
2. Gate integration tests on `AMD_GPU_AVAILABLE` env var
3. Document known-good flag sets from AMD documentation
4. Add `EncoderCapabilities` detection for AMF/VAAPI (similar to existing NVENC/QSV detection)
### Files to modify
- `src/media/ffmpeg/vaapi.rs` — flag corrections if needed
- `src/media/ffmpeg/amf.rs` — flag corrections if needed
- `tests/` — new integration test file gated on hardware
### Verification
- Unit tests pass on CI (no hardware needed)
- Integration tests pass on AMD hardware (manual)
- Generated FFmpeg commands reviewed against AMD documentation

View File

@@ -1,124 +0,0 @@
# Security Best Practices Report
## Executive Summary
I found one critical security bug and one additional high-severity issue in the setup/bootstrap flow.
The critical problem is that first-run setup is remotely accessible without authentication while the server listens on `0.0.0.0`. A network-reachable attacker can win the initial setup race, create the first admin account, and take over the instance.
I did not find evidence of major client-side XSS sinks or obvious SQL injection paths during this audit. Most of the remaining concerns I saw were hardening-level issues rather than immediately exploitable major bugs.
## Critical Findings
### ALCH-SEC-001
- Severity: Critical
- Location:
- `src/server/middleware.rs:80-86`
- `src/server/wizard.rs:95-210`
- `src/server/mod.rs:176-197`
- `README.md:61-79`
- Impact: Any attacker who can reach the service before the legitimate operator completes setup can create the first admin account and fully compromise the instance.
#### Evidence
`auth_middleware` exempts the full `/api/setup` namespace from authentication:
- `src/server/middleware.rs:80-86`
`setup_complete_handler` only checks `setup_required` and then creates the user, session cookie, and persisted config:
- `src/server/wizard.rs:95-210`
The server binds to all interfaces by default:
- `src/server/mod.rs:176-197`
The documented Docker quick-start publishes port `3000` directly:
- `README.md:61-79`
#### Why This Is Exploitable
On a fresh install, or any run where `setup_required == true`, the application accepts unauthenticated requests to `/api/setup/complete`. Because the listener binds `0.0.0.0`, that endpoint is reachable from any network that can reach the host unless an external firewall or reverse proxy blocks it.
That lets a remote attacker:
1. POST their own username and password to `/api/setup/complete`
2. Receive the initial authenticated session cookie
3. Persist attacker-controlled configuration and start operating as the admin user
This is a full-authentication-bypass takeover of the instance during bootstrap.
#### Recommended Fix
Require setup completion to come only from a trusted local origin during bootstrap, matching the stricter treatment already used for `/api/fs/*` during setup.
Minimal safe options:
1. Restrict `/api/setup/*` and `/api/settings/bundle` to loopback-only while `setup_required == true`.
2. Alternatively require an explicit one-time bootstrap secret/token generated on startup and printed locally.
3. Consider binding to `127.0.0.1` by default until setup is complete, then allowing an explicit public bind only after bootstrap.
#### Mitigation Until Fixed
- Do not expose the service to any network before setup is completed.
- Do not publish the container port directly on untrusted networks.
- Complete setup only through a local-only tunnel or host firewall rule.
## High Findings
### ALCH-SEC-002
- Severity: High
- Location:
- `src/server/middleware.rs:116-117`
- `src/server/settings.rs:244-285`
- `src/config.rs:366-390`
- `src/main.rs:369-383`
- `src/db.rs:2566-2571`
- Impact: During setup mode, an unauthenticated remote attacker can read and overwrite the full runtime configuration; after `--reset-auth`, this can expose existing notification endpoints/tokens and let the attacker reconfigure the instance before the operator reclaims it.
#### Evidence
While `setup_required == true`, `auth_middleware` explicitly allows `/api/settings/bundle` without authentication:
- `src/server/middleware.rs:116-117`
`get_settings_bundle_handler` returns the full `Config`, and `update_settings_bundle_handler` writes an attacker-supplied `Config` back to disk and runtime state:
- `src/server/settings.rs:244-285`
The config structure includes notification targets and optional `auth_token` fields:
- `src/config.rs:366-390`
`--reset-auth` only clears users and sessions, then re-enters setup mode:
- `src/main.rs:369-383`
- `src/db.rs:2566-2571`
#### Why This Is Exploitable
This endpoint is effectively a public config API whenever the app is in setup mode. On a brand-new install that broadens the same bootstrap attack surface as ALCH-SEC-001. On an existing deployment where an operator runs `--reset-auth`, the previous configuration remains on disk while authentication is removed, so a remote caller can:
1. GET `/api/settings/bundle` and read the current config
2. Learn configured paths, schedules, webhook targets, and any stored notification bearer tokens
3. PUT a replacement config before the legitimate operator finishes recovery
That creates both confidential-data exposure and unauthenticated remote reconfiguration during recovery/bootstrap windows.
#### Recommended Fix
Do not expose `/api/settings/bundle` anonymously.
Safer options:
1. Apply the same loopback-only setup restriction used for `/api/fs/*`.
2. Split bootstrap-safe fields from privileged configuration and expose only the minimal bootstrap payload anonymously.
3. Redact secret-bearing config fields such as notification tokens from any unauthenticated response path.
## Notes
- I did not find a major DOM-XSS path in `web/src`; there were no `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, `eval`, or similar high-risk sinks in the audited code paths.
- I also did not see obvious raw SQL string interpolation issues; the database code I reviewed uses parameter binding.

View File

@@ -82,12 +82,12 @@ impl QualityProfile {
} }
} }
/// Get FFmpeg quality value for Apple VideoToolbox /// Get FFmpeg quality value for Apple VideoToolbox (-q:v 1-100, lower is better)
pub fn videotoolbox_quality(&self) -> &'static str { pub fn videotoolbox_quality(&self) -> &'static str {
match self { match self {
Self::Quality => "55", Self::Quality => "24",
Self::Balanced => "65", Self::Balanced => "28",
Self::Speed => "75", Self::Speed => "32",
} }
} }
} }
@@ -684,6 +684,13 @@ pub struct SystemConfig {
/// Enable HSTS header (only enable if running behind HTTPS) /// Enable HSTS header (only enable if running behind HTTPS)
#[serde(default)] #[serde(default)]
pub https_only: bool, pub https_only: bool,
/// Explicit list of reverse proxy IPs (e.g. "192.168.1.1") whose
/// X-Forwarded-For / X-Real-IP headers are trusted. When non-empty,
/// only these IPs (plus loopback) are trusted as proxies; private
/// ranges are no longer trusted by default. Leave empty to preserve
/// the previous behaviour (trust all RFC-1918 private addresses).
#[serde(default)]
pub trusted_proxies: Vec<String>,
} }
fn default_true() -> bool { fn default_true() -> bool {
@@ -710,6 +717,7 @@ impl Default for SystemConfig {
log_retention_days: default_log_retention_days(), log_retention_days: default_log_retention_days(),
engine_mode: EngineMode::default(), engine_mode: EngineMode::default(),
https_only: false, https_only: false,
trusted_proxies: Vec::new(),
} }
} }
} }
@@ -825,6 +833,7 @@ impl Default for Config {
log_retention_days: default_log_retention_days(), log_retention_days: default_log_retention_days(),
engine_mode: EngineMode::default(), engine_mode: EngineMode::default(),
https_only: false, https_only: false,
trusted_proxies: Vec::new(),
}, },
} }
} }

View File

@@ -442,6 +442,7 @@ fn build_rate_control(mode: &str, value: Option<u32>, encoder: Encoder) -> Resul
match encoder.backend() { match encoder.backend() {
EncoderBackend::Qsv => Ok(RateControl::QsvQuality { value: quality }), EncoderBackend::Qsv => Ok(RateControl::QsvQuality { value: quality }),
EncoderBackend::Cpu => Ok(RateControl::Crf { value: quality }), EncoderBackend::Cpu => Ok(RateControl::Crf { value: quality }),
EncoderBackend::Videotoolbox => Ok(RateControl::Cq { value: quality }),
_ => Ok(RateControl::Cq { value: quality }), _ => Ok(RateControl::Cq { value: quality }),
} }
} }

584
src/db/config.rs Normal file
View File

@@ -0,0 +1,584 @@
use crate::error::Result;
use sqlx::Row;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::types::*;
use super::Db;
impl Db {
pub async fn get_watch_dirs(&self) -> Result<Vec<WatchDir>> {
let has_is_recursive = self.watch_dir_flags.has_is_recursive;
let has_recursive = self.watch_dir_flags.has_recursive;
let has_enabled = self.watch_dir_flags.has_enabled;
let has_profile_id = self.watch_dir_flags.has_profile_id;
let recursive_expr = if has_is_recursive {
"is_recursive"
} else if has_recursive {
"recursive"
} else {
"1"
};
let enabled_filter = if has_enabled {
"WHERE enabled = 1 "
} else {
""
};
let profile_expr = if has_profile_id { "profile_id" } else { "NULL" };
let query = format!(
"SELECT id, path, {} as is_recursive, {} as profile_id, created_at
FROM watch_dirs {}ORDER BY path ASC",
recursive_expr, profile_expr, enabled_filter
);
let dirs = sqlx::query_as::<_, WatchDir>(&query)
.fetch_all(&self.pool)
.await?;
Ok(dirs)
}
pub async fn add_watch_dir(&self, path: &str, is_recursive: bool) -> Result<WatchDir> {
let has_is_recursive = self.watch_dir_flags.has_is_recursive;
let has_recursive = self.watch_dir_flags.has_recursive;
let has_profile_id = self.watch_dir_flags.has_profile_id;
let row = if has_is_recursive && has_profile_id {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, is_recursive) VALUES (?, ?)
RETURNING id, path, is_recursive, profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
.fetch_one(&self.pool)
.await?
} else if has_is_recursive {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, is_recursive) VALUES (?, ?)
RETURNING id, path, is_recursive, NULL as profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
.fetch_one(&self.pool)
.await?
} else if has_recursive && has_profile_id {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, recursive) VALUES (?, ?)
RETURNING id, path, recursive as is_recursive, profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
.fetch_one(&self.pool)
.await?
} else if has_recursive {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, recursive) VALUES (?, ?)
RETURNING id, path, recursive as is_recursive, NULL as profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
.fetch_one(&self.pool)
.await?
} else {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path) VALUES (?)
RETURNING id, path, 1 as is_recursive, NULL as profile_id, created_at",
)
.bind(path)
.fetch_one(&self.pool)
.await?
};
Ok(row)
}
pub async fn replace_watch_dirs(
&self,
watch_dirs: &[crate::config::WatchDirConfig],
) -> Result<()> {
let has_is_recursive = self.watch_dir_flags.has_is_recursive;
let has_recursive = self.watch_dir_flags.has_recursive;
let has_profile_id = self.watch_dir_flags.has_profile_id;
let preserved_profiles = if has_profile_id {
let rows = sqlx::query("SELECT path, profile_id FROM watch_dirs")
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
let path: String = row.get("path");
let profile_id: Option<i64> = row.get("profile_id");
(path, profile_id)
})
.collect::<HashMap<_, _>>()
} else {
HashMap::new()
};
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM watch_dirs")
.execute(&mut *tx)
.await?;
for watch_dir in watch_dirs {
let preserved_profile_id = preserved_profiles.get(&watch_dir.path).copied().flatten();
if has_is_recursive && has_profile_id {
sqlx::query(
"INSERT INTO watch_dirs (path, is_recursive, profile_id) VALUES (?, ?, ?)",
)
.bind(&watch_dir.path)
.bind(watch_dir.is_recursive)
.bind(preserved_profile_id)
.execute(&mut *tx)
.await?;
} else if has_recursive && has_profile_id {
sqlx::query(
"INSERT INTO watch_dirs (path, recursive, profile_id) VALUES (?, ?, ?)",
)
.bind(&watch_dir.path)
.bind(watch_dir.is_recursive)
.bind(preserved_profile_id)
.execute(&mut *tx)
.await?;
} else if has_recursive {
sqlx::query("INSERT INTO watch_dirs (path, recursive) VALUES (?, ?)")
.bind(&watch_dir.path)
.bind(watch_dir.is_recursive)
.execute(&mut *tx)
.await?;
} else {
sqlx::query("INSERT INTO watch_dirs (path) VALUES (?)")
.bind(&watch_dir.path)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
pub async fn remove_watch_dir(&self, id: i64) -> Result<()> {
let res = sqlx::query("DELETE FROM watch_dirs WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn get_all_profiles(&self) -> Result<Vec<LibraryProfile>> {
let profiles = sqlx::query_as::<_, LibraryProfile>(
"SELECT id, name, preset, codec, quality_profile, hdr_mode, audio_mode,
crf_override, notes, created_at, updated_at
FROM library_profiles
ORDER BY id ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(profiles)
}
pub async fn get_profile(&self, id: i64) -> Result<Option<LibraryProfile>> {
let profile = sqlx::query_as::<_, LibraryProfile>(
"SELECT id, name, preset, codec, quality_profile, hdr_mode, audio_mode,
crf_override, notes, created_at, updated_at
FROM library_profiles
WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(profile)
}
pub async fn create_profile(&self, profile: NewLibraryProfile) -> Result<i64> {
let id = sqlx::query(
"INSERT INTO library_profiles
(name, preset, codec, quality_profile, hdr_mode, audio_mode, crf_override, notes, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
)
.bind(profile.name)
.bind(profile.preset)
.bind(profile.codec)
.bind(profile.quality_profile)
.bind(profile.hdr_mode)
.bind(profile.audio_mode)
.bind(profile.crf_override)
.bind(profile.notes)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
pub async fn update_profile(&self, id: i64, profile: NewLibraryProfile) -> Result<()> {
let result = sqlx::query(
"UPDATE library_profiles
SET name = ?,
preset = ?,
codec = ?,
quality_profile = ?,
hdr_mode = ?,
audio_mode = ?,
crf_override = ?,
notes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?",
)
.bind(profile.name)
.bind(profile.preset)
.bind(profile.codec)
.bind(profile.quality_profile)
.bind(profile.hdr_mode)
.bind(profile.audio_mode)
.bind(profile.crf_override)
.bind(profile.notes)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn delete_profile(&self, id: i64) -> Result<()> {
let result = sqlx::query("DELETE FROM library_profiles WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn assign_profile_to_watch_dir(
&self,
dir_id: i64,
profile_id: Option<i64>,
) -> Result<()> {
let result = sqlx::query(
"UPDATE watch_dirs
SET profile_id = ?
WHERE id = ?",
)
.bind(profile_id)
.bind(dir_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn get_profile_for_path(&self, path: &str) -> Result<Option<LibraryProfile>> {
let normalized = Path::new(path);
let candidate = sqlx::query_as::<_, LibraryProfile>(
"SELECT lp.id, lp.name, lp.preset, lp.codec, lp.quality_profile, lp.hdr_mode,
lp.audio_mode, lp.crf_override, lp.notes, lp.created_at, lp.updated_at
FROM watch_dirs wd
JOIN library_profiles lp ON lp.id = wd.profile_id
WHERE wd.profile_id IS NOT NULL
AND (? = wd.path OR ? LIKE wd.path || '/%' OR ? LIKE wd.path || '\\%')
ORDER BY LENGTH(wd.path) DESC
LIMIT 1",
)
.bind(path)
.bind(path)
.bind(path)
.fetch_optional(&self.pool)
.await?;
if candidate.is_some() {
return Ok(candidate);
}
// SQLite prefix matching is a fast first pass; fall back to strict path ancestry
// if separators or normalization differ.
let rows = sqlx::query(
"SELECT wd.path,
lp.id, lp.name, lp.preset, lp.codec, lp.quality_profile, lp.hdr_mode,
lp.audio_mode, lp.crf_override, lp.notes, lp.created_at, lp.updated_at
FROM watch_dirs wd
JOIN library_profiles lp ON lp.id = wd.profile_id
WHERE wd.profile_id IS NOT NULL",
)
.fetch_all(&self.pool)
.await?;
let mut best: Option<(usize, LibraryProfile)> = None;
for row in rows {
let watch_path: String = row.get("path");
let profile = LibraryProfile {
id: row.get("id"),
name: row.get("name"),
preset: row.get("preset"),
codec: row.get("codec"),
quality_profile: row.get("quality_profile"),
hdr_mode: row.get("hdr_mode"),
audio_mode: row.get("audio_mode"),
crf_override: row.get("crf_override"),
notes: row.get("notes"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
};
let watch_path_buf = PathBuf::from(&watch_path);
if normalized == watch_path_buf || normalized.starts_with(&watch_path_buf) {
let score = watch_path.len();
if best
.as_ref()
.is_none_or(|(best_score, _)| score > *best_score)
{
best = Some((score, profile));
}
}
}
Ok(best.map(|(_, profile)| profile))
}
pub async fn count_watch_dirs_using_profile(&self, profile_id: i64) -> Result<i64> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM watch_dirs WHERE profile_id = ?")
.bind(profile_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn get_notification_targets(&self) -> Result<Vec<NotificationTarget>> {
let targets = sqlx::query_as::<_, NotificationTarget>(
"SELECT id, name, target_type, config_json, events, enabled, created_at FROM notification_targets",
)
.fetch_all(&self.pool)
.await?;
Ok(targets)
}
pub async fn add_notification_target(
&self,
name: &str,
target_type: &str,
config_json: &str,
events: &str,
enabled: bool,
) -> Result<NotificationTarget> {
let row = sqlx::query_as::<_, NotificationTarget>(
"INSERT INTO notification_targets (name, target_type, config_json, events, enabled)
VALUES (?, ?, ?, ?, ?) RETURNING *",
)
.bind(name)
.bind(target_type)
.bind(config_json)
.bind(events)
.bind(enabled)
.fetch_one(&self.pool)
.await?;
Ok(row)
}
pub async fn delete_notification_target(&self, id: i64) -> Result<()> {
let res = sqlx::query("DELETE FROM notification_targets WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn replace_notification_targets(
&self,
targets: &[crate::config::NotificationTargetConfig],
) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM notification_targets")
.execute(&mut *tx)
.await?;
for target in targets {
sqlx::query(
"INSERT INTO notification_targets (name, target_type, config_json, events, enabled) VALUES (?, ?, ?, ?, ?)",
)
.bind(&target.name)
.bind(&target.target_type)
.bind(target.config_json.to_string())
.bind(serde_json::to_string(&target.events).unwrap_or_else(|_| "[]".to_string()))
.bind(target.enabled)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn get_schedule_windows(&self) -> Result<Vec<ScheduleWindow>> {
let windows = sqlx::query_as::<_, ScheduleWindow>("SELECT * FROM schedule_windows")
.fetch_all(&self.pool)
.await?;
Ok(windows)
}
pub async fn add_schedule_window(
&self,
start_time: &str,
end_time: &str,
days_of_week: &str,
enabled: bool,
) -> Result<ScheduleWindow> {
let row = sqlx::query_as::<_, ScheduleWindow>(
"INSERT INTO schedule_windows (start_time, end_time, days_of_week, enabled)
VALUES (?, ?, ?, ?)
RETURNING *",
)
.bind(start_time)
.bind(end_time)
.bind(days_of_week)
.bind(enabled)
.fetch_one(&self.pool)
.await?;
Ok(row)
}
pub async fn delete_schedule_window(&self, id: i64) -> Result<()> {
let res = sqlx::query("DELETE FROM schedule_windows WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn replace_schedule_windows(
&self,
windows: &[crate::config::ScheduleWindowConfig],
) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM schedule_windows")
.execute(&mut *tx)
.await?;
for window in windows {
sqlx::query(
"INSERT INTO schedule_windows (start_time, end_time, days_of_week, enabled) VALUES (?, ?, ?, ?)",
)
.bind(&window.start_time)
.bind(&window.end_time)
.bind(serde_json::to_string(&window.days_of_week).unwrap_or_else(|_| "[]".to_string()))
.bind(window.enabled)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn get_file_settings(&self) -> Result<FileSettings> {
// Migration ensures row 1 exists, but we handle missing just in case
let row = sqlx::query_as::<_, FileSettings>("SELECT * FROM file_settings WHERE id = 1")
.fetch_optional(&self.pool)
.await?;
match row {
Some(s) => Ok(s),
None => {
// If missing (shouldn't happen), return default
Ok(FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
output_root: None,
})
}
}
}
pub async fn update_file_settings(
&self,
delete_source: bool,
output_extension: &str,
output_suffix: &str,
replace_strategy: &str,
output_root: Option<&str>,
) -> Result<FileSettings> {
let row = sqlx::query_as::<_, FileSettings>(
"UPDATE file_settings
SET delete_source = ?, output_extension = ?, output_suffix = ?, replace_strategy = ?, output_root = ?
WHERE id = 1
RETURNING *",
)
.bind(delete_source)
.bind(output_extension)
.bind(output_suffix)
.bind(replace_strategy)
.bind(output_root)
.fetch_one(&self.pool)
.await?;
Ok(row)
}
pub async fn replace_file_settings_projection(
&self,
settings: &crate::config::FileSettingsConfig,
) -> Result<FileSettings> {
self.update_file_settings(
settings.delete_source,
&settings.output_extension,
&settings.output_suffix,
&settings.replace_strategy,
settings.output_root.as_deref(),
)
.await
}
/// Set UI preference
pub async fn set_preference(&self, key: &str, value: &str) -> Result<()> {
sqlx::query(
"INSERT INTO ui_preferences (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
)
.bind(key)
.bind(value)
.execute(&self.pool)
.await?;
Ok(())
}
/// Get UI preference
pub async fn get_preference(&self, key: &str) -> Result<Option<String>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM ui_preferences WHERE key = ?")
.bind(key)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.0))
}
pub async fn delete_preference(&self, key: &str) -> Result<()> {
sqlx::query("DELETE FROM ui_preferences WHERE key = ?")
.bind(key)
.execute(&self.pool)
.await?;
Ok(())
}
}

152
src/db/conversion.rs Normal file
View File

@@ -0,0 +1,152 @@
use crate::error::Result;
use super::types::*;
use super::Db;
impl Db {
pub async fn create_conversion_job(
&self,
upload_path: &str,
mode: &str,
settings_json: &str,
probe_json: Option<&str>,
expires_at: &str,
) -> Result<ConversionJob> {
let row = sqlx::query_as::<_, ConversionJob>(
"INSERT INTO conversion_jobs (upload_path, mode, settings_json, probe_json, expires_at)
VALUES (?, ?, ?, ?, ?)
RETURNING *",
)
.bind(upload_path)
.bind(mode)
.bind(settings_json)
.bind(probe_json)
.bind(expires_at)
.fetch_one(&self.pool)
.await?;
Ok(row)
}
pub async fn get_conversion_job(&self, id: i64) -> Result<Option<ConversionJob>> {
let row = sqlx::query_as::<_, ConversionJob>(
"SELECT id, upload_path, output_path, mode, settings_json, probe_json, linked_job_id, status, expires_at, downloaded_at, created_at, updated_at
FROM conversion_jobs
WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn get_conversion_job_by_linked_job_id(
&self,
linked_job_id: i64,
) -> Result<Option<ConversionJob>> {
let row = sqlx::query_as::<_, ConversionJob>(
"SELECT id, upload_path, output_path, mode, settings_json, probe_json, linked_job_id, status, expires_at, downloaded_at, created_at, updated_at
FROM conversion_jobs
WHERE linked_job_id = ?",
)
.bind(linked_job_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn update_conversion_job_probe(&self, id: i64, probe_json: &str) -> Result<()> {
sqlx::query(
"UPDATE conversion_jobs
SET probe_json = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(probe_json)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn update_conversion_job_settings(
&self,
id: i64,
settings_json: &str,
mode: &str,
) -> Result<()> {
sqlx::query(
"UPDATE conversion_jobs
SET settings_json = ?, mode = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(settings_json)
.bind(mode)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn update_conversion_job_start(
&self,
id: i64,
output_path: &str,
linked_job_id: i64,
) -> Result<()> {
sqlx::query(
"UPDATE conversion_jobs
SET output_path = ?, linked_job_id = ?, status = 'queued', updated_at = datetime('now')
WHERE id = ?",
)
.bind(output_path)
.bind(linked_job_id)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn update_conversion_job_status(&self, id: i64, status: &str) -> Result<()> {
sqlx::query(
"UPDATE conversion_jobs
SET status = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(status)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn mark_conversion_job_downloaded(&self, id: i64) -> Result<()> {
sqlx::query(
"UPDATE conversion_jobs
SET downloaded_at = datetime('now'), status = 'downloaded', updated_at = datetime('now')
WHERE id = ?",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_conversion_job(&self, id: i64) -> Result<()> {
sqlx::query("DELETE FROM conversion_jobs WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_expired_conversion_jobs(&self, now: &str) -> Result<Vec<ConversionJob>> {
let rows = sqlx::query_as::<_, ConversionJob>(
"SELECT id, upload_path, output_path, mode, settings_json, probe_json, linked_job_id, status, expires_at, downloaded_at, created_at, updated_at
FROM conversion_jobs
WHERE expires_at <= ?",
)
.bind(now)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
}

54
src/db/events.rs Normal file
View File

@@ -0,0 +1,54 @@
use crate::explanations::Explanation;
use serde::{Deserialize, Serialize};
use super::types::JobState;
// Typed event channels for separating high-volume vs low-volume events
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum JobEvent {
StateChanged {
job_id: i64,
status: JobState,
},
Progress {
job_id: i64,
percentage: f64,
time: String,
},
Decision {
job_id: i64,
action: String,
reason: String,
explanation: Option<Explanation>,
},
Log {
level: String,
job_id: Option<i64>,
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum ConfigEvent {
Updated(Box<crate::config::Config>),
WatchFolderAdded(String),
WatchFolderRemoved(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum SystemEvent {
ScanStarted,
ScanCompleted,
EngineIdle,
EngineStatusChanged,
HardwareStateChanged,
}
pub struct EventChannels {
pub jobs: tokio::sync::broadcast::Sender<JobEvent>, // 1000 capacity - high volume
pub config: tokio::sync::broadcast::Sender<ConfigEvent>, // 50 capacity - rare
pub system: tokio::sync::broadcast::Sender<SystemEvent>, // 100 capacity - medium
}

1076
src/db/jobs.rs Normal file

File diff suppressed because it is too large Load Diff

148
src/db/mod.rs Normal file
View File

@@ -0,0 +1,148 @@
mod config;
mod conversion;
mod events;
mod jobs;
mod stats;
mod system;
mod types;
pub use events::*;
pub use types::*;
use crate::error::{AlchemistError, Result};
use sha2::{Digest, Sha256};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
use sqlx::SqlitePool;
use std::time::Duration;
use tokio::time::timeout;
use tracing::info;
/// Default timeout for potentially slow database queries
pub(crate) const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
/// Execute a query with a timeout to prevent blocking the job loop
pub(crate) async fn timed_query<T, F, Fut>(operation: &str, f: F) -> Result<T>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
match timeout(QUERY_TIMEOUT, f()).await {
Ok(result) => result,
Err(_) => Err(AlchemistError::QueryTimeout(
QUERY_TIMEOUT.as_secs(),
operation.to_string(),
)),
}
}
#[derive(Clone, Debug)]
pub(crate) struct WatchDirSchemaFlags {
has_is_recursive: bool,
has_recursive: bool,
has_enabled: bool,
has_profile_id: bool,
}
#[derive(Clone, Debug)]
pub struct Db {
pub(crate) pool: SqlitePool,
pub(crate) watch_dir_flags: std::sync::Arc<WatchDirSchemaFlags>,
}
impl Db {
pub async fn new(db_path: &str) -> Result<Self> {
let start = std::time::Instant::now();
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(5));
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await?;
info!(
target: "startup",
"Database connection opened in {} ms",
start.elapsed().as_millis()
);
// Run migrations
let migrate_start = std::time::Instant::now();
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| crate::error::AlchemistError::Database(e.into()))?;
info!(
target: "startup",
"Database migrations completed in {} ms",
migrate_start.elapsed().as_millis()
);
// Cache watch_dirs schema flags once at startup to avoid repeated PRAGMA queries.
let check = |column: &str| {
let pool = pool.clone();
let column = column.to_string();
async move {
let row =
sqlx::query("SELECT name FROM pragma_table_info('watch_dirs') WHERE name = ?")
.bind(&column)
.fetch_optional(&pool)
.await
.unwrap_or(None);
row.is_some()
}
};
let watch_dir_flags = WatchDirSchemaFlags {
has_is_recursive: check("is_recursive").await,
has_recursive: check("recursive").await,
has_enabled: check("enabled").await,
has_profile_id: check("profile_id").await,
};
Ok(Self {
pool,
watch_dir_flags: std::sync::Arc::new(watch_dir_flags),
})
}
}
/// Hash a session token using SHA256 for secure storage.
///
/// # Security: Timing Attack Resistance
///
/// Session tokens are hashed before storage and lookup. Token validation uses
/// SQL `WHERE token = ?` with the hashed value, so the comparison occurs in
/// SQLite rather than in Rust code. This is inherently constant-time from the
/// application's perspective because:
/// 1. The database performs the comparison, not our code
/// 2. Database query time doesn't leak information about partial matches
/// 3. No early-exit comparison in application code
///
/// This design makes timing attacks infeasible without requiring the `subtle`
/// crate for constant-time comparison.
pub(crate) fn hash_session_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for byte in digest {
use std::fmt::Write;
let _ = write!(&mut out, "{:02x}", byte);
}
out
}
pub fn hash_api_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for byte in digest {
use std::fmt::Write;
let _ = write!(&mut out, "{:02x}", byte);
}
out
}

422
src/db/stats.rs Normal file
View File

@@ -0,0 +1,422 @@
use crate::error::Result;
use sqlx::Row;
use super::timed_query;
use super::types::*;
use super::Db;
impl Db {
pub async fn get_stats(&self) -> Result<serde_json::Value> {
let pool = &self.pool;
timed_query("get_stats", || async {
let stats = sqlx::query("SELECT status, count(*) as count FROM jobs GROUP BY status")
.fetch_all(pool)
.await?;
let mut map = serde_json::Map::new();
for row in stats {
use sqlx::Row;
let status: String = row.get("status");
let count: i64 = row.get("count");
map.insert(status, serde_json::Value::Number(count.into()));
}
Ok(serde_json::Value::Object(map))
})
.await
}
/// Save encode statistics
pub async fn save_encode_stats(&self, stats: EncodeStatsInput) -> Result<()> {
let result = sqlx::query(
"INSERT INTO encode_stats
(job_id, input_size_bytes, output_size_bytes, compression_ratio,
encode_time_seconds, encode_speed, avg_bitrate_kbps, vmaf_score, output_codec)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(job_id) DO UPDATE SET
input_size_bytes = excluded.input_size_bytes,
output_size_bytes = excluded.output_size_bytes,
compression_ratio = excluded.compression_ratio,
encode_time_seconds = excluded.encode_time_seconds,
encode_speed = excluded.encode_speed,
avg_bitrate_kbps = excluded.avg_bitrate_kbps,
vmaf_score = excluded.vmaf_score,
output_codec = excluded.output_codec",
)
.bind(stats.job_id)
.bind(stats.input_size as i64)
.bind(stats.output_size as i64)
.bind(stats.compression_ratio)
.bind(stats.encode_time)
.bind(stats.encode_speed)
.bind(stats.avg_bitrate)
.bind(stats.vmaf_score)
.bind(stats.output_codec)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
/// Record a single encode attempt outcome
pub async fn insert_encode_attempt(&self, input: EncodeAttemptInput) -> Result<()> {
sqlx::query(
"INSERT INTO encode_attempts
(job_id, attempt_number, started_at, finished_at, outcome,
failure_code, failure_summary, input_size_bytes, output_size_bytes,
encode_time_seconds)
VALUES (?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?)",
)
.bind(input.job_id)
.bind(input.attempt_number)
.bind(input.started_at)
.bind(input.outcome)
.bind(input.failure_code)
.bind(input.failure_summary)
.bind(input.input_size_bytes)
.bind(input.output_size_bytes)
.bind(input.encode_time_seconds)
.execute(&self.pool)
.await?;
Ok(())
}
/// Get all encode attempts for a job, ordered by attempt_number
pub async fn get_encode_attempts_by_job(&self, job_id: i64) -> Result<Vec<EncodeAttempt>> {
let attempts = sqlx::query_as::<_, EncodeAttempt>(
"SELECT id, job_id, attempt_number, started_at, finished_at, outcome,
failure_code, failure_summary, input_size_bytes, output_size_bytes,
encode_time_seconds, created_at
FROM encode_attempts
WHERE job_id = ?
ORDER BY attempt_number ASC",
)
.bind(job_id)
.fetch_all(&self.pool)
.await?;
Ok(attempts)
}
pub async fn get_encode_stats_by_job_id(&self, job_id: i64) -> Result<DetailedEncodeStats> {
let stats = sqlx::query_as::<_, DetailedEncodeStats>(
"SELECT
e.job_id,
j.input_path,
e.input_size_bytes,
e.output_size_bytes,
e.compression_ratio,
e.encode_time_seconds,
e.encode_speed,
e.avg_bitrate_kbps,
e.vmaf_score,
e.created_at
FROM encode_stats e
JOIN jobs j ON e.job_id = j.id
WHERE e.job_id = ?",
)
.bind(job_id)
.fetch_one(&self.pool)
.await?;
Ok(stats)
}
pub async fn get_aggregated_stats(&self) -> Result<AggregatedStats> {
let pool = &self.pool;
timed_query("get_aggregated_stats", || async {
let row = sqlx::query(
"SELECT
(SELECT COUNT(*) FROM jobs WHERE archived = 0) as total_jobs,
(SELECT COUNT(*) FROM jobs WHERE status = 'completed' AND archived = 0) as completed_jobs,
COALESCE(SUM(input_size_bytes), 0) as total_input_size,
COALESCE(SUM(output_size_bytes), 0) as total_output_size,
AVG(vmaf_score) as avg_vmaf,
COALESCE(SUM(encode_time_seconds), 0.0) as total_encode_time
FROM encode_stats",
)
.fetch_one(pool)
.await?;
Ok(AggregatedStats {
total_jobs: row.get("total_jobs"),
completed_jobs: row.get("completed_jobs"),
total_input_size: row.get("total_input_size"),
total_output_size: row.get("total_output_size"),
avg_vmaf: row.get("avg_vmaf"),
total_encode_time_seconds: row.get("total_encode_time"),
})
})
.await
}
/// Get daily statistics for the last N days (for time-series charts)
pub async fn get_daily_stats(&self, days: i32) -> Result<Vec<DailyStats>> {
let pool = &self.pool;
let days_str = format!("-{}", days);
timed_query("get_daily_stats", || async {
let rows = sqlx::query(
"SELECT
DATE(e.created_at) as date,
COUNT(*) as jobs_completed,
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved,
COALESCE(SUM(e.input_size_bytes), 0) as total_input_bytes,
COALESCE(SUM(e.output_size_bytes), 0) as total_output_bytes
FROM encode_stats e
WHERE e.created_at >= DATE('now', ? || ' days')
GROUP BY DATE(e.created_at)
ORDER BY date ASC",
)
.bind(&days_str)
.fetch_all(pool)
.await?;
let stats = rows
.iter()
.map(|row| DailyStats {
date: row.get("date"),
jobs_completed: row.get("jobs_completed"),
bytes_saved: row.get("bytes_saved"),
total_input_bytes: row.get("total_input_bytes"),
total_output_bytes: row.get("total_output_bytes"),
})
.collect();
Ok(stats)
})
.await
}
/// Get detailed per-job encoding statistics (most recent first)
pub async fn get_detailed_encode_stats(&self, limit: i32) -> Result<Vec<DetailedEncodeStats>> {
let pool = &self.pool;
timed_query("get_detailed_encode_stats", || async {
let stats = sqlx::query_as::<_, DetailedEncodeStats>(
"SELECT
e.job_id,
j.input_path,
e.input_size_bytes,
e.output_size_bytes,
e.compression_ratio,
e.encode_time_seconds,
e.encode_speed,
e.avg_bitrate_kbps,
e.vmaf_score,
e.created_at
FROM encode_stats e
JOIN jobs j ON e.job_id = j.id
ORDER BY e.created_at DESC
LIMIT ?",
)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(stats)
})
.await
}
pub async fn get_savings_summary(&self) -> Result<SavingsSummary> {
let pool = &self.pool;
timed_query("get_savings_summary", || async {
let totals = sqlx::query(
"SELECT
COALESCE(SUM(input_size_bytes), 0) as total_input_bytes,
COALESCE(SUM(output_size_bytes), 0) as total_output_bytes,
COUNT(*) as job_count
FROM encode_stats
WHERE output_size_bytes IS NOT NULL",
)
.fetch_one(pool)
.await?;
let total_input_bytes: i64 = totals.get("total_input_bytes");
let total_output_bytes: i64 = totals.get("total_output_bytes");
let job_count: i64 = totals.get("job_count");
let total_bytes_saved = (total_input_bytes - total_output_bytes).max(0);
let savings_percent = if total_input_bytes > 0 {
(total_bytes_saved as f64 / total_input_bytes as f64) * 100.0
} else {
0.0
};
let savings_by_codec = sqlx::query(
"SELECT
COALESCE(NULLIF(TRIM(e.output_codec), ''), 'unknown') as codec,
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved,
COUNT(*) as job_count
FROM encode_stats e
JOIN jobs j ON j.id = e.job_id
WHERE e.output_size_bytes IS NOT NULL
GROUP BY codec
ORDER BY bytes_saved DESC, codec ASC",
)
.fetch_all(pool)
.await?
.into_iter()
.map(|row| CodecSavings {
codec: row.get("codec"),
bytes_saved: row.get("bytes_saved"),
job_count: row.get("job_count"),
})
.collect::<Vec<_>>();
let savings_over_time = sqlx::query(
"SELECT
DATE(e.created_at) as date,
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved
FROM encode_stats e
WHERE e.output_size_bytes IS NOT NULL
AND e.created_at >= datetime('now', '-30 days')
GROUP BY DATE(e.created_at)
ORDER BY date ASC",
)
.fetch_all(pool)
.await?
.into_iter()
.map(|row| DailySavings {
date: row.get("date"),
bytes_saved: row.get("bytes_saved"),
})
.collect::<Vec<_>>();
Ok(SavingsSummary {
total_input_bytes,
total_output_bytes,
total_bytes_saved,
savings_percent,
job_count,
savings_by_codec,
savings_over_time,
})
})
.await
}
pub async fn get_job_stats(&self) -> Result<JobStats> {
let pool = &self.pool;
timed_query("get_job_stats", || async {
let rows = sqlx::query(
"SELECT status, COUNT(*) as count FROM jobs WHERE archived = 0 GROUP BY status",
)
.fetch_all(pool)
.await?;
let mut stats = JobStats::default();
for row in rows {
let status_str: String = row.get("status");
let count: i64 = row.get("count");
match status_str.as_str() {
"queued" => stats.queued += count,
"encoding" | "analyzing" | "remuxing" | "resuming" => stats.active += count,
"completed" => stats.completed += count,
"failed" | "cancelled" => stats.failed += count,
_ => {}
}
}
Ok(stats)
})
.await
}
pub async fn get_daily_summary_stats(&self) -> Result<DailySummaryStats> {
let pool = &self.pool;
timed_query("get_daily_summary_stats", || async {
let row = sqlx::query(
"SELECT
COALESCE(SUM(CASE WHEN status = 'completed' AND DATE(updated_at, 'localtime') = DATE('now', 'localtime') THEN 1 ELSE 0 END), 0) AS completed,
COALESCE(SUM(CASE WHEN status = 'failed' AND DATE(updated_at, 'localtime') = DATE('now', 'localtime') THEN 1 ELSE 0 END), 0) AS failed,
COALESCE(SUM(CASE WHEN status = 'skipped' AND DATE(updated_at, 'localtime') = DATE('now', 'localtime') THEN 1 ELSE 0 END), 0) AS skipped
FROM jobs",
)
.fetch_one(pool)
.await?;
let completed: i64 = row.get("completed");
let failed: i64 = row.get("failed");
let skipped: i64 = row.get("skipped");
let bytes_row = sqlx::query(
"SELECT COALESCE(SUM(input_size_bytes - output_size_bytes), 0) AS bytes_saved
FROM encode_stats
WHERE DATE(created_at, 'localtime') = DATE('now', 'localtime')",
)
.fetch_one(pool)
.await?;
let bytes_saved: i64 = bytes_row.get("bytes_saved");
let failure_rows = sqlx::query(
"SELECT code, COUNT(*) AS count
FROM job_failure_explanations
WHERE DATE(updated_at, 'localtime') = DATE('now', 'localtime')
GROUP BY code
ORDER BY count DESC, code ASC
LIMIT 3",
)
.fetch_all(pool)
.await?;
let top_failure_reasons = failure_rows
.into_iter()
.map(|row| row.get::<String, _>("code"))
.collect::<Vec<_>>();
let skip_rows = sqlx::query(
"SELECT COALESCE(reason_code, action) AS code, COUNT(*) AS count
FROM decisions
WHERE action = 'skip'
AND DATE(created_at, 'localtime') = DATE('now', 'localtime')
GROUP BY COALESCE(reason_code, action)
ORDER BY count DESC, code ASC
LIMIT 3",
)
.fetch_all(pool)
.await?;
let top_skip_reasons = skip_rows
.into_iter()
.map(|row| row.get::<String, _>("code"))
.collect::<Vec<_>>();
Ok(DailySummaryStats {
completed,
failed,
skipped,
bytes_saved,
top_failure_reasons,
top_skip_reasons,
})
})
.await
}
pub async fn get_skip_reason_counts(&self) -> Result<Vec<(String, i64)>> {
let pool = &self.pool;
timed_query("get_skip_reason_counts", || async {
let rows = sqlx::query(
"SELECT COALESCE(reason_code, action) AS code, COUNT(*) AS count
FROM decisions
WHERE action = 'skip'
AND DATE(created_at, 'localtime') = DATE('now', 'localtime')
GROUP BY COALESCE(reason_code, action)
ORDER BY count DESC, code ASC
LIMIT 20",
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| {
let code: String = row.get("code");
let count: i64 = row.get("count");
(code, count)
})
.collect())
})
.await
}
}

389
src/db/system.rs Normal file
View File

@@ -0,0 +1,389 @@
use chrono::{DateTime, Utc};
use crate::error::Result;
use sqlx::Row;
use super::timed_query;
use super::types::*;
use super::{hash_api_token, hash_session_token, Db};
impl Db {
pub async fn clear_completed_jobs(&self) -> Result<u64> {
let result = sqlx::query(
"UPDATE jobs
SET archived = 1, updated_at = CURRENT_TIMESTAMP
WHERE status = 'completed' AND archived = 0",
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn cleanup_sessions(&self) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP")
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn cleanup_expired_sessions(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP")
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn add_log(&self, level: &str, job_id: Option<i64>, message: &str) -> Result<()> {
sqlx::query("INSERT INTO logs (level, job_id, message) VALUES (?, ?, ?)")
.bind(level)
.bind(job_id)
.bind(message)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_logs(&self, limit: i64, offset: i64) -> Result<Vec<LogEntry>> {
let logs = sqlx::query_as::<_, LogEntry>(
"SELECT id, level, job_id, message, created_at FROM logs ORDER BY created_at DESC LIMIT ? OFFSET ?"
)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(logs)
}
pub async fn get_logs_for_job(&self, job_id: i64, limit: i64) -> Result<Vec<LogEntry>> {
sqlx::query_as::<_, LogEntry>(
"SELECT id, level, job_id, message, created_at
FROM logs
WHERE job_id = ?
ORDER BY created_at ASC
LIMIT ?",
)
.bind(job_id)
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(Into::into)
}
pub async fn clear_logs(&self) -> Result<()> {
sqlx::query("DELETE FROM logs").execute(&self.pool).await?;
Ok(())
}
pub async fn prune_old_logs(&self, max_age_days: u32) -> Result<u64> {
let result = sqlx::query(
"DELETE FROM logs
WHERE created_at < datetime('now', '-' || ? || ' days')",
)
.bind(max_age_days as i64)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<i64> {
let id = sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
.bind(username)
.bind(password_hash)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(user)
}
pub async fn has_users(&self) -> Result<bool> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await?;
Ok(count.0 > 0)
}
pub async fn create_session(
&self,
user_id: i64,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<()> {
let token_hash = hash_session_token(token);
sqlx::query("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)")
.bind(token_hash)
.bind(user_id)
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_session(&self, token: &str) -> Result<Option<Session>> {
let token_hash = hash_session_token(token);
let session = sqlx::query_as::<_, Session>(
"SELECT * FROM sessions WHERE token = ? AND expires_at > CURRENT_TIMESTAMP",
)
.bind(&token_hash)
.fetch_optional(&self.pool)
.await?;
Ok(session)
}
pub async fn delete_session(&self, token: &str) -> Result<()> {
let token_hash = hash_session_token(token);
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(&token_hash)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_api_tokens(&self) -> Result<Vec<ApiToken>> {
let tokens = sqlx::query_as::<_, ApiToken>(
"SELECT id, name, access_level, created_at, last_used_at, revoked_at
FROM api_tokens
ORDER BY created_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(tokens)
}
pub async fn create_api_token(
&self,
name: &str,
token: &str,
access_level: ApiTokenAccessLevel,
) -> Result<ApiToken> {
let token_hash = hash_api_token(token);
let row = sqlx::query_as::<_, ApiToken>(
"INSERT INTO api_tokens (name, token_hash, access_level)
VALUES (?, ?, ?)
RETURNING id, name, access_level, created_at, last_used_at, revoked_at",
)
.bind(name)
.bind(token_hash)
.bind(access_level)
.fetch_one(&self.pool)
.await?;
Ok(row)
}
pub async fn get_active_api_token(&self, token: &str) -> Result<Option<ApiTokenRecord>> {
let token_hash = hash_api_token(token);
let row = sqlx::query_as::<_, ApiTokenRecord>(
"SELECT id, name, token_hash, access_level, created_at, last_used_at, revoked_at
FROM api_tokens
WHERE token_hash = ? AND revoked_at IS NULL",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn update_api_token_last_used(&self, id: i64) -> Result<()> {
sqlx::query("UPDATE api_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn revoke_api_token(&self, id: i64) -> Result<()> {
let result = sqlx::query(
"UPDATE api_tokens
SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
WHERE id = ?",
)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn record_health_check(
&self,
job_id: i64,
issues: Option<&crate::media::health::HealthIssueReport>,
) -> Result<()> {
let serialized_issues = issues
.map(serde_json::to_string)
.transpose()
.map_err(|err| {
crate::error::AlchemistError::Unknown(format!(
"Failed to serialize health issue report: {}",
err
))
})?;
sqlx::query(
"UPDATE jobs
SET health_issues = ?,
last_health_check = datetime('now')
WHERE id = ?",
)
.bind(serialized_issues)
.bind(job_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_health_summary(&self) -> Result<HealthSummary> {
let pool = &self.pool;
timed_query("get_health_summary", || async {
let row = sqlx::query(
"SELECT
(SELECT COUNT(*) FROM jobs WHERE last_health_check IS NOT NULL AND archived = 0) as total_checked,
(SELECT COUNT(*)
FROM jobs
WHERE health_issues IS NOT NULL AND TRIM(health_issues) != '' AND archived = 0) as issues_found,
(SELECT MAX(started_at) FROM health_scan_runs) as last_run",
)
.fetch_one(pool)
.await?;
Ok(HealthSummary {
total_checked: row.get("total_checked"),
issues_found: row.get("issues_found"),
last_run: row.get("last_run"),
})
})
.await
}
pub async fn create_health_scan_run(&self) -> Result<i64> {
let id = sqlx::query("INSERT INTO health_scan_runs DEFAULT VALUES")
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
pub async fn complete_health_scan_run(
&self,
id: i64,
files_checked: i64,
issues_found: i64,
) -> Result<()> {
sqlx::query(
"UPDATE health_scan_runs
SET completed_at = datetime('now'),
files_checked = ?,
issues_found = ?
WHERE id = ?",
)
.bind(files_checked)
.bind(issues_found)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_jobs_with_health_issues(&self) -> Result<Vec<JobWithHealthIssueRow>> {
let pool = &self.pool;
timed_query("get_jobs_with_health_issues", || async {
let jobs = sqlx::query_as::<_, JobWithHealthIssueRow>(
"SELECT j.id, j.input_path, j.output_path, j.status,
(SELECT reason FROM decisions WHERE job_id = j.id ORDER BY created_at DESC LIMIT 1) as decision_reason,
COALESCE(j.priority, 0) as priority,
COALESCE(CAST(j.progress AS REAL), 0.0) as progress,
COALESCE(j.attempt_count, 0) as attempt_count,
(SELECT vmaf_score FROM encode_stats WHERE job_id = j.id) as vmaf_score,
j.created_at, j.updated_at,
j.input_metadata_json,
j.health_issues
FROM jobs j
WHERE j.archived = 0
AND j.health_issues IS NOT NULL
AND TRIM(j.health_issues) != ''
ORDER BY j.updated_at DESC",
)
.fetch_all(pool)
.await?;
Ok(jobs)
})
.await
}
pub async fn reset_auth(&self) -> Result<()> {
sqlx::query("DELETE FROM sessions")
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM users").execute(&self.pool).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::time::SystemTime;
#[tokio::test]
async fn clear_completed_archives_jobs_but_preserves_encode_stats()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let mut db_path = std::env::temp_dir();
let token: u64 = rand::random();
db_path.push(format!("alchemist_archive_completed_{}.db", token));
let db = Db::new(db_path.to_string_lossy().as_ref()).await?;
let input = Path::new("movie.mkv");
let output = Path::new("movie-alchemist.mkv");
let _ = db
.enqueue_job(input, output, SystemTime::UNIX_EPOCH)
.await?;
let job = db
.get_job_by_input_path("movie.mkv")
.await?
.ok_or_else(|| std::io::Error::other("missing job"))?;
db.update_job_status(job.id, JobState::Completed).await?;
db.save_encode_stats(EncodeStatsInput {
job_id: job.id,
input_size: 2_000,
output_size: 1_000,
compression_ratio: 0.5,
encode_time: 42.0,
encode_speed: 1.2,
avg_bitrate: 800.0,
vmaf_score: Some(96.5),
output_codec: Some("av1".to_string()),
})
.await?;
let cleared = db.clear_completed_jobs().await?;
assert_eq!(cleared, 1);
assert!(db.get_job_by_id(job.id).await?.is_none());
assert!(db.get_job_by_input_path("movie.mkv").await?.is_none());
let visible_completed = db.get_jobs_by_status(JobState::Completed).await?;
assert!(visible_completed.is_empty());
let aggregated = db.get_aggregated_stats().await?;
// Archived jobs are excluded from active stats.
assert_eq!(aggregated.completed_jobs, 0);
// encode_stats rows are preserved even after archiving.
assert_eq!(aggregated.total_input_size, 2_000);
assert_eq!(aggregated.total_output_size, 1_000);
drop(db);
let _ = std::fs::remove_file(db_path);
Ok(())
}
}

640
src/db/types.rs Normal file
View File

@@ -0,0 +1,640 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum JobState {
Queued,
Analyzing,
Encoding,
Remuxing,
Completed,
Skipped,
Failed,
Cancelled,
Resuming,
}
impl std::fmt::Display for JobState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
JobState::Queued => "queued",
JobState::Analyzing => "analyzing",
JobState::Encoding => "encoding",
JobState::Remuxing => "remuxing",
JobState::Completed => "completed",
JobState::Skipped => "skipped",
JobState::Failed => "failed",
JobState::Cancelled => "cancelled",
JobState::Resuming => "resuming",
};
write!(f, "{}", s)
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(default)]
pub struct JobStats {
pub active: i64,
pub queued: i64,
pub completed: i64,
pub failed: i64,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(default)]
pub struct DailySummaryStats {
pub completed: i64,
pub failed: i64,
pub skipped: i64,
pub bytes_saved: i64,
pub top_failure_reasons: Vec<String>,
pub top_skip_reasons: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct LogEntry {
pub id: i64,
pub level: String,
pub job_id: Option<i64>,
pub message: String,
pub created_at: String, // SQLite datetime as string
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct Job {
pub id: i64,
pub input_path: String,
pub output_path: String,
pub status: JobState,
pub decision_reason: Option<String>,
pub priority: i32,
pub progress: f64,
pub attempt_count: i32,
pub vmaf_score: Option<f64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub input_metadata_json: Option<String>,
}
impl Job {
pub fn input_metadata(&self) -> Option<crate::media::pipeline::MediaMetadata> {
self.input_metadata_json
.as_ref()
.and_then(|json| serde_json::from_str(json).ok())
}
pub fn is_active(&self) -> bool {
matches!(
self.status,
JobState::Encoding | JobState::Analyzing | JobState::Remuxing | JobState::Resuming
)
}
pub fn can_retry(&self) -> bool {
matches!(self.status, JobState::Failed | JobState::Cancelled)
}
pub fn status_class(&self) -> &'static str {
match self.status {
JobState::Completed => "badge-green",
JobState::Encoding | JobState::Remuxing | JobState::Resuming => "badge-yellow",
JobState::Analyzing => "badge-blue",
JobState::Failed | JobState::Cancelled => "badge-red",
_ => "badge-gray",
}
}
pub fn progress_fixed(&self) -> String {
format!("{:.1}", self.progress)
}
pub fn vmaf_fixed(&self) -> String {
self.vmaf_score
.map(|s| format!("{:.1}", s))
.unwrap_or_else(|| "N/A".to_string())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct JobWithHealthIssueRow {
pub id: i64,
pub input_path: String,
pub output_path: String,
pub status: JobState,
pub decision_reason: Option<String>,
pub priority: i32,
pub progress: f64,
pub attempt_count: i32,
pub vmaf_score: Option<f64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub input_metadata_json: Option<String>,
pub health_issues: String,
}
impl JobWithHealthIssueRow {
pub fn into_parts(self) -> (Job, String) {
(
Job {
id: self.id,
input_path: self.input_path,
output_path: self.output_path,
status: self.status,
decision_reason: self.decision_reason,
priority: self.priority,
progress: self.progress,
attempt_count: self.attempt_count,
vmaf_score: self.vmaf_score,
created_at: self.created_at,
updated_at: self.updated_at,
input_metadata_json: self.input_metadata_json,
},
self.health_issues,
)
}
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct DuplicateCandidate {
pub id: i64,
pub input_path: String,
pub status: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct WatchDir {
pub id: i64,
pub path: String,
pub is_recursive: bool,
pub profile_id: Option<i64>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct LibraryProfile {
pub id: i64,
pub name: String,
pub preset: String,
pub codec: String,
pub quality_profile: String,
pub hdr_mode: String,
pub audio_mode: String,
pub crf_override: Option<i32>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewLibraryProfile {
pub name: String,
pub preset: String,
pub codec: String,
pub quality_profile: String,
pub hdr_mode: String,
pub audio_mode: String,
pub crf_override: Option<i32>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct JobFilterQuery {
pub limit: i64,
pub offset: i64,
pub statuses: Option<Vec<JobState>>,
pub search: Option<String>,
pub sort_by: Option<String>,
pub sort_desc: bool,
pub archived: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct NotificationTarget {
pub id: i64,
pub name: String,
pub target_type: String,
pub config_json: String,
pub events: String,
pub enabled: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct ConversionJob {
pub id: i64,
pub upload_path: String,
pub output_path: Option<String>,
pub mode: String,
pub settings_json: String,
pub probe_json: Option<String>,
pub linked_job_id: Option<i64>,
pub status: String,
pub expires_at: String,
pub downloaded_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct ScheduleWindow {
pub id: i64,
pub start_time: String,
pub end_time: String,
pub days_of_week: String, // as JSON string
pub enabled: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct FileSettings {
pub id: i64,
pub delete_source: bool,
pub output_extension: String,
pub output_suffix: String,
pub replace_strategy: String,
pub output_root: Option<String>,
}
impl FileSettings {
pub fn output_path_for(&self, input_path: &Path) -> PathBuf {
self.output_path_for_source(input_path, None)
}
pub fn output_path_for_source(&self, input_path: &Path, source_root: Option<&Path>) -> PathBuf {
let mut output_path = self.output_base_path(input_path, source_root);
let stem = input_path.file_stem().unwrap_or_default().to_string_lossy();
let extension = self.output_extension.trim_start_matches('.');
let suffix = self.output_suffix.as_str();
let mut filename = String::new();
filename.push_str(&stem);
filename.push_str(suffix);
if !extension.is_empty() {
filename.push('.');
filename.push_str(extension);
}
if filename.is_empty() {
filename.push_str("output");
}
output_path.set_file_name(filename);
if output_path == input_path {
let safe_suffix = if suffix.is_empty() {
"-alchemist".to_string()
} else {
format!("{}-alchemist", suffix)
};
let mut safe_name = String::new();
safe_name.push_str(&stem);
safe_name.push_str(&safe_suffix);
if !extension.is_empty() {
safe_name.push('.');
safe_name.push_str(extension);
}
output_path.set_file_name(safe_name);
}
output_path
}
fn output_base_path(&self, input_path: &Path, source_root: Option<&Path>) -> PathBuf {
let Some(output_root) = self
.output_root
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
return input_path.to_path_buf();
};
let Some(root) = source_root else {
return input_path.to_path_buf();
};
let Ok(relative_path) = input_path.strip_prefix(root) else {
return input_path.to_path_buf();
};
let mut output_path = PathBuf::from(output_root);
if let Some(parent) = relative_path.parent() {
output_path.push(parent);
}
output_path.push(relative_path.file_name().unwrap_or_default());
output_path
}
pub fn should_replace_existing_output(&self) -> bool {
let strategy = self.replace_strategy.trim();
strategy.eq_ignore_ascii_case("replace") || strategy.eq_ignore_ascii_case("overwrite")
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[serde(default)]
pub struct AggregatedStats {
pub total_jobs: i64,
pub completed_jobs: i64,
pub total_input_size: i64,
pub total_output_size: i64,
pub avg_vmaf: Option<f64>,
pub total_encode_time_seconds: f64,
}
impl AggregatedStats {
pub fn total_savings_gb(&self) -> f64 {
self.total_input_size.saturating_sub(self.total_output_size) as f64 / 1_073_741_824.0
}
pub fn total_input_gb(&self) -> f64 {
self.total_input_size as f64 / 1_073_741_824.0
}
pub fn avg_reduction_percentage(&self) -> f64 {
if self.total_input_size == 0 {
0.0
} else {
(1.0 - (self.total_output_size as f64 / self.total_input_size as f64)) * 100.0
}
}
pub fn total_time_hours(&self) -> f64 {
self.total_encode_time_seconds / 3600.0
}
pub fn total_savings_fixed(&self) -> String {
format!("{:.1}", self.total_savings_gb())
}
pub fn total_input_fixed(&self) -> String {
format!("{:.1}", self.total_input_gb())
}
pub fn efficiency_fixed(&self) -> String {
format!("{:.1}", self.avg_reduction_percentage())
}
pub fn time_fixed(&self) -> String {
format!("{:.1}", self.total_time_hours())
}
pub fn avg_vmaf_fixed(&self) -> String {
self.avg_vmaf
.map(|v| format!("{:.1}", v))
.unwrap_or_else(|| "N/A".to_string())
}
}
/// Daily aggregated statistics for time-series charts
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DailyStats {
pub date: String,
pub jobs_completed: i64,
pub bytes_saved: i64,
pub total_input_bytes: i64,
pub total_output_bytes: i64,
}
/// Detailed per-job encoding statistics
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct DetailedEncodeStats {
pub job_id: i64,
pub input_path: String,
pub input_size_bytes: i64,
pub output_size_bytes: i64,
pub compression_ratio: f64,
pub encode_time_seconds: f64,
pub encode_speed: f64,
pub avg_bitrate_kbps: f64,
pub vmaf_score: Option<f64>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct EncodeAttempt {
pub id: i64,
pub job_id: i64,
pub attempt_number: i32,
pub started_at: Option<String>,
pub finished_at: String,
pub outcome: String,
pub failure_code: Option<String>,
pub failure_summary: Option<String>,
pub input_size_bytes: Option<i64>,
pub output_size_bytes: Option<i64>,
pub encode_time_seconds: Option<f64>,
pub created_at: String,
}
#[derive(Debug, Clone)]
pub struct EncodeAttemptInput {
pub job_id: i64,
pub attempt_number: i32,
pub started_at: Option<String>,
pub outcome: String,
pub failure_code: Option<String>,
pub failure_summary: Option<String>,
pub input_size_bytes: Option<i64>,
pub output_size_bytes: Option<i64>,
pub encode_time_seconds: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct EncodeStatsInput {
pub job_id: i64,
pub input_size: u64,
pub output_size: u64,
pub compression_ratio: f64,
pub encode_time: f64,
pub encode_speed: f64,
pub avg_bitrate: f64,
pub vmaf_score: Option<f64>,
pub output_codec: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CodecSavings {
pub codec: String,
pub bytes_saved: i64,
pub job_count: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DailySavings {
pub date: String,
pub bytes_saved: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SavingsSummary {
pub total_input_bytes: i64,
pub total_output_bytes: i64,
pub total_bytes_saved: i64,
pub savings_percent: f64,
pub job_count: i64,
pub savings_by_codec: Vec<CodecSavings>,
pub savings_over_time: Vec<DailySavings>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct HealthSummary {
pub total_checked: i64,
pub issues_found: i64,
pub last_run: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct Decision {
pub id: i64,
pub job_id: i64,
pub action: String, // "encode", "skip", "reject"
pub reason: String,
pub reason_code: Option<String>,
pub reason_payload_json: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub(crate) struct DecisionRecord {
pub(crate) job_id: i64,
pub(crate) action: String,
pub(crate) reason: String,
pub(crate) reason_payload_json: Option<String>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub(crate) struct FailureExplanationRecord {
pub(crate) legacy_summary: Option<String>,
pub(crate) code: String,
pub(crate) payload_json: String,
}
// Auth related structs
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct Session {
pub token: String,
pub user_id: i64,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ApiTokenAccessLevel {
ReadOnly,
FullAccess,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct ApiToken {
pub id: i64,
pub name: String,
pub access_level: ApiTokenAccessLevel,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ApiTokenRecord {
pub id: i64,
pub name: String,
pub token_hash: String,
pub access_level: ApiTokenAccessLevel,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[test]
fn test_output_path_for_suffix() {
let settings = FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
output_root: None,
};
let input = Path::new("video.mp4");
let output = settings.output_path_for(input);
assert_eq!(output, PathBuf::from("video-alchemist.mkv"));
}
#[test]
fn test_output_path_avoids_inplace() {
let settings = FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "".to_string(),
replace_strategy: "keep".to_string(),
output_root: None,
};
let input = Path::new("video.mkv");
let output = settings.output_path_for(input);
assert_ne!(output, input);
}
#[test]
fn test_output_path_mirrors_source_root_under_output_root() {
let settings = FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
output_root: Some("/encoded".to_string()),
};
let input = Path::new("/library/movies/action/video.mp4");
let output = settings.output_path_for_source(input, Some(Path::new("/library")));
assert_eq!(
output,
PathBuf::from("/encoded/movies/action/video-alchemist.mkv")
);
}
#[test]
fn test_output_path_falls_back_when_source_root_does_not_match() {
let settings = FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
output_root: Some("/encoded".to_string()),
};
let input = Path::new("/library/movies/video.mp4");
let output = settings.output_path_for_source(input, Some(Path::new("/other")));
assert_eq!(output, PathBuf::from("/library/movies/video-alchemist.mkv"));
}
#[test]
fn test_replace_strategy() {
let mut settings = FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
output_root: None,
};
assert!(!settings.should_replace_existing_output());
settings.replace_strategy = "replace".to_string();
assert!(settings.should_replace_existing_output());
}
}

View File

@@ -18,7 +18,6 @@ pub mod version;
pub mod wizard; pub mod wizard;
pub use config::QualityProfile; pub use config::QualityProfile;
pub use db::AlchemistEvent;
pub use media::ffmpeg::{EncodeStats, EncoderCapabilities, HardwareAccelerators}; pub use media::ffmpeg::{EncodeStats, EncoderCapabilities, HardwareAccelerators};
pub use media::processor::Agent; pub use media::processor::Agent;
pub use orchestrator::Transcoder; pub use orchestrator::Transcoder;

View File

@@ -306,6 +306,10 @@ async fn run() -> Result<()> {
Ok(mut remuxing_jobs) => jobs.append(&mut remuxing_jobs), Ok(mut remuxing_jobs) => jobs.append(&mut remuxing_jobs),
Err(err) => error!("Failed to load interrupted remuxing jobs: {}", err), Err(err) => error!("Failed to load interrupted remuxing jobs: {}", err),
} }
match db.get_jobs_by_status(db::JobState::Resuming).await {
Ok(mut resuming_jobs) => jobs.append(&mut resuming_jobs),
Err(err) => error!("Failed to load interrupted resuming jobs: {}", err),
}
match db.get_jobs_by_status(db::JobState::Analyzing).await { match db.get_jobs_by_status(db::JobState::Analyzing).await {
Ok(mut analyzing_jobs) => jobs.append(&mut analyzing_jobs), Ok(mut analyzing_jobs) => jobs.append(&mut analyzing_jobs),
Err(err) => error!("Failed to load interrupted analyzing jobs: {}", err), Err(err) => error!("Failed to load interrupted analyzing jobs: {}", err),
@@ -515,9 +519,6 @@ async fn run() -> Result<()> {
system: system_tx, system: system_tx,
}); });
// Keep legacy channel for transition compatibility
let (tx, _rx) = broadcast::channel(100);
let transcoder = Arc::new(Transcoder::new()); let transcoder = Arc::new(Transcoder::new());
let hardware_state = hardware::HardwareState::new(Some(hw_info.clone())); let hardware_state = hardware::HardwareState::new(Some(hw_info.clone()));
let hardware_probe_log = Arc::new(RwLock::new(initial_probe_log)); let hardware_probe_log = Arc::new(RwLock::new(initial_probe_log));
@@ -528,7 +529,7 @@ async fn run() -> Result<()> {
db.as_ref().clone(), db.as_ref().clone(),
config.clone(), config.clone(),
)); ));
notification_manager.start_listener(tx.subscribe()); notification_manager.start_listener(&event_channels);
let maintenance_db = db.clone(); let maintenance_db = db.clone();
let maintenance_config = config.clone(); let maintenance_config = config.clone();
@@ -563,7 +564,6 @@ async fn run() -> Result<()> {
transcoder.clone(), transcoder.clone(),
config.clone(), config.clone(),
hardware_state.clone(), hardware_state.clone(),
tx.clone(),
event_channels.clone(), event_channels.clone(),
matches!(args.command, Some(Commands::Run { dry_run: true, .. })), matches!(args.command, Some(Commands::Run { dry_run: true, .. })),
) )
@@ -767,7 +767,6 @@ async fn run() -> Result<()> {
transcoder, transcoder,
scheduler: scheduler_handle, scheduler: scheduler_handle,
event_channels, event_channels,
tx,
setup_required: setup_mode, setup_required: setup_mode,
config_path: config_path.clone(), config_path: config_path.clone(),
config_mutable, config_mutable,
@@ -1278,7 +1277,6 @@ mod tests {
})); }));
let hardware_probe_log = Arc::new(RwLock::new(hardware::HardwareProbeLog::default())); let hardware_probe_log = Arc::new(RwLock::new(hardware::HardwareProbeLog::default()));
let transcoder = Arc::new(Transcoder::new()); let transcoder = Arc::new(Transcoder::new());
let (tx, _rx) = broadcast::channel(8);
let (jobs_tx, _) = broadcast::channel(100); let (jobs_tx, _) = broadcast::channel(100);
let (config_tx, _) = broadcast::channel(10); let (config_tx, _) = broadcast::channel(10);
let (system_tx, _) = broadcast::channel(10); let (system_tx, _) = broadcast::channel(10);
@@ -1293,7 +1291,6 @@ mod tests {
transcoder, transcoder,
config_state.clone(), config_state.clone(),
hardware_state.clone(), hardware_state.clone(),
tx,
event_channels, event_channels,
true, true,
) )

View File

@@ -1,8 +1,6 @@
use crate::db::{AlchemistEvent, Db, EventChannels, Job, JobEvent}; use crate::db::{Db, EventChannels, Job, JobEvent};
use crate::error::Result; use crate::error::Result;
use crate::media::pipeline::{ use crate::media::pipeline::{Encoder, ExecutionResult, Executor, MediaAnalysis, TranscodePlan};
Encoder, ExecutionResult, ExecutionStats, Executor, MediaAnalysis, TranscodePlan,
};
use crate::orchestrator::{ use crate::orchestrator::{
AsyncExecutionObserver, ExecutionObserver, TranscodeRequest, Transcoder, AsyncExecutionObserver, ExecutionObserver, TranscodeRequest, Transcoder,
}; };
@@ -10,13 +8,12 @@ use crate::system::hardware::HardwareInfo;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::{Mutex, broadcast}; use tokio::sync::Mutex;
pub struct FfmpegExecutor { pub struct FfmpegExecutor {
transcoder: Arc<Transcoder>, transcoder: Arc<Transcoder>,
db: Arc<Db>, db: Arc<Db>,
hw_info: Option<HardwareInfo>, hw_info: Option<HardwareInfo>,
event_tx: Arc<broadcast::Sender<AlchemistEvent>>,
event_channels: Arc<EventChannels>, event_channels: Arc<EventChannels>,
dry_run: bool, dry_run: bool,
} }
@@ -26,7 +23,6 @@ impl FfmpegExecutor {
transcoder: Arc<Transcoder>, transcoder: Arc<Transcoder>,
db: Arc<Db>, db: Arc<Db>,
hw_info: Option<HardwareInfo>, hw_info: Option<HardwareInfo>,
event_tx: Arc<broadcast::Sender<AlchemistEvent>>,
event_channels: Arc<EventChannels>, event_channels: Arc<EventChannels>,
dry_run: bool, dry_run: bool,
) -> Self { ) -> Self {
@@ -34,7 +30,6 @@ impl FfmpegExecutor {
transcoder, transcoder,
db, db,
hw_info, hw_info,
event_tx,
event_channels, event_channels,
dry_run, dry_run,
} }
@@ -44,7 +39,6 @@ impl FfmpegExecutor {
struct JobExecutionObserver { struct JobExecutionObserver {
job_id: i64, job_id: i64,
db: Arc<Db>, db: Arc<Db>,
event_tx: Arc<broadcast::Sender<AlchemistEvent>>,
event_channels: Arc<EventChannels>, event_channels: Arc<EventChannels>,
last_progress: Mutex<Option<(f64, Instant)>>, last_progress: Mutex<Option<(f64, Instant)>>,
} }
@@ -53,13 +47,11 @@ impl JobExecutionObserver {
fn new( fn new(
job_id: i64, job_id: i64,
db: Arc<Db>, db: Arc<Db>,
event_tx: Arc<broadcast::Sender<AlchemistEvent>>,
event_channels: Arc<EventChannels>, event_channels: Arc<EventChannels>,
) -> Self { ) -> Self {
Self { Self {
job_id, job_id,
db, db,
event_tx,
event_channels, event_channels,
last_progress: Mutex::new(None), last_progress: Mutex::new(None),
} }
@@ -68,18 +60,11 @@ impl JobExecutionObserver {
impl AsyncExecutionObserver for JobExecutionObserver { impl AsyncExecutionObserver for JobExecutionObserver {
async fn on_log(&self, message: String) { async fn on_log(&self, message: String) {
// Send to typed channel
let _ = self.event_channels.jobs.send(JobEvent::Log { let _ = self.event_channels.jobs.send(JobEvent::Log {
level: "info".to_string(), level: "info".to_string(),
job_id: Some(self.job_id), job_id: Some(self.job_id),
message: message.clone(), message: message.clone(),
}); });
// Also send to legacy channel for backwards compatibility
let _ = self.event_tx.send(AlchemistEvent::Log {
level: "info".to_string(),
job_id: Some(self.job_id),
message: message.clone(),
});
if let Err(err) = self.db.add_log("info", Some(self.job_id), &message).await { if let Err(err) = self.db.add_log("info", Some(self.job_id), &message).await {
tracing::warn!( tracing::warn!(
"Failed to persist transcode log for job {}: {}", "Failed to persist transcode log for job {}: {}",
@@ -117,14 +102,7 @@ impl AsyncExecutionObserver for JobExecutionObserver {
} }
} }
// Send to typed channel
let _ = self.event_channels.jobs.send(JobEvent::Progress { let _ = self.event_channels.jobs.send(JobEvent::Progress {
job_id: self.job_id,
percentage,
time: progress.time.clone(),
});
// Also send to legacy channel for backwards compatibility
let _ = self.event_tx.send(AlchemistEvent::Progress {
job_id: self.job_id, job_id: self.job_id,
percentage, percentage,
time: progress.time, time: progress.time,
@@ -155,7 +133,6 @@ impl Executor for FfmpegExecutor {
let observer: Arc<dyn ExecutionObserver> = Arc::new(JobExecutionObserver::new( let observer: Arc<dyn ExecutionObserver> = Arc::new(JobExecutionObserver::new(
job.id, job.id,
self.db.clone(), self.db.clone(),
self.event_tx.clone(),
self.event_channels.clone(), self.event_channels.clone(),
)); ));
@@ -274,12 +251,6 @@ impl Executor for FfmpegExecutor {
fallback_occurred: plan.fallback.is_some() || codec_mismatch || encoder_mismatch, fallback_occurred: plan.fallback.is_some() || codec_mismatch || encoder_mismatch,
actual_output_codec, actual_output_codec,
actual_encoder_name, actual_encoder_name,
stats: ExecutionStats {
encode_time_secs: 0.0,
input_size: 0,
output_size: 0,
vmaf: None,
},
}) })
} }
} }
@@ -392,8 +363,7 @@ mod tests {
let Some(job) = db.get_job_by_input_path("input.mkv").await? else { let Some(job) = db.get_job_by_input_path("input.mkv").await? else {
panic!("expected seeded job"); panic!("expected seeded job");
}; };
let (tx, mut rx) = broadcast::channel(8); let (jobs_tx, mut jobs_rx) = broadcast::channel(100);
let (jobs_tx, _) = broadcast::channel(100);
let (config_tx, _) = broadcast::channel(10); let (config_tx, _) = broadcast::channel(10);
let (system_tx, _) = broadcast::channel(10); let (system_tx, _) = broadcast::channel(10);
let event_channels = Arc::new(crate::db::EventChannels { let event_channels = Arc::new(crate::db::EventChannels {
@@ -401,7 +371,7 @@ mod tests {
config: config_tx, config: config_tx,
system: system_tx, system: system_tx,
}); });
let observer = JobExecutionObserver::new(job.id, db.clone(), Arc::new(tx), event_channels); let observer = JobExecutionObserver::new(job.id, db.clone(), event_channels);
LocalExecutionObserver::on_log(&observer, "ffmpeg line".to_string()).await; LocalExecutionObserver::on_log(&observer, "ffmpeg line".to_string()).await;
LocalExecutionObserver::on_progress( LocalExecutionObserver::on_progress(
@@ -423,10 +393,10 @@ mod tests {
}; };
assert!((updated.progress - 20.0).abs() < 0.01); assert!((updated.progress - 20.0).abs() < 0.01);
let first = rx.recv().await?; let first = jobs_rx.recv().await?;
assert!(matches!(first, AlchemistEvent::Log { .. })); assert!(matches!(first, JobEvent::Log { .. }));
let second = rx.recv().await?; let second = jobs_rx.recv().await?;
assert!(matches!(second, AlchemistEvent::Progress { .. })); assert!(matches!(second, JobEvent::Progress { .. }));
drop(db); drop(db);
let _ = std::fs::remove_file(db_path); let _ = std::fs::remove_file(db_path);

View File

@@ -1054,7 +1054,7 @@ mod tests {
.unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}")); .unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}"));
assert!(args.contains(&"hevc_videotoolbox".to_string())); assert!(args.contains(&"hevc_videotoolbox".to_string()));
assert!(!args.contains(&"hvc1".to_string())); assert!(!args.contains(&"hvc1".to_string()));
assert!(!args.contains(&"-q:v".to_string())); assert!(args.contains(&"-q:v".to_string())); // P1-2 fix: Cq maps to -q:v
assert!(!args.contains(&"-b:v".to_string())); assert!(!args.contains(&"-b:v".to_string()));
} }
@@ -1074,7 +1074,7 @@ mod tests {
.unwrap_or_else(|err| panic!("failed to build mp4 videotoolbox args: {err}")); .unwrap_or_else(|err| panic!("failed to build mp4 videotoolbox args: {err}"));
assert!(args.contains(&"hevc_videotoolbox".to_string())); assert!(args.contains(&"hevc_videotoolbox".to_string()));
assert!(args.contains(&"hvc1".to_string())); assert!(args.contains(&"hvc1".to_string()));
assert!(!args.contains(&"-q:v".to_string())); assert!(args.contains(&"-q:v".to_string())); // P1-2 fix: Cq maps to -q:v
} }
#[test] #[test]

View File

@@ -6,10 +6,6 @@ pub fn append_args(
tag_hevc_as_hvc1: bool, tag_hevc_as_hvc1: bool,
rate_control: Option<&RateControl>, rate_control: Option<&RateControl>,
) { ) {
// VideoToolbox quality is controlled via -global_quality (0100, 100=best).
// The config uses CQ-style semantics where lower value = better quality,
// so we invert: global_quality = 100 - cq_value.
// Bitrate mode is handled by the shared builder in mod.rs.
match encoder { match encoder {
Encoder::Av1Videotoolbox => { Encoder::Av1Videotoolbox => {
args.extend(["-c:v".to_string(), "av1_videotoolbox".to_string()]); args.extend(["-c:v".to_string(), "av1_videotoolbox".to_string()]);
@@ -25,8 +21,27 @@ pub fn append_args(
} }
_ => {} _ => {}
} }
if let Some(RateControl::Cq { value }) = rate_control {
let global_quality = 100u8.saturating_sub(*value); match rate_control {
args.extend(["-global_quality".to_string(), global_quality.to_string()]); Some(RateControl::Cq { value }) => {
// VideoToolbox -q:v: 1 (best) to 100 (worst). Config value is CRF-style
// where lower = better quality. Clamp to 1-51 range matching x264/x265.
let q = (*value).clamp(1, 51);
args.extend(["-q:v".to_string(), q.to_string()]);
}
Some(RateControl::Bitrate { kbps, .. }) => {
args.extend([
"-b:v".to_string(),
format!("{}k", kbps),
"-maxrate".to_string(),
format!("{}k", kbps * 2),
"-bufsize".to_string(),
format!("{}k", kbps * 4),
]);
}
_ => {
// Default: constant quality at 28 (HEVC-equivalent mid quality)
args.extend(["-q:v".to_string(), "28".to_string()]);
}
} }
} }

View File

@@ -11,7 +11,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
use tokio::sync::{RwLock, broadcast}; use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaMetadata { pub struct MediaMetadata {
@@ -94,14 +94,6 @@ pub struct MediaAnalysis {
pub confidence: AnalysisConfidence, pub confidence: AnalysisConfidence,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionStats {
pub encode_time_secs: f64,
pub input_size: u64,
pub output_size: u64,
pub vmaf: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DynamicRange { pub enum DynamicRange {
@@ -390,7 +382,6 @@ pub struct ExecutionResult {
pub fallback_occurred: bool, pub fallback_occurred: bool,
pub actual_output_codec: Option<crate::config::OutputCodec>, pub actual_output_codec: Option<crate::config::OutputCodec>,
pub actual_encoder_name: Option<String>, pub actual_encoder_name: Option<String>,
pub stats: ExecutionStats,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -432,7 +423,6 @@ pub struct Pipeline {
orchestrator: Arc<Transcoder>, orchestrator: Arc<Transcoder>,
config: Arc<RwLock<crate::config::Config>>, config: Arc<RwLock<crate::config::Config>>,
hardware_state: HardwareState, hardware_state: HardwareState,
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
event_channels: Arc<crate::db::EventChannels>, event_channels: Arc<crate::db::EventChannels>,
dry_run: bool, dry_run: bool,
} }
@@ -466,7 +456,6 @@ impl Pipeline {
orchestrator: Arc<Transcoder>, orchestrator: Arc<Transcoder>,
config: Arc<RwLock<crate::config::Config>>, config: Arc<RwLock<crate::config::Config>>,
hardware_state: HardwareState, hardware_state: HardwareState,
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
event_channels: Arc<crate::db::EventChannels>, event_channels: Arc<crate::db::EventChannels>,
dry_run: bool, dry_run: bool,
) -> Self { ) -> Self {
@@ -475,7 +464,6 @@ impl Pipeline {
orchestrator, orchestrator,
config, config,
hardware_state, hardware_state,
tx,
event_channels, event_channels,
dry_run, dry_run,
} }
@@ -594,8 +582,7 @@ impl Pipeline {
let job_id = job.id; let job_id = job.id;
// Update status to analyzing // Update status to analyzing
self.db self.update_job_state(job_id, crate::db::JobState::Analyzing)
.update_job_status(job_id, crate::db::JobState::Analyzing)
.await?; .await?;
// Run ffprobe analysis // Run ffprobe analysis
@@ -604,18 +591,24 @@ impl Pipeline {
.analyze(std::path::Path::new(&job.input_path)) .analyze(std::path::Path::new(&job.input_path))
.await .await
{ {
Ok(a) => a, Ok(a) => {
// Store analyzed metadata for completed job detail retrieval
let _ = self.db.set_job_input_metadata(job_id, &a.metadata).await;
a
}
Err(e) => { Err(e) => {
let reason = format!("analysis_failed|error={e}"); let reason = format!("analysis_failed|error={e}");
let failure_explanation = crate::explanations::failure_from_summary(&reason); let failure_explanation = crate::explanations::failure_from_summary(&reason);
let _ = self.db.add_log("error", Some(job_id), &reason).await; if let Err(e) = self.db.add_log("error", Some(job_id), &reason).await {
self.db.add_decision(job_id, "skip", &reason).await.ok(); tracing::warn!(job_id, "Failed to record log: {e}");
self.db }
.upsert_job_failure_explanation(job_id, &failure_explanation) if let Err(e) = self.db.add_decision(job_id, "skip", &reason).await {
.await tracing::warn!(job_id, "Failed to record decision: {e}");
.ok(); }
self.db if let Err(e) = self.db.upsert_job_failure_explanation(job_id, &failure_explanation).await {
.update_job_status(job_id, crate::db::JobState::Failed) tracing::warn!(job_id, "Failed to record failure explanation: {e}");
}
self.update_job_state(job_id, crate::db::JobState::Failed)
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -645,14 +638,16 @@ impl Pipeline {
Err(e) => { Err(e) => {
let reason = format!("planning_failed|error={e}"); let reason = format!("planning_failed|error={e}");
let failure_explanation = crate::explanations::failure_from_summary(&reason); let failure_explanation = crate::explanations::failure_from_summary(&reason);
let _ = self.db.add_log("error", Some(job_id), &reason).await; if let Err(e) = self.db.add_log("error", Some(job_id), &reason).await {
self.db.add_decision(job_id, "skip", &reason).await.ok(); tracing::warn!(job_id, "Failed to record log: {e}");
self.db }
.upsert_job_failure_explanation(job_id, &failure_explanation) if let Err(e) = self.db.add_decision(job_id, "skip", &reason).await {
.await tracing::warn!(job_id, "Failed to record decision: {e}");
.ok(); }
self.db if let Err(e) = self.db.upsert_job_failure_explanation(job_id, &failure_explanation).await {
.update_job_status(job_id, crate::db::JobState::Failed) tracing::warn!(job_id, "Failed to record failure explanation: {e}");
}
self.update_job_state(job_id, crate::db::JobState::Failed)
.await?; .await?;
return Ok(()); return Ok(());
} }
@@ -668,23 +663,26 @@ impl Pipeline {
"Job skipped: {}", "Job skipped: {}",
skip_code skip_code
); );
self.db.add_decision(job_id, "skip", reason).await.ok(); if let Err(e) = self.db.add_decision(job_id, "skip", reason).await {
self.db tracing::warn!(job_id, "Failed to record decision: {e}");
.update_job_status(job_id, crate::db::JobState::Skipped) }
self.update_job_state(job_id, crate::db::JobState::Skipped)
.await?; .await?;
} }
crate::media::pipeline::TranscodeDecision::Remux { reason } => { crate::media::pipeline::TranscodeDecision::Remux { reason } => {
self.db.add_decision(job_id, "transcode", reason).await.ok(); if let Err(e) = self.db.add_decision(job_id, "transcode", reason).await {
tracing::warn!(job_id, "Failed to record decision: {e}");
}
// Leave as queued — will be picked up for remux when engine starts // Leave as queued — will be picked up for remux when engine starts
self.db self.update_job_state(job_id, crate::db::JobState::Queued)
.update_job_status(job_id, crate::db::JobState::Queued)
.await?; .await?;
} }
crate::media::pipeline::TranscodeDecision::Transcode { reason } => { crate::media::pipeline::TranscodeDecision::Transcode { reason } => {
self.db.add_decision(job_id, "transcode", reason).await.ok(); if let Err(e) = self.db.add_decision(job_id, "transcode", reason).await {
tracing::warn!(job_id, "Failed to record decision: {e}");
}
// Leave as queued — will be picked up for encoding when engine starts // Leave as queued — will be picked up for encoding when engine starts
self.db self.update_job_state(job_id, crate::db::JobState::Queued)
.update_job_status(job_id, crate::db::JobState::Queued)
.await?; .await?;
} }
} }
@@ -775,15 +773,16 @@ impl Pipeline {
Err(e) => { Err(e) => {
let msg = format!("Probing failed: {e}"); let msg = format!("Probing failed: {e}");
tracing::error!("Job {}: {}", job.id, msg); tracing::error!("Job {}: {}", job.id, msg);
let _ = self.db.add_log("error", Some(job.id), &msg).await; if let Err(e) = self.db.add_log("error", Some(job.id), &msg).await {
tracing::warn!(job_id = job.id, "Failed to record log: {e}");
}
let explanation = crate::explanations::failure_from_summary(&msg); let explanation = crate::explanations::failure_from_summary(&msg);
let _ = self if let Err(e) = self.db.upsert_job_failure_explanation(job.id, &explanation).await {
.db tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}");
.upsert_job_failure_explanation(job.id, &explanation) }
.await; if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Failed).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to update job state: {e}");
.update_job_state(job.id, crate::db::JobState::Failed) }
.await;
return Err(JobFailure::MediaCorrupt); return Err(JobFailure::MediaCorrupt);
} }
}; };
@@ -829,15 +828,16 @@ impl Pipeline {
Err(err) => { Err(err) => {
let msg = format!("Invalid conversion job settings: {err}"); let msg = format!("Invalid conversion job settings: {err}");
tracing::error!("Job {}: {}", job.id, msg); tracing::error!("Job {}: {}", job.id, msg);
let _ = self.db.add_log("error", Some(job.id), &msg).await; if let Err(e) = self.db.add_log("error", Some(job.id), &msg).await {
tracing::warn!(job_id = job.id, "Failed to record log: {e}");
}
let explanation = crate::explanations::failure_from_summary(&msg); let explanation = crate::explanations::failure_from_summary(&msg);
let _ = self if let Err(e) = self.db.upsert_job_failure_explanation(job.id, &explanation).await {
.db tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}");
.upsert_job_failure_explanation(job.id, &explanation) }
.await; if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Failed).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to update job state: {e}");
.update_job_state(job.id, crate::db::JobState::Failed) }
.await;
return Err(JobFailure::PlannerBug); return Err(JobFailure::PlannerBug);
} }
}; };
@@ -847,15 +847,16 @@ impl Pipeline {
Err(err) => { Err(err) => {
let msg = format!("Conversion planning failed: {err}"); let msg = format!("Conversion planning failed: {err}");
tracing::error!("Job {}: {}", job.id, msg); tracing::error!("Job {}: {}", job.id, msg);
let _ = self.db.add_log("error", Some(job.id), &msg).await; if let Err(e) = self.db.add_log("error", Some(job.id), &msg).await {
tracing::warn!(job_id = job.id, "Failed to record log: {e}");
}
let explanation = crate::explanations::failure_from_summary(&msg); let explanation = crate::explanations::failure_from_summary(&msg);
let _ = self if let Err(e) = self.db.upsert_job_failure_explanation(job.id, &explanation).await {
.db tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}");
.upsert_job_failure_explanation(job.id, &explanation) }
.await; if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Failed).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to update job state: {e}");
.update_job_state(job.id, crate::db::JobState::Failed) }
.await;
return Err(JobFailure::PlannerBug); return Err(JobFailure::PlannerBug);
} }
} }
@@ -866,15 +867,16 @@ impl Pipeline {
Err(err) => { Err(err) => {
let msg = format!("Failed to resolve library profile: {err}"); let msg = format!("Failed to resolve library profile: {err}");
tracing::error!("Job {}: {}", job.id, msg); tracing::error!("Job {}: {}", job.id, msg);
let _ = self.db.add_log("error", Some(job.id), &msg).await; if let Err(e) = self.db.add_log("error", Some(job.id), &msg).await {
tracing::warn!(job_id = job.id, "Failed to record log: {e}");
}
let explanation = crate::explanations::failure_from_summary(&msg); let explanation = crate::explanations::failure_from_summary(&msg);
let _ = self if let Err(e) = self.db.upsert_job_failure_explanation(job.id, &explanation).await {
.db tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}");
.upsert_job_failure_explanation(job.id, &explanation) }
.await; if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Failed).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to update job state: {e}");
.update_job_state(job.id, crate::db::JobState::Failed) }
.await;
return Err(JobFailure::Transient); return Err(JobFailure::Transient);
} }
}; };
@@ -886,15 +888,16 @@ impl Pipeline {
Err(e) => { Err(e) => {
let msg = format!("Planner failed: {e}"); let msg = format!("Planner failed: {e}");
tracing::error!("Job {}: {}", job.id, msg); tracing::error!("Job {}: {}", job.id, msg);
let _ = self.db.add_log("error", Some(job.id), &msg).await; if let Err(e) = self.db.add_log("error", Some(job.id), &msg).await {
tracing::warn!(job_id = job.id, "Failed to record log: {e}");
}
let explanation = crate::explanations::failure_from_summary(&msg); let explanation = crate::explanations::failure_from_summary(&msg);
let _ = self if let Err(e) = self.db.upsert_job_failure_explanation(job.id, &explanation).await {
.db tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}");
.upsert_job_failure_explanation(job.id, &explanation) }
.await; if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Failed).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to update job state: {e}");
.update_job_state(job.id, crate::db::JobState::Failed) }
.await;
return Err(JobFailure::PlannerBug); return Err(JobFailure::PlannerBug);
} }
} }
@@ -954,10 +957,12 @@ impl Pipeline {
explanation.code, explanation.code,
explanation.summary explanation.summary
); );
let _ = self.db.add_decision(job.id, "skip", &reason).await; if let Err(e) = self.db.add_decision(job.id, "skip", &reason).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to record decision: {e}");
.update_job_state(job.id, crate::db::JobState::Skipped) }
.await; if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Skipped).await {
tracing::warn!(job_id = job.id, "Failed to update job state: {e}");
}
return Ok(()); return Ok(());
} }
@@ -981,13 +986,6 @@ impl Pipeline {
reason: explanation.legacy_reason.clone(), reason: explanation.legacy_reason.clone(),
explanation: Some(explanation.clone()), explanation: Some(explanation.clone()),
}); });
let _ = self.tx.send(crate::db::AlchemistEvent::Decision {
job_id: job.id,
action: action.to_string(),
reason: explanation.legacy_reason.clone(),
explanation: Some(explanation),
});
if self.update_job_state(job.id, next_status).await.is_err() { if self.update_job_state(job.id, next_status).await.is_err() {
return Err(JobFailure::Transient); return Err(JobFailure::Transient);
} }
@@ -1022,7 +1020,6 @@ impl Pipeline {
self.orchestrator.clone(), self.orchestrator.clone(),
self.db.clone(), self.db.clone(),
hw_info.clone(), hw_info.clone(),
self.tx.clone(),
self.event_channels.clone(), self.event_channels.clone(),
self.dry_run, self.dry_run,
); );
@@ -1034,28 +1031,28 @@ impl Pipeline {
tracing::error!("Job {}: Encoder fallback detected and not allowed.", job.id); tracing::error!("Job {}: Encoder fallback detected and not allowed.", job.id);
let summary = "Encoder fallback detected and not allowed."; let summary = "Encoder fallback detected and not allowed.";
let explanation = crate::explanations::failure_from_summary(summary); let explanation = crate::explanations::failure_from_summary(summary);
let _ = self.db.add_log("error", Some(job.id), summary).await; if let Err(e) = self.db.add_log("error", Some(job.id), summary).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to record log: {e}");
.db }
.upsert_job_failure_explanation(job.id, &explanation) if let Err(e) = self.db.upsert_job_failure_explanation(job.id, &explanation).await {
.await; tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}");
let _ = self }
.update_job_state(job.id, crate::db::JobState::Failed) if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Failed).await {
.await; tracing::warn!(job_id = job.id, "Failed to update job state: {e}");
let _ = self }
.db if let Err(e) = self.db.insert_encode_attempt(crate::db::EncodeAttemptInput {
.insert_encode_attempt(crate::db::EncodeAttemptInput { job_id: job.id,
job_id: job.id, attempt_number: current_attempt_number,
attempt_number: current_attempt_number, started_at: Some(encode_started_at.to_rfc3339()),
started_at: Some(encode_started_at.to_rfc3339()), outcome: "failed".to_string(),
outcome: "failed".to_string(), failure_code: Some("fallback_blocked".to_string()),
failure_code: Some("fallback_blocked".to_string()), failure_summary: Some(summary.to_string()),
failure_summary: Some(summary.to_string()), input_size_bytes: Some(metadata.size_bytes as i64),
input_size_bytes: Some(metadata.size_bytes as i64), output_size_bytes: None,
output_size_bytes: None, encode_time_seconds: Some(start_time.elapsed().as_secs_f64()),
encode_time_seconds: Some(start_time.elapsed().as_secs_f64()), }).await {
}) tracing::warn!(job_id = job.id, "Failed to record encode attempt: {e}");
.await; }
return Err(JobFailure::EncoderUnavailable); return Err(JobFailure::EncoderUnavailable);
} }
@@ -1137,49 +1134,48 @@ impl Pipeline {
.await; .await;
if let crate::error::AlchemistError::Cancelled = e { if let crate::error::AlchemistError::Cancelled = e {
let _ = self if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Cancelled).await {
.update_job_state(job.id, crate::db::JobState::Cancelled) tracing::warn!(job_id = job.id, "Failed to update job state to cancelled: {e}");
.await; }
let _ = self if let Err(e) = self.db.insert_encode_attempt(crate::db::EncodeAttemptInput {
.db job_id: job.id,
.insert_encode_attempt(crate::db::EncodeAttemptInput { attempt_number: current_attempt_number,
job_id: job.id, started_at: Some(encode_started_at.to_rfc3339()),
attempt_number: current_attempt_number, outcome: "cancelled".to_string(),
started_at: Some(encode_started_at.to_rfc3339()), failure_code: None,
outcome: "cancelled".to_string(), failure_summary: None,
failure_code: None, input_size_bytes: Some(metadata.size_bytes as i64),
failure_summary: None, output_size_bytes: None,
input_size_bytes: Some(metadata.size_bytes as i64), encode_time_seconds: Some(start_time.elapsed().as_secs_f64()),
output_size_bytes: None, }).await {
encode_time_seconds: Some(start_time.elapsed().as_secs_f64()), tracing::warn!(job_id = job.id, "Failed to record encode attempt: {e}");
}) }
.await;
} else { } else {
let msg = format!("Transcode failed: {e}"); let msg = format!("Transcode failed: {e}");
tracing::error!("Job {}: {}", job.id, msg); tracing::error!("Job {}: {}", job.id, msg);
let _ = self.db.add_log("error", Some(job.id), &msg).await; if let Err(e) = self.db.add_log("error", Some(job.id), &msg).await {
tracing::warn!(job_id = job.id, "Failed to record log: {e}");
}
let explanation = crate::explanations::failure_from_summary(&msg); let explanation = crate::explanations::failure_from_summary(&msg);
let _ = self if let Err(e) = self.db.upsert_job_failure_explanation(job.id, &explanation).await {
.db tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}");
.upsert_job_failure_explanation(job.id, &explanation) }
.await; if let Err(e) = self.update_job_state(job.id, crate::db::JobState::Failed).await {
let _ = self tracing::warn!(job_id = job.id, "Failed to update job state to failed: {e}");
.update_job_state(job.id, crate::db::JobState::Failed) }
.await; if let Err(e) = self.db.insert_encode_attempt(crate::db::EncodeAttemptInput {
let _ = self job_id: job.id,
.db attempt_number: current_attempt_number,
.insert_encode_attempt(crate::db::EncodeAttemptInput { started_at: Some(encode_started_at.to_rfc3339()),
job_id: job.id, outcome: "failed".to_string(),
attempt_number: current_attempt_number, failure_code: Some(explanation.code.clone()),
started_at: Some(encode_started_at.to_rfc3339()), failure_summary: Some(msg),
outcome: "failed".to_string(), input_size_bytes: Some(metadata.size_bytes as i64),
failure_code: Some(explanation.code.clone()), output_size_bytes: None,
failure_summary: Some(msg), encode_time_seconds: Some(start_time.elapsed().as_secs_f64()),
input_size_bytes: Some(metadata.size_bytes as i64), }).await {
output_size_bytes: None, tracing::warn!(job_id = job.id, "Failed to record encode attempt: {e}");
encode_time_seconds: Some(start_time.elapsed().as_secs_f64()), }
})
.await;
} }
Err(map_failure(&e)) Err(map_failure(&e))
} }
@@ -1187,30 +1183,51 @@ impl Pipeline {
} }
async fn update_job_state(&self, job_id: i64, status: crate::db::JobState) -> Result<()> { async fn update_job_state(&self, job_id: i64, status: crate::db::JobState) -> Result<()> {
if self.orchestrator.is_cancel_requested(job_id).await {
match status {
crate::db::JobState::Encoding
| crate::db::JobState::Remuxing
| crate::db::JobState::Skipped
| crate::db::JobState::Completed => {
tracing::info!(
"Ignoring state update to {:?} for job {} because it was cancelled",
status,
job_id
);
self.orchestrator.remove_cancel_request(job_id).await;
return Ok(());
}
_ => {}
}
}
if let Err(e) = self.db.update_job_status(job_id, status).await { if let Err(e) = self.db.update_job_status(job_id, status).await {
tracing::error!("Failed to update job {} status {:?}: {}", job_id, status, e); tracing::error!("Failed to update job {} status {:?}: {}", job_id, status, e);
return Err(e); return Err(e);
} }
// Remove from cancel_requested if it's a terminal state
match status {
crate::db::JobState::Completed
| crate::db::JobState::Failed
| crate::db::JobState::Cancelled
| crate::db::JobState::Skipped => {
self.orchestrator.remove_cancel_request(job_id).await;
}
_ => {}
}
let _ = self let _ = self
.event_channels .event_channels
.jobs .jobs
.send(crate::db::JobEvent::StateChanged { job_id, status }); .send(crate::db::JobEvent::StateChanged { job_id, status });
let _ = self
.tx
.send(crate::db::AlchemistEvent::JobStateChanged { job_id, status });
Ok(()) Ok(())
} }
async fn update_job_progress(&self, job_id: i64, progress: f64) { async fn update_job_progress(&self, job_id: i64, progress: f64) {
if let Err(e) = self.db.update_job_progress(job_id, progress).await { if let Err(e) = self.db.update_job_progress(job_id, progress).await {
tracing::error!("Failed to update job progress: {}", e); tracing::error!("Failed to update job progress: {}", e);
return;
} }
let _ = self.tx.send(crate::db::AlchemistEvent::Progress {
job_id,
percentage: progress,
time: String::new(),
});
} }
async fn should_stop_job(&self, job_id: i64) -> Result<bool> { async fn should_stop_job(&self, job_id: i64) -> Result<bool> {
@@ -1247,10 +1264,9 @@ impl Pipeline {
tracing::error!("Job {}: Input file is empty. Finalizing as failed.", job_id); tracing::error!("Job {}: Input file is empty. Finalizing as failed.", job_id);
let _ = std::fs::remove_file(context.temp_output_path); let _ = std::fs::remove_file(context.temp_output_path);
cleanup_temp_subtitle_output(job_id, context.plan).await; cleanup_temp_subtitle_output(job_id, context.plan).await;
return Err(crate::error::AlchemistError::FFmpeg(
self.update_job_state(job_id, crate::db::JobState::Failed) "Input file is empty".to_string(),
.await?; ));
return Ok(());
} }
let reduction = 1.0 - (output_size as f64 / input_size as f64); let reduction = 1.0 - (output_size as f64 / input_size as f64);
@@ -1282,7 +1298,9 @@ impl Pipeline {
reduction, config.transcode.size_reduction_threshold, output_size reduction, config.transcode.size_reduction_threshold, output_size
) )
}; };
let _ = self.db.add_decision(job_id, "skip", &reason).await; if let Err(e) = self.db.add_decision(job_id, "skip", &reason).await {
tracing::warn!(job_id, "Failed to record decision: {e}");
}
self.update_job_state(job_id, crate::db::JobState::Skipped) self.update_job_state(job_id, crate::db::JobState::Skipped)
.await?; .await?;
return Ok(()); return Ok(());
@@ -1353,11 +1371,14 @@ impl Pipeline {
let mut media_duration = context.metadata.duration_secs; let mut media_duration = context.metadata.duration_secs;
if media_duration <= 0.0 { if media_duration <= 0.0 {
media_duration = crate::media::analyzer::Analyzer::probe_async(input_path) match crate::media::analyzer::Analyzer::probe_async(input_path).await {
.await Ok(meta) => {
.ok() media_duration = meta.format.duration.parse::<f64>().unwrap_or(0.0);
.and_then(|meta| meta.format.duration.parse::<f64>().ok()) }
.unwrap_or(0.0); Err(e) => {
tracing::warn!(job_id, "Failed to reprobe output for duration: {e}");
}
}
} }
let encode_speed = if encode_duration > 0.0 && media_duration > 0.0 { let encode_speed = if encode_duration > 0.0 && media_duration > 0.0 {
@@ -1511,14 +1532,17 @@ impl Pipeline {
tracing::error!("Job {}: Finalization failed: {}", job_id, err); tracing::error!("Job {}: Finalization failed: {}", job_id, err);
let message = format!("Finalization failed: {err}"); let message = format!("Finalization failed: {err}");
let _ = self.db.add_log("error", Some(job_id), &message).await; if let Err(e) = self.db.add_log("error", Some(job_id), &message).await {
tracing::warn!(job_id, "Failed to record log: {e}");
}
let failure_explanation = crate::explanations::failure_from_summary(&message); let failure_explanation = crate::explanations::failure_from_summary(&message);
let _ = self if let Err(e) = self.db.upsert_job_failure_explanation(job_id, &failure_explanation).await {
.db tracing::warn!(job_id, "Failed to record failure explanation: {e}");
.upsert_job_failure_explanation(job_id, &failure_explanation) }
.await;
if let crate::error::AlchemistError::QualityCheckFailed(reason) = err { if let crate::error::AlchemistError::QualityCheckFailed(reason) = err {
let _ = self.db.add_decision(job_id, "reject", reason).await; if let Err(e) = self.db.add_decision(job_id, "reject", reason).await {
tracing::warn!(job_id, "Failed to record decision: {e}");
}
} }
if context.temp_output_path.exists() { if context.temp_output_path.exists() {
@@ -1653,7 +1677,7 @@ mod tests {
use crate::db::Db; use crate::db::Db;
use crate::system::hardware::{HardwareInfo, HardwareState, Vendor}; use crate::system::hardware::{HardwareInfo, HardwareState, Vendor};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{RwLock, broadcast}; use tokio::sync::RwLock;
#[test] #[test]
fn generated_output_pattern_matches_default_suffix() { fn generated_output_pattern_matches_default_suffix() {
@@ -1789,10 +1813,9 @@ mod tests {
selection_reason: String::new(), selection_reason: String::new(),
probe_summary: crate::system::hardware::ProbeSummary::default(), probe_summary: crate::system::hardware::ProbeSummary::default(),
})); }));
let (tx, _rx) = broadcast::channel(8); let (jobs_tx, _) = tokio::sync::broadcast::channel(100);
let (jobs_tx, _) = broadcast::channel(100); let (config_tx, _) = tokio::sync::broadcast::channel(10);
let (config_tx, _) = broadcast::channel(10); let (system_tx, _) = tokio::sync::broadcast::channel(10);
let (system_tx, _) = broadcast::channel(10);
let event_channels = Arc::new(crate::db::EventChannels { let event_channels = Arc::new(crate::db::EventChannels {
jobs: jobs_tx, jobs: jobs_tx,
config: config_tx, config: config_tx,
@@ -1803,7 +1826,6 @@ mod tests {
Arc::new(Transcoder::new()), Arc::new(Transcoder::new()),
config.clone(), config.clone(),
hardware_state, hardware_state,
Arc::new(tx),
event_channels, event_channels,
true, true,
); );
@@ -1864,12 +1886,6 @@ mod tests {
fallback_occurred: false, fallback_occurred: false,
actual_output_codec: Some(crate::config::OutputCodec::H264), actual_output_codec: Some(crate::config::OutputCodec::H264),
actual_encoder_name: Some("libx264".to_string()), actual_encoder_name: Some("libx264".to_string()),
stats: ExecutionStats {
encode_time_secs: 0.0,
input_size: 0,
output_size: 0,
vmaf: None,
},
}; };
let config_snapshot = config.read().await.clone(); let config_snapshot = config.read().await.clone();

View File

@@ -1,6 +1,6 @@
use crate::Transcoder; use crate::Transcoder;
use crate::config::Config; use crate::config::Config;
use crate::db::{AlchemistEvent, Db, EventChannels, JobEvent, SystemEvent}; use crate::db::{Db, EventChannels, JobEvent, SystemEvent};
use crate::error::Result; use crate::error::Result;
use crate::media::pipeline::Pipeline; use crate::media::pipeline::Pipeline;
use crate::media::scanner::Scanner; use crate::media::scanner::Scanner;
@@ -8,7 +8,7 @@ use crate::system::hardware::HardwareState;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore, broadcast}; use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore};
use tracing::{debug, error, info}; use tracing::{debug, error, info};
pub struct Agent { pub struct Agent {
@@ -16,7 +16,6 @@ pub struct Agent {
orchestrator: Arc<Transcoder>, orchestrator: Arc<Transcoder>,
config: Arc<RwLock<Config>>, config: Arc<RwLock<Config>>,
hardware_state: HardwareState, hardware_state: HardwareState,
tx: Arc<broadcast::Sender<AlchemistEvent>>,
event_channels: Arc<EventChannels>, event_channels: Arc<EventChannels>,
semaphore: Arc<Semaphore>, semaphore: Arc<Semaphore>,
semaphore_limit: Arc<AtomicUsize>, semaphore_limit: Arc<AtomicUsize>,
@@ -39,7 +38,6 @@ impl Agent {
orchestrator: Arc<Transcoder>, orchestrator: Arc<Transcoder>,
config: Arc<RwLock<Config>>, config: Arc<RwLock<Config>>,
hardware_state: HardwareState, hardware_state: HardwareState,
tx: broadcast::Sender<AlchemistEvent>,
event_channels: Arc<EventChannels>, event_channels: Arc<EventChannels>,
dry_run: bool, dry_run: bool,
) -> Self { ) -> Self {
@@ -54,7 +52,6 @@ impl Agent {
orchestrator, orchestrator,
config, config,
hardware_state, hardware_state,
tx: Arc::new(tx),
event_channels, event_channels,
semaphore: Arc::new(Semaphore::new(concurrent_jobs)), semaphore: Arc::new(Semaphore::new(concurrent_jobs)),
semaphore_limit: Arc::new(AtomicUsize::new(concurrent_jobs)), semaphore_limit: Arc::new(AtomicUsize::new(concurrent_jobs)),
@@ -99,15 +96,8 @@ impl Agent {
job_id: 0, job_id: 0,
status: crate::db::JobState::Queued, status: crate::db::JobState::Queued,
}); });
// Also send to legacy channel for backwards compatibility
let _ = self.tx.send(AlchemistEvent::JobStateChanged {
job_id: 0,
status: crate::db::JobState::Queued,
});
// Notify scan completed
let _ = self.event_channels.system.send(SystemEvent::ScanCompleted); let _ = self.event_channels.system.send(SystemEvent::ScanCompleted);
let _ = self.tx.send(AlchemistEvent::ScanCompleted);
Ok(()) Ok(())
} }
@@ -479,7 +469,7 @@ impl Agent {
if self.in_flight_jobs.load(Ordering::SeqCst) == 0 if self.in_flight_jobs.load(Ordering::SeqCst) == 0
&& !self.idle_notified.swap(true, Ordering::SeqCst) && !self.idle_notified.swap(true, Ordering::SeqCst)
{ {
let _ = self.tx.send(crate::db::AlchemistEvent::EngineIdle); let _ = self.event_channels.system.send(SystemEvent::EngineIdle);
} }
drop(permit); drop(permit);
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
@@ -507,7 +497,6 @@ impl Agent {
self.orchestrator.clone(), self.orchestrator.clone(),
self.config.clone(), self.config.clone(),
self.hardware_state.clone(), self.hardware_state.clone(),
self.tx.clone(),
self.event_channels.clone(), self.event_channels.clone(),
self.dry_run, self.dry_run,
) )

View File

@@ -1,5 +1,5 @@
use crate::config::Config; use crate::config::Config;
use crate::db::{AlchemistEvent, Db, NotificationTarget}; use crate::db::{Db, EventChannels, JobEvent, NotificationTarget, SystemEvent};
use crate::explanations::Explanation; use crate::explanations::Explanation;
use chrono::Timelike; use chrono::Timelike;
use lettre::message::{Mailbox, Message, SinglePart, header::ContentType}; use lettre::message::{Mailbox, Message, SinglePart, header::ContentType};
@@ -12,7 +12,7 @@ use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::net::lookup_host; use tokio::net::lookup_host;
use tokio::sync::{Mutex, RwLock, broadcast}; use tokio::sync::{Mutex, RwLock};
use tracing::{error, warn}; use tracing::{error, warn};
type NotificationResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; type NotificationResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
@@ -86,9 +86,21 @@ fn endpoint_url_for_target(target: &NotificationTarget) -> NotificationResult<Op
} }
} }
fn event_key_from_event(event: &AlchemistEvent) -> Option<&'static str> { /// Internal event type that unifies the events the notification system cares about.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type", content = "data")]
enum NotifiableEvent {
JobStateChanged {
job_id: i64,
status: crate::db::JobState,
},
ScanCompleted,
EngineIdle,
}
fn event_key(event: &NotifiableEvent) -> Option<&'static str> {
match event { match event {
AlchemistEvent::JobStateChanged { status, .. } => match status { NotifiableEvent::JobStateChanged { status, .. } => match status {
crate::db::JobState::Queued => Some(crate::config::NOTIFICATION_EVENT_ENCODE_QUEUED), crate::db::JobState::Queued => Some(crate::config::NOTIFICATION_EVENT_ENCODE_QUEUED),
crate::db::JobState::Encoding | crate::db::JobState::Remuxing => { crate::db::JobState::Encoding | crate::db::JobState::Remuxing => {
Some(crate::config::NOTIFICATION_EVENT_ENCODE_STARTED) Some(crate::config::NOTIFICATION_EVENT_ENCODE_STARTED)
@@ -99,9 +111,8 @@ fn event_key_from_event(event: &AlchemistEvent) -> Option<&'static str> {
crate::db::JobState::Failed => Some(crate::config::NOTIFICATION_EVENT_ENCODE_FAILED), crate::db::JobState::Failed => Some(crate::config::NOTIFICATION_EVENT_ENCODE_FAILED),
_ => None, _ => None,
}, },
AlchemistEvent::ScanCompleted => Some(crate::config::NOTIFICATION_EVENT_SCAN_COMPLETED), NotifiableEvent::ScanCompleted => Some(crate::config::NOTIFICATION_EVENT_SCAN_COMPLETED),
AlchemistEvent::EngineIdle => Some(crate::config::NOTIFICATION_EVENT_ENGINE_IDLE), NotifiableEvent::EngineIdle => Some(crate::config::NOTIFICATION_EVENT_ENGINE_IDLE),
_ => None,
} }
} }
@@ -114,22 +125,104 @@ impl NotificationManager {
} }
} }
pub fn start_listener(&self, mut rx: broadcast::Receiver<AlchemistEvent>) { /// Build an HTTP client with SSRF protections: DNS resolution timeout,
/// private-IP blocking (unless allow_local_notifications), no redirects,
/// and a 10-second request timeout.
async fn build_safe_client(
&self,
target: &NotificationTarget,
) -> NotificationResult<Client> {
if let Some(endpoint_url) = endpoint_url_for_target(target)? {
let url = Url::parse(&endpoint_url)?;
let host = url
.host_str()
.ok_or("notification endpoint host is missing")?;
let port = url.port_or_known_default().ok_or("invalid port")?;
let allow_local = self
.config
.read()
.await
.notifications
.allow_local_notifications;
if !allow_local && host.eq_ignore_ascii_case("localhost") {
return Err("localhost is not allowed as a notification endpoint".into());
}
let addr = format!("{}:{}", host, port);
let ips = tokio::time::timeout(Duration::from_secs(3), lookup_host(&addr)).await??;
let target_ip = if allow_local {
ips.into_iter()
.map(|a| a.ip())
.next()
.ok_or("no IP address found for notification endpoint")?
} else {
ips.into_iter()
.map(|a| a.ip())
.find(|ip| !is_private_ip(*ip))
.ok_or("no public IP address found for notification endpoint")?
};
Ok(Client::builder()
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.resolve(host, std::net::SocketAddr::new(target_ip, port))
.build()?)
} else {
Ok(Client::builder()
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.build()?)
}
}
pub fn start_listener(&self, event_channels: &EventChannels) {
let manager_clone = self.clone(); let manager_clone = self.clone();
let summary_manager = self.clone(); let summary_manager = self.clone();
// Listen for job events (state changes are the only ones we notify on)
let mut jobs_rx = event_channels.jobs.subscribe();
let job_manager = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match rx.recv().await { match jobs_rx.recv().await {
Ok(event) => { Ok(JobEvent::StateChanged { job_id, status }) => {
if let Err(e) = manager_clone.handle_event(event).await { let event = NotifiableEvent::JobStateChanged { job_id, status };
if let Err(e) = job_manager.handle_event(event).await {
error!("Notification error: {}", e); error!("Notification error: {}", e);
} }
} }
Err(broadcast::error::RecvError::Lagged(_)) => { Ok(_) => {} // Ignore Progress, Decision, Log
warn!("Notification listener lagged") Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
warn!("Notification job listener lagged")
} }
Err(broadcast::error::RecvError::Closed) => break, Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
// Listen for system events (scan completed, engine idle)
let mut system_rx = event_channels.system.subscribe();
tokio::spawn(async move {
loop {
match system_rx.recv().await {
Ok(SystemEvent::ScanCompleted) => {
if let Err(e) = manager_clone.handle_event(NotifiableEvent::ScanCompleted).await {
error!("Notification error: {}", e);
}
}
Ok(SystemEvent::EngineIdle) => {
if let Err(e) = manager_clone.handle_event(NotifiableEvent::EngineIdle).await {
error!("Notification error: {}", e);
}
}
Ok(_) => {} // Ignore ScanStarted, EngineStatusChanged, HardwareStateChanged
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
warn!("Notification system listener lagged")
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
} }
} }
}); });
@@ -145,14 +238,14 @@ impl NotificationManager {
} }
pub async fn send_test(&self, target: &NotificationTarget) -> NotificationResult<()> { pub async fn send_test(&self, target: &NotificationTarget) -> NotificationResult<()> {
let event = AlchemistEvent::JobStateChanged { let event = NotifiableEvent::JobStateChanged {
job_id: 0, job_id: 0,
status: crate::db::JobState::Completed, status: crate::db::JobState::Completed,
}; };
self.send(target, &event).await self.send(target, &event).await
} }
async fn handle_event(&self, event: AlchemistEvent) -> NotificationResult<()> { async fn handle_event(&self, event: NotifiableEvent) -> NotificationResult<()> {
let targets = match self.db.get_notification_targets().await { let targets = match self.db.get_notification_targets().await {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@@ -165,7 +258,7 @@ impl NotificationManager {
return Ok(()); return Ok(());
} }
let event_key = match event_key_from_event(&event) { let event_key = match event_key(&event) {
Some(event_key) => event_key, Some(event_key) => event_key,
None => return Ok(()), None => return Ok(()),
}; };
@@ -224,10 +317,13 @@ impl NotificationManager {
let summary_key = now.format("%Y-%m-%d").to_string(); let summary_key = now.format("%Y-%m-%d").to_string();
{ {
let last_sent = self.daily_summary_last_sent.lock().await; let mut last_sent = self.daily_summary_last_sent.lock().await;
if last_sent.as_deref() == Some(summary_key.as_str()) { if last_sent.as_deref() == Some(summary_key.as_str()) {
return Ok(()); return Ok(());
} }
// Mark sent before releasing lock to prevent duplicate sends
// if the scheduler fires twice in the same minute.
*last_sent = Some(summary_key.clone());
} }
let summary = self.db.get_daily_summary_stats().await?; let summary = self.db.get_daily_summary_stats().await?;
@@ -252,63 +348,19 @@ impl NotificationManager {
} }
} }
*self.daily_summary_last_sent.lock().await = Some(summary_key);
Ok(()) Ok(())
} }
async fn send( async fn send(
&self, &self,
target: &NotificationTarget, target: &NotificationTarget,
event: &AlchemistEvent, event: &NotifiableEvent,
) -> NotificationResult<()> { ) -> NotificationResult<()> {
let event_key = event_key_from_event(event).unwrap_or("unknown"); let event_key = event_key(event).unwrap_or("unknown");
let client = if let Some(endpoint_url) = endpoint_url_for_target(target)? { let client = self.build_safe_client(target).await?;
let url = Url::parse(&endpoint_url)?;
let host = url
.host_str()
.ok_or("notification endpoint host is missing")?;
let port = url.port_or_known_default().ok_or("invalid port")?;
let allow_local = self
.config
.read()
.await
.notifications
.allow_local_notifications;
if !allow_local && host.eq_ignore_ascii_case("localhost") {
return Err("localhost is not allowed as a notification endpoint".into());
}
let addr = format!("{}:{}", host, port);
let ips = tokio::time::timeout(Duration::from_secs(3), lookup_host(&addr)).await??;
let target_ip = if allow_local {
ips.into_iter()
.map(|a| a.ip())
.next()
.ok_or("no IP address found for notification endpoint")?
} else {
ips.into_iter()
.map(|a| a.ip())
.find(|ip| !is_private_ip(*ip))
.ok_or("no public IP address found for notification endpoint")?
};
Client::builder()
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.resolve(host, std::net::SocketAddr::new(target_ip, port))
.build()?
} else {
Client::builder()
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.build()?
};
let (decision_explanation, failure_explanation) = match event { let (decision_explanation, failure_explanation) = match event {
AlchemistEvent::JobStateChanged { job_id, status } => { NotifiableEvent::JobStateChanged { job_id, status } => {
let decision_explanation = self let decision_explanation = self
.db .db
.get_job_decision_explanation(*job_id) .get_job_decision_explanation(*job_id)
@@ -423,25 +475,24 @@ impl NotificationManager {
fn message_for_event( fn message_for_event(
&self, &self,
event: &AlchemistEvent, event: &NotifiableEvent,
decision_explanation: Option<&Explanation>, decision_explanation: Option<&Explanation>,
failure_explanation: Option<&Explanation>, failure_explanation: Option<&Explanation>,
) -> String { ) -> String {
match event { match event {
AlchemistEvent::JobStateChanged { job_id, status } => self.notification_message( NotifiableEvent::JobStateChanged { job_id, status } => self.notification_message(
*job_id, *job_id,
&status.to_string(), &status.to_string(),
decision_explanation, decision_explanation,
failure_explanation, failure_explanation,
), ),
AlchemistEvent::ScanCompleted => { NotifiableEvent::ScanCompleted => {
"Library scan completed. Review the queue for newly discovered work.".to_string() "Library scan completed. Review the queue for newly discovered work.".to_string()
} }
AlchemistEvent::EngineIdle => { NotifiableEvent::EngineIdle => {
"The engine is idle. There are no active jobs and no queued work ready to run." "The engine is idle. There are no active jobs and no queued work ready to run."
.to_string() .to_string()
} }
_ => "Event occurred".to_string(),
} }
} }
@@ -472,7 +523,7 @@ impl NotificationManager {
&self, &self,
client: &Client, client: &Client,
target: &NotificationTarget, target: &NotificationTarget,
event: &AlchemistEvent, event: &NotifiableEvent,
event_key: &str, event_key: &str,
decision_explanation: Option<&Explanation>, decision_explanation: Option<&Explanation>,
failure_explanation: Option<&Explanation>, failure_explanation: Option<&Explanation>,
@@ -511,7 +562,7 @@ impl NotificationManager {
&self, &self,
client: &Client, client: &Client,
target: &NotificationTarget, target: &NotificationTarget,
event: &AlchemistEvent, event: &NotifiableEvent,
_event_key: &str, _event_key: &str,
decision_explanation: Option<&Explanation>, decision_explanation: Option<&Explanation>,
failure_explanation: Option<&Explanation>, failure_explanation: Option<&Explanation>,
@@ -536,7 +587,7 @@ impl NotificationManager {
&self, &self,
client: &Client, client: &Client,
target: &NotificationTarget, target: &NotificationTarget,
event: &AlchemistEvent, event: &NotifiableEvent,
event_key: &str, event_key: &str,
decision_explanation: Option<&Explanation>, decision_explanation: Option<&Explanation>,
failure_explanation: Option<&Explanation>, failure_explanation: Option<&Explanation>,
@@ -550,16 +601,21 @@ impl NotificationManager {
_ => 2, _ => 2,
}; };
let req = client.post(&config.server_url).json(&json!({ let req = client
"title": "Alchemist", .post(format!(
"message": message, "{}/message",
"priority": priority, config.server_url.trim_end_matches('/')
"extras": { ))
"client::display": { .json(&json!({
"contentType": "text/plain" "title": "Alchemist",
"message": message,
"priority": priority,
"extras": {
"client::display": {
"contentType": "text/plain"
}
} }
} }));
}));
req.header("X-Gotify-Key", config.app_token) req.header("X-Gotify-Key", config.app_token)
.send() .send()
.await? .await?
@@ -571,7 +627,7 @@ impl NotificationManager {
&self, &self,
client: &Client, client: &Client,
target: &NotificationTarget, target: &NotificationTarget,
event: &AlchemistEvent, event: &NotifiableEvent,
event_key: &str, event_key: &str,
decision_explanation: Option<&Explanation>, decision_explanation: Option<&Explanation>,
failure_explanation: Option<&Explanation>, failure_explanation: Option<&Explanation>,
@@ -601,7 +657,7 @@ impl NotificationManager {
&self, &self,
client: &Client, client: &Client,
target: &NotificationTarget, target: &NotificationTarget,
event: &AlchemistEvent, event: &NotifiableEvent,
_event_key: &str, _event_key: &str,
decision_explanation: Option<&Explanation>, decision_explanation: Option<&Explanation>,
failure_explanation: Option<&Explanation>, failure_explanation: Option<&Explanation>,
@@ -627,7 +683,7 @@ impl NotificationManager {
async fn send_email( async fn send_email(
&self, &self,
target: &NotificationTarget, target: &NotificationTarget,
event: &AlchemistEvent, event: &NotifiableEvent,
_event_key: &str, _event_key: &str,
decision_explanation: Option<&Explanation>, decision_explanation: Option<&Explanation>,
failure_explanation: Option<&Explanation>, failure_explanation: Option<&Explanation>,
@@ -677,10 +733,11 @@ impl NotificationManager {
summary: &crate::db::DailySummaryStats, summary: &crate::db::DailySummaryStats,
) -> NotificationResult<()> { ) -> NotificationResult<()> {
let message = self.daily_summary_message(summary); let message = self.daily_summary_message(summary);
let client = self.build_safe_client(target).await?;
match target.target_type.as_str() { match target.target_type.as_str() {
"discord_webhook" => { "discord_webhook" => {
let config = parse_target_config::<DiscordWebhookConfig>(target)?; let config = parse_target_config::<DiscordWebhookConfig>(target)?;
Client::new() client
.post(config.webhook_url) .post(config.webhook_url)
.json(&json!({ .json(&json!({
"embeds": [{ "embeds": [{
@@ -696,7 +753,7 @@ impl NotificationManager {
} }
"discord_bot" => { "discord_bot" => {
let config = parse_target_config::<DiscordBotConfig>(target)?; let config = parse_target_config::<DiscordBotConfig>(target)?;
Client::new() client
.post(format!( .post(format!(
"https://discord.com/api/v10/channels/{}/messages", "https://discord.com/api/v10/channels/{}/messages",
config.channel_id config.channel_id
@@ -709,7 +766,7 @@ impl NotificationManager {
} }
"gotify" => { "gotify" => {
let config = parse_target_config::<GotifyConfig>(target)?; let config = parse_target_config::<GotifyConfig>(target)?;
Client::new() client
.post(config.server_url) .post(config.server_url)
.header("X-Gotify-Key", config.app_token) .header("X-Gotify-Key", config.app_token)
.json(&json!({ .json(&json!({
@@ -723,7 +780,7 @@ impl NotificationManager {
} }
"webhook" => { "webhook" => {
let config = parse_target_config::<WebhookConfig>(target)?; let config = parse_target_config::<WebhookConfig>(target)?;
let mut req = Client::new().post(config.url).json(&json!({ let mut req = client.post(config.url).json(&json!({
"event": crate::config::NOTIFICATION_EVENT_DAILY_SUMMARY, "event": crate::config::NOTIFICATION_EVENT_DAILY_SUMMARY,
"summary": summary, "summary": summary,
"message": message, "message": message,
@@ -736,7 +793,7 @@ impl NotificationManager {
} }
"telegram" => { "telegram" => {
let config = parse_target_config::<TelegramConfig>(target)?; let config = parse_target_config::<TelegramConfig>(target)?;
Client::new() client
.post(format!( .post(format!(
"https://api.telegram.org/bot{}/sendMessage", "https://api.telegram.org/bot{}/sendMessage",
config.bot_token config.bot_token
@@ -896,7 +953,7 @@ mod tests {
enabled: true, enabled: true,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}; };
let event = AlchemistEvent::JobStateChanged { let event = NotifiableEvent::JobStateChanged {
job_id: 1, job_id: 1,
status: crate::db::JobState::Failed, status: crate::db::JobState::Failed,
}; };
@@ -976,7 +1033,7 @@ mod tests {
enabled: true, enabled: true,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}; };
let event = AlchemistEvent::JobStateChanged { let event = NotifiableEvent::JobStateChanged {
job_id: job.id, job_id: job.id,
status: JobState::Failed, status: JobState::Failed,
}; };

View File

@@ -17,6 +17,7 @@ pub struct Transcoder {
// so there is no deadlock risk. Contention is negligible (≤ concurrent_jobs entries). // so there is no deadlock risk. Contention is negligible (≤ concurrent_jobs entries).
cancel_channels: Arc<Mutex<HashMap<i64, oneshot::Sender<()>>>>, cancel_channels: Arc<Mutex<HashMap<i64, oneshot::Sender<()>>>>,
pending_cancels: Arc<Mutex<HashSet<i64>>>, pending_cancels: Arc<Mutex<HashSet<i64>>>,
pub(crate) cancel_requested: Arc<tokio::sync::RwLock<HashSet<i64>>>,
} }
pub struct TranscodeRequest<'a> { pub struct TranscodeRequest<'a> {
@@ -80,9 +81,22 @@ impl Transcoder {
Self { Self {
cancel_channels: Arc::new(Mutex::new(HashMap::new())), cancel_channels: Arc::new(Mutex::new(HashMap::new())),
pending_cancels: Arc::new(Mutex::new(HashSet::new())), pending_cancels: Arc::new(Mutex::new(HashSet::new())),
cancel_requested: Arc::new(tokio::sync::RwLock::new(HashSet::new())),
} }
} }
pub async fn is_cancel_requested(&self, job_id: i64) -> bool {
self.cancel_requested.read().await.contains(&job_id)
}
pub async fn remove_cancel_request(&self, job_id: i64) {
self.cancel_requested.write().await.remove(&job_id);
}
pub async fn add_cancel_request(&self, job_id: i64) {
self.cancel_requested.write().await.insert(job_id);
}
pub fn cancel_job(&self, job_id: i64) -> bool { pub fn cancel_job(&self, job_id: i64) -> bool {
let mut channels = match self.cancel_channels.lock() { let mut channels = match self.cancel_channels.lock() {
Ok(channels) => channels, Ok(channels) => channels,

View File

@@ -39,12 +39,14 @@ pub(crate) fn blocked_jobs_response(message: impl Into<String>, blocked: &[Job])
} }
pub(crate) async fn request_job_cancel(state: &AppState, job: &Job) -> Result<bool> { pub(crate) async fn request_job_cancel(state: &AppState, job: &Job) -> Result<bool> {
state.transcoder.add_cancel_request(job.id).await;
match job.status { match job.status {
JobState::Queued => { JobState::Queued => {
state state
.db .db
.update_job_status(job.id, JobState::Cancelled) .update_job_status(job.id, JobState::Cancelled)
.await?; .await?;
state.transcoder.remove_cancel_request(job.id).await;
Ok(true) Ok(true)
} }
JobState::Analyzing | JobState::Resuming => { JobState::Analyzing | JobState::Resuming => {
@@ -55,6 +57,7 @@ pub(crate) async fn request_job_cancel(state: &AppState, job: &Job) -> Result<bo
.db .db
.update_job_status(job.id, JobState::Cancelled) .update_job_status(job.id, JobState::Cancelled)
.await?; .await?;
state.transcoder.remove_cancel_request(job.id).await;
Ok(true) Ok(true)
} }
JobState::Encoding | JobState::Remuxing => Ok(state.transcoder.cancel_job(job.id)), JobState::Encoding | JobState::Remuxing => Ok(state.transcoder.cancel_job(job.id)),
@@ -162,17 +165,49 @@ pub(crate) async fn batch_jobs_handler(
match payload.action.as_str() { match payload.action.as_str() {
"cancel" => { "cancel" => {
let mut count = 0_u64; // Add all cancel requests first (in-memory, cheap).
for job in &jobs { for job in &jobs {
match request_job_cancel(&state, job).await { state.transcoder.add_cancel_request(job.id).await;
Ok(true) => count += 1, }
Ok(false) => {}
Err(e) if is_row_not_found(&e) => {} // Collect IDs that can be immediately set to Cancelled in the DB.
let mut immediate_ids: Vec<i64> = Vec::new();
let mut active_count: u64 = 0;
for job in &jobs {
match job.status {
JobState::Queued => {
immediate_ids.push(job.id);
}
JobState::Analyzing | JobState::Resuming => {
if state.transcoder.cancel_job(job.id) {
immediate_ids.push(job.id);
}
}
JobState::Encoding | JobState::Remuxing => {
if state.transcoder.cancel_job(job.id) {
active_count += 1;
}
}
_ => {}
}
}
// Single batch DB update instead of N individual queries.
if !immediate_ids.is_empty() {
match state.db.batch_cancel_jobs(&immediate_ids).await {
Ok(_) => {}
Err(e) => { Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
} }
} }
// Remove cancel requests for jobs already resolved in DB.
for id in &immediate_ids {
state.transcoder.remove_cancel_request(*id).await;
}
} }
let count = immediate_ids.len() as u64 + active_count;
axum::Json(serde_json::json!({ "count": count })).into_response() axum::Json(serde_json::json!({ "count": count })).into_response()
} }
"delete" | "restart" => { "delete" | "restart" => {
@@ -330,6 +365,7 @@ pub(crate) struct JobDetailResponse {
job_failure_summary: Option<String>, job_failure_summary: Option<String>,
decision_explanation: Option<Explanation>, decision_explanation: Option<Explanation>,
failure_explanation: Option<Explanation>, failure_explanation: Option<Explanation>,
queue_position: Option<u32>,
} }
pub(crate) async fn get_job_detail_handler( pub(crate) async fn get_job_detail_handler(
@@ -342,19 +378,7 @@ pub(crate) async fn get_job_detail_handler(
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}; };
// Avoid long probes while the job is still active. let metadata = job.input_metadata();
let metadata = match job.status {
JobState::Queued | JobState::Analyzing => None,
_ => {
let analyzer = crate::media::analyzer::FfmpegAnalyzer;
use crate::media::pipeline::Analyzer;
analyzer
.analyze(std::path::Path::new(&job.input_path))
.await
.ok()
.map(|analysis| analysis.metadata)
}
};
// Try to get encode stats (using the subquery result or a specific query) // Try to get encode stats (using the subquery result or a specific query)
// For now we'll just query the encode_stats table if completed // For now we'll just query the encode_stats table if completed
@@ -406,6 +430,12 @@ pub(crate) async fn get_job_detail_handler(
.await .await
.unwrap_or_default(); .unwrap_or_default();
let queue_position = if job.status == JobState::Queued {
state.db.get_queue_position(id).await.unwrap_or(None)
} else {
None
};
axum::Json(JobDetailResponse { axum::Json(JobDetailResponse {
job, job,
metadata, metadata,
@@ -415,6 +445,7 @@ pub(crate) async fn get_job_detail_handler(
job_failure_summary, job_failure_summary,
decision_explanation, decision_explanation,
failure_explanation, failure_explanation,
queue_position,
}) })
.into_response() .into_response()
} }

View File

@@ -76,16 +76,26 @@ pub(crate) async fn auth_middleware(
let path = req.uri().path(); let path = req.uri().path();
let method = req.method().clone(); let method = req.method().clone();
if state.setup_required.load(Ordering::Relaxed) if state.setup_required.load(Ordering::Relaxed) && path != "/api/health" && path != "/api/ready"
&& path != "/api/health"
&& path != "/api/ready"
&& !request_is_lan(&req)
{ {
return ( let allowed = if let Some(expected_token) = &state.setup_token {
StatusCode::FORBIDDEN, // Token mode: require `?token=<value>` regardless of client IP.
"Alchemist setup is only available from the local network", req.uri()
) .query()
.into_response(); .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(|t| t == expected_token.as_str())
.unwrap_or(false)
} else {
request_is_lan(&req, &state.trusted_proxies)
};
if !allowed {
return (
StatusCode::FORBIDDEN,
"Alchemist setup is only available from the local network",
)
.into_response();
}
} }
// 1. API Protection: Only lock down /api routes // 1. API Protection: Only lock down /api routes
@@ -148,12 +158,12 @@ pub(crate) async fn auth_middleware(
next.run(req).await next.run(req).await
} }
fn request_is_lan(req: &Request) -> bool { fn request_is_lan(req: &Request, trusted_proxies: &[IpAddr]) -> bool {
let direct_peer = req let direct_peer = req
.extensions() .extensions()
.get::<ConnectInfo<SocketAddr>>() .get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0.ip()); .map(|info| info.0.ip());
let resolved = request_ip(req); let resolved = request_ip(req, trusted_proxies);
// If resolved IP differs from direct peer, forwarded headers were used. // If resolved IP differs from direct peer, forwarded headers were used.
// Warn operators so misconfigured proxies surface in logs. // Warn operators so misconfigured proxies surface in logs.
@@ -216,7 +226,7 @@ pub(crate) async fn rate_limit_middleware(
return next.run(req).await; return next.run(req).await;
} }
let ip = request_ip(&req).unwrap_or(IpAddr::from([0, 0, 0, 0])); let ip = request_ip(&req, &state.trusted_proxies).unwrap_or(IpAddr::from([0, 0, 0, 0]));
if !allow_global_request(&state, ip).await { if !allow_global_request(&state, ip).await {
return (StatusCode::TOO_MANY_REQUESTS, "Too many requests").into_response(); return (StatusCode::TOO_MANY_REQUESTS, "Too many requests").into_response();
} }
@@ -287,18 +297,18 @@ pub(crate) fn get_cookie_value(headers: &axum::http::HeaderMap, name: &str) -> O
None None
} }
pub(crate) fn request_ip(req: &Request) -> Option<IpAddr> { pub(crate) fn request_ip(req: &Request, trusted_proxies: &[IpAddr]) -> Option<IpAddr> {
let peer_ip = req let peer_ip = req
.extensions() .extensions()
.get::<ConnectInfo<SocketAddr>>() .get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0.ip()); .map(|info| info.0.ip());
// Only trust proxy headers (X-Forwarded-For, X-Real-IP) when the direct // Only trust proxy headers (X-Forwarded-For, X-Real-IP) when the direct
// TCP peer is a loopback or private IP — i.e., a trusted reverse proxy. // TCP peer is a trusted reverse proxy. When trusted_proxies is non-empty,
// This prevents external attackers from spoofing these headers to bypass // only those exact IPs (plus loopback) are trusted. Otherwise, fall back
// rate limiting. // to trusting all RFC-1918 private ranges (legacy behaviour).
if let Some(peer) = peer_ip { if let Some(peer) = peer_ip {
if is_trusted_peer(peer) { if is_trusted_peer(peer, trusted_proxies) {
if let Some(xff) = req.headers().get("X-Forwarded-For") { if let Some(xff) = req.headers().get("X-Forwarded-For") {
if let Ok(xff_str) = xff.to_str() { if let Ok(xff_str) = xff.to_str() {
if let Some(ip_str) = xff_str.split(',').next() { if let Some(ip_str) = xff_str.split(',').next() {
@@ -321,13 +331,27 @@ pub(crate) fn request_ip(req: &Request) -> Option<IpAddr> {
peer_ip peer_ip
} }
/// Returns true if the peer IP is a loopback or private address, /// Returns true if the peer IP may be trusted to set forwarded headers.
/// meaning it is likely a local reverse proxy that can be trusted ///
/// to set forwarded headers. /// When `trusted_proxies` is non-empty, only loopback addresses and the
fn is_trusted_peer(ip: IpAddr) -> bool { /// explicitly configured IPs are trusted, tightening the default which
match ip { /// previously trusted all RFC-1918 private ranges.
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), fn is_trusted_peer(ip: IpAddr, trusted_proxies: &[IpAddr]) -> bool {
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(), let is_loopback = match ip {
IpAddr::V4(v4) => v4.is_loopback(),
IpAddr::V6(v6) => v6.is_loopback(),
};
if is_loopback {
return true;
}
if trusted_proxies.is_empty() {
// Legacy: trust all private ranges when no explicit list is configured.
match ip {
IpAddr::V4(v4) => v4.is_private() || v4.is_link_local(),
IpAddr::V6(v6) => v6.is_unique_local() || v6.is_unicast_link_local(),
}
} else {
trusted_proxies.contains(&ip)
} }
} }

View File

@@ -17,7 +17,7 @@ mod tests;
use crate::Agent; use crate::Agent;
use crate::Transcoder; use crate::Transcoder;
use crate::config::Config; use crate::config::Config;
use crate::db::{AlchemistEvent, Db, EventChannels}; use crate::db::{Db, EventChannels};
use crate::error::{AlchemistError, Result}; use crate::error::{AlchemistError, Result};
use crate::system::hardware::{HardwareInfo, HardwareProbeLog, HardwareState}; use crate::system::hardware::{HardwareInfo, HardwareProbeLog, HardwareState};
use axum::{ use axum::{
@@ -38,7 +38,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant; use std::time::Instant;
use tokio::net::lookup_host; use tokio::net::lookup_host;
use tokio::sync::{Mutex, RwLock, broadcast}; use tokio::sync::{Mutex, RwLock};
use tokio::time::Duration; use tokio::time::Duration;
#[cfg(not(feature = "embed-web"))] #[cfg(not(feature = "embed-web"))]
use tracing::warn; use tracing::warn;
@@ -71,7 +71,6 @@ pub struct AppState {
pub transcoder: Arc<Transcoder>, pub transcoder: Arc<Transcoder>,
pub scheduler: crate::scheduler::SchedulerHandle, pub scheduler: crate::scheduler::SchedulerHandle,
pub event_channels: Arc<EventChannels>, pub event_channels: Arc<EventChannels>,
pub tx: broadcast::Sender<AlchemistEvent>, // Legacy channel for transition
pub setup_required: Arc<AtomicBool>, pub setup_required: Arc<AtomicBool>,
pub start_time: Instant, pub start_time: Instant,
pub telemetry_runtime_id: String, pub telemetry_runtime_id: String,
@@ -87,6 +86,10 @@ pub struct AppState {
pub(crate) login_rate_limiter: Mutex<HashMap<IpAddr, RateLimitEntry>>, pub(crate) login_rate_limiter: Mutex<HashMap<IpAddr, RateLimitEntry>>,
pub(crate) global_rate_limiter: Mutex<HashMap<IpAddr, RateLimitEntry>>, pub(crate) global_rate_limiter: Mutex<HashMap<IpAddr, RateLimitEntry>>,
pub(crate) sse_connections: Arc<std::sync::atomic::AtomicUsize>, pub(crate) sse_connections: Arc<std::sync::atomic::AtomicUsize>,
/// IPs whose proxy headers are trusted. Empty = trust all private ranges.
pub(crate) trusted_proxies: Vec<IpAddr>,
/// If set, setup endpoints require `?token=<value>` query parameter.
pub(crate) setup_token: Option<String>,
} }
pub struct RunServerArgs { pub struct RunServerArgs {
@@ -96,7 +99,6 @@ pub struct RunServerArgs {
pub transcoder: Arc<Transcoder>, pub transcoder: Arc<Transcoder>,
pub scheduler: crate::scheduler::SchedulerHandle, pub scheduler: crate::scheduler::SchedulerHandle,
pub event_channels: Arc<EventChannels>, pub event_channels: Arc<EventChannels>,
pub tx: broadcast::Sender<AlchemistEvent>, // Legacy channel for transition
pub setup_required: bool, pub setup_required: bool,
pub config_path: PathBuf, pub config_path: PathBuf,
pub config_mutable: bool, pub config_mutable: bool,
@@ -115,7 +117,6 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
transcoder, transcoder,
scheduler, scheduler,
event_channels, event_channels,
tx,
setup_required, setup_required,
config_path, config_path,
config_mutable, config_mutable,
@@ -145,6 +146,34 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
sys.refresh_cpu_usage(); sys.refresh_cpu_usage();
sys.refresh_memory(); sys.refresh_memory();
// Read setup token from environment (opt-in security layer).
let setup_token = std::env::var("ALCHEMIST_SETUP_TOKEN").ok();
if setup_token.is_some() {
info!("ALCHEMIST_SETUP_TOKEN is set — setup endpoints require token query param");
}
// Parse trusted proxy IPs from config. Unparseable entries are logged and skipped.
let trusted_proxies: Vec<IpAddr> = {
let cfg = config.read().await;
cfg.system
.trusted_proxies
.iter()
.filter_map(|s| {
s.parse::<IpAddr>()
.map_err(|_| {
error!("Invalid trusted_proxy entry (not a valid IP address): {s}");
})
.ok()
})
.collect()
};
if !trusted_proxies.is_empty() {
info!(
"Trusted proxies configured ({}): only these IPs will be trusted for X-Forwarded-For headers",
trusted_proxies.len()
);
}
let state = Arc::new(AppState { let state = Arc::new(AppState {
db, db,
config, config,
@@ -152,7 +181,6 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
transcoder, transcoder,
scheduler, scheduler,
event_channels, event_channels,
tx,
setup_required: Arc::new(AtomicBool::new(setup_required)), setup_required: Arc::new(AtomicBool::new(setup_required)),
start_time: std::time::Instant::now(), start_time: std::time::Instant::now(),
telemetry_runtime_id: Uuid::new_v4().to_string(), telemetry_runtime_id: Uuid::new_v4().to_string(),
@@ -168,6 +196,8 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
login_rate_limiter: Mutex::new(HashMap::new()), login_rate_limiter: Mutex::new(HashMap::new()),
global_rate_limiter: Mutex::new(HashMap::new()), global_rate_limiter: Mutex::new(HashMap::new()),
sse_connections: Arc::new(std::sync::atomic::AtomicUsize::new(0)), sse_connections: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
trusted_proxies,
setup_token,
}); });
// Clone agent for shutdown handler before moving state into router // Clone agent for shutdown handler before moving state into router

View File

@@ -126,7 +126,7 @@ async fn run_library_health_scan(db: Arc<crate::db::Db>) {
let semaphore = Arc::new(tokio::sync::Semaphore::new(2)); let semaphore = Arc::new(tokio::sync::Semaphore::new(2));
stream::iter(jobs) stream::iter(jobs)
.for_each_concurrent(Some(10), { .for_each_concurrent(None, {
let db = db.clone(); let db = db.clone();
let counters = counters.clone(); let counters = counters.clone();
let semaphore = semaphore.clone(); let semaphore = semaphore.clone();

View File

@@ -108,6 +108,10 @@ pub(crate) fn sse_message_for_system_event(event: &SystemEvent) -> SseMessage {
event_name: "scan_completed", event_name: "scan_completed",
data: "{}".to_string(), data: "{}".to_string(),
}, },
SystemEvent::EngineIdle => SseMessage {
event_name: "engine_idle",
data: "{}".to_string(),
},
SystemEvent::EngineStatusChanged => SseMessage { SystemEvent::EngineStatusChanged => SseMessage {
event_name: "engine_status_changed", event_name: "engine_status_changed",
data: "{}".to_string(), data: "{}".to_string(),

View File

@@ -1,7 +1,7 @@
//! System information, hardware info, resources, health handlers. //! System information, hardware info, resources, health handlers.
use super::{AppState, config_read_error_response}; use super::{AppState, config_read_error_response};
use crate::media::pipeline::{Analyzer as _, Planner as _, TranscodeDecision}; use crate::media::pipeline::{Planner as _, TranscodeDecision};
use axum::{ use axum::{
extract::State, extract::State,
http::StatusCode, http::StatusCode,
@@ -195,7 +195,6 @@ pub(crate) async fn library_intelligence_handler(State(state): State<Arc<AppStat
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); return StatusCode::INTERNAL_SERVER_ERROR.into_response();
} }
}; };
let analyzer = crate::media::analyzer::FfmpegAnalyzer;
let config_snapshot = state.config.read().await.clone(); let config_snapshot = state.config.read().await.clone();
let hw_snapshot = state.hardware_state.snapshot().await; let hw_snapshot = state.hardware_state.snapshot().await;
let planner = crate::media::planner::BasicPlanner::new( let planner = crate::media::planner::BasicPlanner::new(
@@ -207,14 +206,16 @@ pub(crate) async fn library_intelligence_handler(State(state): State<Arc<AppStat
if job.status == crate::db::JobState::Cancelled { if job.status == crate::db::JobState::Cancelled {
continue; continue;
} }
let input_path = std::path::Path::new(&job.input_path);
if !input_path.exists() {
continue;
}
let analysis = match analyzer.analyze(input_path).await { // Use stored metadata only — no live ffprobe spawning per job.
Ok(analysis) => analysis, let metadata = match job.input_metadata() {
Err(_) => continue, Some(m) => m,
None => continue,
};
let analysis = crate::media::pipeline::MediaAnalysis {
metadata,
warnings: vec![],
confidence: crate::media::pipeline::AnalysisConfidence::High,
}; };
let profile: Option<crate::db::LibraryProfile> = state let profile: Option<crate::db::LibraryProfile> = state

View File

@@ -61,7 +61,6 @@ where
probe_summary: crate::system::hardware::ProbeSummary::default(), probe_summary: crate::system::hardware::ProbeSummary::default(),
})); }));
let hardware_probe_log = Arc::new(RwLock::new(HardwareProbeLog::default())); let hardware_probe_log = Arc::new(RwLock::new(HardwareProbeLog::default()));
let (tx, _rx) = broadcast::channel(tx_capacity);
let transcoder = Arc::new(Transcoder::new()); let transcoder = Arc::new(Transcoder::new());
// Create event channels before Agent // Create event channels before Agent
@@ -81,7 +80,6 @@ where
transcoder.clone(), transcoder.clone(),
config.clone(), config.clone(),
hardware_state.clone(), hardware_state.clone(),
tx.clone(),
event_channels.clone(), event_channels.clone(),
true, true,
) )
@@ -101,7 +99,6 @@ where
transcoder, transcoder,
scheduler: scheduler.handle(), scheduler: scheduler.handle(),
event_channels, event_channels,
tx,
setup_required: Arc::new(AtomicBool::new(setup_required)), setup_required: Arc::new(AtomicBool::new(setup_required)),
start_time: Instant::now(), start_time: Instant::now(),
telemetry_runtime_id: "test-runtime".to_string(), telemetry_runtime_id: "test-runtime".to_string(),
@@ -120,6 +117,8 @@ where
login_rate_limiter: Mutex::new(HashMap::new()), login_rate_limiter: Mutex::new(HashMap::new()),
global_rate_limiter: Mutex::new(HashMap::new()), global_rate_limiter: Mutex::new(HashMap::new()),
sse_connections: Arc::new(std::sync::atomic::AtomicUsize::new(0)), sse_connections: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
trusted_proxies: Vec::new(),
setup_token: None,
}); });
Ok((state.clone(), app_router(state), config_path, db_path)) Ok((state.clone(), app_router(state), config_path, db_path))
@@ -1719,7 +1718,9 @@ async fn clear_completed_archives_jobs_and_preserves_stats()
assert!(state.db.get_job_by_id(job.id).await?.is_none()); assert!(state.db.get_job_by_id(job.id).await?.is_none());
let aggregated = state.db.get_aggregated_stats().await?; let aggregated = state.db.get_aggregated_stats().await?;
assert_eq!(aggregated.completed_jobs, 1); // Archived jobs are excluded from active stats.
assert_eq!(aggregated.completed_jobs, 0);
// encode_stats rows are preserved even after archiving.
assert_eq!(aggregated.total_input_size, 2_000); assert_eq!(aggregated.total_input_size, 2_000);
assert_eq!(aggregated.total_output_size, 1_000); assert_eq!(aggregated.total_output_size, 1_000);

View File

@@ -317,7 +317,6 @@ where
selection_reason: String::new(), selection_reason: String::new(),
probe_summary: alchemist::system::hardware::ProbeSummary::default(), probe_summary: alchemist::system::hardware::ProbeSummary::default(),
})), })),
Arc::new(broadcast::channel(16).0),
event_channels, event_channels,
false, false,
); );

View File

@@ -120,7 +120,6 @@ where
selection_reason: String::new(), selection_reason: String::new(),
probe_summary: alchemist::system::hardware::ProbeSummary::default(), probe_summary: alchemist::system::hardware::ProbeSummary::default(),
})), })),
Arc::new(broadcast::channel(16).0),
event_channels, event_channels,
false, false,
); );

View File

@@ -182,6 +182,7 @@ function JobManager() {
const serverIsTerminal = terminal.includes(serverJob.status); const serverIsTerminal = terminal.includes(serverJob.status);
if ( if (
local && local &&
local.status === serverJob.status &&
terminal.includes(local.status) && terminal.includes(local.status) &&
serverIsTerminal serverIsTerminal
) { ) {
@@ -232,7 +233,7 @@ function JobManager() {
}; };
}, []); }, []);
useJobSSE({ setJobs, fetchJobsRef, encodeStartTimes }); useJobSSE({ setJobs, setFocusedJob, fetchJobsRef, encodeStartTimes });
useEffect(() => { useEffect(() => {
const encodingJobIds = new Set<number>(); const encodingJobIds = new Set<number>();

View File

@@ -179,7 +179,7 @@ export function JobDetailModal({
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="text-helios-slate font-medium">Reduction</span> <span className="text-helios-slate font-medium">Reduction</span>
<span className="text-green-500 font-bold"> <span className="text-green-500 font-bold">
{((1 - focusedJob.encode_stats.compression_ratio) * 100).toFixed(1)}% Saved {(focusedJob.encode_stats.compression_ratio * 100).toFixed(1)}% Saved
</span> </span>
</div> </div>
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
@@ -262,6 +262,11 @@ export function JobDetailModal({
<p className="text-xs text-helios-slate mt-0.5"> <p className="text-xs text-helios-slate mt-0.5">
{focusedEmptyState.detail} {focusedEmptyState.detail}
</p> </p>
{focusedJob.job.status === "queued" && focusedJob.queue_position != null && (
<p className="text-xs text-helios-slate mt-1">
Queue position: <span className="font-semibold text-helios-ink">#{focusedJob.queue_position}</span>
</p>
)}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -91,6 +91,7 @@ export interface JobDetail {
job_failure_summary: string | null; job_failure_summary: string | null;
decision_explanation: ExplanationPayload | null; decision_explanation: ExplanationPayload | null;
failure_explanation: ExplanationPayload | null; failure_explanation: ExplanationPayload | null;
queue_position: number | null;
} }
export interface CountMessageResponse { export interface CountMessageResponse {

View File

@@ -1,14 +1,15 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { MutableRefObject, Dispatch, SetStateAction } from "react"; import type { MutableRefObject, Dispatch, SetStateAction } from "react";
import type { Job } from "./types"; import type { Job, JobDetail } from "./types";
interface UseJobSSEOptions { interface UseJobSSEOptions {
setJobs: Dispatch<SetStateAction<Job[]>>; setJobs: Dispatch<SetStateAction<Job[]>>;
setFocusedJob: Dispatch<SetStateAction<JobDetail | null>>;
fetchJobsRef: MutableRefObject<() => Promise<void>>; fetchJobsRef: MutableRefObject<() => Promise<void>>;
encodeStartTimes: MutableRefObject<Map<number, number>>; encodeStartTimes: MutableRefObject<Map<number, number>>;
} }
export function useJobSSE({ setJobs, fetchJobsRef, encodeStartTimes }: UseJobSSEOptions): void { export function useJobSSE({ setJobs, setFocusedJob, fetchJobsRef, encodeStartTimes }: UseJobSSEOptions): void {
useEffect(() => { useEffect(() => {
let eventSource: EventSource | null = null; let eventSource: EventSource | null = null;
let cancelled = false; let cancelled = false;
@@ -38,14 +39,18 @@ export function useJobSSE({ setJobs, fetchJobsRef, encodeStartTimes }: UseJobSSE
job_id: number; job_id: number;
status: string; status: string;
}; };
const terminalStatuses = ["completed", "failed", "cancelled", "skipped"];
if (status === "encoding") { if (status === "encoding") {
encodeStartTimes.current.set(job_id, Date.now()); encodeStartTimes.current.set(job_id, Date.now());
} else { } else if (terminalStatuses.includes(status)) {
encodeStartTimes.current.delete(job_id); encodeStartTimes.current.delete(job_id);
} }
setJobs((prev) => setJobs((prev) =>
prev.map((job) => job.id === job_id ? { ...job, status } : job) prev.map((job) => job.id === job_id ? { ...job, status } : job)
); );
setFocusedJob((prev) =>
prev?.job.id === job_id ? { ...prev, job: { ...prev.job, status } } : prev
);
} catch { } catch {
/* ignore malformed */ /* ignore malformed */
} }