diff --git a/CHANGELOG.md b/CHANGELOG.md index 551598e..673f131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [0.3.1-rc.5] - 2026-04-16 + +### Reliability & Stability + +- **Segment-based encode resume** — interrupted encode jobs now persist resume sessions and completed segments so restart and recovery flows can continue without discarding all completed work. +- **Notification target compatibility hardening** — notification target reads/writes now preserve the additive migration path, tolerate legacy shapes, and avoid duplicate-delete projection bugs in settings management. +- **Daily summary reliability** — summary delivery now retries safely after transient failures and avoids duplicate sends across restart boundaries by persisting the last successful day. +- **Job-detail correctness** — completed-job detail loading now fails closed on database errors instead of returning partial `200 OK` payloads, and encode stat duration fallback uses the encoded output rather than the source file. +- **Auth and settings safety** — login now returns server errors for real database failures, and duplicate notification/schedule rows no longer disappear together from a single delete action. + +### Jobs & UX + +- **Manual enqueue flow** — the jobs UI now supports enqueueing a single absolute file path through the same backend dedupe and output rules used by library scans. +- **Queued-job visibility** — job detail now exposes queue position and processor blocked reasons so operators can see why a queued job is not starting. +- **Attempt-history surfacing** — job detail now shows encode attempt history directly in the modal, including outcome, timing, and captured failure summary. +- **Jobs UI follow-through** — the `JobManager` refactor now ships with dedicated controller/dialog helpers and tighter SSE reconciliation so filtered tables and open detail modals stay aligned with backend truth. +- **Intelligence actions** — remux recommendations and duplicate candidates are now actionable directly from the Intelligence page. + ## [0.3.1-rc.3] - 2026-04-12 ### New Features diff --git a/Cargo.lock b/Cargo.lock index 29647bc..c7f00f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "alchemist" -version = "0.3.1-rc.4" +version = "0.3.1-rc.5" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index a21c6d4..68f2ae5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "alchemist" -version = "0.3.1-rc.4" +version = "0.3.1-rc.5" edition = "2024" rust-version = "1.85" license = "GPL-3.0" diff --git a/RELEASING.md b/RELEASING.md index 18479c7..639c1f3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -30,15 +30,15 @@ Then complete the release-candidate preflight: Promote to stable only after the RC burn-in is complete and the same automated preflight is still green. -1. Run `just bump 0.3.0`. +1. Run `just bump 0.3.1`. 2. Update `CHANGELOG.md` and `docs/docs/changelog.md` for the stable cut. 3. Run `just release-check`. 4. Re-run the manual smoke checklist against the final release artifacts: - Docker fresh install - Packaged binary first-run - - Upgrade from the most recent `0.2.x` or `0.3.0-rc.x` + - Upgrade from the most recent `0.2.x` or `0.3.1-rc.x` - Encode, skip, failure, and notification verification 5. Re-run the Windows contributor verification checklist if Windows parity changed after the last RC. 6. Confirm release notes, docs, and hardware-support wording match the tested release state. 7. Merge the stable release commit to `main`. -8. Create the annotated tag `v0.3.0` on the exact merged commit. +8. Create the annotated tag `v0.3.1` on the exact merged commit. diff --git a/VERSION b/VERSION index 6f2e72b..3d9532f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1-rc.4 +0.3.1-rc.5 diff --git a/backlog.md b/backlog.md index 91ef5d4..7df8d9d 100644 --- a/backlog.md +++ b/backlog.md @@ -59,37 +59,37 @@ documentation, or iteration. - remux-only opportunities - wasteful audio layouts - commentary/descriptive-track cleanup candidates +- Direct actions now exist for queueing remux recommendations and opening duplicate candidates in the shared job-detail flow + +### Engine Lifecycle + Planner Docs +- Runtime drain/restart controls exist in the product surface +- Backend and Playwright lifecycle coverage now exists for the current behavior +- Planner and engine lifecycle docs are in-repo and should now be kept in sync with shipped semantics rather than treated as missing work + +### Jobs UI Refactor / In Flight +- `JobManager` has been decomposed into focused jobs subcomponents and controller hooks +- SSE ownership is now centered in a dedicated hook and job-detail controller flow +- Treat the current jobs UI surface as shipping product that still needs stabilization and regression coverage, not as a future refactor candidate --- ## Active Priorities -### Engine Lifecycle Controls -- Finish and harden restart/shutdown semantics from the About/header surface -- Restart must reset the engine loop without re-execing the process -- Shutdown must cancel active jobs and exit cleanly -- Add final backend and Playwright coverage for lifecycle transitions +### `0.3.1` RC Stability Follow-Through +- Keep the current in-flight backend/frontend/test delta focused on reliability, upgrade safety, and release hardening +- Expand regression coverage for resume/restart/cancel flows, job-detail refresh semantics, settings projection, and intelligence actions +- Keep release docs, changelog entries, and support wording aligned with what the RC actually ships -### Planner and Lifecycle Documentation -- Document planner heuristics and stable skip/transcode/remux decision boundaries -- Document hardware fallback rules and backend selection semantics -- Document pause, drain, restart, cancel, and shutdown semantics from actual behavior - -### Per-File Encode History -- Show full attempt history in job detail, grouped by canonical file identity -- Include outcome, encode stats, and failure reason where available -- Make retries, reruns, and settings-driven requeues legible - -### Behavior-Preserving Refactor Pass -- Decompose `web/src/components/JobManager.tsx` without changing current behavior -- Extract shared formatting logic -- Clarify SSE vs polling ownership -- Add regression coverage before deeper structural cleanup +### Per-File Encode History Follow-Through +- Attempt history now exists in job detail, but it is still job-scoped rather than grouped by canonical file identity +- Next hardening pass should make retries, reruns, and settings-driven requeues legible across a file’s full history +- Include outcome, encode stats, and failure reason where available without regressing the existing job-detail flow ### AMD AV1 Validation - Validate Linux VAAPI and Windows AMF AV1 paths on real hardware - Confirm encoder selection, fallback behavior, and defaults - Keep support claims conservative until validation is real +- Deferred from the current `0.3.1-rc.5` automated-stability pass; do not broaden support claims before this work is complete --- diff --git a/docs/bun.lock b/docs/bun.lock index 32d6eec..a138b02 100644 --- a/docs/bun.lock +++ b/docs/bun.lock @@ -24,6 +24,7 @@ }, }, "overrides": { + "follow-redirects": "^1.16.0", "lodash": "^4.18.1", "serialize-javascript": "^7.0.5", }, @@ -1108,7 +1109,7 @@ "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="], diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 6ffadc8..c0504fc 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -3,6 +3,24 @@ title: Changelog description: Release history for Alchemist. --- +## [0.3.1-rc.5] - 2026-04-16 + +### Reliability & Stability + +- **Segment-based encode resume** — interrupted encode jobs now persist resume sessions and completed segments so restart and recovery flows can continue without discarding all completed work. +- **Notification target compatibility hardening** — notification target reads/writes now preserve the additive migration path, tolerate legacy shapes, and avoid duplicate-delete projection bugs in settings management. +- **Daily summary reliability** — summary delivery now retries safely after transient failures and avoids duplicate sends across restart boundaries by persisting the last successful day. +- **Job-detail correctness** — completed-job detail loading now fails closed on database errors instead of returning partial `200 OK` payloads, and encode stat duration fallback uses the encoded output rather than the source file. +- **Auth and settings safety** — login now returns server errors for real database failures, and duplicate notification/schedule rows no longer disappear together from a single delete action. + +### Jobs & UX + +- **Manual enqueue flow** — the jobs UI now supports enqueueing a single absolute file path through the same backend dedupe and output rules used by library scans. +- **Queued-job visibility** — job detail now exposes queue position and processor blocked reasons so operators can see why a queued job is not starting. +- **Attempt-history surfacing** — job detail now shows encode attempt history directly in the modal, including outcome, timing, and captured failure summary. +- **Jobs UI follow-through** — the `JobManager` refactor now ships with dedicated controller/dialog helpers and tighter SSE reconciliation so filtered tables and open detail modals stay aligned with backend truth. +- **Intelligence actions** — remux recommendations and duplicate candidates are now actionable directly from the Intelligence page. + ## [0.3.1-rc.3] - 2026-04-12 ### New Features diff --git a/docs/package.json b/docs/package.json index dd7d5a9..0631cc5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "alchemist-docs", - "version": "0.3.1-rc.4", + "version": "0.3.1-rc.5", "private": true, "packageManager": "bun@1.3.5", "scripts": { @@ -48,6 +48,7 @@ "node": ">=20.0" }, "overrides": { + "follow-redirects": "^1.16.0", "lodash": "^4.18.1", "serialize-javascript": "^7.0.5" } diff --git a/migrations/20260407110000_notification_targets_v2_and_conversion_jobs.sql b/migrations/20260407110000_notification_targets_v2_and_conversion_jobs.sql index 7be416b..39f4679 100644 --- a/migrations/20260407110000_notification_targets_v2_and_conversion_jobs.sql +++ b/migrations/20260407110000_notification_targets_v2_and_conversion_jobs.sql @@ -1,34 +1,25 @@ -CREATE TABLE IF NOT EXISTS notification_targets_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - target_type TEXT CHECK(target_type IN ('discord_webhook', 'discord_bot', 'gotify', 'webhook', 'telegram', 'email')) NOT NULL, - config_json TEXT NOT NULL DEFAULT '{}', - events TEXT NOT NULL DEFAULT '["encode.failed","encode.completed"]', - enabled BOOLEAN DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); +ALTER TABLE notification_targets + ADD COLUMN target_type_v2 TEXT; -INSERT INTO notification_targets_new (id, name, target_type, config_json, events, enabled, created_at) -SELECT - id, - name, - CASE target_type +ALTER TABLE notification_targets + ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}'; + +UPDATE notification_targets +SET + target_type_v2 = CASE target_type WHEN 'discord' THEN 'discord_webhook' WHEN 'gotify' THEN 'gotify' ELSE 'webhook' END, - CASE target_type + config_json = CASE target_type WHEN 'discord' THEN json_object('webhook_url', endpoint_url) WHEN 'gotify' THEN json_object('server_url', endpoint_url, 'app_token', COALESCE(auth_token, '')) ELSE json_object('url', endpoint_url, 'auth_token', auth_token) - END, - COALESCE(events, '["failed","completed"]'), - enabled, - created_at -FROM notification_targets; - -DROP TABLE notification_targets; -ALTER TABLE notification_targets_new RENAME TO notification_targets; + END +WHERE target_type_v2 IS NULL + OR target_type_v2 = '' + OR config_json IS NULL + OR trim(config_json) = ''; CREATE INDEX IF NOT EXISTS idx_notification_targets_enabled ON notification_targets(enabled); diff --git a/migrations/20260414010000_job_resume_sessions.sql b/migrations/20260414010000_job_resume_sessions.sql new file mode 100644 index 0000000..e404297 --- /dev/null +++ b/migrations/20260414010000_job_resume_sessions.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS job_resume_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE, + strategy TEXT NOT NULL, + plan_hash TEXT NOT NULL, + mtime_hash TEXT NOT NULL, + temp_dir TEXT NOT NULL, + concat_manifest_path TEXT NOT NULL, + segment_length_secs INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS job_resume_segments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + segment_index INTEGER NOT NULL, + start_secs REAL NOT NULL, + duration_secs REAL NOT NULL, + temp_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(job_id, segment_index) +); + +CREATE INDEX IF NOT EXISTS idx_job_resume_sessions_status + ON job_resume_sessions(status); + +CREATE INDEX IF NOT EXISTS idx_job_resume_segments_job_status + ON job_resume_segments(job_id, status); + +INSERT OR REPLACE INTO schema_info (key, value) VALUES + ('schema_version', '9'), + ('min_compatible_version', '0.2.5'), + ('last_updated', datetime('now')); diff --git a/src/db/config.rs b/src/db/config.rs index 4a2a22b..ac02ce4 100644 --- a/src/db/config.rs +++ b/src/db/config.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use serde_json::Value as JsonValue; use sqlx::Row; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -6,6 +7,54 @@ use std::path::{Path, PathBuf}; use super::Db; use super::types::*; +fn notification_config_string(config_json: &str, key: &str) -> Option { + serde_json::from_str::(config_json) + .ok() + .and_then(|value| { + value + .get(key) + .and_then(JsonValue::as_str) + .map(str::to_string) + }) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn notification_legacy_columns( + target_type: &str, + config_json: &str, +) -> (String, Option, Option) { + match target_type { + "discord_webhook" => ( + "discord".to_string(), + notification_config_string(config_json, "webhook_url"), + None, + ), + "discord_bot" => ( + "discord".to_string(), + Some("https://discord.com".to_string()), + notification_config_string(config_json, "bot_token"), + ), + "gotify" => ( + "gotify".to_string(), + notification_config_string(config_json, "server_url"), + notification_config_string(config_json, "app_token"), + ), + "webhook" => ( + "webhook".to_string(), + notification_config_string(config_json, "url"), + notification_config_string(config_json, "auth_token"), + ), + "telegram" => ( + "webhook".to_string(), + Some("https://api.telegram.org".to_string()), + notification_config_string(config_json, "bot_token"), + ), + "email" => ("webhook".to_string(), None, None), + other => (other.to_string(), None, None), + } +} + impl Db { pub async fn get_watch_dirs(&self) -> Result> { let has_is_recursive = self.watch_dir_flags.has_is_recursive; @@ -292,13 +341,23 @@ impl Db { 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 || '\\%') + AND ( + ? = wd.path + OR ( + length(?) > length(wd.path) + AND ( + substr(?, 1, length(wd.path) + 1) = wd.path || '/' + OR substr(?, 1, length(wd.path) + 1) = wd.path || '\\' + ) + ) + ) ORDER BY LENGTH(wd.path) DESC LIMIT 1", ) .bind(path) .bind(path) .bind(path) + .bind(path) .fetch_optional(&self.pool) .await?; @@ -359,11 +418,43 @@ impl Db { } pub async fn get_notification_targets(&self) -> Result> { - let targets = sqlx::query_as::<_, NotificationTarget>( - "SELECT id, name, target_type, config_json, events, enabled, created_at FROM notification_targets", - ) + let flags = &self.notification_target_flags; + let targets = if flags.has_target_type_v2 { + sqlx::query_as::<_, NotificationTarget>( + "SELECT + id, + name, + COALESCE( + NULLIF(target_type_v2, ''), + CASE target_type + WHEN 'discord' THEN 'discord_webhook' + WHEN 'gotify' THEN 'gotify' + ELSE 'webhook' + END + ) AS target_type, + CASE + WHEN trim(config_json) != '' THEN config_json + WHEN target_type = 'discord' THEN json_object('webhook_url', endpoint_url) + WHEN target_type = 'gotify' THEN json_object('server_url', endpoint_url, 'app_token', COALESCE(auth_token, '')) + ELSE json_object('url', endpoint_url, 'auth_token', auth_token) + END AS config_json, + events, + enabled, + created_at + FROM notification_targets + ORDER BY id ASC", + ) .fetch_all(&self.pool) - .await?; + .await? + } else { + sqlx::query_as::<_, NotificationTarget>( + "SELECT id, name, target_type, config_json, events, enabled, created_at + FROM notification_targets + ORDER BY id ASC", + ) + .fetch_all(&self.pool) + .await? + }; Ok(targets) } @@ -375,18 +466,42 @@ impl Db { events: &str, enabled: bool, ) -> Result { - 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) + let flags = &self.notification_target_flags; + if flags.has_target_type_v2 { + let (legacy_target_type, endpoint_url, auth_token) = + notification_legacy_columns(target_type, config_json); + let result = sqlx::query( + "INSERT INTO notification_targets + (name, target_type, target_type_v2, endpoint_url, auth_token, config_json, events, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(name) + .bind(legacy_target_type) + .bind(target_type) + .bind(endpoint_url) + .bind(auth_token) + .bind(config_json) + .bind(events) + .bind(enabled) + .execute(&self.pool) + .await?; + self.get_notification_target_by_id(result.last_insert_rowid()) + .await + } else { + let result = sqlx::query( + "INSERT INTO notification_targets (name, target_type, config_json, events, enabled) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(name) + .bind(target_type) + .bind(config_json) + .bind(events) + .bind(enabled) + .execute(&self.pool) + .await?; + self.get_notification_target_by_id(result.last_insert_rowid()) + .await + } } pub async fn delete_notification_target(&self, id: i64) -> Result<()> { @@ -406,30 +521,97 @@ impl Db { &self, targets: &[crate::config::NotificationTargetConfig], ) -> Result<()> { + let flags = &self.notification_target_flags; 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?; + let config_json = target.config_json.to_string(); + let events = serde_json::to_string(&target.events).unwrap_or_else(|_| "[]".to_string()); + if flags.has_target_type_v2 { + let (legacy_target_type, endpoint_url, auth_token) = + notification_legacy_columns(&target.target_type, &config_json); + sqlx::query( + "INSERT INTO notification_targets + (name, target_type, target_type_v2, endpoint_url, auth_token, config_json, events, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&target.name) + .bind(legacy_target_type) + .bind(&target.target_type) + .bind(endpoint_url) + .bind(auth_token) + .bind(&config_json) + .bind(&events) + .bind(target.enabled) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + "INSERT INTO notification_targets (name, target_type, config_json, events, enabled) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&target.name) + .bind(&target.target_type) + .bind(&config_json) + .bind(&events) + .bind(target.enabled) + .execute(&mut *tx) + .await?; + } } tx.commit().await?; Ok(()) } + async fn get_notification_target_by_id(&self, id: i64) -> Result { + let flags = &self.notification_target_flags; + let row = if flags.has_target_type_v2 { + sqlx::query_as::<_, NotificationTarget>( + "SELECT + id, + name, + COALESCE( + NULLIF(target_type_v2, ''), + CASE target_type + WHEN 'discord' THEN 'discord_webhook' + WHEN 'gotify' THEN 'gotify' + ELSE 'webhook' + END + ) AS target_type, + CASE + WHEN trim(config_json) != '' THEN config_json + WHEN target_type = 'discord' THEN json_object('webhook_url', endpoint_url) + WHEN target_type = 'gotify' THEN json_object('server_url', endpoint_url, 'app_token', COALESCE(auth_token, '')) + ELSE json_object('url', endpoint_url, 'auth_token', auth_token) + END AS config_json, + events, + enabled, + created_at + FROM notification_targets + WHERE id = ?", + ) + .bind(id) + .fetch_one(&self.pool) + .await? + } else { + sqlx::query_as::<_, NotificationTarget>( + "SELECT id, name, target_type, config_json, events, enabled, created_at + FROM notification_targets + WHERE id = ?", + ) + .bind(id) + .fetch_one(&self.pool) + .await? + }; + Ok(row) + } + pub async fn get_schedule_windows(&self) -> Result> { - let windows = sqlx::query_as::<_, ScheduleWindow>("SELECT * FROM schedule_windows") - .fetch_all(&self.pool) - .await?; + let windows = + sqlx::query_as::<_, ScheduleWindow>("SELECT * FROM schedule_windows ORDER BY id ASC") + .fetch_all(&self.pool) + .await?; Ok(windows) } @@ -582,3 +764,101 @@ impl Db { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_db_path(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("{prefix}_{}.db", rand::random::())); + path + } + + fn sample_profile(name: &str) -> NewLibraryProfile { + NewLibraryProfile { + name: name.to_string(), + preset: "balanced".to_string(), + codec: "av1".to_string(), + quality_profile: "balanced".to_string(), + hdr_mode: "preserve".to_string(), + audio_mode: "copy".to_string(), + crf_override: None, + notes: None, + } + } + + #[tokio::test] + async fn profile_lookup_treats_percent_and_underscore_as_literals() -> anyhow::Result<()> { + let db_path = temp_db_path("alchemist_profile_lookup_literals"); + let db = Db::new(db_path.to_string_lossy().as_ref()).await?; + + let underscore_profile = db.create_profile(sample_profile("underscore")).await?; + let percent_profile = db.create_profile(sample_profile("percent")).await?; + + let underscore_watch = db.add_watch_dir("/media/TV_4K", true).await?; + db.assign_profile_to_watch_dir(underscore_watch.id, Some(underscore_profile)) + .await?; + + let percent_watch = db.add_watch_dir("/media/Movies%20", true).await?; + db.assign_profile_to_watch_dir(percent_watch.id, Some(percent_profile)) + .await?; + + assert_eq!( + db.get_profile_for_path("/media/TV_4K/show/file.mkv") + .await? + .map(|profile| profile.name), + Some("underscore".to_string()) + ); + assert_eq!( + db.get_profile_for_path("/media/TVA4K/show/file.mkv") + .await? + .map(|profile| profile.name), + None + ); + assert_eq!( + db.get_profile_for_path("/media/Movies%20/title/file.mkv") + .await? + .map(|profile| profile.name), + Some("percent".to_string()) + ); + assert_eq!( + db.get_profile_for_path("/media/MoviesABCD/title/file.mkv") + .await? + .map(|profile| profile.name), + None + ); + + db.pool.close().await; + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn profile_lookup_prefers_longest_literal_matching_watch_dir() -> anyhow::Result<()> { + let db_path = temp_db_path("alchemist_profile_lookup_longest"); + let db = Db::new(db_path.to_string_lossy().as_ref()).await?; + + let base_profile = db.create_profile(sample_profile("base")).await?; + let nested_profile = db.create_profile(sample_profile("nested")).await?; + + let base_watch = db.add_watch_dir("/media", true).await?; + db.assign_profile_to_watch_dir(base_watch.id, Some(base_profile)) + .await?; + + let nested_watch = db.add_watch_dir("/media/TV_4K", true).await?; + db.assign_profile_to_watch_dir(nested_watch.id, Some(nested_profile)) + .await?; + + assert_eq!( + db.get_profile_for_path("/media/TV_4K/show/file.mkv") + .await? + .map(|profile| profile.name), + Some("nested".to_string()) + ); + + db.pool.close().await; + let _ = std::fs::remove_file(db_path); + Ok(()) + } +} diff --git a/src/db/jobs.rs b/src/db/jobs.rs index 8d50ccf..d64df4f 100644 --- a/src/db/jobs.rs +++ b/src/db/jobs.rs @@ -662,6 +662,166 @@ impl Db { Ok(Some((pos + 1) as u32)) } + pub async fn get_resume_session(&self, job_id: i64) -> Result> { + let session = sqlx::query_as::<_, JobResumeSession>( + "SELECT id, job_id, strategy, plan_hash, mtime_hash, temp_dir, + concat_manifest_path, segment_length_secs, status, created_at, updated_at + FROM job_resume_sessions + WHERE job_id = ?", + ) + .bind(job_id) + .fetch_optional(&self.pool) + .await?; + Ok(session) + } + + pub async fn get_resume_sessions_by_job_ids( + &self, + ids: &[i64], + ) -> Result> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + let mut qb = sqlx::QueryBuilder::::new( + "SELECT id, job_id, strategy, plan_hash, mtime_hash, temp_dir, + concat_manifest_path, segment_length_secs, status, created_at, updated_at + FROM job_resume_sessions + WHERE job_id IN (", + ); + let mut separated = qb.separated(", "); + for id in ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + + let sessions = qb + .build_query_as::() + .fetch_all(&self.pool) + .await?; + Ok(sessions) + } + + pub async fn upsert_resume_session( + &self, + input: &UpsertJobResumeSessionInput, + ) -> Result { + let session = sqlx::query_as::<_, JobResumeSession>( + "INSERT INTO job_resume_sessions + (job_id, strategy, plan_hash, mtime_hash, temp_dir, + concat_manifest_path, segment_length_secs, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(job_id) DO UPDATE SET + strategy = excluded.strategy, + plan_hash = excluded.plan_hash, + mtime_hash = excluded.mtime_hash, + temp_dir = excluded.temp_dir, + concat_manifest_path = excluded.concat_manifest_path, + segment_length_secs = excluded.segment_length_secs, + status = excluded.status, + updated_at = CURRENT_TIMESTAMP + RETURNING id, job_id, strategy, plan_hash, mtime_hash, temp_dir, + concat_manifest_path, segment_length_secs, status, created_at, updated_at", + ) + .bind(input.job_id) + .bind(&input.strategy) + .bind(&input.plan_hash) + .bind(&input.mtime_hash) + .bind(&input.temp_dir) + .bind(&input.concat_manifest_path) + .bind(input.segment_length_secs) + .bind(&input.status) + .fetch_one(&self.pool) + .await?; + Ok(session) + } + + pub async fn delete_resume_session(&self, job_id: i64) -> Result<()> { + sqlx::query("DELETE FROM job_resume_sessions WHERE job_id = ?") + .bind(job_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn list_resume_segments(&self, job_id: i64) -> Result> { + let segments = sqlx::query_as::<_, JobResumeSegment>( + "SELECT id, job_id, segment_index, start_secs, duration_secs, + temp_path, status, attempt_count, created_at, updated_at + FROM job_resume_segments + WHERE job_id = ? + ORDER BY segment_index ASC", + ) + .bind(job_id) + .fetch_all(&self.pool) + .await?; + Ok(segments) + } + + pub async fn upsert_resume_segment( + &self, + input: &UpsertJobResumeSegmentInput, + ) -> Result { + let segment = sqlx::query_as::<_, JobResumeSegment>( + "INSERT INTO job_resume_segments + (job_id, segment_index, start_secs, duration_secs, temp_path, status, attempt_count) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(job_id, segment_index) DO UPDATE SET + start_secs = excluded.start_secs, + duration_secs = excluded.duration_secs, + temp_path = excluded.temp_path, + status = excluded.status, + attempt_count = excluded.attempt_count, + updated_at = CURRENT_TIMESTAMP + RETURNING id, job_id, segment_index, start_secs, duration_secs, + temp_path, status, attempt_count, created_at, updated_at", + ) + .bind(input.job_id) + .bind(input.segment_index) + .bind(input.start_secs) + .bind(input.duration_secs) + .bind(&input.temp_path) + .bind(&input.status) + .bind(input.attempt_count) + .fetch_one(&self.pool) + .await?; + Ok(segment) + } + + pub async fn set_resume_segment_status( + &self, + job_id: i64, + segment_index: i64, + status: &str, + attempt_count: i32, + ) -> Result<()> { + sqlx::query( + "UPDATE job_resume_segments + SET status = ?, attempt_count = ?, updated_at = CURRENT_TIMESTAMP + WHERE job_id = ? AND segment_index = ?", + ) + .bind(status) + .bind(attempt_count) + .bind(job_id) + .bind(segment_index) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn completed_resume_duration_secs(&self, job_id: i64) -> Result { + let duration = sqlx::query_scalar::<_, Option>( + "SELECT SUM(duration_secs) + FROM job_resume_segments + WHERE job_id = ? AND status = 'completed'", + ) + .bind(job_id) + .fetch_one(&self.pool) + .await? + .unwrap_or(0.0); + Ok(duration) + } + /// Returns all jobs in queued or failed state that need /// analysis. Used by the startup auto-analyzer. pub async fn get_jobs_for_analysis(&self) -> Result> { diff --git a/src/db/mod.rs b/src/db/mod.rs index a284df8..1020e6d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -43,10 +43,16 @@ pub(crate) struct WatchDirSchemaFlags { has_profile_id: bool, } +#[derive(Clone, Debug)] +pub(crate) struct NotificationTargetSchemaFlags { + has_target_type_v2: bool, +} + #[derive(Clone, Debug)] pub struct Db { pub(crate) pool: SqlitePool, pub(crate) watch_dir_flags: std::sync::Arc, + pub(crate) notification_target_flags: std::sync::Arc, } impl Db { @@ -102,9 +108,28 @@ impl Db { has_profile_id: check("profile_id").await, }; + let notification_check = |column: &str| { + let pool = pool.clone(); + let column = column.to_string(); + async move { + let row = sqlx::query( + "SELECT name FROM pragma_table_info('notification_targets') WHERE name = ?", + ) + .bind(&column) + .fetch_optional(&pool) + .await + .unwrap_or(None); + row.is_some() + } + }; + let notification_target_flags = NotificationTargetSchemaFlags { + has_target_type_v2: notification_check("target_type_v2").await, + }; + Ok(Self { pool, watch_dir_flags: std::sync::Arc::new(watch_dir_flags), + notification_target_flags: std::sync::Arc::new(notification_target_flags), }) } } diff --git a/src/db/types.rs b/src/db/types.rs index fff64ed..c2beca2 100644 --- a/src/db/types.rs +++ b/src/db/types.rs @@ -238,6 +238,58 @@ pub struct ConversionJob { pub updated_at: String, } +#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] +pub struct JobResumeSession { + pub id: i64, + pub job_id: i64, + pub strategy: String, + pub plan_hash: String, + pub mtime_hash: String, + pub temp_dir: String, + pub concat_manifest_path: String, + pub segment_length_secs: i64, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] +pub struct JobResumeSegment { + pub id: i64, + pub job_id: i64, + pub segment_index: i64, + pub start_secs: f64, + pub duration_secs: f64, + pub temp_path: String, + pub status: String, + pub attempt_count: i32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone)] +pub struct UpsertJobResumeSessionInput { + pub job_id: i64, + pub strategy: String, + pub plan_hash: String, + pub mtime_hash: String, + pub temp_dir: String, + pub concat_manifest_path: String, + pub segment_length_secs: i64, + pub status: String, +} + +#[derive(Debug, Clone)] +pub struct UpsertJobResumeSegmentInput { + pub job_id: i64, + pub segment_index: i64, + pub start_secs: f64, + pub duration_secs: f64, + pub temp_path: String, + pub status: String, + pub attempt_count: i32, +} + #[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] pub struct ScheduleWindow { pub id: i64, diff --git a/src/main.rs b/src/main.rs index 131a0d1..00327b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -321,6 +321,11 @@ async fn run() -> Result<()> { Ok(count) if count > 0 => { warn!("{} interrupted jobs reset to queued", count); for job in interrupted_jobs { + let has_resume_session = + db.get_resume_session(job.id).await.ok().flatten().is_some(); + if has_resume_session { + continue; + } let temp_path = orphaned_temp_output_path(&job.output_path); if std::fs::metadata(&temp_path).is_ok() { match std::fs::remove_file(&temp_path) { diff --git a/src/media/executor.rs b/src/media/executor.rs index bba85e4..b412f68 100644 --- a/src/media/executor.rs +++ b/src/media/executor.rs @@ -154,6 +154,8 @@ impl Executor for FfmpegExecutor { metadata: &analysis.metadata, plan, observer: Some(observer.clone()), + clip_start_seconds: None, + clip_duration_seconds: None, }) .await?; @@ -171,6 +173,8 @@ impl Executor for FfmpegExecutor { metadata: &analysis.metadata, plan, observer: Some(observer), + clip_start_seconds: None, + clip_duration_seconds: None, }) .await?; } @@ -251,7 +255,7 @@ impl Executor for FfmpegExecutor { } } -fn output_codec_from_name(codec: &str) -> Option { +pub(crate) fn output_codec_from_name(codec: &str) -> Option { if codec.eq_ignore_ascii_case("av1") { Some(crate::config::OutputCodec::Av1) } else if codec.eq_ignore_ascii_case("hevc") || codec.eq_ignore_ascii_case("h265") { @@ -263,7 +267,10 @@ fn output_codec_from_name(codec: &str) -> Option { } } -fn encoder_tag_matches(requested: crate::media::pipeline::Encoder, encoder_tag: &str) -> bool { +pub(crate) fn encoder_tag_matches( + requested: crate::media::pipeline::Encoder, + encoder_tag: &str, +) -> bool { let tag = encoder_tag.to_ascii_lowercase(); let expected_markers: &[&str] = match requested { crate::media::pipeline::Encoder::Av1Qsv diff --git a/src/media/ffmpeg/mod.rs b/src/media/ffmpeg/mod.rs index c98f6f9..b372570 100644 --- a/src/media/ffmpeg/mod.rs +++ b/src/media/ffmpeg/mod.rs @@ -135,6 +135,8 @@ pub struct FFmpegCommandBuilder<'a> { metadata: &'a crate::media::pipeline::MediaMetadata, plan: &'a TranscodePlan, hw_info: Option<&'a HardwareInfo>, + clip_start_seconds: Option, + clip_duration_seconds: Option, } impl<'a> FFmpegCommandBuilder<'a> { @@ -150,6 +152,8 @@ impl<'a> FFmpegCommandBuilder<'a> { metadata, plan, hw_info: None, + clip_start_seconds: None, + clip_duration_seconds: None, } } @@ -158,6 +162,16 @@ impl<'a> FFmpegCommandBuilder<'a> { self } + pub fn with_clip( + mut self, + clip_start_seconds: Option, + clip_duration_seconds: Option, + ) -> Self { + self.clip_start_seconds = clip_start_seconds; + self.clip_duration_seconds = clip_duration_seconds; + self + } + pub fn build(self) -> Result { let args = self.build_args()?; let mut cmd = tokio::process::Command::new("ffmpeg"); @@ -189,14 +203,23 @@ impl<'a> FFmpegCommandBuilder<'a> { "-nostats".to_string(), "-progress".to_string(), "pipe:2".to_string(), - "-i".to_string(), - self.input.display().to_string(), - "-map_metadata".to_string(), - "0".to_string(), - "-map".to_string(), - "0:v:0".to_string(), ]; + args.push("-i".to_string()); + args.push(self.input.display().to_string()); + if let Some(clip_start_seconds) = self.clip_start_seconds { + args.push("-ss".to_string()); + args.push(format!("{clip_start_seconds:.3}")); + } + if let Some(clip_duration_seconds) = self.clip_duration_seconds { + args.push("-t".to_string()); + args.push(format!("{clip_duration_seconds:.3}")); + } + args.push("-map_metadata".to_string()); + args.push("0".to_string()); + args.push("-map".to_string()); + args.push("0:v:0".to_string()); + if !matches!(self.plan.audio, AudioStreamPlan::Drop) { match &self.plan.audio_stream_indices { None => { @@ -1039,6 +1062,30 @@ mod tests { assert!(args.iter().any(|arg| arg.contains("format=nv12,hwupload"))); } + #[test] + fn vaapi_cq_mode_sets_inverted_global_quality() { + let metadata = metadata(); + let mut plan = plan_for(Encoder::HevcVaapi); + plan.rate_control = Some(RateControl::Cq { value: 23 }); + let mut info = hw_info("/dev/dri/renderD128"); + info.vendor = crate::system::hardware::Vendor::Amd; + let builder = FFmpegCommandBuilder::new( + Path::new("/tmp/in.mkv"), + Path::new("/tmp/out.mkv"), + &metadata, + &plan, + ) + .with_hardware(Some(&info)); + let args = builder + .build_args() + .unwrap_or_else(|err| panic!("failed to build vaapi cq args: {err}")); + let quality_index = args + .iter() + .position(|arg| arg == "-global_quality") + .unwrap_or_else(|| panic!("missing -global_quality")); + assert_eq!(args.get(quality_index + 1).map(String::as_str), Some("77")); + } + #[test] fn command_args_cover_videotoolbox_backend() { let metadata = metadata(); @@ -1148,6 +1195,42 @@ mod tests { assert!(args.contains(&"hevc_amf".to_string())); } + #[test] + fn amf_cq_mode_sets_cqp_flags() { + let metadata = metadata(); + let mut plan = plan_for(Encoder::HevcAmf); + plan.rate_control = Some(RateControl::Cq { value: 19 }); + let builder = FFmpegCommandBuilder::new( + Path::new("/tmp/in.mkv"), + Path::new("/tmp/out.mkv"), + &metadata, + &plan, + ); + let args = builder + .build_args() + .unwrap_or_else(|err| panic!("failed to build amf cq args: {err}")); + assert!(args.windows(2).any(|window| window == ["-rc", "cqp"])); + assert!(args.windows(2).any(|window| window == ["-qp_i", "19"])); + assert!(args.windows(2).any(|window| window == ["-qp_p", "19"])); + } + + #[test] + fn clip_window_adds_trim_arguments() { + let metadata = metadata(); + let plan = plan_for(Encoder::H264X264); + let args = FFmpegCommandBuilder::new( + Path::new("/tmp/in.mkv"), + Path::new("/tmp/out.mkv"), + &metadata, + &plan, + ) + .with_clip(Some(12.5), Some(8.0)) + .build_args() + .unwrap_or_else(|err| panic!("failed to build clipped args: {err}")); + assert!(args.windows(2).any(|window| window == ["-ss", "12.500"])); + assert!(args.windows(2).any(|window| window == ["-t", "8.000"])); + } + #[test] fn mp4_audio_transcode_uses_aac_profile() { let mut plan = plan_for(Encoder::H264X264); diff --git a/src/media/pipeline.rs b/src/media/pipeline.rs index 5d716af..d660fa6 100644 --- a/src/media/pipeline.rs +++ b/src/media/pipeline.rs @@ -3,15 +3,17 @@ use crate::error::Result; use crate::media::analyzer::FfmpegAnalyzer; use crate::media::executor::FfmpegExecutor; use crate::media::planner::BasicPlanner; +use crate::orchestrator::AsyncExecutionObserver; use crate::orchestrator::Transcoder; use crate::system::hardware::HardwareState; use crate::telemetry::{TelemetryEvent, encoder_label, hardware_label, resolution_bucket}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::SystemTime; -use tokio::sync::RwLock; +use std::time::{Instant, SystemTime}; +use tokio::sync::{Mutex, RwLock}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaMetadata { @@ -450,6 +452,139 @@ struct FinalizeFailureContext<'a> { temp_output_path: &'a Path, } +const RESUME_STRATEGY_SEGMENT_V1: &str = "segment_v1"; +const RESUME_SESSION_STATUS_ACTIVE: &str = "active"; +const RESUME_SESSION_STATUS_SEGMENTS_COMPLETE: &str = "segments_complete"; +const RESUME_SEGMENT_STATUS_PENDING: &str = "pending"; +const RESUME_SEGMENT_STATUS_ENCODING: &str = "encoding"; +const RESUME_SEGMENT_STATUS_COMPLETED: &str = "completed"; +const RESUME_SEGMENT_LENGTH_SECS: i64 = 120; +#[cfg(test)] +static RESUME_SEGMENT_LENGTH_OVERRIDE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn resume_segment_length_secs() -> i64 { + #[cfg(test)] + { + let override_secs = RESUME_SEGMENT_LENGTH_OVERRIDE + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + .ok() + .and_then(|guard| *guard); + override_secs.unwrap_or(RESUME_SEGMENT_LENGTH_SECS) + } + + #[cfg(not(test))] + { + RESUME_SEGMENT_LENGTH_SECS + } +} + +#[derive(Debug, Clone)] +struct ResumeSegment { + segment_index: i64, + start_secs: f64, + duration_secs: f64, + temp_path: PathBuf, + status: String, + attempt_count: i32, +} + +struct ResumeSegmentObserver { + job_id: i64, + db: Arc, + event_channels: Arc, + segment_start_secs: f64, + segment_duration_secs: f64, + total_duration_secs: f64, + last_progress: Mutex>, +} + +impl ResumeSegmentObserver { + fn new( + job_id: i64, + db: Arc, + event_channels: Arc, + segment_start_secs: f64, + segment_duration_secs: f64, + total_duration_secs: f64, + ) -> Self { + Self { + job_id, + db, + event_channels, + segment_start_secs, + segment_duration_secs, + total_duration_secs, + last_progress: Mutex::new(None), + } + } +} + +impl AsyncExecutionObserver for ResumeSegmentObserver { + async fn on_log(&self, message: String) { + let _ = self.event_channels.jobs.send(crate::db::JobEvent::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 { + tracing::warn!( + job_id = self.job_id, + "Failed to persist resume-segment log: {err}" + ); + } + } + + async fn on_progress( + &self, + progress: crate::media::ffmpeg::FFmpegProgress, + total_duration: f64, + ) { + let segment_total = if total_duration > 0.0 { + total_duration + } else { + self.segment_duration_secs + }; + let segment_pct = progress.percentage(segment_total).clamp(0.0, 100.0); + let completed_secs = + self.segment_start_secs + (segment_pct / 100.0) * self.segment_duration_secs; + let overall_pct = if self.total_duration_secs > 0.0 { + (completed_secs / self.total_duration_secs * 100.0).clamp(0.0, 99.9) + } else { + 0.0 + }; + let now = Instant::now(); + let mut last_progress = self.last_progress.lock().await; + let should_persist = match *last_progress { + Some((last_pct, last_time)) => { + overall_pct >= last_pct + 0.5 || now.duration_since(last_time).as_secs() >= 2 + } + None => true, + }; + + if should_persist { + if let Err(err) = self.db.update_job_progress(self.job_id, overall_pct).await { + tracing::warn!( + job_id = self.job_id, + "Failed to persist resume progress: {err}" + ); + } else { + *last_progress = Some((overall_pct, now)); + } + } + + let _ = self + .event_channels + .jobs + .send(crate::db::JobEvent::Progress { + job_id: self.job_id, + percentage: overall_pct, + time: progress.time, + }); + } +} + impl Pipeline { pub fn new( db: Arc, @@ -469,6 +604,433 @@ impl Pipeline { } } + async fn store_job_input_metadata(&self, job_id: i64, metadata: &MediaMetadata) { + if let Err(err) = self.db.set_job_input_metadata(job_id, metadata).await { + tracing::warn!(job_id, "Failed to store input metadata: {err}"); + } + } + + async fn record_job_log(&self, job_id: i64, level: &str, message: &str) { + if let Err(err) = self.db.add_log(level, Some(job_id), message).await { + tracing::warn!(job_id, "Failed to record log: {err}"); + } + } + + async fn record_job_decision(&self, job_id: i64, action: &str, reason: &str) { + if let Err(err) = self.db.add_decision(job_id, action, reason).await { + tracing::warn!(job_id, "Failed to record decision: {err}"); + } + } + + async fn record_job_decision_with_explanation( + &self, + job_id: i64, + action: &str, + explanation: &crate::explanations::Explanation, + ) { + if let Err(err) = self + .db + .add_decision_with_explanation(job_id, action, explanation) + .await + { + tracing::warn!(job_id, "Failed to record decision explanation: {err}"); + } + } + + async fn record_job_failure_explanation( + &self, + job_id: i64, + explanation: &crate::explanations::Explanation, + ) { + if let Err(err) = self + .db + .upsert_job_failure_explanation(job_id, explanation) + .await + { + tracing::warn!(job_id, "Failed to record failure explanation: {err}"); + } + } + + async fn record_encode_attempt(&self, job_id: i64, input: crate::db::EncodeAttemptInput) { + if let Err(err) = self.db.insert_encode_attempt(input).await { + tracing::warn!(job_id, "Failed to record encode attempt: {err}"); + } + } + + async fn purge_resume_session_state(&self, job_id: i64) -> Result<()> { + let session = self.db.get_resume_session(job_id).await?; + self.db.delete_resume_session(job_id).await?; + if let Some(session) = session { + let temp_dir = PathBuf::from(session.temp_dir); + if temp_dir.exists() { + tokio::fs::remove_dir_all(&temp_dir).await.map_err(|err| { + crate::error::AlchemistError::Io(std::io::Error::new( + err.kind(), + format!( + "Failed to remove resume temp dir {}: {err}", + temp_dir.display() + ), + )) + })?; + } + } + Ok(()) + } + + async fn prepare_resume_session( + &self, + job: &Job, + plan: &TranscodePlan, + metadata: &MediaMetadata, + output_path: &Path, + ) -> Result)>> { + if !resumable_plan_supported(plan, metadata) { + if self.db.get_resume_session(job.id).await?.is_some() { + self.purge_resume_session_state(job.id).await?; + } + return Ok(None); + } + + let mtime_hash = mtime_hash_from_path(Path::new(&job.input_path))?; + let plan_hash = plan_hash_for_resume(plan, output_path, &mtime_hash)?; + let temp_dir = resume_temp_dir_for(output_path, job.id); + let concat_manifest_path = concat_manifest_path_for(&temp_dir); + let existing_session = self.db.get_resume_session(job.id).await?; + + if let Some(session) = &existing_session { + if session.strategy != RESUME_STRATEGY_SEGMENT_V1 + || session.plan_hash != plan_hash + || session.mtime_hash != mtime_hash + { + self.purge_resume_session_state(job.id).await?; + } + } + + tokio::fs::create_dir_all(&temp_dir).await?; + let session = self + .db + .upsert_resume_session(&crate::db::UpsertJobResumeSessionInput { + job_id: job.id, + strategy: RESUME_STRATEGY_SEGMENT_V1.to_string(), + plan_hash, + mtime_hash, + temp_dir: temp_dir.display().to_string(), + concat_manifest_path: concat_manifest_path.display().to_string(), + segment_length_secs: resume_segment_length_secs(), + status: RESUME_SESSION_STATUS_ACTIVE.to_string(), + }) + .await?; + + let existing_segments = self.db.list_resume_segments(job.id).await?; + let segments = enumerate_resume_segments( + metadata.duration_secs, + &temp_dir, + output_path, + &existing_segments, + ); + for segment in &segments { + self.db + .upsert_resume_segment(&crate::db::UpsertJobResumeSegmentInput { + job_id: job.id, + segment_index: segment.segment_index, + start_secs: segment.start_secs, + duration_secs: segment.duration_secs, + temp_path: segment.temp_path.display().to_string(), + status: segment.status.clone(), + attempt_count: segment.attempt_count, + }) + .await?; + } + + Ok(Some((session, segments))) + } + + async fn encode_resume_segment( + &self, + job: &Job, + plan: &TranscodePlan, + metadata: &MediaMetadata, + segment: &ResumeSegment, + ) -> Result<()> { + let next_attempt = segment.attempt_count + 1; + self.db + .set_resume_segment_status( + job.id, + segment.segment_index, + RESUME_SEGMENT_STATUS_ENCODING, + next_attempt, + ) + .await?; + + if segment.temp_path.exists() { + let _ = tokio::fs::remove_file(&segment.temp_path).await; + } + + let mut segment_plan = plan.clone(); + segment_plan.output_path = Some(segment.temp_path.clone()); + let hardware_info = self.hardware_state.snapshot().await; + let observer: Arc = + Arc::new(ResumeSegmentObserver::new( + job.id, + self.db.clone(), + self.event_channels.clone(), + segment.start_secs, + segment.duration_secs, + metadata.duration_secs, + )); + + let result = self + .orchestrator + .transcode_media(crate::orchestrator::TranscodeRequest { + job_id: Some(job.id), + input: Path::new(&job.input_path), + output: &segment.temp_path, + hw_info: hardware_info.as_ref(), + dry_run: self.dry_run, + metadata, + plan: &segment_plan, + observer: Some(observer), + clip_start_seconds: Some(segment.start_secs), + clip_duration_seconds: Some(segment.duration_secs), + }) + .await; + + match result { + Ok(()) => { + self.db + .set_resume_segment_status( + job.id, + segment.segment_index, + RESUME_SEGMENT_STATUS_COMPLETED, + next_attempt, + ) + .await?; + let completed = self.db.completed_resume_duration_secs(job.id).await?; + let progress = if metadata.duration_secs > 0.0 { + (completed / metadata.duration_secs * 100.0).clamp(0.0, 99.9) + } else { + 0.0 + }; + self.update_job_progress(job.id, progress).await; + Ok(()) + } + Err(err) => { + let _ = tokio::fs::remove_file(&segment.temp_path).await; + self.db + .set_resume_segment_status( + job.id, + segment.segment_index, + RESUME_SEGMENT_STATUS_PENDING, + next_attempt, + ) + .await?; + Err(err) + } + } + } + + async fn concat_resume_segments( + &self, + job_id: i64, + session: &crate::db::JobResumeSession, + segments: &[ResumeSegment], + temp_output_path: &Path, + container: &str, + ) -> Result<()> { + let manifest_path = PathBuf::from(&session.concat_manifest_path); + let mut manifest = String::from("ffconcat version 1.0\n"); + for segment in segments { + manifest.push_str("file '"); + manifest.push_str(&escape_ffconcat_path(&segment.temp_path)); + manifest.push_str("'\n"); + } + tokio::fs::write(&manifest_path, manifest).await?; + + if temp_output_path.exists() { + let _ = tokio::fs::remove_file(temp_output_path).await; + } + + let output = tokio::process::Command::new("ffmpeg") + .args([ + "-hide_banner", + "-y", + "-loglevel", + "error", + "-f", + "concat", + "-safe", + "0", + "-i", + ]) + .arg(&manifest_path) + .args(["-c", "copy", "-f"]) + .arg(ffmpeg_muxer_for_container(container)) + .arg(temp_output_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + "FFmpeg concat failed".to_string() + } else { + format!("FFmpeg concat failed: {stderr}") + }; + self.record_job_log(job_id, "error", &message).await; + return Err(crate::error::AlchemistError::FFmpeg(message)); + } + + Ok(()) + } + + async fn build_execution_result_for_output( + &self, + job_id: i64, + plan: &TranscodePlan, + output_path: &Path, + ) -> ExecutionResult { + let encoder = plan.encoder; + let planned_output_codec = plan.output_codec.unwrap_or_else(|| { + encoder + .map(Encoder::output_codec) + .unwrap_or(plan.requested_codec) + }); + let actual_probe = if !self.dry_run && output_path.exists() { + crate::media::analyzer::Analyzer::probe_output_details(output_path) + .await + .ok() + } else { + None + }; + let actual_output_codec = actual_probe + .as_ref() + .and_then(|probe| crate::media::executor::output_codec_from_name(&probe.codec_name)); + let actual_encoder_name = actual_probe + .as_ref() + .and_then(|probe| { + probe + .stream_encoder_tag + .clone() + .or_else(|| probe.format_encoder_tag.clone()) + }) + .or_else(|| { + if plan.is_remux { + Some("copy".to_string()) + } else { + encoder.map(|enc| enc.ffmpeg_encoder_name().to_string()) + } + }); + let codec_mismatch = + actual_output_codec.is_some_and(|actual_codec| actual_codec != planned_output_codec); + let encoder_mismatch = encoder.is_some_and(|enc| { + actual_probe + .as_ref() + .and_then(|probe| probe.stream_encoder_tag.as_deref()) + .is_some_and(|tag| !crate::media::executor::encoder_tag_matches(enc, tag)) + }); + + if let (true, Some(codec)) = (codec_mismatch, actual_output_codec) { + tracing::warn!( + "Job {}: Planned codec {} but resumable output probed as {}", + job_id, + planned_output_codec.as_str(), + codec.as_str() + ); + } + + ExecutionResult { + requested_codec: plan.requested_codec, + planned_output_codec, + requested_encoder: encoder, + used_encoder: encoder, + used_backend: plan.backend.or_else(|| encoder.map(Encoder::backend)), + fallback: plan.fallback.clone(), + fallback_occurred: plan.fallback.is_some() || codec_mismatch || encoder_mismatch, + actual_output_codec, + actual_encoder_name, + } + } + + async fn execute_resumable_transcode( + &self, + job: &Job, + plan: &TranscodePlan, + metadata: &MediaMetadata, + temp_output_path: &Path, + ) -> Result> { + let Some((session, segments)) = self + .prepare_resume_session(job, plan, metadata, Path::new(&job.output_path)) + .await? + else { + return Ok(None); + }; + + let pending_segments = segments + .iter() + .filter(|segment| segment.status != RESUME_SEGMENT_STATUS_COMPLETED) + .cloned() + .collect::>(); + let completed_secs = segments + .iter() + .filter(|segment| segment.status == RESUME_SEGMENT_STATUS_COMPLETED) + .map(|segment| segment.duration_secs) + .sum::(); + if metadata.duration_secs > 0.0 && completed_secs > 0.0 { + let progress = (completed_secs / metadata.duration_secs * 100.0).clamp(0.0, 99.9); + self.update_job_progress(job.id, progress).await; + } + + for segment in &pending_segments { + if self.should_stop_job(job.id).await? { + return Err(crate::error::AlchemistError::Cancelled); + } + self.encode_resume_segment(job, plan, metadata, segment) + .await?; + } + + self.db + .upsert_resume_session(&crate::db::UpsertJobResumeSessionInput { + job_id: session.job_id, + strategy: session.strategy.clone(), + plan_hash: session.plan_hash.clone(), + mtime_hash: session.mtime_hash.clone(), + temp_dir: session.temp_dir.clone(), + concat_manifest_path: session.concat_manifest_path.clone(), + segment_length_secs: session.segment_length_secs, + status: RESUME_SESSION_STATUS_SEGMENTS_COMPLETE.to_string(), + }) + .await?; + + let completed_segments = self + .db + .list_resume_segments(job.id) + .await? + .into_iter() + .map(|segment| ResumeSegment { + segment_index: segment.segment_index, + start_secs: segment.start_secs, + duration_secs: segment.duration_secs, + temp_path: PathBuf::from(segment.temp_path), + status: segment.status, + attempt_count: segment.attempt_count, + }) + .collect::>(); + + self.concat_resume_segments( + job.id, + &session, + &completed_segments, + temp_output_path, + &plan.container, + ) + .await?; + + Ok(Some( + self.build_execution_result_for_output(job.id, plan, temp_output_path) + .await, + )) + } + pub async fn enqueue_discovered(&self, discovered: DiscoveredMedia) -> Result<()> { let _ = enqueue_discovered_with_db(&self.db, discovered).await?; Ok(()) @@ -574,6 +1136,116 @@ fn temp_output_path_for(path: &Path) -> PathBuf { parent.join(format!("{filename}.alchemist.tmp")) } +fn resume_temp_dir_for(output_path: &Path, job_id: i64) -> PathBuf { + let parent = output_path.parent().unwrap_or_else(|| Path::new("")); + let filename = output_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("output"); + parent.join(format!(".{filename}.alchemist.resume-{job_id}")) +} + +fn concat_manifest_path_for(temp_dir: &Path) -> PathBuf { + temp_dir.join("segments.ffconcat") +} + +fn escape_ffconcat_path(path: &Path) -> String { + path.display().to_string().replace('\'', "'\\''") +} + +fn ffmpeg_muxer_for_container(container: &str) -> String { + match container.to_ascii_lowercase().as_str() { + "mkv" => "matroska".to_string(), + "mp4" => "mp4".to_string(), + "mov" => "mov".to_string(), + "avi" => "avi".to_string(), + other => other.to_string(), + } +} + +fn mtime_hash_from_path(path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); + let duration = modified + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + Ok(format!( + "{}.{:09}", + duration.as_secs(), + duration.subsec_nanos() + )) +} + +fn plan_hash_for_resume( + plan: &TranscodePlan, + output_path: &Path, + mtime_hash: &str, +) -> Result { + let serialized = serde_json::to_vec(&(plan, output_path, mtime_hash)).map_err(|err| { + crate::error::AlchemistError::Unknown(format!("Failed to hash plan: {err}")) + })?; + let mut hasher = Sha256::new(); + hasher.update(serialized); + Ok(format!("{:x}", hasher.finalize())) +} + +fn resumable_plan_supported(plan: &TranscodePlan, metadata: &MediaMetadata) -> bool { + matches!(plan.decision, TranscodeDecision::Transcode { .. }) + && !plan.is_remux + && !matches!(plan.subtitles, SubtitleStreamPlan::Extract { .. }) + && metadata.duration_secs > 0.0 +} + +fn enumerate_resume_segments( + total_duration_secs: f64, + temp_dir: &Path, + output_path: &Path, + existing_segments: &[crate::db::JobResumeSegment], +) -> Vec { + let extension = output_path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("mkv"); + let segment_length_secs = resume_segment_length_secs(); + let total_segments = (total_duration_secs / segment_length_secs as f64).ceil() as i64; + let mut by_index = HashMap::new(); + for segment in existing_segments { + by_index.insert(segment.segment_index, segment); + } + + (0..total_segments) + .map(|segment_index| { + let start_secs = segment_index as f64 * segment_length_secs as f64; + let duration_secs = (total_duration_secs - start_secs).min(segment_length_secs as f64); + let temp_path = temp_dir.join(format!("segment-{segment_index:05}.{extension}")); + let existing = by_index.get(&segment_index); + let status = match existing { + Some(segment) + if segment.status == RESUME_SEGMENT_STATUS_COMPLETED && temp_path.exists() => + { + segment.status.clone() + } + Some(segment) => { + if segment.status == RESUME_SEGMENT_STATUS_COMPLETED && !temp_path.exists() { + RESUME_SEGMENT_STATUS_PENDING.to_string() + } else { + segment.status.clone() + } + } + None => RESUME_SEGMENT_STATUS_PENDING.to_string(), + }; + ResumeSegment { + segment_index, + start_secs, + duration_secs, + temp_path, + status, + attempt_count: existing.map(|segment| segment.attempt_count).unwrap_or(0), + } + }) + .collect() +} + impl Pipeline { /// Runs only the analysis and planning phases for a job. /// Does not execute any encode. Used by the startup @@ -593,25 +1265,16 @@ impl Pipeline { { Ok(a) => { // Store analyzed metadata for completed job detail retrieval - let _ = self.db.set_job_input_metadata(job_id, &a.metadata).await; + self.store_job_input_metadata(job_id, &a.metadata).await; a } Err(e) => { let reason = format!("analysis_failed|error={e}"); let failure_explanation = crate::explanations::failure_from_summary(&reason); - if let Err(e) = self.db.add_log("error", Some(job_id), &reason).await { - tracing::warn!(job_id, "Failed to record log: {e}"); - } - if let Err(e) = self.db.add_decision(job_id, "skip", &reason).await { - tracing::warn!(job_id, "Failed to record decision: {e}"); - } - if let Err(e) = self - .db - .upsert_job_failure_explanation(job_id, &failure_explanation) - .await - { - tracing::warn!(job_id, "Failed to record failure explanation: {e}"); - } + self.record_job_log(job_id, "error", &reason).await; + self.record_job_decision(job_id, "skip", &reason).await; + self.record_job_failure_explanation(job_id, &failure_explanation) + .await; self.update_job_state(job_id, crate::db::JobState::Failed) .await?; return Ok(()); @@ -642,19 +1305,10 @@ impl Pipeline { Err(e) => { let reason = format!("planning_failed|error={e}"); let failure_explanation = crate::explanations::failure_from_summary(&reason); - if let Err(e) = self.db.add_log("error", Some(job_id), &reason).await { - tracing::warn!(job_id, "Failed to record log: {e}"); - } - if let Err(e) = self.db.add_decision(job_id, "skip", &reason).await { - tracing::warn!(job_id, "Failed to record decision: {e}"); - } - if let Err(e) = self - .db - .upsert_job_failure_explanation(job_id, &failure_explanation) - .await - { - tracing::warn!(job_id, "Failed to record failure explanation: {e}"); - } + self.record_job_log(job_id, "error", &reason).await; + self.record_job_decision(job_id, "skip", &reason).await; + self.record_job_failure_explanation(job_id, &failure_explanation) + .await; self.update_job_state(job_id, crate::db::JobState::Failed) .await?; return Ok(()); @@ -671,24 +1325,18 @@ impl Pipeline { "Job skipped: {}", skip_code ); - if let Err(e) = self.db.add_decision(job_id, "skip", reason).await { - tracing::warn!(job_id, "Failed to record decision: {e}"); - } + self.record_job_decision(job_id, "skip", reason).await; self.update_job_state(job_id, crate::db::JobState::Skipped) .await?; } crate::media::pipeline::TranscodeDecision::Remux { reason } => { - if let Err(e) = self.db.add_decision(job_id, "transcode", reason).await { - tracing::warn!(job_id, "Failed to record decision: {e}"); - } + self.record_job_decision(job_id, "transcode", reason).await; // Leave as queued — will be picked up for remux when engine starts self.update_job_state(job_id, crate::db::JobState::Queued) .await?; } crate::media::pipeline::TranscodeDecision::Transcode { reason } => { - if let Err(e) = self.db.add_decision(job_id, "transcode", reason).await { - tracing::warn!(job_id, "Failed to record decision: {e}"); - } + self.record_job_decision(job_id, "transcode", reason).await; // Leave as queued — will be picked up for encoding when engine starts self.update_job_state(job_id, crate::db::JobState::Queued) .await?; @@ -717,9 +1365,7 @@ impl Pipeline { "Job {}: Output path matches input path; refusing to overwrite source.", job.id ); - let _ = self - .db - .add_decision(job.id, "skip", "Output path matches input path") + self.record_job_decision(job.id, "skip", "Output path matches input path") .await; let _ = self .update_job_state(job.id, crate::db::JobState::Skipped) @@ -732,9 +1378,7 @@ impl Pipeline { "Job {}: Output exists and replace_strategy is keep. Skipping.", job.id ); - let _ = self - .db - .add_decision(job.id, "skip", "Output already exists") + self.record_job_decision(job.id, "skip", "Output already exists") .await; let _ = self .update_job_state(job.id, crate::db::JobState::Skipped) @@ -781,17 +1425,10 @@ impl Pipeline { Err(e) => { let msg = format!("Probing failed: {e}"); tracing::error!("Job {}: {}", job.id, msg); - 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}"); - } + self.record_job_log(job.id, "error", &msg).await; let explanation = crate::explanations::failure_from_summary(&msg); - if let Err(e) = self - .db - .upsert_job_failure_explanation(job.id, &explanation) - .await - { - tracing::warn!(job_id = job.id, "Failed to record failure explanation: {e}"); - } + self.record_job_failure_explanation(job.id, &explanation) + .await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Failed) .await @@ -843,20 +1480,10 @@ impl Pipeline { Err(err) => { let msg = format!("Invalid conversion job settings: {err}"); tracing::error!("Job {}: {}", job.id, msg); - 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}"); - } + self.record_job_log(job.id, "error", &msg).await; let explanation = crate::explanations::failure_from_summary(&msg); - if let Err(e) = self - .db - .upsert_job_failure_explanation(job.id, &explanation) - .await - { - tracing::warn!( - job_id = job.id, - "Failed to record failure explanation: {e}" - ); - } + self.record_job_failure_explanation(job.id, &explanation) + .await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Failed) .await @@ -872,20 +1499,10 @@ impl Pipeline { Err(err) => { let msg = format!("Conversion planning failed: {err}"); tracing::error!("Job {}: {}", job.id, msg); - 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}"); - } + self.record_job_log(job.id, "error", &msg).await; let explanation = crate::explanations::failure_from_summary(&msg); - if let Err(e) = self - .db - .upsert_job_failure_explanation(job.id, &explanation) - .await - { - tracing::warn!( - job_id = job.id, - "Failed to record failure explanation: {e}" - ); - } + self.record_job_failure_explanation(job.id, &explanation) + .await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Failed) .await @@ -902,20 +1519,10 @@ impl Pipeline { Err(err) => { let msg = format!("Failed to resolve library profile: {err}"); tracing::error!("Job {}: {}", job.id, msg); - 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}"); - } + self.record_job_log(job.id, "error", &msg).await; let explanation = crate::explanations::failure_from_summary(&msg); - if let Err(e) = self - .db - .upsert_job_failure_explanation(job.id, &explanation) - .await - { - tracing::warn!( - job_id = job.id, - "Failed to record failure explanation: {e}" - ); - } + self.record_job_failure_explanation(job.id, &explanation) + .await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Failed) .await @@ -933,20 +1540,10 @@ impl Pipeline { Err(e) => { let msg = format!("Planner failed: {e}"); tracing::error!("Job {}: {}", job.id, msg); - 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}"); - } + self.record_job_log(job.id, "error", &msg).await; let explanation = crate::explanations::failure_from_summary(&msg); - if let Err(e) = self - .db - .upsert_job_failure_explanation(job.id, &explanation) - .await - { - tracing::warn!( - job_id = job.id, - "Failed to record failure explanation: {e}" - ); - } + self.record_job_failure_explanation(job.id, &explanation) + .await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Failed) .await @@ -1012,9 +1609,7 @@ impl Pipeline { explanation.code, explanation.summary ); - if let Err(e) = self.db.add_decision(job.id, "skip", &reason).await { - tracing::warn!(job_id = job.id, "Failed to record decision: {e}"); - } + self.record_job_decision(job.id, "skip", &reason).await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Skipped) .await @@ -1031,9 +1626,7 @@ impl Pipeline { &reason ); let explanation = crate::explanations::decision_from_legacy(action, &reason); - let _ = self - .db - .add_decision_with_explanation(job.id, action, &explanation) + self.record_job_decision_with_explanation(job.id, action, &explanation) .await; let _ = self .event_channels @@ -1083,34 +1676,32 @@ impl Pipeline { ); let encode_started_at = chrono::Utc::now(); - match executor.execute(&job, &plan, &analysis).await { + let execution_result = match self + .execute_resumable_transcode(&job, &plan, metadata, &temp_output_path) + .await + { + Ok(Some(result)) => Ok(result), + Ok(None) => executor.execute(&job, &plan, &analysis).await, + Err(err) => Err(err), + }; + match execution_result { Ok(result) => { if result.fallback_occurred && !plan.allow_fallback { tracing::error!("Job {}: Encoder fallback detected and not allowed.", job.id); let summary = "Encoder fallback detected and not allowed."; let explanation = crate::explanations::failure_from_summary(summary); - if let Err(e) = self.db.add_log("error", Some(job.id), summary).await { - tracing::warn!(job_id = job.id, "Failed to record log: {e}"); - } - if let Err(e) = self - .db - .upsert_job_failure_explanation(job.id, &explanation) - .await - { - tracing::warn!( - job_id = job.id, - "Failed to record failure explanation: {e}" - ); - } + self.record_job_log(job.id, "error", summary).await; + self.record_job_failure_explanation(job.id, &explanation) + .await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Failed) .await { tracing::warn!(job_id = job.id, "Failed to update job state: {e}"); } - if let Err(e) = self - .db - .insert_encode_attempt(crate::db::EncodeAttemptInput { + self.record_encode_attempt( + job.id, + crate::db::EncodeAttemptInput { job_id: job.id, attempt_number: current_attempt_number, started_at: Some(encode_started_at.to_rfc3339()), @@ -1120,11 +1711,9 @@ impl Pipeline { input_size_bytes: Some(metadata.size_bytes as i64), output_size_bytes: None, 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); } @@ -1164,6 +1753,22 @@ impl Pipeline { return Err(JobFailure::Transient); } + if self + .db + .get_resume_session(job.id) + .await + .ok() + .flatten() + .is_some() + { + if let Err(err) = self.purge_resume_session_state(job.id).await { + tracing::warn!( + job_id = job.id, + "Failed to purge resume session after successful finalize: {err}" + ); + } + } + Ok(()) } Err(e) => { @@ -1215,9 +1820,9 @@ impl Pipeline { "Failed to update job state to cancelled: {e}" ); } - if let Err(e) = self - .db - .insert_encode_attempt(crate::db::EncodeAttemptInput { + self.record_encode_attempt( + job.id, + crate::db::EncodeAttemptInput { job_id: job.id, attempt_number: current_attempt_number, started_at: Some(encode_started_at.to_rfc3339()), @@ -1227,28 +1832,16 @@ impl Pipeline { input_size_bytes: Some(metadata.size_bytes as i64), output_size_bytes: None, encode_time_seconds: Some(start_time.elapsed().as_secs_f64()), - }) - .await - { - tracing::warn!(job_id = job.id, "Failed to record encode attempt: {e}"); - } + }, + ) + .await; } else { let msg = format!("Transcode failed: {e}"); tracing::error!("Job {}: {}", job.id, msg); - 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}"); - } + self.record_job_log(job.id, "error", &msg).await; let explanation = crate::explanations::failure_from_summary(&msg); - if let Err(e) = self - .db - .upsert_job_failure_explanation(job.id, &explanation) - .await - { - tracing::warn!( - job_id = job.id, - "Failed to record failure explanation: {e}" - ); - } + self.record_job_failure_explanation(job.id, &explanation) + .await; if let Err(e) = self .update_job_state(job.id, crate::db::JobState::Failed) .await @@ -1258,9 +1851,9 @@ impl Pipeline { "Failed to update job state to failed: {e}" ); } - if let Err(e) = self - .db - .insert_encode_attempt(crate::db::EncodeAttemptInput { + self.record_encode_attempt( + job.id, + crate::db::EncodeAttemptInput { job_id: job.id, attempt_number: current_attempt_number, started_at: Some(encode_started_at.to_rfc3339()), @@ -1270,11 +1863,9 @@ impl Pipeline { input_size_bytes: Some(metadata.size_bytes as i64), output_size_bytes: None, encode_time_seconds: Some(start_time.elapsed().as_secs_f64()), - }) - .await - { - tracing::warn!(job_id = job.id, "Failed to record encode attempt: {e}"); - } + }, + ) + .await; } Err(map_failure(&e)) } @@ -1397,9 +1988,7 @@ impl Pipeline { reduction, config.transcode.size_reduction_threshold, output_size ) }; - if let Err(e) = self.db.add_decision(job_id, "skip", &reason).await { - tracing::warn!(job_id, "Failed to record decision: {e}"); - } + self.record_job_decision(job_id, "skip", &reason).await; self.update_job_state(job_id, crate::db::JobState::Skipped) .await?; return Ok(()); @@ -1442,17 +2031,15 @@ impl Pipeline { ); let _ = std::fs::remove_file(context.temp_output_path); cleanup_temp_subtitle_output(job_id, context.plan).await; - let _ = self - .db - .add_decision( - job_id, - "skip", - &format!( - "quality_below_threshold|metric=vmaf,score={:.1},threshold={:.1}", - s, config.quality.min_vmaf_score - ), - ) - .await; + self.record_job_decision( + job_id, + "skip", + &format!( + "quality_below_threshold|metric=vmaf,score={:.1},threshold={:.1}", + s, config.quality.min_vmaf_score + ), + ) + .await; self.update_job_state(job_id, crate::db::JobState::Skipped) .await?; return Ok(()); @@ -1470,12 +2057,21 @@ impl Pipeline { let mut media_duration = context.metadata.duration_secs; if media_duration <= 0.0 { - match crate::media::analyzer::Analyzer::probe_async(input_path).await { + let reprobe_path = if context.temp_output_path.exists() { + context.temp_output_path + } else { + context.output_path + }; + match crate::media::analyzer::Analyzer::probe_async(reprobe_path).await { Ok(meta) => { media_duration = meta.format.duration.parse::().unwrap_or(0.0); } Err(e) => { - tracing::warn!(job_id, "Failed to reprobe output for duration: {e}"); + tracing::warn!( + job_id, + path = %reprobe_path.display(), + "Failed to reprobe encoded output for duration: {e}" + ); } } } @@ -1555,9 +2151,9 @@ impl Pipeline { self.update_job_state(job_id, crate::db::JobState::Completed) .await?; self.update_job_progress(job_id, 100.0).await; - let _ = self - .db - .insert_encode_attempt(crate::db::EncodeAttemptInput { + self.record_encode_attempt( + job_id, + crate::db::EncodeAttemptInput { job_id, attempt_number: context.attempt_number, started_at: Some(context.encode_started_at.to_rfc3339()), @@ -1567,8 +2163,9 @@ impl Pipeline { input_size_bytes: Some(input_size as i64), output_size_bytes: Some(output_size as i64), encode_time_seconds: Some(encode_duration), - }) - .await; + }, + ) + .await; self.emit_telemetry_event(TelemetryEventParams { telemetry_enabled, @@ -1631,21 +2228,12 @@ impl Pipeline { tracing::error!("Job {}: Finalization failed: {}", job_id, err); let message = format!("Finalization failed: {err}"); - if let Err(e) = self.db.add_log("error", Some(job_id), &message).await { - tracing::warn!(job_id, "Failed to record log: {e}"); - } + self.record_job_log(job_id, "error", &message).await; let failure_explanation = crate::explanations::failure_from_summary(&message); - if let Err(e) = self - .db - .upsert_job_failure_explanation(job_id, &failure_explanation) - .await - { - tracing::warn!(job_id, "Failed to record failure explanation: {e}"); - } + self.record_job_failure_explanation(job_id, &failure_explanation) + .await; if let crate::error::AlchemistError::QualityCheckFailed(reason) = err { - if let Err(e) = self.db.add_decision(job_id, "reject", reason).await { - tracing::warn!(job_id, "Failed to record decision: {e}"); - } + self.record_job_decision(job_id, "reject", reason).await; } if context.temp_output_path.exists() { @@ -1682,9 +2270,9 @@ impl Pipeline { let _ = self .update_job_state(job_id, crate::db::JobState::Failed) .await; - let _ = self - .db - .insert_encode_attempt(crate::db::EncodeAttemptInput { + self.record_encode_attempt( + job_id, + crate::db::EncodeAttemptInput { job_id, attempt_number: context.attempt_number, started_at: Some(context.encode_started_at.to_rfc3339()), @@ -1694,8 +2282,9 @@ impl Pipeline { input_size_bytes: Some(context.metadata.size_bytes as i64), output_size_bytes: None, encode_time_seconds: Some(context.start_time.elapsed().as_secs_f64()), - }) - .await; + }, + ) + .await; } async fn emit_telemetry_event(&self, params: TelemetryEventParams<'_>) { @@ -1779,9 +2368,31 @@ mod tests { use crate::Transcoder; use crate::db::Db; use crate::system::hardware::{HardwareInfo, HardwareState, Vendor}; + use std::process::Command; use std::sync::Arc; use tokio::sync::RwLock; + fn ffmpeg_ready() -> bool { + let ffmpeg = Command::new("ffmpeg") + .arg("-version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false); + let ffprobe = Command::new("ffprobe") + .arg("-version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false); + ffmpeg && ffprobe + } + + fn set_test_resume_segment_length(value: Option) { + let lock = RESUME_SEGMENT_LENGTH_OVERRIDE.get_or_init(|| std::sync::Mutex::new(None)); + if let Ok(mut guard) = lock.lock() { + *guard = value; + } + } + #[test] fn generated_output_pattern_matches_default_suffix() { let settings = default_file_settings(); @@ -2026,4 +2637,844 @@ mod tests { let _ = std::fs::remove_file(db_path); Ok(()) } + + #[tokio::test] + async fn process_job_skips_even_when_decision_persistence_fails() -> anyhow::Result<()> { + let db_path = std::env::temp_dir().join(format!( + "alchemist_decision_persistence_{}.db", + rand::random::() + )); + let temp_root = std::env::temp_dir().join(format!( + "alchemist_decision_persistence_{}", + rand::random::() + )); + std::fs::create_dir_all(&temp_root)?; + + let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?); + db.update_file_settings(false, "mkv", "-alchemist", "keep", None) + .await?; + + let input = temp_root.join("movie.mkv"); + let output = temp_root.join("movie-alchemist.mkv"); + std::fs::write(&input, b"source")?; + std::fs::write(&output, b"existing-output")?; + + let _ = db + .enqueue_job(&input, &output, SystemTime::UNIX_EPOCH) + .await?; + let job = db + .get_job_by_input_path(input.to_string_lossy().as_ref()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing queued job"))?; + + sqlx::query( + "CREATE TRIGGER decisions_fail_insert + BEFORE INSERT ON decisions + BEGIN + SELECT RAISE(FAIL, 'forced decision failure'); + END;", + ) + .execute(&db.pool) + .await?; + + let config = Arc::new(RwLock::new(crate::config::Config::default())); + let hardware_state = HardwareState::new(Some(HardwareInfo { + vendor: Vendor::Cpu, + device_path: None, + supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()], + backends: Vec::new(), + detection_notes: Vec::new(), + selection_reason: String::new(), + probe_summary: crate::system::hardware::ProbeSummary::default(), + })); + let (jobs_tx, _) = tokio::sync::broadcast::channel(100); + let (config_tx, _) = tokio::sync::broadcast::channel(10); + let (system_tx, _) = tokio::sync::broadcast::channel(10); + let event_channels = Arc::new(crate::db::EventChannels { + jobs: jobs_tx, + config: config_tx, + system: system_tx, + }); + let pipeline = Pipeline::new( + db.clone(), + Arc::new(Transcoder::new()), + config, + hardware_state, + event_channels, + false, + ); + + pipeline + .process_job(job.clone()) + .await + .map_err(|err| anyhow::anyhow!("process_job failed: {err:?}"))?; + let updated = db + .get_job_by_id(job.id) + .await? + .ok_or_else(|| anyhow::anyhow!("missing skipped job"))?; + assert_eq!(updated.status, crate::db::JobState::Skipped); + + let _ = std::fs::remove_dir_all(temp_root); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn finalize_job_succeeds_when_encode_attempt_persistence_fails() -> anyhow::Result<()> { + if !ffmpeg_ready() { + return Ok(()); + } + + let db_path = std::env::temp_dir().join(format!( + "alchemist_attempt_persistence_{}.db", + rand::random::() + )); + let temp_root = std::env::temp_dir().join(format!( + "alchemist_attempt_persistence_{}", + rand::random::() + )); + std::fs::create_dir_all(&temp_root)?; + + let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?); + let input = temp_root.join("movie.mkv"); + let output = temp_root.join("movie-alchemist.mkv"); + std::fs::write(&input, b"source")?; + + let _ = db + .enqueue_job(&input, &output, SystemTime::UNIX_EPOCH) + .await?; + let job = db + .get_job_by_input_path(input.to_string_lossy().as_ref()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing queued job"))?; + + let temp_output = temp_output_path_for(&output); + let ffmpeg_status = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "color=c=black:s=16x16:d=1", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-f", + "matroska", + ]) + .arg(&temp_output) + .status()?; + if !ffmpeg_status.success() { + return Err(anyhow::anyhow!( + "ffmpeg failed to generate finalize fixture" + )); + } + + sqlx::query("DROP TABLE encode_attempts") + .execute(&db.pool) + .await?; + + let config = Arc::new(RwLock::new(crate::config::Config::default())); + let hardware_state = HardwareState::new(Some(HardwareInfo { + vendor: Vendor::Cpu, + device_path: None, + supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()], + backends: Vec::new(), + detection_notes: Vec::new(), + selection_reason: String::new(), + probe_summary: crate::system::hardware::ProbeSummary::default(), + })); + let (jobs_tx, _) = tokio::sync::broadcast::channel(100); + let (config_tx, _) = tokio::sync::broadcast::channel(10); + let (system_tx, _) = tokio::sync::broadcast::channel(10); + let event_channels = Arc::new(crate::db::EventChannels { + jobs: jobs_tx, + config: config_tx, + system: system_tx, + }); + let pipeline = Pipeline::new( + db.clone(), + Arc::new(Transcoder::new()), + config, + hardware_state, + event_channels, + false, + ); + + let plan = TranscodePlan { + decision: TranscodeDecision::Transcode { + reason: "test".to_string(), + }, + is_remux: false, + copy_video: false, + output_path: Some(temp_output.clone()), + container: "mkv".to_string(), + requested_codec: crate::config::OutputCodec::H264, + output_codec: Some(crate::config::OutputCodec::H264), + encoder: Some(Encoder::H264X264), + backend: Some(EncoderBackend::Cpu), + rate_control: Some(RateControl::Crf { value: 21 }), + encoder_preset: Some("medium".to_string()), + threads: 0, + audio: AudioStreamPlan::Drop, + audio_stream_indices: None, + subtitles: SubtitleStreamPlan::Drop, + filters: Vec::new(), + allow_fallback: true, + fallback: None, + }; + let metadata = MediaMetadata { + path: input.clone(), + duration_secs: 1.0, + codec_name: "h264".to_string(), + width: 16, + height: 16, + bit_depth: Some(8), + color_primaries: None, + color_transfer: None, + color_space: None, + color_range: None, + size_bytes: 6, + video_bitrate_bps: Some(10_000), + container_bitrate_bps: Some(10_000), + fps: 1.0, + container: "mkv".to_string(), + audio_codec: None, + audio_bitrate_bps: None, + audio_channels: None, + audio_is_heavy: false, + subtitle_streams: Vec::new(), + audio_streams: Vec::new(), + dynamic_range: DynamicRange::Sdr, + }; + let result = ExecutionResult { + requested_codec: crate::config::OutputCodec::H264, + planned_output_codec: crate::config::OutputCodec::H264, + requested_encoder: Some(Encoder::H264X264), + used_encoder: Some(Encoder::H264X264), + used_backend: Some(EncoderBackend::Cpu), + fallback: None, + fallback_occurred: false, + actual_output_codec: Some(crate::config::OutputCodec::H264), + actual_encoder_name: Some("libx264".to_string()), + }; + + pipeline + .finalize_job( + job.clone(), + &input, + FinalizeJobContext { + output_path: &output, + temp_output_path: &temp_output, + plan: &plan, + bypass_quality_gates: true, + start_time: std::time::Instant::now(), + encode_started_at: chrono::Utc::now(), + attempt_number: 1, + metadata: &metadata, + execution_result: &result, + }, + ) + .await?; + + let updated = db + .get_job_by_id(job.id) + .await? + .ok_or_else(|| anyhow::anyhow!("missing completed job"))?; + assert_eq!(updated.status, crate::db::JobState::Completed); + + let _ = std::fs::remove_dir_all(temp_root); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn finalize_failure_marks_failed_when_log_persistence_fails() -> anyhow::Result<()> { + let db_path = std::env::temp_dir().join(format!( + "alchemist_log_persistence_{}.db", + rand::random::() + )); + let temp_root = std::env::temp_dir().join(format!( + "alchemist_log_persistence_{}", + rand::random::() + )); + std::fs::create_dir_all(&temp_root)?; + + let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?); + let input = temp_root.join("movie.mkv"); + let output = temp_root.join("movie-alchemist.mkv"); + std::fs::write(&input, b"source")?; + + let _ = db + .enqueue_job(&input, &output, SystemTime::UNIX_EPOCH) + .await?; + let job = db + .get_job_by_input_path(input.to_string_lossy().as_ref()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing queued job"))?; + db.update_job_status(job.id, crate::db::JobState::Encoding) + .await?; + + let temp_output = temp_output_path_for(&output); + std::fs::write(&temp_output, b"partial")?; + sqlx::query("DROP TABLE logs").execute(&db.pool).await?; + + let config = Arc::new(RwLock::new(crate::config::Config::default())); + let config_snapshot = config.read().await.clone(); + let hardware_state = HardwareState::new(Some(HardwareInfo { + vendor: Vendor::Cpu, + device_path: None, + supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()], + backends: Vec::new(), + detection_notes: Vec::new(), + selection_reason: String::new(), + probe_summary: crate::system::hardware::ProbeSummary::default(), + })); + let (jobs_tx, _) = tokio::sync::broadcast::channel(100); + let (config_tx, _) = tokio::sync::broadcast::channel(10); + let (system_tx, _) = tokio::sync::broadcast::channel(10); + let event_channels = Arc::new(crate::db::EventChannels { + jobs: jobs_tx, + config: config_tx, + system: system_tx, + }); + let pipeline = Pipeline::new( + db.clone(), + Arc::new(Transcoder::new()), + config, + hardware_state, + event_channels, + false, + ); + + let plan = TranscodePlan { + decision: TranscodeDecision::Transcode { + reason: "test".to_string(), + }, + is_remux: false, + copy_video: false, + output_path: Some(temp_output.clone()), + container: "mkv".to_string(), + requested_codec: crate::config::OutputCodec::H264, + output_codec: Some(crate::config::OutputCodec::H264), + encoder: Some(Encoder::H264X264), + backend: Some(EncoderBackend::Cpu), + rate_control: Some(RateControl::Crf { value: 21 }), + encoder_preset: Some("medium".to_string()), + threads: 0, + audio: AudioStreamPlan::Drop, + audio_stream_indices: None, + subtitles: SubtitleStreamPlan::Drop, + filters: Vec::new(), + allow_fallback: true, + fallback: None, + }; + let metadata = MediaMetadata { + path: input.clone(), + duration_secs: 12.0, + codec_name: "h264".to_string(), + width: 16, + height: 16, + bit_depth: Some(8), + color_primaries: None, + color_transfer: None, + color_space: None, + color_range: None, + size_bytes: 6, + video_bitrate_bps: Some(10_000), + container_bitrate_bps: Some(10_000), + fps: 1.0, + container: "mkv".to_string(), + audio_codec: None, + audio_bitrate_bps: None, + audio_channels: None, + audio_is_heavy: false, + subtitle_streams: Vec::new(), + audio_streams: Vec::new(), + dynamic_range: DynamicRange::Sdr, + }; + let result = ExecutionResult { + requested_codec: crate::config::OutputCodec::H264, + planned_output_codec: crate::config::OutputCodec::H264, + requested_encoder: Some(Encoder::H264X264), + used_encoder: Some(Encoder::H264X264), + used_backend: Some(EncoderBackend::Cpu), + fallback: None, + fallback_occurred: false, + actual_output_codec: Some(crate::config::OutputCodec::H264), + actual_encoder_name: Some("libx264".to_string()), + }; + + pipeline + .handle_finalize_failure( + job.id, + FinalizeFailureContext { + plan: &plan, + metadata: &metadata, + execution_result: &result, + config_snapshot: &config_snapshot, + start_time: std::time::Instant::now(), + encode_started_at: chrono::Utc::now(), + attempt_number: 1, + temp_output_path: &temp_output, + }, + &crate::error::AlchemistError::Unknown("disk full".to_string()), + ) + .await; + + let updated = db + .get_job_by_id(job.id) + .await? + .ok_or_else(|| anyhow::anyhow!("missing failed job"))?; + assert_eq!(updated.status, crate::db::JobState::Failed); + + let _ = std::fs::remove_dir_all(temp_root); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn finalize_job_reprobes_encoded_output_duration_for_stats() -> anyhow::Result<()> { + let ffmpeg_available = std::process::Command::new("ffmpeg") + .arg("-version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false); + let ffprobe_available = std::process::Command::new("ffprobe") + .arg("-version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false); + if !ffmpeg_available || !ffprobe_available { + return Ok(()); + } + + let db_path = std::env::temp_dir().join(format!( + "alchemist_finalize_duration_{}.db", + rand::random::() + )); + let temp_root = std::env::temp_dir().join(format!( + "alchemist_finalize_duration_{}", + rand::random::() + )); + std::fs::create_dir_all(&temp_root)?; + + let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?); + let input = temp_root.join("source.mkv"); + let output = temp_root.join("source-alchemist.mkv"); + std::fs::write(&input, b"source-bytes")?; + + let _ = db + .enqueue_job(&input, &output, SystemTime::UNIX_EPOCH) + .await?; + let job = db + .get_job_by_input_path(input.to_string_lossy().as_ref()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing queued job"))?; + let job_id = job.id; + db.update_job_status(job.id, crate::db::JobState::Encoding) + .await?; + + let temp_output = temp_output_path_for(&output); + let ffmpeg_status = std::process::Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "color=c=black:s=16x16:d=1", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-f", + "matroska", + ]) + .arg(&temp_output) + .status()?; + if !ffmpeg_status.success() { + return Err(anyhow::anyhow!("ffmpeg failed to generate test output")); + } + + let config = Arc::new(RwLock::new(crate::config::Config::default())); + let hardware_state = HardwareState::new(Some(HardwareInfo { + vendor: Vendor::Cpu, + device_path: None, + supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()], + backends: Vec::new(), + detection_notes: Vec::new(), + selection_reason: String::new(), + probe_summary: crate::system::hardware::ProbeSummary::default(), + })); + let (jobs_tx, _) = tokio::sync::broadcast::channel(100); + let (config_tx, _) = tokio::sync::broadcast::channel(10); + let (system_tx, _) = tokio::sync::broadcast::channel(10); + let event_channels = Arc::new(crate::db::EventChannels { + jobs: jobs_tx, + config: config_tx, + system: system_tx, + }); + let pipeline = Pipeline::new( + db.clone(), + Arc::new(Transcoder::new()), + config, + hardware_state, + event_channels, + true, + ); + + let plan = TranscodePlan { + decision: TranscodeDecision::Transcode { + reason: "test".to_string(), + }, + is_remux: false, + copy_video: false, + output_path: Some(temp_output.clone()), + container: "mkv".to_string(), + requested_codec: crate::config::OutputCodec::H264, + output_codec: Some(crate::config::OutputCodec::H264), + encoder: Some(Encoder::H264X264), + backend: Some(EncoderBackend::Cpu), + rate_control: Some(RateControl::Crf { value: 21 }), + encoder_preset: Some("medium".to_string()), + threads: 0, + audio: AudioStreamPlan::Copy, + audio_stream_indices: None, + subtitles: SubtitleStreamPlan::Drop, + filters: Vec::new(), + allow_fallback: true, + fallback: None, + }; + let metadata = MediaMetadata { + path: input.clone(), + duration_secs: 0.0, + codec_name: "unknown".to_string(), + width: 16, + height: 16, + bit_depth: Some(8), + color_primaries: None, + color_transfer: None, + color_space: None, + color_range: None, + size_bytes: 12, + video_bitrate_bps: None, + container_bitrate_bps: None, + fps: 24.0, + container: "mkv".to_string(), + audio_codec: None, + audio_bitrate_bps: None, + audio_channels: None, + audio_is_heavy: false, + subtitle_streams: Vec::new(), + audio_streams: Vec::new(), + dynamic_range: DynamicRange::Sdr, + }; + let result = ExecutionResult { + requested_codec: crate::config::OutputCodec::H264, + planned_output_codec: crate::config::OutputCodec::H264, + requested_encoder: Some(Encoder::H264X264), + used_encoder: Some(Encoder::H264X264), + used_backend: Some(EncoderBackend::Cpu), + fallback: None, + fallback_occurred: false, + actual_output_codec: Some(crate::config::OutputCodec::H264), + actual_encoder_name: Some("libx264".to_string()), + }; + + pipeline + .finalize_job( + job, + &input, + FinalizeJobContext { + output_path: &output, + temp_output_path: &temp_output, + plan: &plan, + bypass_quality_gates: true, + start_time: std::time::Instant::now(), + encode_started_at: chrono::Utc::now(), + attempt_number: 1, + metadata: &metadata, + execution_result: &result, + }, + ) + .await?; + + let stats = db.get_encode_stats_by_job_id(job_id).await?; + assert!(stats.encode_speed > 0.0); + assert!(stats.avg_bitrate_kbps > 0.0); + assert!(output.exists()); + assert!(!temp_output.exists()); + + db.pool.close().await; + let _ = std::fs::remove_dir_all(temp_root); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn resumable_transcode_skips_completed_segments_on_retry() -> anyhow::Result<()> { + if !ffmpeg_ready() { + return Ok(()); + } + set_test_resume_segment_length(Some(1)); + + let db_path = std::env::temp_dir().join(format!( + "alchemist_resume_retry_{}.db", + rand::random::() + )); + let temp_root = + std::env::temp_dir().join(format!("alchemist_resume_retry_{}", rand::random::())); + std::fs::create_dir_all(&temp_root)?; + + let input = temp_root.join("resume-source.mkv"); + let output = temp_root.join("resume-output.mkv"); + let ffmpeg_status = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "color=c=black:s=16x16:r=1:d=3", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + ]) + .arg(&input) + .status()?; + if !ffmpeg_status.success() { + return Err(anyhow::anyhow!("ffmpeg failed to create resume test input")); + } + + let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?); + let _ = db + .enqueue_job(&input, &output, SystemTime::UNIX_EPOCH) + .await?; + let job = db + .get_job_by_input_path(input.to_string_lossy().as_ref()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing queued job"))?; + + let config = Arc::new(RwLock::new(crate::config::Config::default())); + let hardware_state = HardwareState::new(Some(HardwareInfo { + vendor: Vendor::Cpu, + device_path: None, + supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()], + backends: Vec::new(), + detection_notes: Vec::new(), + selection_reason: String::new(), + probe_summary: crate::system::hardware::ProbeSummary::default(), + })); + let (jobs_tx, _) = tokio::sync::broadcast::channel(100); + let (config_tx, _) = tokio::sync::broadcast::channel(10); + let (system_tx, _) = tokio::sync::broadcast::channel(10); + let event_channels = Arc::new(crate::db::EventChannels { + jobs: jobs_tx, + config: config_tx, + system: system_tx, + }); + let pipeline = Pipeline::new( + db.clone(), + Arc::new(Transcoder::new()), + config, + hardware_state, + event_channels, + false, + ); + + let analyzer = FfmpegAnalyzer; + let analysis = analyzer.analyze(&input).await?; + let plan = TranscodePlan { + decision: TranscodeDecision::Transcode { + reason: "resume-test".to_string(), + }, + is_remux: false, + copy_video: false, + output_path: Some(temp_output_path_for(&output)), + container: "mkv".to_string(), + requested_codec: crate::config::OutputCodec::H264, + output_codec: Some(crate::config::OutputCodec::H264), + encoder: Some(Encoder::H264X264), + backend: Some(EncoderBackend::Cpu), + rate_control: Some(RateControl::Crf { value: 21 }), + encoder_preset: Some("ultrafast".to_string()), + threads: 0, + audio: AudioStreamPlan::Drop, + audio_stream_indices: None, + subtitles: SubtitleStreamPlan::Drop, + filters: Vec::new(), + allow_fallback: true, + fallback: None, + }; + + let (_session, segments) = pipeline + .prepare_resume_session(&job, &plan, &analysis.metadata, &output) + .await? + .ok_or_else(|| anyhow::anyhow!("resume session not created"))?; + pipeline + .encode_resume_segment(&job, &plan, &analysis.metadata, &segments[0]) + .await?; + let first_segment_mtime = std::fs::metadata(&segments[0].temp_path)?.modified()?; + + let temp_output = temp_output_path_for(&output); + let result = pipeline + .execute_resumable_transcode(&job, &plan, &analysis.metadata, &temp_output) + .await?; + assert!(result.is_some()); + assert!(temp_output.exists()); + assert_eq!( + std::fs::metadata(&segments[0].temp_path)?.modified()?, + first_segment_mtime + ); + + let segments = db.list_resume_segments(job.id).await?; + assert_eq!(segments.len(), 3); + assert!(segments.iter().all(|segment| segment.status == "completed")); + + let _ = pipeline.purge_resume_session_state(job.id).await; + let _ = std::fs::remove_dir_all(temp_root); + let _ = std::fs::remove_file(db_path); + set_test_resume_segment_length(None); + Ok(()) + } + + #[tokio::test] + async fn resumable_transcode_invalidates_stale_session_when_input_changes() -> anyhow::Result<()> + { + if !ffmpeg_ready() { + return Ok(()); + } + set_test_resume_segment_length(Some(1)); + + let db_path = std::env::temp_dir().join(format!( + "alchemist_resume_invalidate_{}.db", + rand::random::() + )); + let temp_root = std::env::temp_dir().join(format!( + "alchemist_resume_invalidate_{}", + rand::random::() + )); + std::fs::create_dir_all(&temp_root)?; + + let input = temp_root.join("invalidate-source.mkv"); + let output = temp_root.join("invalidate-output.mkv"); + let ffmpeg_status = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "color=c=black:s=16x16:r=1:d=3", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + ]) + .arg(&input) + .status()?; + if !ffmpeg_status.success() { + return Err(anyhow::anyhow!( + "ffmpeg failed to create invalidation test input" + )); + } + + let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?); + let _ = db + .enqueue_job(&input, &output, SystemTime::UNIX_EPOCH) + .await?; + let job = db + .get_job_by_input_path(input.to_string_lossy().as_ref()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing queued job"))?; + + let config = Arc::new(RwLock::new(crate::config::Config::default())); + let hardware_state = HardwareState::new(Some(HardwareInfo { + vendor: Vendor::Cpu, + device_path: None, + supported_codecs: vec!["av1".to_string(), "hevc".to_string(), "h264".to_string()], + backends: Vec::new(), + detection_notes: Vec::new(), + selection_reason: String::new(), + probe_summary: crate::system::hardware::ProbeSummary::default(), + })); + let (jobs_tx, _) = tokio::sync::broadcast::channel(100); + let (config_tx, _) = tokio::sync::broadcast::channel(10); + let (system_tx, _) = tokio::sync::broadcast::channel(10); + let event_channels = Arc::new(crate::db::EventChannels { + jobs: jobs_tx, + config: config_tx, + system: system_tx, + }); + let pipeline = Pipeline::new( + db.clone(), + Arc::new(Transcoder::new()), + config, + hardware_state, + event_channels, + false, + ); + + let analyzer = FfmpegAnalyzer; + let analysis = analyzer.analyze(&input).await?; + let plan = TranscodePlan { + decision: TranscodeDecision::Transcode { + reason: "resume-invalidate".to_string(), + }, + is_remux: false, + copy_video: false, + output_path: Some(temp_output_path_for(&output)), + container: "mkv".to_string(), + requested_codec: crate::config::OutputCodec::H264, + output_codec: Some(crate::config::OutputCodec::H264), + encoder: Some(Encoder::H264X264), + backend: Some(EncoderBackend::Cpu), + rate_control: Some(RateControl::Crf { value: 21 }), + encoder_preset: Some("ultrafast".to_string()), + threads: 0, + audio: AudioStreamPlan::Drop, + audio_stream_indices: None, + subtitles: SubtitleStreamPlan::Drop, + filters: Vec::new(), + allow_fallback: true, + fallback: None, + }; + + let (session, _segments) = pipeline + .prepare_resume_session(&job, &plan, &analysis.metadata, &output) + .await? + .ok_or_else(|| anyhow::anyhow!("resume session not created"))?; + let sentinel = PathBuf::from(&session.temp_dir).join("stale.txt"); + std::fs::write(&sentinel, b"stale")?; + + std::thread::sleep(std::time::Duration::from_millis(1100)); + let bytes = std::fs::read(&input)?; + std::fs::write(&input, &bytes)?; + + let (_new_session, segments) = pipeline + .prepare_resume_session(&job, &plan, &analysis.metadata, &output) + .await? + .ok_or_else(|| anyhow::anyhow!("resume session not recreated"))?; + + assert!(!sentinel.exists()); + assert!(segments.iter().all(|segment| segment.status == "pending")); + + let _ = pipeline.purge_resume_session_state(job.id).await; + let _ = std::fs::remove_dir_all(temp_root); + let _ = std::fs::remove_file(db_path); + set_test_resume_segment_length(None); + Ok(()) + } } diff --git a/src/notifications.rs b/src/notifications.rs index 5a33dac..956fc23 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -16,6 +16,7 @@ use tokio::sync::{Mutex, RwLock}; use tracing::{error, warn}; type NotificationResult = Result>; +const DAILY_SUMMARY_LAST_SUCCESS_KEY: &str = "notifications.daily_summary.last_success_date"; #[derive(Clone)] pub struct NotificationManager { @@ -231,9 +232,15 @@ impl NotificationManager { }); tokio::spawn(async move { + let start = tokio::time::Instant::now() + + delay_until_next_minute_boundary(chrono::Local::now()); + let mut interval = tokio::time::interval_at(start, Duration::from_secs(60)); loop { - tokio::time::sleep(Duration::from_secs(30)).await; - if let Err(err) = summary_manager.maybe_send_daily_summary().await { + interval.tick().await; + if let Err(err) = summary_manager + .maybe_send_daily_summary_at(chrono::Local::now()) + .await + { error!("Daily summary notification error: {}", err); } } @@ -301,9 +308,11 @@ impl NotificationManager { Ok(()) } - async fn maybe_send_daily_summary(&self) -> NotificationResult<()> { + async fn maybe_send_daily_summary_at( + &self, + now: chrono::DateTime, + ) -> NotificationResult<()> { let config = self.config.read().await.clone(); - let now = chrono::Local::now(); let parts = config .notifications .daily_summary_time_local @@ -314,43 +323,100 @@ impl NotificationManager { } let hour = parts[0].parse::().unwrap_or(9); let minute = parts[1].parse::().unwrap_or(0); - if now.hour() != hour || now.minute() != minute { + let Some(scheduled_at) = now + .with_hour(hour) + .and_then(|value| value.with_minute(minute)) + .and_then(|value| value.with_second(0)) + .and_then(|value| value.with_nanosecond(0)) + else { + return Ok(()); + }; + if now < scheduled_at { return Ok(()); } let summary_key = now.format("%Y-%m-%d").to_string(); - { - let mut last_sent = self.daily_summary_last_sent.lock().await; - if last_sent.as_deref() == Some(summary_key.as_str()) { - 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()); + if self.daily_summary_already_sent(&summary_key).await? { + return Ok(()); } - let summary = self.db.get_daily_summary_stats().await?; let targets = self.db.get_notification_targets().await?; + let mut eligible_targets = Vec::new(); for target in targets { if !target.enabled { continue; } - let allowed: Vec = serde_json::from_str(&target.events).unwrap_or_default(); + let allowed: Vec = match serde_json::from_str(&target.events) { + Ok(events) => events, + Err(err) => { + warn!( + "Failed to parse events for notification target '{}': {}", + target.name, err + ); + Vec::new() + } + }; let normalized_allowed = crate::config::normalize_notification_events(&allowed); - if !normalized_allowed + if normalized_allowed .iter() .any(|event| event == crate::config::NOTIFICATION_EVENT_DAILY_SUMMARY) { - continue; + eligible_targets.push(target); } + } + + if eligible_targets.is_empty() { + self.mark_daily_summary_sent(&summary_key).await?; + return Ok(()); + } + + let summary = self.db.get_daily_summary_stats().await?; + let mut delivered = 0usize; + for target in eligible_targets { if let Err(err) = self.send_daily_summary_target(&target, &summary).await { error!( "Failed to send daily summary to target '{}': {}", target.name, err ); + continue; + } + delivered += 1; + } + + if delivered > 0 { + self.mark_daily_summary_sent(&summary_key).await?; + } + + Ok(()) + } + + async fn daily_summary_already_sent(&self, summary_key: &str) -> NotificationResult { + { + let last_sent = self.daily_summary_last_sent.lock().await; + if last_sent.as_deref() == Some(summary_key) { + return Ok(true); } } + let persisted = self + .db + .get_preference(DAILY_SUMMARY_LAST_SUCCESS_KEY) + .await?; + if persisted.as_deref() == Some(summary_key) { + let mut last_sent = self.daily_summary_last_sent.lock().await; + *last_sent = Some(summary_key.to_string()); + return Ok(true); + } + + Ok(false) + } + + async fn mark_daily_summary_sent(&self, summary_key: &str) -> NotificationResult<()> { + self.db + .set_preference(DAILY_SUMMARY_LAST_SUCCESS_KEY, summary_key) + .await?; + let mut last_sent = self.daily_summary_last_sent.lock().await; + *last_sent = Some(summary_key.to_string()); Ok(()) } @@ -851,6 +917,17 @@ impl NotificationManager { } } +fn delay_until_next_minute_boundary(now: chrono::DateTime) -> Duration { + let remaining_seconds = 60_u64.saturating_sub(now.second() as u64).max(1); + let mut delay = Duration::from_secs(remaining_seconds); + if now.nanosecond() > 0 { + delay = delay + .checked_sub(Duration::from_nanos(now.nanosecond() as u64)) + .unwrap_or_else(|| Duration::from_millis(1)); + } + delay +} + async fn _unused_ensure_public_endpoint(raw: &str) -> Result<(), Box> { let url = Url::parse(raw)?; let host = match url.host_str() { @@ -912,9 +989,38 @@ fn is_private_ip(ip: IpAddr) -> bool { mod tests { use super::*; use crate::db::JobState; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + fn scheduled_test_time(hour: u32, minute: u32) -> chrono::DateTime { + chrono::Local::now() + .with_hour(hour) + .and_then(|value| value.with_minute(minute)) + .and_then(|value| value.with_second(0)) + .and_then(|value| value.with_nanosecond(0)) + .unwrap_or_else(chrono::Local::now) + } + + async fn add_daily_summary_webhook_target( + db: &Db, + addr: std::net::SocketAddr, + ) -> NotificationResult<()> { + let config_json = serde_json::json!({ "url": format!("http://{}", addr) }).to_string(); + db.add_notification_target( + "daily-summary", + "webhook", + &config_json, + "[\"daily.summary\"]", + true, + ) + .await?; + Ok(()) + } + #[tokio::test] async fn test_webhook_errors_on_non_success() -> std::result::Result<(), Box> { @@ -1061,4 +1167,154 @@ mod tests { let _ = std::fs::remove_file(db_path); Ok(()) } + + #[tokio::test] + async fn daily_summary_retries_after_failed_delivery_and_marks_success() + -> std::result::Result<(), Box> { + let mut db_path = std::env::temp_dir(); + let token: u64 = rand::random(); + db_path.push(format!("alchemist_notifications_daily_retry_{}.db", token)); + + let db = Db::new(db_path.to_string_lossy().as_ref()).await?; + let mut test_config = crate::config::Config::default(); + test_config.notifications.allow_local_notifications = true; + test_config.notifications.daily_summary_time_local = "09:00".to_string(); + let config = Arc::new(RwLock::new(test_config)); + let manager = NotificationManager::new(db.clone(), config); + + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + return Ok(()); + } + Err(err) => return Err(err.into()), + }; + let addr = listener.local_addr()?; + add_daily_summary_webhook_target(&db, addr).await?; + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_task = request_count.clone(); + let listener_task = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let index = request_count_task.fetch_add(1, Ordering::SeqCst); + let response = if index == 0 { + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n" + } else { + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" + }; + let _ = socket.write_all(response.as_bytes()).await; + } + }); + + let first_now = scheduled_test_time(9, 5); + manager.maybe_send_daily_summary_at(first_now).await?; + assert_eq!(request_count.load(Ordering::SeqCst), 1); + assert_eq!( + db.get_preference(DAILY_SUMMARY_LAST_SUCCESS_KEY).await?, + None + ); + + manager + .maybe_send_daily_summary_at(first_now + chrono::Duration::minutes(1)) + .await?; + assert_eq!(request_count.load(Ordering::SeqCst), 2); + assert_eq!( + db.get_preference(DAILY_SUMMARY_LAST_SUCCESS_KEY).await?, + Some(first_now.format("%Y-%m-%d").to_string()) + ); + + listener_task.abort(); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn daily_summary_is_restart_safe_after_successful_delivery() + -> std::result::Result<(), Box> { + let mut db_path = std::env::temp_dir(); + let token: u64 = rand::random(); + db_path.push(format!( + "alchemist_notifications_daily_restart_{}.db", + token + )); + + let db = Db::new(db_path.to_string_lossy().as_ref()).await?; + let mut test_config = crate::config::Config::default(); + test_config.notifications.allow_local_notifications = true; + test_config.notifications.daily_summary_time_local = "09:00".to_string(); + let config = Arc::new(RwLock::new(test_config)); + + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + return Ok(()); + } + Err(err) => return Err(err.into()), + }; + let addr = listener.local_addr()?; + add_daily_summary_webhook_target(&db, addr).await?; + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_task = request_count.clone(); + let listener_task = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + request_count_task.fetch_add(1, Ordering::SeqCst); + let _ = socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await; + } + }); + + let first_now = scheduled_test_time(9, 2); + let manager = NotificationManager::new(db.clone(), config.clone()); + manager.maybe_send_daily_summary_at(first_now).await?; + assert_eq!(request_count.load(Ordering::SeqCst), 1); + + let restarted_manager = NotificationManager::new(db.clone(), config.clone()); + restarted_manager + .maybe_send_daily_summary_at(first_now + chrono::Duration::minutes(10)) + .await?; + assert_eq!(request_count.load(Ordering::SeqCst), 1); + + listener_task.abort(); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn daily_summary_marks_day_sent_when_no_targets_are_eligible() + -> std::result::Result<(), Box> { + let mut db_path = std::env::temp_dir(); + let token: u64 = rand::random(); + db_path.push(format!( + "alchemist_notifications_daily_no_targets_{}.db", + token + )); + + let db = Db::new(db_path.to_string_lossy().as_ref()).await?; + let mut test_config = crate::config::Config::default(); + test_config.notifications.daily_summary_time_local = "09:00".to_string(); + let config = Arc::new(RwLock::new(test_config)); + let manager = NotificationManager::new(db.clone(), config); + + let now = scheduled_test_time(9, 1); + manager.maybe_send_daily_summary_at(now).await?; + assert_eq!( + db.get_preference(DAILY_SUMMARY_LAST_SUCCESS_KEY).await?, + Some(now.format("%Y-%m-%d").to_string()) + ); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } } diff --git a/src/orchestrator.rs b/src/orchestrator.rs index 44b48ef..210efb1 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -29,6 +29,8 @@ pub struct TranscodeRequest<'a> { pub metadata: &'a crate::media::pipeline::MediaMetadata, pub plan: &'a TranscodePlan, pub observer: Option>, + pub clip_start_seconds: Option, + pub clip_duration_seconds: Option, } #[allow(async_fn_in_trait)] @@ -187,6 +189,7 @@ impl Transcoder { request.plan, ) .with_hardware(request.hw_info) + .with_clip(request.clip_start_seconds, request.clip_duration_seconds) .build()?; info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); diff --git a/src/server/auth.rs b/src/server/auth.rs index 7c83458..ce07c6f 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -15,6 +15,7 @@ use chrono::Utc; use rand::Rng; use std::net::SocketAddr; use std::sync::Arc; +use tracing::error; #[derive(serde::Deserialize)] pub(crate) struct LoginPayload { @@ -32,11 +33,13 @@ pub(crate) async fn login_handler( } let mut is_valid = true; - let user_result = state - .db - .get_user_by_username(&payload.username) - .await - .unwrap_or(None); + let user_result = match state.db.get_user_by_username(&payload.username).await { + Ok(user) => user, + Err(err) => { + error!("Login lookup failed for '{}': {}", payload.username, err); + return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + }; // A valid argon2 static hash of a random string used to simulate work and equalize timing const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHN0cmluZzEyMzQ1Ng$1tJ2tA109qj15m3u5+kS/sX5X1UoZ6/H9b/30tX9N/g"; diff --git a/src/server/jobs.rs b/src/server/jobs.rs index 6287fe6..cf52f1c 100644 --- a/src/server/jobs.rs +++ b/src/server/jobs.rs @@ -10,7 +10,11 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::{ + path::{Path as FsPath, PathBuf}, + sync::Arc, + time::SystemTime, +}; #[derive(Serialize)] struct BlockedJob { @@ -24,6 +28,17 @@ struct BlockedJobsResponse { blocked: Vec, } +#[derive(Deserialize)] +pub(crate) struct EnqueueJobPayload { + path: String, +} + +#[derive(Serialize)] +pub(crate) struct EnqueueJobResponse { + enqueued: bool, + message: String, +} + pub(crate) fn blocked_jobs_response(message: impl Into, blocked: &[Job]) -> Response { let payload = BlockedJobsResponse { message: message.into(), @@ -38,6 +53,166 @@ pub(crate) fn blocked_jobs_response(message: impl Into, blocked: &[Job]) (StatusCode::CONFLICT, axum::Json(payload)).into_response() } +fn resolve_source_root(path: &FsPath, watch_dirs: &[crate::db::WatchDir]) -> Option { + watch_dirs + .iter() + .map(|watch_dir| PathBuf::from(&watch_dir.path)) + .filter(|watch_dir| path.starts_with(watch_dir)) + .max_by_key(|watch_dir| watch_dir.components().count()) +} + +async fn purge_resume_sessions_for_jobs(state: &AppState, ids: &[i64]) { + let sessions = match state.db.get_resume_sessions_by_job_ids(ids).await { + Ok(sessions) => sessions, + Err(err) => { + tracing::warn!("Failed to load resume sessions for purge: {}", err); + return; + } + }; + + for session in sessions { + if let Err(err) = state.db.delete_resume_session(session.job_id).await { + tracing::warn!( + job_id = session.job_id, + "Failed to delete resume session rows: {err}" + ); + continue; + } + + let temp_dir = PathBuf::from(&session.temp_dir); + if temp_dir.exists() { + if let Err(err) = tokio::fs::remove_dir_all(&temp_dir).await { + tracing::warn!( + job_id = session.job_id, + path = %temp_dir.display(), + "Failed to remove resume temp dir: {err}" + ); + } + } + } +} + +pub(crate) async fn enqueue_job_handler( + State(state): State>, + axum::Json(payload): axum::Json, +) -> impl IntoResponse { + let submitted_path = payload.path.trim(); + if submitted_path.is_empty() { + return ( + StatusCode::BAD_REQUEST, + axum::Json(EnqueueJobResponse { + enqueued: false, + message: "Path must not be empty.".to_string(), + }), + ) + .into_response(); + } + + let requested_path = PathBuf::from(submitted_path); + if !requested_path.is_absolute() { + return ( + StatusCode::BAD_REQUEST, + axum::Json(EnqueueJobResponse { + enqueued: false, + message: "Path must be absolute.".to_string(), + }), + ) + .into_response(); + } + + let canonical_path = match std::fs::canonicalize(&requested_path) { + Ok(path) => path, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + axum::Json(EnqueueJobResponse { + enqueued: false, + message: format!("Unable to resolve path: {err}"), + }), + ) + .into_response(); + } + }; + + let metadata = match std::fs::metadata(&canonical_path) { + Ok(metadata) => metadata, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + axum::Json(EnqueueJobResponse { + enqueued: false, + message: format!("Unable to read file metadata: {err}"), + }), + ) + .into_response(); + } + }; + if !metadata.is_file() { + return ( + StatusCode::BAD_REQUEST, + axum::Json(EnqueueJobResponse { + enqueued: false, + message: "Path must point to a file.".to_string(), + }), + ) + .into_response(); + } + + let extension = canonical_path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + let supported = crate::media::scanner::Scanner::new().extensions; + if extension + .as_deref() + .is_none_or(|value| !supported.iter().any(|candidate| candidate == value)) + { + return ( + StatusCode::BAD_REQUEST, + axum::Json(EnqueueJobResponse { + enqueued: false, + message: "File type is not supported for enqueue.".to_string(), + }), + ) + .into_response(); + } + + let watch_dirs = match state.db.get_watch_dirs().await { + Ok(watch_dirs) => watch_dirs, + Err(err) => { + return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + }; + + let discovered = crate::media::pipeline::DiscoveredMedia { + path: canonical_path.clone(), + mtime: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), + source_root: resolve_source_root(&canonical_path, &watch_dirs), + }; + + match crate::media::pipeline::enqueue_discovered_with_db(state.db.as_ref(), discovered).await { + Ok(true) => ( + StatusCode::OK, + axum::Json(EnqueueJobResponse { + enqueued: true, + message: format!("Enqueued {}.", canonical_path.display()), + }), + ) + .into_response(), + Ok(false) => ( + StatusCode::OK, + axum::Json(EnqueueJobResponse { + enqueued: false, + message: + "File was not enqueued because it matched existing output or dedupe rules." + .to_string(), + }), + ) + .into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + } +} + pub(crate) async fn request_job_cancel(state: &AppState, job: &Job) -> Result { state.transcoder.add_cancel_request(job.id).await; match job.status { @@ -226,7 +401,12 @@ pub(crate) async fn batch_jobs_handler( }; match result { - Ok(count) => axum::Json(serde_json::json!({ "count": count })).into_response(), + Ok(count) => { + if payload.action == "delete" { + purge_resume_sessions_for_jobs(state.as_ref(), &payload.ids).await; + } + axum::Json(serde_json::json!({ "count": count })).into_response() + } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } @@ -270,8 +450,13 @@ pub(crate) async fn restart_failed_handler( pub(crate) async fn clear_completed_handler( State(state): State>, ) -> impl IntoResponse { + let completed_job_ids = match state.db.get_jobs_by_status(JobState::Completed).await { + Ok(jobs) => jobs.into_iter().map(|job| job.id).collect::>(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; match state.db.clear_completed_jobs().await { Ok(count) => { + purge_resume_sessions_for_jobs(state.as_ref(), &completed_job_ids).await; let message = if count == 0 { "No completed jobs were waiting to be cleared.".to_string() } else if count == 1 { @@ -324,7 +509,10 @@ pub(crate) async fn delete_job_handler( state.transcoder.cancel_job(id); match state.db.delete_job(id).await { - Ok(_) => StatusCode::OK.into_response(), + Ok(_) => { + purge_resume_sessions_for_jobs(state.as_ref(), &[id]).await; + StatusCode::OK.into_response() + } Err(e) if is_row_not_found(&e) => StatusCode::NOT_FOUND.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } @@ -383,7 +571,13 @@ pub(crate) async fn get_job_detail_handler( // 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 let encode_stats = if job.status == JobState::Completed { - state.db.get_encode_stats_by_job_id(id).await.ok() + match state.db.get_encode_stats_by_job_id(id).await { + Ok(stats) => Some(stats), + Err(err) if is_row_not_found(&err) => None, + Err(err) => { + return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + } } else { None }; @@ -424,14 +618,18 @@ pub(crate) async fn get_job_detail_handler( (None, None) }; - let encode_attempts = state - .db - .get_encode_attempts_by_job(id) - .await - .unwrap_or_default(); + let encode_attempts = match state.db.get_encode_attempts_by_job(id).await { + Ok(attempts) => attempts, + Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + }; let queue_position = if job.status == JobState::Queued { - state.db.get_queue_position(id).await.unwrap_or(None) + match state.db.get_queue_position(id).await { + Ok(position) => position, + Err(err) => { + return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + } } else { None }; diff --git a/src/server/mod.rs b/src/server/mod.rs index c8871f0..3728c29 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -341,6 +341,7 @@ fn app_router(state: Arc) -> Router { // Canonical job list endpoint. .route("/api/jobs", get(jobs_table_handler)) .route("/api/jobs/table", get(jobs_table_handler)) + .route("/api/jobs/enqueue", post(enqueue_job_handler)) .route("/api/jobs/batch", post(batch_jobs_handler)) .route("/api/logs/history", get(logs_history_handler)) .route("/api/logs", delete(clear_logs_handler)) @@ -376,6 +377,7 @@ fn app_router(state: Arc) -> Router { get(get_engine_mode_handler).post(set_engine_mode_handler), ) .route("/api/engine/status", get(engine_status_handler)) + .route("/api/processor/status", get(processor_status_handler)) .route( "/api/settings/transcode", get(get_transcode_settings_handler).post(update_transcode_settings_handler), diff --git a/src/server/settings.rs b/src/server/settings.rs index 02a4404..9870767 100644 --- a/src/server/settings.rs +++ b/src/server/settings.rs @@ -641,9 +641,8 @@ pub(crate) async fn add_notification_handler( } match state.db.get_notification_targets().await { - Ok(targets) => targets - .into_iter() - .find(|target| target.name == payload.name) + Ok(mut targets) => targets + .pop() .map(|target| axum::Json(notification_target_response(target)).into_response()) .unwrap_or_else(|| StatusCode::OK.into_response()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), @@ -654,23 +653,23 @@ pub(crate) async fn delete_notification_handler( State(state): State>, Path(id): Path, ) -> impl IntoResponse { - let target = match state.db.get_notification_targets().await { - Ok(targets) => targets.into_iter().find(|target| target.id == id), + let target_index = match state.db.get_notification_targets().await { + Ok(targets) => targets.iter().position(|target| target.id == id), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; - let Some(target) = target else { + let Some(target_index) = target_index else { return StatusCode::NOT_FOUND.into_response(); }; let mut next_config = state.config.read().await.clone(); - let target_config_json = target.config_json.clone(); - let parsed_target_config_json = - serde_json::from_str::(&target_config_json).unwrap_or(JsonValue::Null); - next_config.notifications.targets.retain(|candidate| { - !(candidate.name == target.name - && candidate.target_type == target.target_type - && candidate.config_json == parsed_target_config_json) - }); + if target_index >= next_config.notifications.targets.len() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "notification settings projection is out of sync with config", + ) + .into_response(); + } + next_config.notifications.targets.remove(target_index); if let Err(response) = save_config_or_response(&state, &next_config).await { return *response; } @@ -837,13 +836,8 @@ pub(crate) async fn add_schedule_handler( state.scheduler.trigger(); match state.db.get_schedule_windows().await { - Ok(windows) => windows - .into_iter() - .find(|window| { - window.start_time == start_time - && window.end_time == end_time - && window.enabled == payload.enabled - }) + Ok(mut windows) => windows + .pop() .map(|window| axum::Json(serde_json::json!(window)).into_response()) .unwrap_or_else(|| StatusCode::OK.into_response()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), @@ -854,22 +848,23 @@ pub(crate) async fn delete_schedule_handler( State(state): State>, Path(id): Path, ) -> impl IntoResponse { - let window = match state.db.get_schedule_windows().await { - Ok(windows) => windows.into_iter().find(|window| window.id == id), + let window_index = match state.db.get_schedule_windows().await { + Ok(windows) => windows.iter().position(|window| window.id == id), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; - let Some(window) = window else { + let Some(window_index) = window_index else { return StatusCode::NOT_FOUND.into_response(); }; - let days_of_week: Vec = serde_json::from_str(&window.days_of_week).unwrap_or_default(); let mut next_config = state.config.read().await.clone(); - next_config.schedule.windows.retain(|candidate| { - !(candidate.start_time == window.start_time - && candidate.end_time == window.end_time - && candidate.enabled == window.enabled - && candidate.days_of_week == days_of_week) - }); + if window_index >= next_config.schedule.windows.len() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "schedule settings projection is out of sync with config", + ) + .into_response(); + } + next_config.schedule.windows.remove(window_index); if let Err(response) = save_config_or_response(&state, &next_config).await { return *response; } diff --git a/src/server/system.rs b/src/server/system.rs index 8ace029..980511c 100644 --- a/src/server/system.rs +++ b/src/server/system.rs @@ -27,6 +27,17 @@ struct SystemResources { gpu_memory_percent: Option, } +#[derive(Serialize)] +pub(crate) struct ProcessorStatusResponse { + blocked_reason: Option<&'static str>, + message: String, + manual_paused: bool, + scheduler_paused: bool, + draining: bool, + active_jobs: i64, + concurrent_limit: usize, +} + #[derive(Serialize)] struct DuplicateGroup { stem: String, @@ -135,6 +146,54 @@ pub(crate) async fn system_resources_handler(State(state): State>) axum::Json(value).into_response() } +pub(crate) async fn processor_status_handler(State(state): State>) -> Response { + let stats = match state.db.get_job_stats().await { + Ok(stats) => stats, + Err(err) => return config_read_error_response("load processor status", &err), + }; + + let concurrent_limit = state.agent.concurrent_jobs_limit(); + let manual_paused = state.agent.is_manual_paused(); + let scheduler_paused = state.agent.is_scheduler_paused(); + let draining = state.agent.is_draining(); + let active_jobs = stats.active; + + let (blocked_reason, message) = if manual_paused { + ( + Some("manual_paused"), + "The engine is manually paused and will not start queued jobs.".to_string(), + ) + } else if scheduler_paused { + ( + Some("scheduled_pause"), + "The schedule is currently pausing the engine.".to_string(), + ) + } else if draining { + ( + Some("draining"), + "The engine is draining and will not start new queued jobs.".to_string(), + ) + } else if active_jobs >= concurrent_limit as i64 { + ( + Some("workers_busy"), + "All worker slots are currently busy.".to_string(), + ) + } else { + (None, "Workers are available.".to_string()) + }; + + axum::Json(ProcessorStatusResponse { + blocked_reason, + message, + manual_paused, + scheduler_paused, + draining, + active_jobs, + concurrent_limit, + }) + .into_response() +} + pub(crate) async fn library_intelligence_handler(State(state): State>) -> Response { use std::collections::HashMap; use std::path::Path; diff --git a/src/server/tests.rs b/src/server/tests.rs index 0340142..458ba58 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -547,6 +547,69 @@ async fn engine_status_endpoint_reports_draining_state() Ok(()) } +#[tokio::test] +async fn processor_status_endpoint_reports_blocking_reason_precedence() +-> std::result::Result<(), Box> { + let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + let token = create_session(state.db.as_ref()).await?; + let (_job, input_path, output_path) = seed_job(state.db.as_ref(), JobState::Encoding).await?; + + let response = app + .clone() + .oneshot(auth_request( + Method::GET, + "/api/processor/status", + &token, + Body::empty(), + )) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = serde_json::from_str(&body_text(response).await)?; + assert_eq!(payload["blocked_reason"], "workers_busy"); + + state.agent.drain(); + let response = app + .clone() + .oneshot(auth_request( + Method::GET, + "/api/processor/status", + &token, + Body::empty(), + )) + .await?; + let payload: serde_json::Value = serde_json::from_str(&body_text(response).await)?; + assert_eq!(payload["blocked_reason"], "draining"); + + state.agent.set_scheduler_paused(true); + let response = app + .clone() + .oneshot(auth_request( + Method::GET, + "/api/processor/status", + &token, + Body::empty(), + )) + .await?; + let payload: serde_json::Value = serde_json::from_str(&body_text(response).await)?; + assert_eq!(payload["blocked_reason"], "scheduled_pause"); + + state.agent.pause(); + let response = app + .clone() + .oneshot(auth_request( + Method::GET, + "/api/processor/status", + &token, + Body::empty(), + )) + .await?; + let payload: serde_json::Value = serde_json::from_str(&body_text(response).await)?; + assert_eq!(payload["blocked_reason"], "manual_paused"); + + cleanup_paths(&[input_path, output_path, config_path, db_path]); + Ok(()) +} + #[tokio::test] async fn read_only_api_token_allows_observability_only_routes() -> std::result::Result<(), Box> { @@ -1159,6 +1222,35 @@ async fn public_clients_can_reach_login_after_setup() Ok(()) } +#[tokio::test] +async fn login_returns_internal_error_when_user_lookup_fails() +-> std::result::Result<(), Box> { + let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + state.db.pool.close().await; + + let mut request = remote_request( + Method::POST, + "/api/auth/login", + Body::from( + json!({ + "username": "tester", + "password": "not-important" + }) + .to_string(), + ), + ); + request.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + + let response = app.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + cleanup_paths(&[config_path, db_path]); + Ok(()) +} + #[tokio::test] async fn settings_bundle_requires_auth_after_setup() -> std::result::Result<(), Box> { @@ -1363,6 +1455,93 @@ async fn settings_bundle_put_projects_extended_settings_to_db() Ok(()) } +#[tokio::test] +async fn delete_notification_removes_only_one_duplicate_target() +-> std::result::Result<(), Box> { + let duplicate_target = crate::config::NotificationTargetConfig { + name: "Discord".to_string(), + target_type: "discord_webhook".to_string(), + config_json: serde_json::json!({ + "webhook_url": "https://discord.com/api/webhooks/test" + }), + endpoint_url: None, + auth_token: None, + events: vec!["encode.completed".to_string()], + enabled: true, + }; + let (state, app, config_path, db_path) = build_test_app(false, 8, |config| { + config.notifications.targets = vec![duplicate_target.clone(), duplicate_target.clone()]; + }) + .await?; + let projected = state.config.read().await.clone(); + crate::settings::project_config_to_db(state.db.as_ref(), &projected).await?; + let token = create_session(state.db.as_ref()).await?; + + let targets = state.db.get_notification_targets().await?; + assert_eq!(targets.len(), 2); + + let response = app + .clone() + .oneshot(auth_request( + Method::DELETE, + &format!("/api/settings/notifications/{}", targets[0].id), + &token, + Body::empty(), + )) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let persisted = crate::config::Config::load(config_path.as_path())?; + assert_eq!(persisted.notifications.targets.len(), 1); + + let stored_targets = state.db.get_notification_targets().await?; + assert_eq!(stored_targets.len(), 1); + + cleanup_paths(&[config_path, db_path]); + Ok(()) +} + +#[tokio::test] +async fn delete_schedule_removes_only_one_duplicate_window() +-> std::result::Result<(), Box> { + let duplicate_window = crate::config::ScheduleWindowConfig { + start_time: "22:00".to_string(), + end_time: "06:00".to_string(), + days_of_week: vec![1, 2, 3], + enabled: true, + }; + let (state, app, config_path, db_path) = build_test_app(false, 8, |config| { + config.schedule.windows = vec![duplicate_window.clone(), duplicate_window.clone()]; + }) + .await?; + let projected = state.config.read().await.clone(); + crate::settings::project_config_to_db(state.db.as_ref(), &projected).await?; + let token = create_session(state.db.as_ref()).await?; + + let windows = state.db.get_schedule_windows().await?; + assert_eq!(windows.len(), 2); + + let response = app + .clone() + .oneshot(auth_request( + Method::DELETE, + &format!("/api/settings/schedule/{}", windows[0].id), + &token, + Body::empty(), + )) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let persisted = crate::config::Config::load(config_path.as_path())?; + assert_eq!(persisted.schedule.windows.len(), 1); + + let stored_windows = state.db.get_schedule_windows().await?; + assert_eq!(stored_windows.len(), 1); + + cleanup_paths(&[config_path, db_path]); + Ok(()) +} + #[tokio::test] async fn raw_config_put_overwrites_divergent_db_projection() -> std::result::Result<(), Box> { @@ -1615,6 +1794,219 @@ async fn job_detail_route_falls_back_to_legacy_failure_summary() Ok(()) } +#[tokio::test] +async fn job_detail_route_returns_internal_error_when_encode_attempts_query_fails() +-> std::result::Result<(), Box> { + let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + let token = create_session(state.db.as_ref()).await?; + let (job, input_path, output_path) = seed_job(state.db.as_ref(), JobState::Queued).await?; + + sqlx::query("DROP TABLE encode_attempts") + .execute(&state.db.pool) + .await?; + + let response = app + .clone() + .oneshot(auth_request( + Method::GET, + &format!("/api/jobs/{}/details", job.id), + &token, + Body::empty(), + )) + .await?; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + cleanup_paths(&[input_path, output_path, config_path, db_path]); + Ok(()) +} + +#[tokio::test] +async fn enqueue_job_endpoint_accepts_supported_absolute_files() +-> std::result::Result<(), Box> { + let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + let token = create_session(state.db.as_ref()).await?; + + let input_path = temp_path("alchemist_enqueue_input", "mkv"); + std::fs::write(&input_path, b"test")?; + let canonical_input = std::fs::canonicalize(&input_path)?; + + let response = app + .clone() + .oneshot(auth_json_request( + Method::POST, + "/api/jobs/enqueue", + &token, + json!({ "path": input_path.to_string_lossy() }), + )) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let payload: serde_json::Value = serde_json::from_str(&body_text(response).await)?; + assert_eq!(payload["enqueued"], true); + assert!( + state + .db + .get_job_by_input_path(canonical_input.to_string_lossy().as_ref()) + .await? + .is_some() + ); + + cleanup_paths(&[input_path, config_path, db_path]); + Ok(()) +} + +#[tokio::test] +async fn enqueue_job_endpoint_rejects_relative_paths_and_unsupported_extensions() +-> std::result::Result<(), Box> { + let (_state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + let token = create_session(_state.db.as_ref()).await?; + + let response = app + .clone() + .oneshot(auth_json_request( + Method::POST, + "/api/jobs/enqueue", + &token, + json!({ "path": "relative/movie.mkv" }), + )) + .await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let unsupported = temp_path("alchemist_enqueue_unsupported", "txt"); + std::fs::write(&unsupported, b"test")?; + + let response = app + .clone() + .oneshot(auth_json_request( + Method::POST, + "/api/jobs/enqueue", + &token, + json!({ "path": unsupported.to_string_lossy() }), + )) + .await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + cleanup_paths(&[unsupported, config_path, db_path]); + Ok(()) +} + +#[tokio::test] +async fn enqueue_job_endpoint_returns_noop_for_generated_output_paths() +-> std::result::Result<(), Box> { + let (_state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + let token = create_session(_state.db.as_ref()).await?; + + let generated_dir = temp_path("alchemist_enqueue_generated_dir", "dir"); + std::fs::create_dir_all(&generated_dir)?; + let generated = generated_dir.join("movie-alchemist.mkv"); + std::fs::write(&generated, b"test")?; + + let response = app + .clone() + .oneshot(auth_json_request( + Method::POST, + "/api/jobs/enqueue", + &token, + json!({ "path": generated.to_string_lossy() }), + )) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let payload: serde_json::Value = serde_json::from_str(&body_text(response).await)?; + assert_eq!(payload["enqueued"], false); + + cleanup_paths(&[generated_dir, config_path, db_path]); + Ok(()) +} + +#[tokio::test] +async fn delete_job_endpoint_purges_resume_session_temp_dir() +-> std::result::Result<(), Box> { + let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + let token = create_session(state.db.as_ref()).await?; + let (job, input_path, output_path) = seed_job(state.db.as_ref(), JobState::Failed).await?; + + let resume_dir = temp_path("alchemist_resume_delete", "dir"); + std::fs::create_dir_all(&resume_dir)?; + std::fs::write(resume_dir.join("segment-00000.mkv"), b"segment")?; + state + .db + .upsert_resume_session(&crate::db::UpsertJobResumeSessionInput { + job_id: job.id, + strategy: "segment_v1".to_string(), + plan_hash: "plan".to_string(), + mtime_hash: "mtime".to_string(), + temp_dir: resume_dir.to_string_lossy().to_string(), + concat_manifest_path: resume_dir + .join("segments.ffconcat") + .to_string_lossy() + .to_string(), + segment_length_secs: 120, + status: "active".to_string(), + }) + .await?; + + let response = app + .clone() + .oneshot(auth_request( + Method::POST, + &format!("/api/jobs/{}/delete", job.id), + &token, + Body::empty(), + )) + .await?; + assert_eq!(response.status(), StatusCode::OK); + assert!(state.db.get_resume_session(job.id).await?.is_none()); + assert!(!resume_dir.exists()); + + cleanup_paths(&[resume_dir, input_path, output_path, config_path, db_path]); + Ok(()) +} + +#[tokio::test] +async fn clear_completed_purges_resume_sessions() +-> std::result::Result<(), Box> { + let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?; + let token = create_session(state.db.as_ref()).await?; + let (job, input_path, output_path) = seed_job(state.db.as_ref(), JobState::Completed).await?; + + let resume_dir = temp_path("alchemist_resume_clear_completed", "dir"); + std::fs::create_dir_all(&resume_dir)?; + std::fs::write(resume_dir.join("segment-00000.mkv"), b"segment")?; + state + .db + .upsert_resume_session(&crate::db::UpsertJobResumeSessionInput { + job_id: job.id, + strategy: "segment_v1".to_string(), + plan_hash: "plan".to_string(), + mtime_hash: "mtime".to_string(), + temp_dir: resume_dir.to_string_lossy().to_string(), + concat_manifest_path: resume_dir + .join("segments.ffconcat") + .to_string_lossy() + .to_string(), + segment_length_secs: 120, + status: "segments_complete".to_string(), + }) + .await?; + + let response = app + .clone() + .oneshot(auth_request( + Method::POST, + "/api/jobs/clear-completed", + &token, + Body::empty(), + )) + .await?; + assert_eq!(response.status(), StatusCode::OK); + assert!(state.db.get_resume_session(job.id).await?.is_none()); + assert!(!resume_dir.exists()); + + cleanup_paths(&[resume_dir, input_path, output_path, config_path, db_path]); + Ok(()) +} + #[tokio::test] async fn delete_active_job_returns_conflict() -> std::result::Result<(), Box> { diff --git a/tests/integration_db_upgrade.rs b/tests/integration_db_upgrade.rs index b706b90..cf3ffb7 100644 --- a/tests/integration_db_upgrade.rs +++ b/tests/integration_db_upgrade.rs @@ -49,6 +49,12 @@ async fn v0_2_5_fixture_upgrades_and_preserves_core_state() -> Result<()> { let notifications = db.get_notification_targets().await?; assert_eq!(notifications.len(), 1); assert_eq!(notifications[0].target_type, "discord_webhook"); + let notification_config: serde_json::Value = + serde_json::from_str(¬ifications[0].config_json)?; + assert_eq!( + notification_config["webhook_url"].as_str(), + Some("https://discord.invalid/webhook") + ); let schedule_windows = db.get_schedule_windows().await?; assert_eq!(schedule_windows.len(), 1); @@ -101,7 +107,7 @@ async fn v0_2_5_fixture_upgrades_and_preserves_core_state() -> Result<()> { .fetch_one(&pool) .await? .get("value"); - assert_eq!(schema_version, "8"); + assert_eq!(schema_version, "9"); let min_compatible_version: String = sqlx::query("SELECT value FROM schema_info WHERE key = 'min_compatible_version'") @@ -153,6 +159,45 @@ async fn v0_2_5_fixture_upgrades_and_preserves_core_state() -> Result<()> { .get("count"); assert_eq!(job_failure_explanations_exists, 1); + let notification_columns = sqlx::query("PRAGMA table_info(notification_targets)") + .fetch_all(&pool) + .await? + .into_iter() + .map(|row| row.get::("name")) + .collect::>(); + assert!( + notification_columns + .iter() + .any(|name| name == "endpoint_url") + ); + assert!(notification_columns.iter().any(|name| name == "auth_token")); + assert!( + notification_columns + .iter() + .any(|name| name == "target_type_v2") + ); + assert!( + notification_columns + .iter() + .any(|name| name == "config_json") + ); + + let resume_sessions_exists: i64 = sqlx::query( + "SELECT COUNT(*) as count FROM sqlite_master WHERE type = 'table' AND name = 'job_resume_sessions'", + ) + .fetch_one(&pool) + .await? + .get("count"); + assert_eq!(resume_sessions_exists, 1); + + let resume_segments_exists: i64 = sqlx::query( + "SELECT COUNT(*) as count FROM sqlite_master WHERE type = 'table' AND name = 'job_resume_segments'", + ) + .fetch_one(&pool) + .await? + .get("count"); + assert_eq!(resume_segments_exists, 1); + pool.close().await; drop(db); let _ = fs::remove_file(&db_path); diff --git a/tests/integration_ffmpeg.rs b/tests/integration_ffmpeg.rs index 7b0f7e4..5a38398 100644 --- a/tests/integration_ffmpeg.rs +++ b/tests/integration_ffmpeg.rs @@ -43,6 +43,20 @@ fn ffmpeg_ready() -> bool { ffmpeg_available() && ffprobe_available() } +fn ffmpeg_has_encoder(name: &str) -> bool { + Command::new("ffmpeg") + .args(["-hide_banner", "-encoders"]) + .output() + .ok() + .map(|output| { + output.status.success() + && String::from_utf8_lossy(&output.stdout) + .lines() + .any(|line| line.contains(name)) + }) + .unwrap_or(false) +} + /// Get the path to test fixtures fn fixtures_path() -> PathBuf { let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -68,6 +82,75 @@ fn cleanup_temp_dir(path: &Path) { let _ = std::fs::remove_dir_all(path); } +#[tokio::test] +async fn amd_vaapi_smoke_test_is_hardware_gated() -> Result<()> { + let Some(device_path) = std::env::var("ALCHEMIST_TEST_AMD_VAAPI_DEVICE").ok() else { + println!("Skipping test: ALCHEMIST_TEST_AMD_VAAPI_DEVICE not set"); + return Ok(()); + }; + if !ffmpeg_available() || !ffmpeg_has_encoder("h264_vaapi") { + println!("Skipping test: ffmpeg or h264_vaapi encoder not available"); + return Ok(()); + } + + let status = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-vaapi_device", + &device_path, + "-f", + "lavfi", + "-i", + "testsrc=size=64x64:rate=1:d=1", + "-vf", + "format=nv12,hwupload", + "-c:v", + "h264_vaapi", + "-f", + "null", + "-", + ]) + .status()?; + assert!( + status.success(), + "expected VAAPI smoke transcode to succeed" + ); + Ok(()) +} + +#[tokio::test] +async fn amd_amf_smoke_test_is_hardware_gated() -> Result<()> { + if std::env::var("ALCHEMIST_TEST_AMD_AMF").ok().as_deref() != Some("1") { + println!("Skipping test: ALCHEMIST_TEST_AMD_AMF not set"); + return Ok(()); + } + if !ffmpeg_available() || !ffmpeg_has_encoder("h264_amf") { + println!("Skipping test: ffmpeg or h264_amf encoder not available"); + return Ok(()); + } + + let status = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc=size=64x64:rate=1:d=1", + "-c:v", + "h264_amf", + "-f", + "null", + "-", + ]) + .status()?; + assert!(status.success(), "expected AMF smoke transcode to succeed"); + Ok(()) +} + /// Create a test database async fn create_test_db() -> Result<(Arc, PathBuf)> { let mut db_path = std::env::temp_dir(); diff --git a/web-e2e/package.json b/web-e2e/package.json index 33b5c79..1a7ac37 100644 --- a/web-e2e/package.json +++ b/web-e2e/package.json @@ -1,6 +1,6 @@ { "name": "alchemist-web-e2e", - "version": "0.3.1-rc.4", + "version": "0.3.1-rc.5", "private": true, "packageManager": "bun@1", "type": "module", @@ -8,7 +8,7 @@ "test": "playwright test", "test:headed": "playwright test --headed", "test:ui": "playwright test --ui", - "test:reliability": "playwright test tests/settings-nonok.spec.ts tests/setup-recovery.spec.ts tests/setup-happy-path.spec.ts tests/new-user-redirect.spec.ts tests/stats-poller.spec.ts tests/jobs-actions-nonok.spec.ts tests/jobs-stability.spec.ts tests/library-intake-stability.spec.ts" + "test:reliability": "playwright test tests/settings-nonok.spec.ts tests/setup-recovery.spec.ts tests/setup-happy-path.spec.ts tests/new-user-redirect.spec.ts tests/stats-poller.spec.ts tests/jobs-actions-nonok.spec.ts tests/jobs-stability.spec.ts tests/library-intake-stability.spec.ts tests/intelligence-actions.spec.ts" }, "devDependencies": { "@playwright/test": "^1.54.2" diff --git a/web-e2e/playwright.config.ts b/web-e2e/playwright.config.ts index 2f4a99b..9faa622 100644 --- a/web-e2e/playwright.config.ts +++ b/web-e2e/playwright.config.ts @@ -37,10 +37,10 @@ export default defineConfig({ ], webServer: { command: - "sh -c 'mkdir -p .runtime/media && cd .. && (cd web && bun install --frozen-lockfile && bun run build) && if [ -x ./target/debug/alchemist ]; then ./target/debug/alchemist --reset-auth; else cargo run --locked --no-default-features -- --reset-auth; fi'", + "sh -c 'mkdir -p .runtime/media && rm -f .runtime/alchemist.db .runtime/alchemist.db-wal .runtime/alchemist.db-shm && cd .. && (cd web && bun install --frozen-lockfile && bun run build) && if [ -x ./target/debug/alchemist ]; then ./target/debug/alchemist --reset-auth; else cargo run --locked --no-default-features -- --reset-auth; fi'", url: `${BASE_URL}/api/health`, reuseExistingServer: false, - timeout: 120_000, + timeout: 300_000, env: { ALCHEMIST_CONFIG_PATH: CONFIG_PATH, ALCHEMIST_DB_PATH: DB_PATH, diff --git a/web-e2e/tests/intelligence-actions.spec.ts b/web-e2e/tests/intelligence-actions.spec.ts new file mode 100644 index 0000000..d475db0 --- /dev/null +++ b/web-e2e/tests/intelligence-actions.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from "@playwright/test"; +import { + type JobDetailFixture, + fulfillJson, + mockEngineStatus, + mockJobDetails, +} from "./helpers"; + +const completedDetail: JobDetailFixture = { + job: { + id: 51, + input_path: "/media/duplicates/movie-copy-1.mkv", + output_path: "/output/movie-copy-1-av1.mkv", + status: "completed", + priority: 0, + progress: 100, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-02T00:00:00Z", + vmaf_score: 95.1, + }, + metadata: { + duration_secs: 120, + codec_name: "hevc", + width: 1920, + height: 1080, + bit_depth: 10, + size_bytes: 2_000_000_000, + video_bitrate_bps: 12_000_000, + container_bitrate_bps: 12_500_000, + fps: 24, + container: "mkv", + audio_codec: "aac", + audio_channels: 2, + dynamic_range: "hdr10", + }, + encode_stats: { + input_size_bytes: 2_000_000_000, + output_size_bytes: 900_000_000, + compression_ratio: 0.55, + encode_time_seconds: 1800, + encode_speed: 1.6, + avg_bitrate_kbps: 6000, + vmaf_score: 95.1, + }, + job_logs: [], +}; + +test.use({ storageState: undefined }); + +test.beforeEach(async ({ page }) => { + await mockEngineStatus(page); +}); + +test("intelligence actions queue remux opportunities and review duplicate jobs", async ({ + page, +}) => { + let enqueueCount = 0; + + await page.route("**/api/library/intelligence", async (route) => { + await fulfillJson(route, 200, { + duplicate_groups: [ + { + stem: "movie-copy", + count: 2, + paths: [ + { id: 51, path: "/media/duplicates/movie-copy-1.mkv", status: "completed" }, + { id: 52, path: "/media/duplicates/movie-copy-2.mkv", status: "queued" }, + ], + }, + ], + total_duplicates: 1, + recommendation_counts: { + duplicates: 1, + remux_only_candidate: 2, + wasteful_audio_layout: 0, + commentary_cleanup_candidate: 0, + }, + recommendations: [ + { + type: "remux_only_candidate", + title: "Remux movie one", + summary: "The file can be normalized with a container-only remux.", + path: "/media/remux/movie-one.mkv", + suggested_action: "Queue a remux to normalize the container without re-encoding the video stream.", + }, + { + type: "remux_only_candidate", + title: "Remux movie two", + summary: "The file can be normalized with a container-only remux.", + path: "/media/remux/movie-two.mkv", + suggested_action: "Queue a remux to normalize the container without re-encoding the video stream.", + }, + ], + }); + }); + await page.route("**/api/jobs/enqueue", async (route) => { + enqueueCount += 1; + const body = route.request().postDataJSON() as { path: string }; + await fulfillJson(route, 200, { + enqueued: true, + message: `Enqueued ${body.path}.`, + }); + }); + await mockJobDetails(page, { 51: completedDetail }); + + await page.goto("/intelligence"); + + await page.getByRole("button", { name: "Queue all" }).click(); + await expect.poll(() => enqueueCount).toBe(2); + await expect( + page.getByText("Queue all finished: 2 enqueued, 0 skipped, 0 failed.").first(), + ).toBeVisible(); + + await page.getByRole("button", { name: "Review" }).first().click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByText("Encode Results")).toBeVisible(); + await expect(page.getByRole("dialog").getByText("/media/duplicates/movie-copy-1.mkv")).toBeVisible(); +}); diff --git a/web-e2e/tests/jobs-stability.spec.ts b/web-e2e/tests/jobs-stability.spec.ts index 15db529..1d96a84 100644 --- a/web-e2e/tests/jobs-stability.spec.ts +++ b/web-e2e/tests/jobs-stability.spec.ts @@ -19,6 +19,17 @@ const completedJob: JobFixture = { vmaf_score: 95.4, }; +const queuedJob: JobFixture = { + id: 44, + input_path: "/media/queued-blocked.mkv", + output_path: "/output/queued-blocked-av1.mkv", + status: "queued", + priority: 0, + progress: 0, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-02T00:00:00Z", +}; + const completedDetail: JobDetailFixture = { job: completedJob, metadata: { @@ -183,3 +194,57 @@ test("failed job detail prefers structured failure explanation", async ({ page } await expect(page.getByText("Structured failure detail from the backend.")).toBeVisible(); await expect(page.getByText("Structured failure guidance from the backend.")).toBeVisible(); }); + +test("queued job detail shows the processor blocked reason", async ({ page }) => { + await page.route("**/api/jobs/table**", async (route) => { + await fulfillJson(route, 200, [queuedJob]); + }); + await mockJobDetails(page, { + 44: { + job: queuedJob, + job_logs: [], + queue_position: 3, + }, + }); + await page.route("**/api/processor/status", async (route) => { + await fulfillJson(route, 200, { + blocked_reason: "workers_busy", + message: "All worker slots are currently busy.", + manual_paused: false, + scheduler_paused: false, + draining: false, + active_jobs: 1, + concurrent_limit: 1, + }); + }); + + await page.goto("/jobs"); + await page.getByTitle("/media/queued-blocked.mkv").click(); + + await expect(page.getByText("Queue position:")).toBeVisible(); + await expect(page.getByText("Blocked:")).toBeVisible(); + await expect(page.getByText("All worker slots are currently busy.")).toBeVisible(); +}); + +test("add file submits the enqueue request and surfaces the response", async ({ page }) => { + let postedPath = ""; + await page.route("**/api/jobs/table**", async (route) => { + await fulfillJson(route, 200, []); + }); + await page.route("**/api/jobs/enqueue", async (route) => { + const body = route.request().postDataJSON() as { path: string }; + postedPath = body.path; + await fulfillJson(route, 200, { + enqueued: true, + message: `Enqueued ${body.path}.`, + }); + }); + + await page.goto("/jobs"); + await page.getByRole("button", { name: "Add file" }).click(); + await page.getByPlaceholder("/Volumes/Media/Movies/example.mkv").fill("/media/manual-add.mkv"); + await page.getByRole("dialog").getByRole("button", { name: "Add File", exact: true }).click(); + + await expect.poll(() => postedPath).toBe("/media/manual-add.mkv"); + await expect(page.getByText("Enqueued /media/manual-add.mkv.").first()).toBeVisible(); +}); diff --git a/web/package.json b/web/package.json index 372be92..5798b8d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "alchemist-web", - "version": "0.3.1-rc.4", + "version": "0.3.1-rc.5", "private": true, "packageManager": "bun@1", "type": "module", diff --git a/web/src/components/JobManager.tsx b/web/src/components/JobManager.tsx index 557c05c..ba405c5 100644 --- a/web/src/components/JobManager.tsx +++ b/web/src/components/JobManager.tsx @@ -5,55 +5,16 @@ import { apiAction, apiJson, isApiError } from "../lib/api"; import { useDebouncedValue } from "../lib/useDebouncedValue"; import { showToast } from "../lib/toast"; import ConfirmDialog from "./ui/ConfirmDialog"; -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; import { withErrorBoundary } from "./ErrorBoundary"; -import type { Job, JobDetail, TabType, SortField, ConfirmConfig, CountMessageResponse } from "./jobs/types"; -import { SORT_OPTIONS, isJobActive, jobDetailEmptyState } from "./jobs/types"; -import { normalizeDecisionExplanation, normalizeFailureExplanation } from "./jobs/JobExplanations"; +import type { Job, TabType, SortField, CountMessageResponse } from "./jobs/types"; +import { isJobActive } from "./jobs/types"; import { useJobSSE } from "./jobs/useJobSSE"; import { JobsToolbar } from "./jobs/JobsToolbar"; import { JobsTable } from "./jobs/JobsTable"; import { JobDetailModal } from "./jobs/JobDetailModal"; - -function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -function focusableElements(root: HTMLElement): HTMLElement[] { - const selector = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - "[tabindex]:not([tabindex='-1'])", - ].join(","); - - return Array.from(root.querySelectorAll(selector)).filter( - (element) => !element.hasAttribute("disabled") - ); -} - -function getStatusBadge(status: string) { - const styles: Record = { - queued: "bg-helios-slate/10 text-helios-slate border-helios-slate/20", - analyzing: "bg-blue-500/10 text-blue-500 border-blue-500/20", - encoding: "bg-helios-solar/10 text-helios-solar border-helios-solar/20 animate-pulse", - remuxing: "bg-helios-solar/10 text-helios-solar border-helios-solar/20 animate-pulse", - completed: "bg-green-500/10 text-green-500 border-green-500/20", - failed: "bg-red-500/10 text-red-500 border-red-500/20", - cancelled: "bg-red-500/10 text-red-500 border-red-500/20", - skipped: "bg-gray-500/10 text-gray-500 border-gray-500/20", - archived: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", - resuming: "bg-helios-solar/10 text-helios-solar border-helios-solar/20 animate-pulse", - }; - return ( - - {status} - - ); -} +import { EnqueuePathDialog } from "./jobs/EnqueuePathDialog"; +import { getStatusBadge } from "./jobs/jobStatusBadge"; +import { useJobDetailController } from "./jobs/useJobDetailController"; function JobManager() { const [jobs, setJobs] = useState([]); @@ -67,18 +28,17 @@ function JobManager() { const [sortBy, setSortBy] = useState("updated_at"); const [sortDesc, setSortDesc] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [focusedJob, setFocusedJob] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); const [actionError, setActionError] = useState(null); const [menuJobId, setMenuJobId] = useState(null); + const [enqueueDialogOpen, setEnqueueDialogOpen] = useState(false); + const [enqueuePath, setEnqueuePath] = useState(""); + const [enqueueSubmitting, setEnqueueSubmitting] = useState(false); const menuRef = useRef(null); - const detailDialogRef = useRef(null); - const detailLastFocusedRef = useRef(null); const compactSearchRef = useRef(null); const compactSearchInputRef = useRef(null); - const confirmOpenRef = useRef(false); const encodeStartTimes = useRef>(new Map()); - const [confirmState, setConfirmState] = useState(null); + const focusedJobIdRef = useRef(null); + const refreshFocusedJobRef = useRef<() => Promise>(async () => undefined); const [tick, setTick] = useState(0); useEffect(() => { @@ -233,7 +193,51 @@ function JobManager() { }; }, []); - useJobSSE({ setJobs, setFocusedJob, fetchJobsRef, encodeStartTimes }); + const { + focusedJob, + setFocusedJob, + detailLoading, + confirmState, + detailDialogRef, + openJobDetails, + handleAction, + handlePriority, + openConfirm, + setConfirmState, + closeJobDetails, + focusedDecision, + focusedFailure, + focusedJobLogs, + shouldShowFfmpegOutput, + completedEncodeStats, + focusedEmptyState, + } = useJobDetailController({ + onRefresh: async () => { + await fetchJobs(); + }, + }); + + useEffect(() => { + focusedJobIdRef.current = focusedJob?.job.id ?? null; + }, [focusedJob?.job.id]); + + useEffect(() => { + refreshFocusedJobRef.current = async () => { + const jobId = focusedJobIdRef.current; + if (jobId !== null) { + await openJobDetails(jobId); + } + }; + }, [openJobDetails]); + + useJobSSE({ + setJobs, + setFocusedJob, + fetchJobsRef, + focusedJobIdRef, + refreshFocusedJobRef, + encodeStartTimes, + }); useEffect(() => { const encodingJobIds = new Set(); @@ -268,76 +272,6 @@ function JobManager() { return () => document.removeEventListener("mousedown", handleClick); }, [menuJobId]); - useEffect(() => { - confirmOpenRef.current = confirmState !== null; - }, [confirmState]); - - useEffect(() => { - if (!focusedJob) { - return; - } - - detailLastFocusedRef.current = document.activeElement as HTMLElement | null; - - const root = detailDialogRef.current; - if (root) { - const focusables = focusableElements(root); - if (focusables.length > 0) { - focusables[0].focus(); - } else { - root.focus(); - } - } - - const onKeyDown = (event: KeyboardEvent) => { - if (!focusedJob || confirmOpenRef.current) { - return; - } - - if (event.key === "Escape") { - event.preventDefault(); - setFocusedJob(null); - return; - } - - if (event.key !== "Tab") { - return; - } - - const dialogRoot = detailDialogRef.current; - if (!dialogRoot) { - return; - } - - const focusables = focusableElements(dialogRoot); - if (focusables.length === 0) { - event.preventDefault(); - dialogRoot.focus(); - return; - } - - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - const current = document.activeElement as HTMLElement | null; - - if (event.shiftKey && current === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && current === last) { - event.preventDefault(); - first.focus(); - } - }; - - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - if (detailLastFocusedRef.current) { - detailLastFocusedRef.current.focus(); - } - }; - }, [focusedJob]); - const toggleSelect = (id: number) => { const newSet = new Set(selected); if (newSet.has(id)) newSet.delete(id); @@ -407,96 +341,31 @@ function JobManager() { } }; - const fetchJobDetails = async (id: number) => { + const handleEnqueuePath = async () => { setActionError(null); - setDetailLoading(true); + setEnqueueSubmitting(true); try { - const data = await apiJson(`/api/jobs/${id}/details`); - setFocusedJob(data); - } catch (e) { - const message = isApiError(e) ? e.message : "Failed to fetch job details"; + const payload = await apiJson<{ enqueued: boolean; message: string }>("/api/jobs/enqueue", { + method: "POST", + body: JSON.stringify({ path: enqueuePath }), + }); + showToast({ + kind: payload.enqueued ? "success" : "info", + title: "Jobs", + message: payload.message, + }); + setEnqueueDialogOpen(false); + setEnqueuePath(""); + await fetchJobs(); + } catch (error) { + const message = isApiError(error) ? error.message : "Failed to enqueue file"; setActionError(message); showToast({ kind: "error", title: "Jobs", message }); } finally { - setDetailLoading(false); + setEnqueueSubmitting(false); } }; - const handleAction = async (id: number, action: "cancel" | "restart" | "delete") => { - setActionError(null); - try { - await apiAction(`/api/jobs/${id}/${action}`, { method: "POST" }); - if (action === "delete") { - setFocusedJob((current) => (current?.job.id === id ? null : current)); - } else if (focusedJob?.job.id === id) { - await fetchJobDetails(id); - } - await fetchJobs(); - showToast({ - kind: "success", - title: "Jobs", - message: `Job ${action} request completed.`, - }); - } catch (e) { - const message = formatJobActionError(e, `Job ${action} failed`); - setActionError(message); - showToast({ kind: "error", title: "Jobs", message }); - } - }; - - const handlePriority = async (job: Job, priority: number, label: string) => { - setActionError(null); - try { - await apiAction(`/api/jobs/${job.id}/priority`, { - method: "POST", - body: JSON.stringify({ priority }), - }); - if (focusedJob?.job.id === job.id) { - setFocusedJob({ - ...focusedJob, - job: { - ...focusedJob.job, - priority, - }, - }); - } - await fetchJobs(); - showToast({ kind: "success", title: "Jobs", message: `${label} for job #${job.id}.` }); - } catch (e) { - const message = formatJobActionError(e, "Failed to update priority"); - setActionError(message); - showToast({ kind: "error", title: "Jobs", message }); - } - }; - - const openConfirm = (config: ConfirmConfig) => { - setConfirmState(config); - }; - - const focusedDecision = focusedJob - ? normalizeDecisionExplanation( - focusedJob.decision_explanation ?? focusedJob.job.decision_explanation, - focusedJob.job.decision_reason, - ) - : null; - const focusedFailure = focusedJob - ? normalizeFailureExplanation( - focusedJob.failure_explanation, - focusedJob.job_failure_summary, - focusedJob.job_logs, - ) - : null; - const focusedJobLogs = focusedJob?.job_logs ?? []; - const shouldShowFfmpegOutput = focusedJob - ? ["failed", "completed", "skipped"].includes(focusedJob.job.status) && focusedJobLogs.length > 0 - : false; - const completedEncodeStats = focusedJob?.job.status === "completed" - ? focusedJob.encode_stats - : null; - const focusedEmptyState = focusedJob - ? jobDetailEmptyState(focusedJob.job.status) - : null; - return (
@@ -530,6 +399,7 @@ function JobManager() { setSortDesc={setSortDesc} refreshing={refreshing} fetchJobs={fetchJobs} + openEnqueueDialog={() => setEnqueueDialogOpen(true)} /> {actionError && ( @@ -613,7 +483,7 @@ function JobManager() { menuRef={menuRef} toggleSelect={toggleSelect} toggleSelectAll={toggleSelectAll} - fetchJobDetails={fetchJobDetails} + fetchJobDetails={openJobDetails} setMenuJobId={setMenuJobId} openConfirm={openConfirm} handleAction={handleAction} @@ -646,7 +516,7 @@ function JobManager() { focusedJob={focusedJob} detailDialogRef={detailDialogRef} detailLoading={detailLoading} - onClose={() => setFocusedJob(null)} + onClose={closeJobDetails} focusedDecision={focusedDecision} focusedFailure={focusedFailure} focusedJobLogs={focusedJobLogs} @@ -661,6 +531,22 @@ function JobManager() { document.body )} + {typeof document !== "undefined" && createPortal( + { + if (!enqueueSubmitting) { + setEnqueueDialogOpen(false); + } + }} + onSubmit={handleEnqueuePath} + />, + document.body, + )} + (null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [queueingRemux, setQueueingRemux] = useState(false); - useEffect(() => { - const fetch = async () => { - try { - const result = await apiJson("/api/library/intelligence"); - setData(result); - } catch (e) { - const message = isApiError(e) ? e.message : "Failed to load intelligence data."; - setError(message); - showToast({ - kind: "error", - title: "Intelligence", - message, - }); - } finally { - setLoading(false); - } - }; - - void fetch(); + const fetchIntelligence = useCallback(async () => { + try { + const result = await apiJson("/api/library/intelligence"); + setData(result); + setError(null); + } catch (e) { + const message = isApiError(e) ? e.message : "Failed to load intelligence data."; + setError(message); + showToast({ + kind: "error", + title: "Intelligence", + message, + }); + } finally { + setLoading(false); + } }, []); - const groupedRecommendations = data?.recommendations.reduce>( - (groups, recommendation) => { - groups[recommendation.type] ??= []; - groups[recommendation.type].push(recommendation); - return groups; - }, - {}, - ) ?? {}; + const { + focusedJob, + detailLoading, + confirmState, + detailDialogRef, + openJobDetails, + handleAction, + handlePriority, + openConfirm, + setConfirmState, + closeJobDetails, + focusedDecision, + focusedFailure, + focusedJobLogs, + shouldShowFfmpegOutput, + completedEncodeStats, + focusedEmptyState, + } = useJobDetailController({ + onRefresh: fetchIntelligence, + }); + + useEffect(() => { + void fetchIntelligence(); + }, [fetchIntelligence]); + + const groupedRecommendations = useMemo( + () => data?.recommendations.reduce>( + (groups, recommendation) => { + groups[recommendation.type] ??= []; + groups[recommendation.type].push(recommendation); + return groups; + }, + {}, + ) ?? {}, + [data], + ); + + const handleQueueAllRemux = async () => { + const remuxPaths = groupedRecommendations.remux_only_candidate ?? []; + if (remuxPaths.length === 0) { + return; + } + + setQueueingRemux(true); + let enqueued = 0; + let skipped = 0; + let failed = 0; + + for (const recommendation of remuxPaths) { + try { + const result = await apiJson<{ enqueued: boolean; message: string }>("/api/jobs/enqueue", { + method: "POST", + body: JSON.stringify({ path: recommendation.path }), + }); + if (result.enqueued) { + enqueued += 1; + } else { + skipped += 1; + } + } catch { + failed += 1; + } + } + + setQueueingRemux(false); + await fetchIntelligence(); + showToast({ + kind: failed > 0 ? "error" : "success", + title: "Intelligence", + message: `Queue all finished: ${enqueued} enqueued, ${skipped} skipped, ${failed} failed.`, + }); + }; return (
@@ -128,6 +195,16 @@ export default function LibraryIntelligence() {

{TYPE_LABELS[type] ?? type}

+ {type === "remux_only_candidate" && recommendations.length > 0 && ( + + )}
{recommendations.map((recommendation, index) => ( @@ -137,6 +214,28 @@ export default function LibraryIntelligence() {

{recommendation.title}

{recommendation.summary}

+ {type === "remux_only_candidate" && ( + + )}

{recommendation.path}

@@ -197,6 +296,13 @@ export default function LibraryIntelligence() { {path.path} + {path.status} @@ -209,6 +315,41 @@ export default function LibraryIntelligence() { )} )} + + {typeof document !== "undefined" && createPortal( + , + document.body, + )} + + setConfirmState(null)} + onConfirm={async () => { + if (!confirmState) { + return; + } + await confirmState.onConfirm(); + }} + />
); } diff --git a/web/src/components/SystemSettings.tsx b/web/src/components/SystemSettings.tsx index 3ce3c42..d6183e4 100644 --- a/web/src/components/SystemSettings.tsx +++ b/web/src/components/SystemSettings.tsx @@ -16,6 +16,7 @@ interface SystemSettingsPayload { } interface EngineStatus { + status: "running" | "paused" | "draining"; mode: "background" | "balanced" | "throughput"; concurrent_limit: number; is_manual_override: boolean; @@ -41,6 +42,7 @@ export default function SystemSettings() { const [engineStatus, setEngineStatus] = useState(null); const [modeLoading, setModeLoading] = useState(false); + const [engineActionLoading, setEngineActionLoading] = useState(false); useEffect(() => { void fetchSettings(); @@ -129,6 +131,32 @@ export default function SystemSettings() { } }; + const handleEngineAction = async (action: "pause" | "resume") => { + setEngineActionLoading(true); + try { + await apiAction(`/api/engine/${action === "pause" ? "pause" : "resume"}`, { + method: "POST", + }); + const updatedStatus = await apiJson("/api/engine/status"); + setEngineStatus(updatedStatus); + showToast({ + kind: "success", + title: "Engine", + message: action === "pause" ? "Engine paused." : "Engine resumed.", + }); + } catch (err) { + showToast({ + kind: "error", + title: "Engine", + message: isApiError(err) + ? err.message + : "Failed to update engine state.", + }); + } finally { + setEngineActionLoading(false); + } + }; + if (loading) { return
Loading system settings...
; } @@ -210,6 +238,25 @@ export default function SystemSettings() {

)}
+ +
+
+

+ Engine State +

+

+ {engineStatus.status} +

+
+ +
)} diff --git a/web/src/components/jobs/EnqueuePathDialog.tsx b/web/src/components/jobs/EnqueuePathDialog.tsx new file mode 100644 index 0000000..57147a2 --- /dev/null +++ b/web/src/components/jobs/EnqueuePathDialog.tsx @@ -0,0 +1,98 @@ +import type { FormEvent } from "react"; +import { X } from "lucide-react"; + +interface EnqueuePathDialogProps { + open: boolean; + path: string; + submitting: boolean; + onPathChange: (value: string) => void; + onClose: () => void; + onSubmit: () => Promise; +} + +export function EnqueuePathDialog({ + open, + path, + submitting, + onPathChange, + onClose, + onSubmit, +}: EnqueuePathDialogProps) { + if (!open) { + return null; + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + await onSubmit(); + }; + + return ( + <> +
+
+
void handleSubmit(event)} + role="dialog" + aria-modal="true" + aria-labelledby="enqueue-path-title" + className="w-full max-w-xl rounded-xl border border-helios-line/20 bg-helios-surface shadow-2xl" + > +
+
+

Add File

+

+ Enqueue one absolute filesystem path without running a full scan. +

+
+ +
+ +
+ + onPathChange(event.target.value)} + placeholder="/Volumes/Media/Movies/example.mkv" + className="w-full rounded-lg border border-helios-line/20 bg-helios-surface px-4 py-3 text-sm text-helios-ink outline-none focus:border-helios-solar" + autoFocus + /> +

+ Supported media files only. Paths are resolved on the server before enqueue. +

+
+ +
+ + +
+
+
+ + ); +} diff --git a/web/src/components/jobs/JobDetailModal.tsx b/web/src/components/jobs/JobDetailModal.tsx index 281fa9e..69c5efc 100644 --- a/web/src/components/jobs/JobDetailModal.tsx +++ b/web/src/components/jobs/JobDetailModal.tsx @@ -2,9 +2,10 @@ import { X, Clock, Info, Activity, Database, Zap, Maximize2, AlertCircle, Refres import { motion, AnimatePresence } from "framer-motion"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -import type { RefObject } from "react"; +import { useEffect, useState, type RefObject } from "react"; import type React from "react"; -import type { JobDetail, EncodeStats, ExplanationView, LogEntry, ConfirmConfig, Job } from "./types"; +import { apiJson } from "../../lib/api"; +import type { JobDetail, EncodeStats, ExplanationView, LogEntry, ConfirmConfig, Job, ProcessorStatus } from "./types"; import { formatBytes, formatDuration, logLevelClass, isJobActive } from "./types"; function cn(...inputs: ClassValue[]) { @@ -34,6 +35,32 @@ export function JobDetailModal({ completedEncodeStats, focusedEmptyState, openConfirm, handleAction, handlePriority, getStatusBadge, }: JobDetailModalProps) { + const [processorStatus, setProcessorStatus] = useState(null); + + useEffect(() => { + if (!focusedJob || focusedJob.job.status !== "queued") { + setProcessorStatus(null); + return; + } + + let cancelled = false; + void apiJson("/api/processor/status") + .then((status) => { + if (!cancelled) { + setProcessorStatus(status); + } + }) + .catch(() => { + if (!cancelled) { + setProcessorStatus(null); + } + }); + + return () => { + cancelled = true; + }; + }, [focusedJob]); + return ( {focusedJob && ( @@ -267,6 +294,11 @@ export function JobDetailModal({ Queue position: #{focusedJob.queue_position}

)} + {focusedJob.job.status === "queued" && processorStatus?.blocked_reason && ( +

+ Blocked: {processorStatus.message} +

+ )}
) : null} diff --git a/web/src/components/jobs/JobsToolbar.tsx b/web/src/components/jobs/JobsToolbar.tsx index 8f16274..8928c2a 100644 --- a/web/src/components/jobs/JobsToolbar.tsx +++ b/web/src/components/jobs/JobsToolbar.tsx @@ -1,4 +1,4 @@ -import { Search, RefreshCw, ArrowDown, ArrowUp } from "lucide-react"; +import { Search, RefreshCw, ArrowDown, ArrowUp, Plus } from "lucide-react"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import type { RefObject } from "react"; @@ -26,6 +26,7 @@ interface JobsToolbarProps { setSortDesc: (fn: boolean | ((prev: boolean) => boolean)) => void; refreshing: boolean; fetchJobs: () => Promise; + openEnqueueDialog: () => void; } export function JobsToolbar({ @@ -33,7 +34,7 @@ export function JobsToolbar({ searchInput, setSearchInput, compactSearchOpen, setCompactSearchOpen, compactSearchRef, compactSearchInputRef, sortBy, setSortBy, sortDesc, setSortDesc, - refreshing, fetchJobs, + refreshing, fetchJobs, openEnqueueDialog, }: JobsToolbarProps) { return (
@@ -94,6 +95,13 @@ export function JobsToolbar({
+