fix bug with set up causing unreachable set up page

This commit is contained in:
2026-04-08 11:39:36 -04:00
parent 4000640f85
commit f47e90c658
40 changed files with 1035 additions and 559 deletions

6
.idea/alchemist.iml generated
View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4"> <module type="EMPTY_MODULE" version="4">
<component name="FacetManager">
<facet type="Python" name="Python facet">
<configuration sdkName="Python 3.14 (alchemist)" />
</facet>
</component>
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
@@ -8,5 +13,6 @@
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Python 3.14 (alchemist) interpreter library" level="application" />
</component> </component>
</module> </module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

6
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.14 (alchemist)" />
</component>
</project>

View File

@@ -2,6 +2,32 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.3.1-rc.1] - 2026-04-08
### New Features
#### Conversion & Library Workflows
- **Experimental Conversion / Remux page** — upload a single file, inspect streams, preview the generated FFmpeg command, run a remux/transcode job through Alchemist, and download the result when complete.
- **Expanded Library Intelligence** — duplicate detection now sits alongside storage-focused recommendation sections for remux-only opportunities, wasteful audio layouts, and commentary/descriptive-track cleanup candidates.
#### Authentication & Automation
- **Named API tokens** — create bearer tokens from Settings with `read_only` or `full_access` access classes. Tokens are only shown once at creation time and stored server-side as hashes.
- **OpenAPI contract** — hand-maintained OpenAPI spec added alongside expanded human API docs for auth, token management, and update-check behavior.
#### Notifications
- **Provider-specific notification targets** — notification settings now use provider-specific configuration payloads instead of the old shared endpoint/token shape.
- **Provider expansion** — Discord webhook, Discord bot, Gotify, generic webhook, Telegram, and SMTP email targets are supported.
- **Richer event model** — notification events now distinguish queue/start/completion/failure plus scan completion, engine idle, and daily summary delivery.
- **Daily summary scheduling** — notifications include a global `daily_summary_time_local` setting and per-target opt-in for digest delivery.
#### Deployment & Distribution
- **Windows update check** — the About dialog now checks GitHub Releases for the latest stable version and links directly to the release download page when an update is available.
- **Distribution metadata generation** — in-repo Homebrew and AUR packaging templates plus workflow rendering were added as the foundation for package-manager distribution.
### Documentation
- **Config path clarity** — docs now consistently describe `~/.config/alchemist/config.toml` as the default host-side config location on Linux/macOS, while Docker examples still use `/app/config/config.toml` inside the container.
- **Backlog realignment** — the backlog was rewritten around current repo reality, marking large newly implemented surfaces as “Implemented / In Progress” and keeping the roadmap automation-first.
## [0.3.0] - 2026-04-06 ## [0.3.0] - 2026-04-06
### Security ### Security

View File

@@ -74,6 +74,7 @@ services:
``` ```
Then open [http://localhost:3000](http://localhost:3000) in your browser. Then open [http://localhost:3000](http://localhost:3000) in your browser.
First-time setup is only reachable from the local network.
On Linux and macOS, the default host-side config location is On Linux and macOS, the default host-side config location is
`~/.config/alchemist/config.toml`. When you use Docker, the `~/.config/alchemist/config.toml`. When you use Docker, the
@@ -132,10 +133,26 @@ just check
The core contributor path is supported on Windows. Broader release and utility recipes remain Unix-first. The core contributor path is supported on Windows. Broader release and utility recipes remain Unix-first.
## CLI
Alchemist exposes explicit CLI subcommands:
```bash
alchemist scan /path/to/media
alchemist run /path/to/media
alchemist plan /path/to/media
alchemist plan /path/to/media --json
```
- `scan` enqueues matching work and exits
- `run` scans, enqueues, and waits for processing to finish
- `plan` analyzes files and reports what Alchemist would do without enqueuing jobs
## First Run ## First Run
1. Open [http://localhost:3000](http://localhost:3000). 1. Open [http://localhost:3000](http://localhost:3000).
2. Complete the setup wizard. It takes about 2 minutes. 2. Complete the setup wizard. It takes about 2 minutes.
During first-time setup, the web UI is reachable only from the local network.
3. Add your media folders in Watch Folders. 3. Add your media folders in Watch Folders.
4. Alchemist scans and starts working automatically. 4. Alchemist scans and starts working automatically.
5. Check the Dashboard to see progress and savings. 5. Check the Dashboard to see progress and savings.
@@ -144,8 +161,6 @@ The core contributor path is supported on Windows. Broader release and utility r
- API automation can use bearer tokens created in **Settings → API Tokens**. - API automation can use bearer tokens created in **Settings → API Tokens**.
- Read-only tokens are limited to observability and monitoring routes. - Read-only tokens are limited to observability and monitoring routes.
- Alchemist can also be served under a subpath such as `/alchemist`
using `ALCHEMIST_BASE_URL=/alchemist`.
## Supported Platforms ## Supported Platforms

3
TODO.md Normal file
View File

@@ -0,0 +1,3 @@
# Todo List
Remove `src/wizard.rs` from the project, the web setup handles it.. maybe keep for CLI users?

View File

@@ -45,11 +45,6 @@ documentation, or iteration.
- Token management endpoints and Settings UI - Token management endpoints and Settings UI
- Hand-maintained OpenAPI contract plus human API docs - Hand-maintained OpenAPI contract plus human API docs
### Base URL / Subpath Support
- `ALCHEMIST_BASE_URL` and matching config support
- Router nesting under a configured path prefix
- Frontend fetches, redirects, navigation, and SSE path generation updated for subpaths
### Distribution Foundation ### Distribution Foundation
- In-repo distribution metadata sources for: - In-repo distribution metadata sources for:
- Homebrew - Homebrew

View File

@@ -9,8 +9,9 @@ except:
- `/api/auth/*` - `/api/auth/*`
- `/api/health` - `/api/health`
- `/api/ready` - `/api/ready`
- setup-mode exceptions: `/api/setup/*`, `/api/fs/*`, - during first-time setup, the setup UI and setup-related
`/api/settings/bundle`, `/api/system/hardware` unauthenticated routes are only reachable from the local
network
Authentication is established by `POST /api/auth/login`. Authentication is established by `POST /api/auth/login`.
The backend also accepts `Authorization: Bearer <token>`. The backend also accepts `Authorization: Bearer <token>`.

View File

@@ -3,6 +3,32 @@ title: Changelog
description: Release history for Alchemist. description: Release history for Alchemist.
--- ---
## [0.3.1-rc.1] - 2026-04-08
### New Features
#### Conversion & Library Workflows
- **Experimental Conversion / Remux page** — upload a single file, inspect streams, preview the generated FFmpeg command, run a remux/transcode job through Alchemist, and download the result when complete.
- **Expanded Library Intelligence** — duplicate detection now sits alongside storage-focused recommendation sections for remux-only opportunities, wasteful audio layouts, and commentary/descriptive-track cleanup candidates.
#### Authentication & Automation
- **Named API tokens** — create bearer tokens from Settings with `read_only` or `full_access` access classes. Tokens are only shown once at creation time and stored server-side as hashes.
- **OpenAPI contract** — hand-maintained OpenAPI spec added alongside expanded human API docs for auth, token management, and update-check behavior.
#### Notifications
- **Provider-specific notification targets** — notification settings now use provider-specific configuration payloads instead of the old shared endpoint/token shape.
- **Provider expansion** — Discord webhook, Discord bot, Gotify, generic webhook, Telegram, and SMTP email targets are supported.
- **Richer event model** — notification events now distinguish queue/start/completion/failure plus scan completion, engine idle, and daily summary delivery.
- **Daily summary scheduling** — notifications include a global `daily_summary_time_local` setting and per-target opt-in for digest delivery.
#### Deployment & Distribution
- **Windows update check** — the About dialog now checks GitHub Releases for the latest stable version and links directly to the release download page when an update is available.
- **Distribution metadata generation** — in-repo Homebrew and AUR packaging templates plus workflow rendering were added as the foundation for package-manager distribution.
### Documentation
- **Config path clarity** — docs now consistently describe `~/.config/alchemist/config.toml` as the default host-side config location on Linux/macOS, while Docker examples still use `/app/config/config.toml` inside the container.
- **Backlog realignment** — the backlog was rewritten around current repo reality, marking large newly implemented surfaces as “Implemented / In Progress” and keeping the roadmap automation-first.
## [0.3.0] - 2026-04-06 ## [0.3.0] - 2026-04-06
### Security ### Security

View File

@@ -97,7 +97,6 @@ requires at least one day in every window.
| `enable_telemetry` | bool | `false` | Opt-in anonymous telemetry switch | | `enable_telemetry` | bool | `false` | Opt-in anonymous telemetry switch |
| `log_retention_days` | int | `30` | Log retention period in days | | `log_retention_days` | int | `30` | Log retention period in days |
| `engine_mode` | string | `"balanced"` | Runtime engine mode: `background`, `balanced`, or `throughput` | | `engine_mode` | string | `"balanced"` | Runtime engine mode: `background`, `balanced`, or `throughput` |
| `base_url` | string | `""` | Path prefix for serving Alchemist under a subpath such as `/alchemist` |
## Example ## Example

View File

@@ -9,7 +9,6 @@ description: All environment variables Alchemist reads at startup.
| `ALCHEMIST_CONFIG` | (alias) | Alias for `ALCHEMIST_CONFIG_PATH` | | `ALCHEMIST_CONFIG` | (alias) | Alias for `ALCHEMIST_CONFIG_PATH` |
| `ALCHEMIST_DB_PATH` | `~/.config/alchemist/alchemist.db` | Path to SQLite database | | `ALCHEMIST_DB_PATH` | `~/.config/alchemist/alchemist.db` | Path to SQLite database |
| `ALCHEMIST_DATA_DIR` | (none) | Sets data dir; `alchemist.db` placed here | | `ALCHEMIST_DATA_DIR` | (none) | Sets data dir; `alchemist.db` placed here |
| `ALCHEMIST_BASE_URL` | root (`/`) | Path prefix for serving Alchemist under a subpath such as `/alchemist` |
| `ALCHEMIST_CONFIG_MUTABLE` | `true` | Set `false` to block runtime config writes | | `ALCHEMIST_CONFIG_MUTABLE` | `true` | Set `false` to block runtime config writes |
| `RUST_LOG` | `info` | Log level: `info`, `debug`, `alchemist=trace` | | `RUST_LOG` | `info` | Log level: `info`, `debug`, `alchemist=trace` |

View File

@@ -5,7 +5,8 @@ description: Getting through the setup wizard and starting your first scan.
When you first open Alchemist at `http://localhost:3000` When you first open Alchemist at `http://localhost:3000`
the setup wizard runs automatically. It takes about two the setup wizard runs automatically. It takes about two
minutes. minutes. Until the first account is created, setup is
reachable only from the local network.
## Wizard steps ## Wizard steps

View File

@@ -32,7 +32,8 @@ docker compose up -d
``` ```
Open [http://localhost:3000](http://localhost:3000). The Open [http://localhost:3000](http://localhost:3000). The
setup wizard runs on first visit. setup wizard runs on first visit and is only reachable
from the local network until the first account is created.
For GPU passthrough (NVIDIA, Intel, AMD) see For GPU passthrough (NVIDIA, Intel, AMD) see
[GPU Passthrough](/gpu-passthrough). [GPU Passthrough](/gpu-passthrough).
@@ -110,6 +111,19 @@ just dev
Windows contributor support covers the core `install/dev/check` path. Windows contributor support covers the core `install/dev/check` path.
Broader `just` release and utility recipes remain Unix-first. Broader `just` release and utility recipes remain Unix-first.
## CLI subcommands
```bash
alchemist scan /path/to/media
alchemist run /path/to/media
alchemist plan /path/to/media
alchemist plan /path/to/media --json
```
- `scan` enqueues matching jobs and exits
- `run` scans, enqueues, and waits for processing to finish
- `plan` reports what Alchemist would do without enqueueing jobs
## Nightly builds ## Nightly builds
```bash ```bash

View File

@@ -682,8 +682,6 @@ pub struct SystemConfig {
/// Enable HSTS header (only enable if running behind HTTPS) /// Enable HSTS header (only enable if running behind HTTPS)
#[serde(default)] #[serde(default)]
pub https_only: bool, pub https_only: bool,
#[serde(default)]
pub base_url: String,
} }
fn default_true() -> bool { fn default_true() -> bool {
@@ -710,7 +708,6 @@ impl Default for SystemConfig {
log_retention_days: default_log_retention_days(), log_retention_days: default_log_retention_days(),
engine_mode: EngineMode::default(), engine_mode: EngineMode::default(),
https_only: false, https_only: false,
base_url: String::new(),
} }
} }
} }
@@ -826,7 +823,6 @@ impl Default for Config {
log_retention_days: default_log_retention_days(), log_retention_days: default_log_retention_days(),
engine_mode: EngineMode::default(), engine_mode: EngineMode::default(),
https_only: false, https_only: false,
base_url: String::new(),
}, },
} }
} }
@@ -923,7 +919,6 @@ impl Config {
} }
validate_schedule_time(&self.notifications.daily_summary_time_local)?; validate_schedule_time(&self.notifications.daily_summary_time_local)?;
normalize_base_url(&self.system.base_url)?;
for target in &self.notifications.targets { for target in &self.notifications.targets {
target.validate()?; target.validate()?;
} }
@@ -1026,7 +1021,6 @@ impl Config {
} }
pub(crate) fn canonicalize_for_save(&mut self) { pub(crate) fn canonicalize_for_save(&mut self) {
self.system.base_url = normalize_base_url(&self.system.base_url).unwrap_or_default();
if !self.notifications.targets.is_empty() { if !self.notifications.targets.is_empty() {
self.notifications.webhook_url = None; self.notifications.webhook_url = None;
self.notifications.discord_webhook = None; self.notifications.discord_webhook = None;
@@ -1046,33 +1040,7 @@ impl Config {
} }
} }
pub(crate) fn apply_env_overrides(&mut self) { pub(crate) fn apply_env_overrides(&mut self) {}
if let Ok(base_url) = std::env::var("ALCHEMIST_BASE_URL") {
self.system.base_url = base_url;
}
self.system.base_url = normalize_base_url(&self.system.base_url).unwrap_or_default();
}
}
pub fn normalize_base_url(value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "/" {
return Ok(String::new());
}
if trimmed.contains("://") {
anyhow::bail!("system.base_url must be a path prefix, not a full URL");
}
if !trimmed.starts_with('/') {
anyhow::bail!("system.base_url must start with '/'");
}
if trimmed.contains('?') || trimmed.contains('#') {
anyhow::bail!("system.base_url must not contain query or fragment components");
}
let normalized = trimmed.trim_end_matches('/');
if normalized.contains("//") {
anyhow::bail!("system.base_url must not contain repeated slashes");
}
Ok(normalized.to_string())
} }
fn validate_schedule_time(value: &str) -> Result<()> { fn validate_schedule_time(value: &str) -> Result<()> {
@@ -1158,65 +1126,4 @@ mod tests {
assert_eq!(EngineMode::default(), EngineMode::Balanced); assert_eq!(EngineMode::default(), EngineMode::Balanced);
assert_eq!(EngineMode::Balanced.concurrent_jobs_for_cpu_count(8), 4); assert_eq!(EngineMode::Balanced.concurrent_jobs_for_cpu_count(8), 4);
} }
#[test]
fn normalize_base_url_accepts_root_or_empty() {
assert_eq!(
normalize_base_url("").unwrap_or_else(|err| panic!("empty base url: {err}")),
""
);
assert_eq!(
normalize_base_url("/").unwrap_or_else(|err| panic!("root base url: {err}")),
""
);
assert_eq!(
normalize_base_url("/alchemist/")
.unwrap_or_else(|err| panic!("trimmed base url: {err}")),
"/alchemist"
);
}
#[test]
fn normalize_base_url_rejects_invalid_values() {
assert!(normalize_base_url("alchemist").is_err());
assert!(normalize_base_url("https://example.com/alchemist").is_err());
assert!(normalize_base_url("/a//b").is_err());
}
#[test]
fn env_base_url_override_takes_priority_on_load() {
let config_path = std::env::temp_dir().join(format!(
"alchemist_base_url_override_{}.toml",
rand::random::<u64>()
));
std::fs::write(
&config_path,
r#"
[transcode]
size_reduction_threshold = 0.3
min_bpp_threshold = 0.1
min_file_size_mb = 50
concurrent_jobs = 1
[hardware]
preferred_vendor = "cpu"
allow_cpu_fallback = true
[scanner]
directories = []
[system]
base_url = "/from-config"
"#,
)
.unwrap_or_else(|err| panic!("failed to write temp config: {err}"));
// SAFETY: test-only environment mutation.
unsafe { std::env::set_var("ALCHEMIST_BASE_URL", "/from-env") };
let config =
Config::load(&config_path).unwrap_or_else(|err| panic!("failed to load config: {err}"));
assert_eq!(config.system.base_url, "/from-env");
unsafe { std::env::remove_var("ALCHEMIST_BASE_URL") };
let _ = std::fs::remove_file(config_path);
}
} }

View File

@@ -2,15 +2,18 @@
use alchemist::db::EventChannels; use alchemist::db::EventChannels;
use alchemist::error::Result; use alchemist::error::Result;
use alchemist::media::pipeline::{Analyzer as _, Planner as _};
use alchemist::system::hardware; use alchemist::system::hardware;
use alchemist::version; use alchemist::version;
use alchemist::{Agent, Transcoder, config, db, runtime}; use alchemist::{Agent, Transcoder, config, db, runtime};
use clap::Parser; use clap::{Parser, Subcommand};
use serde::Serialize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::time::time;
use notify::{RecursiveMode, Watcher}; use notify::{RecursiveMode, Watcher};
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -19,21 +22,55 @@ use tokio::sync::broadcast;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version = version::current(), about, long_about = None)] #[command(author, version = version::current(), about, long_about = None)]
struct Args { struct Args {
/// Run in CLI mode (process directories and exit)
#[arg(long)]
cli: bool,
/// Directories to scan for media files (CLI mode only)
#[arg(long, value_name = "DIR")]
directories: Vec<PathBuf>,
/// Dry run (don't actually transcode)
#[arg(short, long)]
dry_run: bool,
/// Reset admin user/password and sessions (forces setup mode) /// Reset admin user/password and sessions (forces setup mode)
#[arg(long)] #[arg(long)]
reset_auth: bool, reset_auth: bool,
/// Enable verbose terminal logging and default DEBUG filtering
#[arg(long)]
debug_flags: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug, Clone)]
enum Commands {
/// Scan directories and enqueue matching work, then exit
Scan {
#[arg(value_name = "DIR", required = true)]
directories: Vec<PathBuf>,
},
/// Scan directories, enqueue work, and wait for processing to finish
Run {
#[arg(value_name = "DIR", required = true)]
directories: Vec<PathBuf>,
/// Don't actually transcode
#[arg(short, long)]
dry_run: bool,
},
/// Analyze files and report what Alchemist would do without enqueuing jobs
Plan {
#[arg(value_name = "DIR", required = true)]
directories: Vec<PathBuf>,
/// Emit machine-readable JSON instead of human-readable text
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Serialize)]
struct CliPlanItem {
input_path: String,
output_path: Option<String>,
profile: Option<String>,
decision: String,
reason: String,
encoder: Option<String>,
backend: Option<String>,
rate_control: Option<String>,
fallback: Option<String>,
error: Option<String>,
} }
#[tokio::main] #[tokio::main]
@@ -160,76 +197,79 @@ fn should_enter_setup_mode_for_missing_users(is_server_mode: bool, has_users: bo
} }
async fn run() -> Result<()> { async fn run() -> Result<()> {
// Initialize logging let args = Args::parse();
tracing_subscriber::fmt() init_logging(args.debug_flags);
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) let is_server_mode = args.command.is_none();
.with_target(true)
.with_thread_ids(true)
.with_thread_names(true)
.init();
let boot_start = Instant::now(); let boot_start = Instant::now();
// Startup Banner
info!(
" ______ __ ______ __ __ ______ __ __ __ ______ ______ "
);
info!(
"/\\ __ \\ /\\ \\ /\\ ___\\ /\\ \\_\\ \\ /\\ ___\\ /\\ \"-./ \\ /\\ \\ /\\ ___\\ /\\__ _\\"
);
info!(
"\\ \\ __ \\ \\ \\ \\____ \\ \\ \\____ \\ \\ __ \\ \\ \\ __\\ \\ \\ \\-./\\ \\ \\ \\ \\ \\ \\___ \\ \\/_/\\ \\/"
);
info!(
" \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\_\\"
);
info!(
" \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_____/ \\/_/ \\/_/ \\/_/ \\/_____/ \\/_/"
);
info!("");
info!("");
let version = alchemist::version::current();
let build_info = option_env!("BUILD_INFO")
.or(option_env!("GIT_SHA"))
.or(option_env!("VERGEN_GIT_SHA"))
.unwrap_or("unknown");
info!("Version: {}", version);
info!("Build: {}", build_info);
info!("");
info!("System Information:");
info!(
" OS: {} ({})",
std::env::consts::OS,
std::env::consts::ARCH
);
info!(" CPUs: {}", num_cpus::get());
info!("");
let args = Args::parse();
info!( info!(
target: "startup", target: "startup",
"Parsed CLI args: cli_mode={}, reset_auth={}, dry_run={}, directories={}", "Parsed CLI args: command={:?}, reset_auth={}, debug_flags={}",
args.cli, args.command,
args.reset_auth, args.reset_auth,
args.dry_run, args.debug_flags
args.directories.len()
); );
// ... rest of logic remains largely the same, just inside run() if is_server_mode {
// Default to server mode unless CLI is explicitly requested. info!(
let is_server_mode = !args.cli; " ______ __ ______ __ __ ______ __ __ __ ______ ______ "
info!(target: "startup", "Resolved server mode: {}", is_server_mode); );
if is_server_mode && !args.directories.is_empty() { info!(
warn!("Directories were provided without --cli; ignoring CLI inputs."); "/\\ __ \\ /\\ \\ /\\ ___\\ /\\ \\_\\ \\ /\\ ___\\ /\\ \"-./ \\ /\\ \\ /\\ ___\\ /\\__ _\\"
);
info!(
"\\ \\ __ \\ \\ \\ \\____ \\ \\ \\____ \\ \\ __ \\ \\ \\ __\\ \\ \\ \\-./\\ \\ \\ \\ \\ \\ \\___ \\ \\/_/\\ \\/"
);
info!(
" \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\_\\"
);
info!(
" \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_____/ \\/_/ \\/_/ \\/_/ \\/_____/ \\/_/"
);
info!("");
info!("");
let version = alchemist::version::current();
let build_info = option_env!("BUILD_INFO")
.or(option_env!("GIT_SHA"))
.or(option_env!("VERGEN_GIT_SHA"))
.unwrap_or("unknown");
info!("Version: {}", version);
info!("Build: {}", build_info);
info!("");
info!("System Information:");
info!(
" OS: {} ({})",
std::env::consts::OS,
std::env::consts::ARCH
);
info!(" CPUs: {}", num_cpus::get());
info!("");
} }
info!(target: "startup", "Resolved server mode: {}", is_server_mode);
// 0. Load Configuration // 0. Load Configuration
let config_start = Instant::now(); let config_start = Instant::now();
let config_path = runtime::config_path(); let config_path = runtime::config_path();
let db_path = runtime::db_path(); let db_path = runtime::db_path();
let config_mutable = runtime::config_mutable(); let config_mutable = runtime::config_mutable();
let (config, mut setup_mode, config_exists) = let (config, mut setup_mode, config_exists) = if is_server_mode {
load_startup_config(config_path.as_path(), is_server_mode); load_startup_config(config_path.as_path(), true)
} else {
if !config_path.exists() {
error!(
"Configuration required. Run Alchemist in server mode to complete setup, or create {:?} manually.",
config_path
);
return Err(alchemist::error::AlchemistError::Config(
"Missing configuration".into(),
));
}
let config = config::Config::load(config_path.as_path())
.map_err(|err| alchemist::error::AlchemistError::Config(err.to_string()))?;
(config, false, true)
};
info!( info!(
target: "startup", target: "startup",
"Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms", "Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms",
@@ -371,9 +411,9 @@ async fn run() -> Result<()> {
warn!("Auth reset requested. All users and sessions cleared."); warn!("Auth reset requested. All users and sessions cleared.");
setup_mode = true; setup_mode = true;
} }
let has_users = db.has_users().await?;
if is_server_mode { if is_server_mode {
let users_start = Instant::now(); let users_start = Instant::now();
let has_users = db.has_users().await?;
info!( info!(
target: "startup", target: "startup",
"User check completed (has_users={}) in {} ms", "User check completed (has_users={}) in {} ms",
@@ -386,6 +426,13 @@ async fn run() -> Result<()> {
} }
setup_mode = true; setup_mode = true;
} }
} else if !has_users {
error!(
"Setup is not complete. Run Alchemist in server mode to finish creating the first account."
);
return Err(alchemist::error::AlchemistError::Config(
"Setup incomplete".into(),
));
} }
if !setup_mode { if !setup_mode {
@@ -518,7 +565,7 @@ async fn run() -> Result<()> {
hardware_state.clone(), hardware_state.clone(),
tx.clone(), tx.clone(),
event_channels.clone(), event_channels.clone(),
args.dry_run, matches!(args.command, Some(Commands::Run { dry_run: true, .. })),
) )
.await, .await,
); );
@@ -748,56 +795,307 @@ async fn run() -> Result<()> {
} }
} }
} else { } else {
// CLI Mode match args
if setup_mode { .command
error!( .clone()
"Configuration required. Run without --cli to use the web-based setup wizard, or create {:?} manually.", .expect("CLI branch requires a subcommand")
config_path {
); Commands::Scan { directories } => {
agent.scan_and_enqueue(directories).await?;
// CLI early exit - error info!("Scan complete. Matching files were enqueued.");
// (Caller will handle pause-on-exit if needed) }
return Err(alchemist::error::AlchemistError::Config( Commands::Run { directories, .. } => {
"Missing configuration".into(), agent.scan_and_enqueue(directories).await?;
)); wait_for_cli_jobs(db.as_ref()).await?;
} info!("All jobs processed.");
}
if args.directories.is_empty() { Commands::Plan { directories, json } => {
error!("No directories provided. Usage: alchemist --cli --dir <DIR> [--dir <DIR> ...]"); let items =
return Err(alchemist::error::AlchemistError::Config( build_cli_plan(db.as_ref(), config.clone(), &hardware_state, directories)
"Missing directories for CLI mode".into(), .await?;
)); if json {
} println!(
agent.scan_and_enqueue(args.directories).await?; "{}",
serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string())
// Wait until all jobs are processed );
info!("Waiting for jobs to complete..."); } else {
loop { print_cli_plan(&items);
let stats = db.get_stats().await?; }
let active = stats
.as_object()
.map(|m| {
m.iter()
.filter(|(k, _)| {
["encoding", "analyzing", "remuxing", "resuming"].contains(&k.as_str())
})
.map(|(_, v)| v.as_i64().unwrap_or(0))
.sum::<i64>()
})
.unwrap_or(0);
let queued = stats.get("queued").and_then(|v| v.as_i64()).unwrap_or(0);
if active + queued == 0 {
break;
} }
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
} }
info!("All jobs processed.");
} }
Ok(()) Ok(())
} }
async fn wait_for_cli_jobs(db: &db::Db) -> Result<()> {
info!("Waiting for jobs to complete...");
loop {
let stats = db.get_stats().await?;
let active = stats
.as_object()
.map(|m| {
m.iter()
.filter(|(k, _)| {
["encoding", "analyzing", "remuxing", "resuming"].contains(&k.as_str())
})
.map(|(_, v)| v.as_i64().unwrap_or(0))
.sum::<i64>()
})
.unwrap_or(0);
let queued = stats.get("queued").and_then(|v| v.as_i64()).unwrap_or(0);
if active + queued == 0 {
break;
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
Ok(())
}
async fn build_cli_plan(
db: &db::Db,
config_state: Arc<RwLock<config::Config>>,
hardware_state: &hardware::HardwareState,
directories: Vec<PathBuf>,
) -> Result<Vec<CliPlanItem>> {
let files = tokio::task::spawn_blocking(move || {
let scanner = alchemist::media::scanner::Scanner::new();
scanner.scan(directories)
})
.await
.map_err(|err| alchemist::error::AlchemistError::Unknown(format!("scan task failed: {err}")))?;
let file_settings = match db.get_file_settings().await {
Ok(settings) => settings,
Err(err) => {
error!("Failed to fetch file settings, using defaults: {}", err);
alchemist::media::pipeline::default_file_settings()
}
};
let config_snapshot = Arc::new(config_state.read().await.clone());
let hw_info = hardware_state.snapshot().await;
let planner = alchemist::media::planner::BasicPlanner::new(config_snapshot, hw_info);
let analyzer = alchemist::media::analyzer::FfmpegAnalyzer;
let mut items = Vec::new();
for discovered in files {
let input_path = discovered.path.clone();
let input_path_string = input_path.display().to_string();
if let Some(reason) = alchemist::media::pipeline::skip_reason_for_discovered_path(
db,
&input_path,
&file_settings,
)
.await?
{
items.push(CliPlanItem {
input_path: input_path_string,
output_path: None,
profile: None,
decision: "skip".to_string(),
reason: reason.to_string(),
encoder: None,
backend: None,
rate_control: None,
fallback: None,
error: None,
});
continue;
}
let output_path =
file_settings.output_path_for_source(&input_path, discovered.source_root.as_deref());
if output_path.exists() && !file_settings.should_replace_existing_output() {
items.push(CliPlanItem {
input_path: input_path_string,
output_path: Some(output_path.display().to_string()),
profile: None,
decision: "skip".to_string(),
reason: "output exists and replace strategy is keep".to_string(),
encoder: None,
backend: None,
rate_control: None,
fallback: None,
error: None,
});
continue;
}
let analysis = match analyzer.analyze(&input_path).await {
Ok(analysis) => analysis,
Err(err) => {
items.push(CliPlanItem {
input_path: input_path_string,
output_path: Some(output_path.display().to_string()),
profile: None,
decision: "error".to_string(),
reason: "analysis failed".to_string(),
encoder: None,
backend: None,
rate_control: None,
fallback: None,
error: Some(err.to_string()),
});
continue;
}
};
let profile = match db.get_profile_for_path(&input_path.to_string_lossy()).await {
Ok(profile) => profile,
Err(err) => {
items.push(CliPlanItem {
input_path: input_path_string,
output_path: Some(output_path.display().to_string()),
profile: None,
decision: "error".to_string(),
reason: "profile resolution failed".to_string(),
encoder: None,
backend: None,
rate_control: None,
fallback: None,
error: Some(err.to_string()),
});
continue;
}
};
let plan = match planner
.plan(&analysis, &output_path, profile.as_ref())
.await
{
Ok(plan) => plan,
Err(err) => {
items.push(CliPlanItem {
input_path: input_path_string,
output_path: Some(output_path.display().to_string()),
profile: profile.as_ref().map(|p| p.name.clone()),
decision: "error".to_string(),
reason: "planning failed".to_string(),
encoder: None,
backend: None,
rate_control: None,
fallback: None,
error: Some(err.to_string()),
});
continue;
}
};
let (decision, reason) = match &plan.decision {
alchemist::media::pipeline::TranscodeDecision::Skip { reason } => {
("skip".to_string(), reason.clone())
}
alchemist::media::pipeline::TranscodeDecision::Remux { reason } => {
("remux".to_string(), reason.clone())
}
alchemist::media::pipeline::TranscodeDecision::Transcode { reason } => {
("transcode".to_string(), reason.clone())
}
};
items.push(CliPlanItem {
input_path: input_path_string,
output_path: Some(output_path.display().to_string()),
profile: profile.as_ref().map(|p| p.name.clone()),
decision,
reason,
encoder: plan
.encoder
.map(|encoder| encoder.ffmpeg_encoder_name().to_string()),
backend: plan.backend.map(|backend| backend.as_str().to_string()),
rate_control: plan.rate_control.as_ref().map(format_rate_control),
fallback: plan
.fallback
.as_ref()
.map(|fallback| fallback.reason.clone()),
error: None,
});
}
Ok(items)
}
fn format_rate_control(rate_control: &alchemist::media::pipeline::RateControl) -> String {
match rate_control {
alchemist::media::pipeline::RateControl::Crf { value } => format!("crf:{value}"),
alchemist::media::pipeline::RateControl::Cq { value } => format!("cq:{value}"),
alchemist::media::pipeline::RateControl::QsvQuality { value } => {
format!("qsv_quality:{value}")
}
alchemist::media::pipeline::RateControl::Bitrate { kbps } => format!("bitrate:{kbps}k"),
}
}
fn print_cli_plan(items: &[CliPlanItem]) {
for item in items {
println!("{}", item.input_path);
println!(" decision: {}{}", item.decision, item.reason);
if let Some(output_path) = &item.output_path {
println!(" output: {}", output_path);
}
if let Some(profile) = &item.profile {
println!(" profile: {}", profile);
}
if let Some(encoder) = &item.encoder {
let backend = item.backend.as_deref().unwrap_or("unknown");
println!(" encoder: {} ({})", encoder, backend);
}
if let Some(rate_control) = &item.rate_control {
println!(" rate: {}", rate_control);
}
if let Some(fallback) = &item.fallback {
println!(" fallback: {}", fallback);
}
if let Some(error) = &item.error {
println!(" error: {}", error);
}
println!();
}
}
fn init_logging(debug_flags: bool) {
let default_level = if debug_flags {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
};
let env_filter = EnvFilter::from_default_env().add_directive(default_level.into());
if debug_flags {
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_timer(time())
.init();
} else {
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.without_time()
.with_target(false)
.with_thread_ids(false)
.with_thread_names(false)
.compact()
.init();
}
}
#[cfg(test)]
mod logging_tests {
use super::*;
use clap::Parser;
#[test]
fn debug_flags_arg_parses() {
let args = Args::try_parse_from(["alchemist", "--debug-flags"])
.unwrap_or_else(|err| panic!("failed to parse debug flag: {err}"));
assert!(args.debug_flags);
}
}
#[cfg(test)] #[cfg(test)]
mod version_cli_tests { mod version_cli_tests {
use super::*; use super::*;
@@ -836,6 +1134,41 @@ mod tests {
assert!(Args::try_parse_from(["alchemist", "--output-dir", "/tmp/out"]).is_err()); assert!(Args::try_parse_from(["alchemist", "--output-dir", "/tmp/out"]).is_err());
} }
#[test]
fn args_reject_removed_cli_flag() {
assert!(Args::try_parse_from(["alchemist", "--cli"]).is_err());
}
#[test]
fn scan_subcommand_parses() {
let args = Args::try_parse_from(["alchemist", "scan", "/tmp/media"])
.unwrap_or_else(|err| panic!("failed to parse scan subcommand: {err}"));
assert!(matches!(
args.command,
Some(Commands::Scan { directories }) if directories == vec![PathBuf::from("/tmp/media")]
));
}
#[test]
fn run_subcommand_parses_with_dry_run() {
let args = Args::try_parse_from(["alchemist", "run", "/tmp/media", "--dry-run"])
.unwrap_or_else(|err| panic!("failed to parse run subcommand: {err}"));
assert!(matches!(
args.command,
Some(Commands::Run { directories, dry_run }) if directories == vec![PathBuf::from("/tmp/media")] && dry_run
));
}
#[test]
fn plan_subcommand_parses_with_json() {
let args = Args::try_parse_from(["alchemist", "plan", "/tmp/media", "--json"])
.unwrap_or_else(|err| panic!("failed to parse plan subcommand: {err}"));
assert!(matches!(
args.command,
Some(Commands::Plan { directories, json }) if directories == vec![PathBuf::from("/tmp/media")] && json
));
}
#[test] #[test]
fn config_reload_matches_create_modify_and_rename_events() { fn config_reload_matches_create_modify_and_rename_events() {
let config_path = PathBuf::from("/tmp/alchemist-config.toml"); let config_path = PathBuf::from("/tmp/alchemist-config.toml");

View File

@@ -6,6 +6,7 @@ pub fn append_args(
encoder: Encoder, encoder: Encoder,
rate_control: Option<RateControl>, rate_control: Option<RateControl>,
preset: Option<&str>, preset: Option<&str>,
tag_hevc_as_hvc1: bool,
) { ) {
match encoder { match encoder {
Encoder::Av1Svt => { Encoder::Av1Svt => {
@@ -48,9 +49,10 @@ pub fn append_args(
preset.unwrap_or(CpuPreset::Medium.as_str()).to_string(), preset.unwrap_or(CpuPreset::Medium.as_str()).to_string(),
"-crf".to_string(), "-crf".to_string(),
crf, crf,
"-tag:v".to_string(),
"hvc1".to_string(),
]); ]);
if tag_hevc_as_hvc1 {
args.extend(["-tag:v".to_string(), "hvc1".to_string()]);
}
} }
Encoder::H264X264 => { Encoder::H264X264 => {
let crf = match rate_control { let crf = match rate_control {

View File

@@ -182,6 +182,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
} }
let rate_control = self.plan.rate_control.clone(); let rate_control = self.plan.rate_control.clone();
let tag_hevc_as_hvc1 = uses_quicktime_container(&self.plan.container);
let mut args = vec![ let mut args = vec![
"-hide_banner".to_string(), "-hide_banner".to_string(),
"-y".to_string(), "-y".to_string(),
@@ -249,12 +250,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
Encoder::Av1Videotoolbox Encoder::Av1Videotoolbox
| Encoder::HevcVideotoolbox | Encoder::HevcVideotoolbox
| Encoder::H264Videotoolbox => { | Encoder::H264Videotoolbox => {
videotoolbox::append_args( videotoolbox::append_args(&mut args, encoder, tag_hevc_as_hvc1);
&mut args,
encoder,
rate_control.clone(),
default_quality(&self.plan.rate_control, 65),
);
} }
Encoder::Av1Svt | Encoder::Av1Aom | Encoder::HevcX265 | Encoder::H264X264 => { Encoder::Av1Svt | Encoder::Av1Aom | Encoder::HevcX265 | Encoder::H264X264 => {
cpu::append_args( cpu::append_args(
@@ -262,6 +258,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
encoder, encoder,
rate_control.clone(), rate_control.clone(),
self.plan.encoder_preset.as_deref(), self.plan.encoder_preset.as_deref(),
tag_hevc_as_hvc1,
); );
} }
} }
@@ -285,7 +282,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
apply_subtitle_plan(&mut args, &self.plan.subtitles); apply_subtitle_plan(&mut args, &self.plan.subtitles);
apply_color_metadata(&mut args, self.metadata, &self.plan.filters); apply_color_metadata(&mut args, self.metadata, &self.plan.filters);
if matches!(self.plan.container.as_str(), "mp4" | "m4v" | "mov") { if uses_quicktime_container(&self.plan.container) {
args.push("-movflags".to_string()); args.push("-movflags".to_string());
args.push("+faststart".to_string()); args.push("+faststart".to_string());
} }
@@ -483,6 +480,10 @@ fn output_format_name(container: &str) -> &str {
} }
} }
fn uses_quicktime_container(container: &str) -> bool {
matches!(container, "mp4" | "m4v" | "mov")
}
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct FFmpegProgress { pub struct FFmpegProgress {
pub frame: u64, pub frame: u64,
@@ -1041,6 +1042,83 @@ mod tests {
.build_args() .build_args()
.unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}")); .unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}"));
assert!(args.contains(&"hevc_videotoolbox".to_string())); assert!(args.contains(&"hevc_videotoolbox".to_string()));
assert!(!args.contains(&"hvc1".to_string()));
assert!(!args.contains(&"-q:v".to_string()));
assert!(!args.contains(&"-b:v".to_string()));
}
#[test]
fn hevc_videotoolbox_mp4_adds_hvc1_tag() {
let metadata = metadata();
let mut plan = plan_for(Encoder::HevcVideotoolbox);
plan.container = "mp4".to_string();
let builder = FFmpegCommandBuilder::new(
Path::new("/tmp/in.mkv"),
Path::new("/tmp/out.mp4"),
&metadata,
&plan,
);
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build mp4 videotoolbox args: {err}"));
assert!(args.contains(&"hevc_videotoolbox".to_string()));
assert!(args.contains(&"hvc1".to_string()));
assert!(!args.contains(&"-q:v".to_string()));
}
#[test]
fn hevc_videotoolbox_bitrate_mode_uses_generic_bitrate_flag() {
let metadata = metadata();
let mut plan = plan_for(Encoder::HevcVideotoolbox);
plan.rate_control = Some(RateControl::Bitrate { kbps: 2500 });
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 bitrate videotoolbox args: {err}"));
assert!(args.contains(&"hevc_videotoolbox".to_string()));
assert!(args.contains(&"-b:v".to_string()));
assert!(args.contains(&"2500k".to_string()));
assert!(!args.contains(&"-q:v".to_string()));
}
#[test]
fn hevc_x265_mkv_does_not_add_hvc1_tag() {
let metadata = metadata();
let plan = plan_for(Encoder::HevcX265);
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 mkv x265 args: {err}"));
assert!(args.contains(&"libx265".to_string()));
assert!(!args.contains(&"hvc1".to_string()));
}
#[test]
fn hevc_x265_mp4_adds_hvc1_tag() {
let metadata = metadata();
let mut plan = plan_for(Encoder::HevcX265);
plan.container = "mp4".to_string();
let builder = FFmpegCommandBuilder::new(
Path::new("/tmp/in.mkv"),
Path::new("/tmp/out.mp4"),
&metadata,
&plan,
);
let args = builder
.build_args()
.unwrap_or_else(|err| panic!("failed to build mp4 x265 args: {err}"));
assert!(args.contains(&"libx265".to_string()));
assert!(args.contains(&"hvc1".to_string()));
} }
#[test] #[test]

View File

@@ -1,51 +1,29 @@
use crate::media::pipeline::{Encoder, RateControl}; use crate::media::pipeline::Encoder;
pub fn append_args(
args: &mut Vec<String>,
encoder: Encoder,
rate_control: Option<RateControl>,
default_quality: u8,
) {
let cq = match rate_control {
Some(RateControl::Cq { value }) => value,
_ => default_quality,
};
pub fn append_args(args: &mut Vec<String>, encoder: Encoder, tag_hevc_as_hvc1: bool) {
// Current FFmpeg VideoToolbox encoders on macOS do not expose qscale-style
// quality controls, so bitrate mode is handled by the shared builder and
// CQ-style requests intentionally fall back to the encoder defaults.
match encoder { match encoder {
Encoder::Av1Videotoolbox => { Encoder::Av1Videotoolbox => {
args.extend([ args.extend([
"-c:v".to_string(), "-c:v".to_string(),
"av1_videotoolbox".to_string(), "av1_videotoolbox".to_string(),
"-b:v".to_string(),
"0".to_string(),
"-q:v".to_string(),
cq.to_string(),
"-allow_sw".to_string(), "-allow_sw".to_string(),
"1".to_string(), "1".to_string(),
]); ]);
} }
Encoder::HevcVideotoolbox => { Encoder::HevcVideotoolbox => {
args.extend([ args.extend(["-c:v".to_string(), "hevc_videotoolbox".to_string()]);
"-c:v".to_string(), if tag_hevc_as_hvc1 {
"hevc_videotoolbox".to_string(), args.extend(["-tag:v".to_string(), "hvc1".to_string()]);
"-b:v".to_string(), }
"0".to_string(), args.extend(["-allow_sw".to_string(), "1".to_string()]);
"-q:v".to_string(),
cq.to_string(),
"-tag:v".to_string(),
"hvc1".to_string(),
"-allow_sw".to_string(),
"1".to_string(),
]);
} }
Encoder::H264Videotoolbox => { Encoder::H264Videotoolbox => {
args.extend([ args.extend([
"-c:v".to_string(), "-c:v".to_string(),
"h264_videotoolbox".to_string(), "h264_videotoolbox".to_string(),
"-b:v".to_string(),
"0".to_string(),
"-q:v".to_string(),
cq.to_string(),
"-allow_sw".to_string(), "-allow_sw".to_string(),
"1".to_string(), "1".to_string(),
]); ]);

View File

@@ -514,7 +514,7 @@ pub async fn enqueue_discovered_with_db(
.await .await
} }
fn default_file_settings() -> crate::db::FileSettings { pub fn default_file_settings() -> crate::db::FileSettings {
crate::db::FileSettings { crate::db::FileSettings {
id: 1, id: 1,
delete_source: false, delete_source: false,
@@ -525,7 +525,10 @@ fn default_file_settings() -> crate::db::FileSettings {
} }
} }
fn matches_generated_output_pattern(path: &Path, settings: &crate::db::FileSettings) -> bool { pub(crate) fn matches_generated_output_pattern(
path: &Path,
settings: &crate::db::FileSettings,
) -> bool {
let expected_extension = settings.output_extension.trim_start_matches('.'); let expected_extension = settings.output_extension.trim_start_matches('.');
if !expected_extension.is_empty() { if !expected_extension.is_empty() {
let actual_extension = match path.extension().and_then(|extension| extension.to_str()) { let actual_extension = match path.extension().and_then(|extension| extension.to_str()) {
@@ -548,7 +551,7 @@ fn matches_generated_output_pattern(path: &Path, settings: &crate::db::FileSetti
.is_some_and(|stem| stem.ends_with(suffix)) .is_some_and(|stem| stem.ends_with(suffix))
} }
async fn skip_reason_for_discovered_path( pub async fn skip_reason_for_discovered_path(
db: &crate::db::Db, db: &crate::db::Db,
path: &Path, path: &Path,
settings: &crate::db::FileSettings, settings: &crate::db::FileSettings,

View File

@@ -170,9 +170,9 @@ impl Agent {
pub fn set_boot_analyzing(&self, value: bool) { pub fn set_boot_analyzing(&self, value: bool) {
self.analyzing_boot.store(value, Ordering::SeqCst); self.analyzing_boot.store(value, Ordering::SeqCst);
if value { if value {
info!("Boot analysis started — engine claim loop paused."); debug!("Boot analysis started — engine claim loop paused.");
} else { } else {
info!("Boot analysis complete — engine claim loop resumed."); debug!("Boot analysis complete — engine claim loop resumed.");
} }
} }
@@ -218,7 +218,7 @@ impl Agent {
/// semaphore permit. /// semaphore permit.
async fn _run_analysis_pass(&self) { async fn _run_analysis_pass(&self) {
self.set_boot_analyzing(true); self.set_boot_analyzing(true);
info!("Auto-analysis: starting pass..."); debug!("Auto-analysis: starting pass...");
// NOTE: reset_interrupted_jobs is intentionally // NOTE: reset_interrupted_jobs is intentionally
// NOT called here. It is a one-time startup // NOT called here. It is a one-time startup
@@ -244,7 +244,7 @@ impl Agent {
} }
let batch_len = batch.len(); let batch_len = batch.len();
info!("Auto-analysis: analyzing {} job(s)...", batch_len); debug!("Auto-analysis: analyzing {} job(s)...", batch_len);
for job in batch { for job in batch {
let pipeline = self.pipeline(); let pipeline = self.pipeline();
@@ -264,9 +264,9 @@ impl Agent {
self.set_boot_analyzing(false); self.set_boot_analyzing(false);
if total_analyzed == 0 { if total_analyzed == 0 {
info!("Auto-analysis: no jobs pending analysis."); debug!("Auto-analysis: no jobs pending analysis.");
} else { } else {
info!( debug!(
"Auto-analysis: complete. {} job(s) analyzed.", "Auto-analysis: complete. {} job(s) analyzed.",
total_analyzed total_analyzed
); );
@@ -359,7 +359,7 @@ impl Agent {
} }
pub async fn run_loop(self: Arc<Self>) { pub async fn run_loop(self: Arc<Self>) {
info!("Agent loop started."); debug!("Agent loop started.");
loop { loop {
// Block while paused OR while boot analysis runs // Block while paused OR while boot analysis runs
if self.is_paused() || self.is_boot_analyzing() { if self.is_paused() || self.is_boot_analyzing() {

View File

@@ -2,7 +2,7 @@ use rayon::prelude::*;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
use tracing::{debug, error, info}; use tracing::{debug, error};
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::media::pipeline::DiscoveredMedia; use crate::media::pipeline::DiscoveredMedia;
@@ -45,7 +45,7 @@ impl Scanner {
); );
directories.into_par_iter().for_each(|(dir, recursive)| { directories.into_par_iter().for_each(|(dir, recursive)| {
info!("Scanning directory: {:?} (recursive: {})", dir, recursive); debug!("Scanning directory: {:?} (recursive: {})", dir, recursive);
let mut local_files = Vec::new(); let mut local_files = Vec::new();
let source_roots = source_roots.clone(); let source_roots = source_roots.clone();
let walker = if recursive { let walker = if recursive {
@@ -90,7 +90,6 @@ impl Scanner {
// Deterministic ordering // Deterministic ordering
final_files.sort_by(|a, b| a.path.cmp(&b.path)); final_files.sort_by(|a, b| a.path.cmp(&b.path));
info!("Found {} candidate media files", final_files.len());
final_files final_files
} }
} }

View File

@@ -76,6 +76,18 @@ pub(crate) async fn auth_middleware(
let path = req.uri().path(); let path = req.uri().path();
let method = req.method().clone(); let method = req.method().clone();
if state.setup_required.load(Ordering::Relaxed)
&& path != "/api/health"
&& path != "/api/ready"
&& !request_is_lan(&req)
{
return (
StatusCode::FORBIDDEN,
"Alchemist setup is only available from the local network",
)
.into_response();
}
// 1. API Protection: Only lock down /api routes // 1. API Protection: Only lock down /api routes
if path.starts_with("/api") { if path.starts_with("/api") {
// Public API endpoints // Public API endpoints
@@ -92,28 +104,7 @@ pub(crate) async fn auth_middleware(
return next.run(req).await; return next.run(req).await;
} }
if state.setup_required.load(Ordering::Relaxed) && path.starts_with("/api/fs/") { if state.setup_required.load(Ordering::Relaxed) && path.starts_with("/api/fs/") {
// Only allow filesystem browsing from localhost return next.run(req).await;
// during setup — no account exists yet so we
// cannot authenticate the caller.
let connect_info = req.extensions().get::<ConnectInfo<SocketAddr>>();
let is_local = connect_info
.map(|ci| {
let ip = ci.0.ip();
ip.is_loopback()
})
.unwrap_or(false);
if is_local {
return next.run(req).await;
}
// Non-local request during setup -> 403
return Response::builder()
.status(StatusCode::FORBIDDEN)
.body(axum::body::Body::from(
"Filesystem browsing is only available \
from localhost during setup",
))
.unwrap_or_else(|_| StatusCode::FORBIDDEN.into_response());
} }
if state.setup_required.load(Ordering::Relaxed) && path == "/api/settings/bundle" { if state.setup_required.load(Ordering::Relaxed) && path == "/api/settings/bundle" {
return next.run(req).await; return next.run(req).await;
@@ -157,6 +148,10 @@ pub(crate) async fn auth_middleware(
next.run(req).await next.run(req).await
} }
fn request_is_lan(req: &Request) -> bool {
request_ip(req).is_some_and(is_lan_ip)
}
fn read_only_api_token_allows(method: &Method, path: &str) -> bool { fn read_only_api_token_allows(method: &Method, path: &str) -> bool {
if *method != Method::GET && *method != Method::HEAD { if *method != Method::GET && *method != Method::HEAD {
return false; return false;
@@ -314,3 +309,10 @@ fn is_trusted_peer(ip: IpAddr) -> bool {
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(), IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(),
} }
} }
fn is_lan_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(),
}
}

View File

@@ -25,7 +25,7 @@ use axum::{
extract::State, extract::State,
http::{StatusCode, Uri, header}, http::{StatusCode, Uri, header},
middleware as axum_middleware, middleware as axum_middleware,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Response},
routing::{delete, get, post}, routing::{delete, get, post},
}; };
#[cfg(feature = "embed-web")] #[cfg(feature = "embed-web")]
@@ -81,7 +81,6 @@ pub struct AppState {
pub library_scanner: Arc<crate::system::scanner::LibraryScanner>, pub library_scanner: Arc<crate::system::scanner::LibraryScanner>,
pub config_path: PathBuf, pub config_path: PathBuf,
pub config_mutable: bool, pub config_mutable: bool,
pub base_url: String,
pub hardware_state: HardwareState, pub hardware_state: HardwareState,
pub hardware_probe_log: Arc<tokio::sync::RwLock<HardwareProbeLog>>, pub hardware_probe_log: Arc<tokio::sync::RwLock<HardwareProbeLog>>,
pub resources_cache: Arc<tokio::sync::Mutex<Option<(serde_json::Value, std::time::Instant)>>>, pub resources_cache: Arc<tokio::sync::Mutex<Option<(serde_json::Value, std::time::Instant)>>>,
@@ -146,11 +145,6 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
sys.refresh_cpu_usage(); sys.refresh_cpu_usage();
sys.refresh_memory(); sys.refresh_memory();
let base_url = {
let config = config.read().await;
config.system.base_url.clone()
};
let state = Arc::new(AppState { let state = Arc::new(AppState {
db, db,
config, config,
@@ -168,7 +162,6 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
library_scanner, library_scanner,
config_path, config_path,
config_mutable, config_mutable,
base_url: base_url.clone(),
hardware_state, hardware_state,
hardware_probe_log, hardware_probe_log,
resources_cache: Arc::new(tokio::sync::Mutex::new(None)), resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
@@ -180,18 +173,7 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
// Clone agent for shutdown handler before moving state into router // Clone agent for shutdown handler before moving state into router
let shutdown_agent = state.agent.clone(); let shutdown_agent = state.agent.clone();
let inner_app = app_router(state.clone()); let app = app_router(state.clone());
let app = if base_url.is_empty() {
inner_app
} else {
let redirect_target = format!("{base_url}/");
Router::new()
.route(
"/",
get(move || async move { Redirect::permanent(&redirect_target) }),
)
.nest(&base_url, inner_app)
};
let port = std::env::var("ALCHEMIST_SERVER_PORT") let port = std::env::var("ALCHEMIST_SERVER_PORT")
.ok() .ok()
@@ -828,7 +810,7 @@ async fn index_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse
static_handler(State(state), Uri::from_static("/index.html")).await static_handler(State(state), Uri::from_static("/index.html")).await
} }
async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl IntoResponse { async fn static_handler(State(_state): State<Arc<AppState>>, uri: Uri) -> impl IntoResponse {
let raw_path = uri.path().trim_start_matches('/'); let raw_path = uri.path().trim_start_matches('/');
let path = match sanitize_asset_path(raw_path) { let path = match sanitize_asset_path(raw_path) {
Some(path) => path, Some(path) => path,
@@ -837,11 +819,7 @@ async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl In
if let Some(content) = load_static_asset(&path) { if let Some(content) = load_static_asset(&path) {
let mime = mime_guess::from_path(&path).first_or_octet_stream(); let mime = mime_guess::from_path(&path).first_or_octet_stream();
return ( return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
[(header::CONTENT_TYPE, mime.as_ref())],
maybe_inject_base_url(content, mime.as_ref(), &state.base_url),
)
.into_response();
} }
// Attempt to serve index.html for directory paths (e.g. /jobs -> jobs/index.html) // Attempt to serve index.html for directory paths (e.g. /jobs -> jobs/index.html)
@@ -849,11 +827,7 @@ async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl In
let index_path = format!("{}/index.html", path); let index_path = format!("{}/index.html", path);
if let Some(content) = load_static_asset(&index_path) { if let Some(content) = load_static_asset(&index_path) {
let mime = mime_guess::from_path("index.html").first_or_octet_stream(); let mime = mime_guess::from_path("index.html").first_or_octet_stream();
return ( return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
[(header::CONTENT_TYPE, mime.as_ref())],
maybe_inject_base_url(content, mime.as_ref(), &state.base_url),
)
.into_response();
} }
} }
@@ -890,14 +864,3 @@ async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl In
// Default fallback to 404 for missing files. // Default fallback to 404 for missing files.
StatusCode::NOT_FOUND.into_response() StatusCode::NOT_FOUND.into_response()
} }
fn maybe_inject_base_url(content: Vec<u8>, mime: &str, base_url: &str) -> Vec<u8> {
if !mime.starts_with("text/html") {
return content;
}
let Ok(text) = String::from_utf8(content.clone()) else {
return content;
};
text.replace("__ALCHEMIST_BASE_URL__", base_url)
.into_bytes()
}

View File

@@ -114,7 +114,6 @@ where
library_scanner: Arc::new(crate::system::scanner::LibraryScanner::new(db, config)), library_scanner: Arc::new(crate::system::scanner::LibraryScanner::new(db, config)),
config_path: config_path.clone(), config_path: config_path.clone(),
config_mutable: true, config_mutable: true,
base_url: String::new(),
hardware_state, hardware_state,
hardware_probe_log, hardware_probe_log,
resources_cache: Arc::new(tokio::sync::Mutex::new(None)), resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
@@ -211,6 +210,17 @@ fn remote_request(method: Method, uri: &str, body: Body) -> Request<Body> {
request request
} }
fn lan_request(method: Method, uri: &str, body: Body) -> Request<Body> {
let mut request = match Request::builder().method(method).uri(uri).body(body) {
Ok(request) => request,
Err(err) => panic!("failed to build LAN request: {err}"),
};
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from(([192, 168, 1, 25], 3000))));
request
}
async fn body_text(response: axum::response::Response) -> String { async fn body_text(response: axum::response::Response) -> String {
let bytes = match to_bytes(response.into_body(), usize::MAX).await { let bytes = match to_bytes(response.into_body(), usize::MAX).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
@@ -740,32 +750,6 @@ async fn read_only_api_token_cannot_access_settings_config()
Ok(()) Ok(())
} }
#[tokio::test]
async fn nested_base_url_routes_engine_status_through_auth_middleware()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let (state, _app, config_path, db_path) = build_test_app(false, 8, |config| {
config.system.base_url = "/alchemist".to_string();
})
.await?;
let token = create_session(state.db.as_ref()).await?;
let app = Router::new().nest("/alchemist", app_router(state.clone()));
let response = app
.oneshot(auth_request(
Method::GET,
"/alchemist/api/engine/status",
&token,
Body::empty(),
))
.await?;
assert_eq!(response.status(), StatusCode::OK);
drop(state);
let _ = std::fs::remove_file(config_path);
let _ = std::fs::remove_file(db_path);
Ok(())
}
#[tokio::test] #[tokio::test]
async fn hardware_probe_log_route_returns_runtime_log() async fn hardware_probe_log_route_returns_runtime_log()
-> std::result::Result<(), Box<dyn std::error::Error>> { -> std::result::Result<(), Box<dyn std::error::Error>> {
@@ -818,12 +802,11 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
let response = app let response = app
.clone() .clone()
.oneshot( .oneshot({
Request::builder() let mut request = localhost_request(
.method(Method::POST) Method::POST,
.uri("/api/setup/complete") "/api/setup/complete",
.header(header::CONTENT_TYPE, "application/json") Body::from(
.body(Body::from(
json!({ json!({
"username": "admin", "username": "admin",
"password": "password123", "password": "password123",
@@ -838,9 +821,14 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
"quality_profile": "balanced" "quality_profile": "balanced"
}) })
.to_string(), .to_string(),
)) ),
.unwrap_or_else(|err| panic!("failed to build setup completion request: {err}")), );
) request.headers_mut().insert(
header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
request
})
.await?; .await?;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
@@ -932,23 +920,25 @@ async fn setup_complete_accepts_nested_settings_payload()
let response = app let response = app
.clone() .clone()
.oneshot( .oneshot({
Request::builder() let mut request = localhost_request(
.method(Method::POST) Method::POST,
.uri("/api/setup/complete") "/api/setup/complete",
.header(header::CONTENT_TYPE, "application/json") Body::from(
.body(Body::from(
json!({ json!({
"username": "admin", "username": "admin",
"password": "password123", "password": "password123",
"settings": settings, "settings": settings,
}) })
.to_string(), .to_string(),
)) ),
.unwrap_or_else(|err| { );
panic!("failed to build nested setup completion request: {err}") request.headers_mut().insert(
}), header::CONTENT_TYPE,
) axum::http::HeaderValue::from_static("application/json"),
);
request
})
.await?; .await?;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
assert!( assert!(
@@ -981,23 +971,25 @@ async fn setup_complete_rejects_nested_settings_without_library_directories()
let response = app let response = app
.clone() .clone()
.oneshot( .oneshot({
Request::builder() let mut request = localhost_request(
.method(Method::POST) Method::POST,
.uri("/api/setup/complete") "/api/setup/complete",
.header(header::CONTENT_TYPE, "application/json") Body::from(
.body(Body::from(
json!({ json!({
"username": "admin", "username": "admin",
"password": "password123", "password": "password123",
"settings": settings, "settings": settings,
}) })
.to_string(), .to_string(),
)) ),
.unwrap_or_else(|err| { );
panic!("failed to build nested setup rejection request: {err}") request.headers_mut().insert(
}), header::CONTENT_TYPE,
) axum::http::HeaderValue::from_static("application/json"),
);
request
})
.await?; .await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_text(response).await; let body = body_text(response).await;
@@ -1076,7 +1068,7 @@ async fn fs_endpoints_require_loopback_during_setup()
.await?; .await?;
assert_eq!(browse_response.status(), StatusCode::FORBIDDEN); assert_eq!(browse_response.status(), StatusCode::FORBIDDEN);
let browse_body = body_text(browse_response).await; let browse_body = body_text(browse_response).await;
assert!(browse_body.contains("Filesystem browsing is only available")); assert!(browse_body.contains("local network"));
let mut preview_request = remote_request( let mut preview_request = remote_request(
Method::POST, Method::POST,
@@ -1096,12 +1088,78 @@ async fn fs_endpoints_require_loopback_during_setup()
let preview_response = app.clone().oneshot(preview_request).await?; let preview_response = app.clone().oneshot(preview_request).await?;
assert_eq!(preview_response.status(), StatusCode::FORBIDDEN); assert_eq!(preview_response.status(), StatusCode::FORBIDDEN);
let preview_body = body_text(preview_response).await; let preview_body = body_text(preview_response).await;
assert!(preview_body.contains("Filesystem browsing is only available")); assert!(preview_body.contains("local network"));
cleanup_paths(&[browse_root, config_path, db_path]); cleanup_paths(&[browse_root, config_path, db_path]);
Ok(()) Ok(())
} }
#[tokio::test]
async fn setup_html_routes_allow_lan_clients() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let (_state, app, config_path, db_path) = build_test_app(true, 8, |_| {}).await?;
let response = app
.clone()
.oneshot(lan_request(Method::GET, "/setup", Body::empty()))
.await?;
assert_ne!(response.status(), StatusCode::FORBIDDEN);
cleanup_paths(&[config_path, db_path]);
Ok(())
}
#[tokio::test]
async fn setup_html_routes_reject_public_clients()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let (_state, app, config_path, db_path) = build_test_app(true, 8, |_| {}).await?;
let response = app
.clone()
.oneshot(remote_request(Method::GET, "/setup", Body::empty()))
.await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = body_text(response).await;
assert!(body.contains("only available from the local network"));
cleanup_paths(&[config_path, db_path]);
Ok(())
}
#[tokio::test]
async fn setup_status_rejects_public_clients_during_setup()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let (_state, app, config_path, db_path) = build_test_app(true, 8, |_| {}).await?;
let response = app
.clone()
.oneshot(remote_request(
Method::GET,
"/api/setup/status",
Body::empty(),
))
.await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
cleanup_paths(&[config_path, db_path]);
Ok(())
}
#[tokio::test]
async fn public_clients_can_reach_login_after_setup()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let (_state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
let response = app
.clone()
.oneshot(remote_request(Method::GET, "/login", Body::empty()))
.await?;
assert_ne!(response.status(), StatusCode::FORBIDDEN);
cleanup_paths(&[config_path, db_path]);
Ok(())
}
#[tokio::test] #[tokio::test]
async fn settings_bundle_requires_auth_after_setup() async fn settings_bundle_requires_auth_after_setup()
-> std::result::Result<(), Box<dyn std::error::Error>> { -> std::result::Result<(), Box<dyn std::error::Error>> {

View File

@@ -1,6 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { apiFetch, apiJson } from "../lib/api"; import { apiFetch, apiJson } from "../lib/api";
import { stripBasePath, withBasePath } from "../lib/basePath";
interface SetupStatus { interface SetupStatus {
setup_required?: boolean; setup_required?: boolean;
@@ -11,7 +10,7 @@ export default function AuthGuard() {
let cancelled = false; let cancelled = false;
const checkAuth = async () => { const checkAuth = async () => {
const path = stripBasePath(window.location.pathname); const path = window.location.pathname;
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup"); const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
if (isAuthPage) { if (isAuthPage) {
return; return;
@@ -28,9 +27,7 @@ export default function AuthGuard() {
return; return;
} }
window.location.href = setupStatus.setup_required window.location.href = setupStatus.setup_required ? "/setup" : "/login";
? withBasePath("/setup")
: withBasePath("/login");
} catch { } catch {
// Keep user on current page on transient backend/network failures. // Keep user on current page on transient backend/network failures.
} }

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Upload, Wand2, Play, Download, Trash2 } from "lucide-react"; import { Upload, Wand2, Play, Download, Trash2 } from "lucide-react";
import { apiAction, apiFetch, apiJson, isApiError } from "../lib/api"; import { apiAction, apiFetch, apiJson, isApiError } from "../lib/api";
import { withBasePath } from "../lib/basePath";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
interface SubtitleStreamMetadata { interface SubtitleStreamMetadata {
@@ -105,7 +104,7 @@ const DEFAULT_SETTINGS: ConversionSettings = {
}, },
}; };
export default function ConversionTool() { export function ConversionTool() {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [previewing, setPreviewing] = useState(false); const [previewing, setPreviewing] = useState(false);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
@@ -121,13 +120,14 @@ export default function ConversionTool() {
const id = window.setInterval(() => { const id = window.setInterval(() => {
void apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`) void apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`)
.then(setStatus) .then(setStatus)
.catch(() => {}); .catch(() => {
});
}, 2000); }, 2000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [conversionJobId]); }, [conversionJobId]);
const updateSettings = (patch: Partial<ConversionSettings>) => { const updateSettings = (patch: Partial<ConversionSettings>) => {
setSettings((current) => ({ ...current, ...patch })); setSettings((current) => ({...current, ...patch}));
}; };
const uploadFile = async (file: File) => { const uploadFile = async (file: File) => {
@@ -157,7 +157,7 @@ export default function ConversionTool() {
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Upload failed"; const message = err instanceof Error ? err.message : "Upload failed";
setError(message); setError(message);
showToast({ kind: "error", title: "Conversion", message }); showToast({kind: "error", title: "Conversion", message});
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -169,7 +169,7 @@ export default function ConversionTool() {
try { try {
const payload = await apiJson<PreviewResponse>("/api/conversion/preview", { const payload = await apiJson<PreviewResponse>("/api/conversion/preview", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {"Content-Type": "application/json"},
body: JSON.stringify({ body: JSON.stringify({
conversion_job_id: conversionJobId, conversion_job_id: conversionJobId,
settings, settings,
@@ -177,11 +177,11 @@ export default function ConversionTool() {
}); });
setSettings(payload.normalized_settings); setSettings(payload.normalized_settings);
setCommandPreview(payload.command_preview); setCommandPreview(payload.command_preview);
showToast({ kind: "success", title: "Conversion", message: "Preview updated." }); showToast({kind: "success", title: "Conversion", message: "Preview updated."});
} catch (err) { } catch (err) {
const message = isApiError(err) ? err.message : "Preview failed"; const message = isApiError(err) ? err.message : "Preview failed";
setError(message); setError(message);
showToast({ kind: "error", title: "Conversion", message }); showToast({kind: "error", title: "Conversion", message});
} finally { } finally {
setPreviewing(false); setPreviewing(false);
} }
@@ -191,14 +191,14 @@ export default function ConversionTool() {
if (!conversionJobId) return; if (!conversionJobId) return;
setStarting(true); setStarting(true);
try { try {
await apiAction(`/api/conversion/jobs/${conversionJobId}/start`, { method: "POST" }); await apiAction(`/api/conversion/jobs/${conversionJobId}/start`, {method: "POST"});
const payload = await apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`); const payload = await apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`);
setStatus(payload); setStatus(payload);
showToast({ kind: "success", title: "Conversion", message: "Conversion job queued." }); showToast({kind: "success", title: "Conversion", message: "Conversion job queued."});
} catch (err) { } catch (err) {
const message = isApiError(err) ? err.message : "Failed to start conversion"; const message = isApiError(err) ? err.message : "Failed to start conversion";
setError(message); setError(message);
showToast({ kind: "error", title: "Conversion", message }); showToast({kind: "error", title: "Conversion", message});
} finally { } finally {
setStarting(false); setStarting(false);
} }
@@ -207,23 +207,23 @@ export default function ConversionTool() {
const remove = async () => { const remove = async () => {
if (!conversionJobId) return; if (!conversionJobId) return;
try { try {
await apiAction(`/api/conversion/jobs/${conversionJobId}`, { method: "DELETE" }); await apiAction(`/api/conversion/jobs/${conversionJobId}`, {method: "DELETE"});
setConversionJobId(null); setConversionJobId(null);
setProbe(null); setProbe(null);
setStatus(null); setStatus(null);
setSettings(DEFAULT_SETTINGS); setSettings(DEFAULT_SETTINGS);
setCommandPreview(""); setCommandPreview("");
showToast({ kind: "success", title: "Conversion", message: "Conversion job removed." }); showToast({kind: "success", title: "Conversion", message: "Conversion job removed."});
} catch (err) { } catch (err) {
const message = isApiError(err) ? err.message : "Failed to remove conversion job"; const message = isApiError(err) ? err.message : "Failed to remove conversion job";
setError(message); setError(message);
showToast({ kind: "error", title: "Conversion", message }); showToast({kind: "error", title: "Conversion", message});
} }
}; };
const download = async () => { const download = async () => {
if (!conversionJobId) return; if (!conversionJobId) return;
window.location.href = withBasePath(`/api/conversion/jobs/${conversionJobId}/download`); window.location.href = `/api/conversion/jobs/${conversionJobId}/download`;
}; };
return ( return (
@@ -231,22 +231,26 @@ export default function ConversionTool() {
<div> <div>
<h1 className="text-xl font-bold text-helios-ink">Conversion / Remux</h1> <h1 className="text-xl font-bold text-helios-ink">Conversion / Remux</h1>
<p className="mt-1 text-sm text-helios-slate"> <p className="mt-1 text-sm text-helios-slate">
Upload a single file, inspect the streams, preview the generated FFmpeg command, and run it through Alchemist. Upload a single file, inspect the streams, preview the generated FFmpeg command, and run it through
Alchemist.
</p> </p>
</div> </div>
{error && ( {error && (
<div className="rounded-lg border border-status-error/20 bg-status-error/10 px-4 py-3 text-sm text-status-error"> <div
className="rounded-lg border border-status-error/20 bg-status-error/10 px-4 py-3 text-sm text-status-error">
{error} {error}
</div> </div>
)} )}
{!probe && ( {!probe && (
<label className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-helios-line/30 bg-helios-surface p-10 text-center cursor-pointer hover:bg-helios-surface-soft transition-colors"> <label
<Upload size={28} className="text-helios-solar" /> className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-helios-line/30 bg-helios-surface p-10 text-center cursor-pointer hover:bg-helios-surface-soft transition-colors">
<Upload size={28} className="text-helios-solar"/>
<div> <div>
<p className="text-sm font-semibold text-helios-ink">Upload a source file</p> <p className="text-sm font-semibold text-helios-ink">Upload a source file</p>
<p className="text-xs text-helios-slate mt-1">The uploaded file is stored temporarily under Alchemist-managed temp storage.</p> <p className="text-xs text-helios-slate mt-1">You can select a couple options here to
convert/remux a video file.</p>
</div> </div>
<input <input
type="file" type="file"
@@ -270,16 +274,18 @@ export default function ConversionTool() {
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4"> <section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<h2 className="text-sm font-semibold text-helios-ink">Input</h2> <h2 className="text-sm font-semibold text-helios-ink">Input</h2>
<div className="grid gap-3 md:grid-cols-4 text-sm"> <div className="grid gap-3 md:grid-cols-4 text-sm">
<Stat label="Container" value={probe.metadata.container} /> <Stat label="Container" value={probe.metadata.container}/>
<Stat label="Video" value={probe.metadata.codec_name} /> <Stat label="Video" value={probe.metadata.codec_name}/>
<Stat label="Resolution" value={`${probe.metadata.width}x${probe.metadata.height}`} /> <Stat label="Resolution" value={`${probe.metadata.width}x${probe.metadata.height}`}/>
<Stat label="Dynamic Range" value={probe.metadata.dynamic_range} /> <Stat label="Dynamic Range" value={probe.metadata.dynamic_range}/>
</div> </div>
</section> </section>
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4"> <section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<h2 className="text-sm font-semibold text-helios-ink">Output Container</h2> <h2 className="text-sm font-semibold text-helios-ink">Output Container</h2>
<select value={settings.output_container} onChange={(event) => updateSettings({ output_container: event.target.value })} className="w-full md:w-60 bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink"> <select value={settings.output_container}
onChange={(event) => updateSettings({output_container: event.target.value})}
className="w-full md:w-60 bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink">
{["mkv", "mp4", "webm", "mov"].map((option) => ( {["mkv", "mp4", "webm", "mov"].map((option) => (
<option key={option} value={option}>{option.toUpperCase()}</option> <option key={option} value={option}>{option.toUpperCase()}</option>
))} ))}
@@ -293,7 +299,7 @@ export default function ConversionTool() {
<input <input
type="checkbox" type="checkbox"
checked={settings.remux_only} checked={settings.remux_only}
onChange={(event) => updateSettings({ remux_only: event.target.checked })} onChange={(event) => updateSettings({remux_only: event.target.checked})}
/> />
Remux only Remux only
</label> </label>
@@ -311,41 +317,59 @@ export default function ConversionTool() {
value={settings.video.codec} value={settings.video.codec}
disabled={settings.remux_only} disabled={settings.remux_only}
options={["copy", "h264", "hevc", "av1"]} options={["copy", "h264", "hevc", "av1"]}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, codec: value } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {...current.video, codec: value}
}))}
/> />
<SelectField <SelectField
label="Mode" label="Mode"
value={settings.video.mode} value={settings.video.mode}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
options={["crf", "bitrate"]} options={["crf", "bitrate"]}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, mode: value } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {...current.video, mode: value}
}))}
/> />
<NumberField <NumberField
label={settings.video.mode === "bitrate" ? "Bitrate (kbps)" : "Quality Value"} label={settings.video.mode === "bitrate" ? "Bitrate (kbps)" : "Quality Value"}
value={settings.video.value ?? 0} value={settings.video.value ?? 0}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, value } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {...current.video, value}
}))}
/> />
<SelectField <SelectField
label="Preset" label="Preset"
value={settings.video.preset ?? "medium"} value={settings.video.preset ?? "medium"}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
options={["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"]} options={["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"]}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, preset: value } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {...current.video, preset: value}
}))}
/> />
<SelectField <SelectField
label="Resolution Mode" label="Resolution Mode"
value={settings.video.resolution.mode} value={settings.video.resolution.mode}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
options={["original", "custom", "scale_factor"]} options={["original", "custom", "scale_factor"]}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, mode: value } } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {...current.video, resolution: {...current.video.resolution, mode: value}}
}))}
/> />
<SelectField <SelectField
label="HDR" label="HDR"
value={settings.video.hdr_mode} value={settings.video.hdr_mode}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
options={["preserve", "tonemap", "strip_metadata"]} options={["preserve", "tonemap", "strip_metadata"]}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, hdr_mode: value } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {...current.video, hdr_mode: value}
}))}
/> />
{settings.video.resolution.mode === "custom" && ( {settings.video.resolution.mode === "custom" && (
<> <>
@@ -353,13 +377,25 @@ export default function ConversionTool() {
label="Width" label="Width"
value={settings.video.resolution.width ?? probe.metadata.width} value={settings.video.resolution.width ?? probe.metadata.width}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, width: value } } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {
...current.video,
resolution: {...current.video.resolution, width: value}
}
}))}
/> />
<NumberField <NumberField
label="Height" label="Height"
value={settings.video.resolution.height ?? probe.metadata.height} value={settings.video.resolution.height ?? probe.metadata.height}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, height: value } } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {
...current.video,
resolution: {...current.video.resolution, height: value}
}
}))}
/> />
</> </>
)} )}
@@ -369,7 +405,13 @@ export default function ConversionTool() {
value={settings.video.resolution.scale_factor ?? 1} value={settings.video.resolution.scale_factor ?? 1}
disabled={settings.remux_only || settings.video.codec === "copy"} disabled={settings.remux_only || settings.video.codec === "copy"}
step="0.1" step="0.1"
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, scale_factor: value } } }))} onChange={(value) => setSettings((current) => ({
...current,
video: {
...current.video,
resolution: {...current.video.resolution, scale_factor: value}
}
}))}
/> />
)} )}
</div> </div>
@@ -383,20 +425,29 @@ export default function ConversionTool() {
value={settings.audio.codec} value={settings.audio.codec}
disabled={settings.remux_only} disabled={settings.remux_only}
options={["copy", "aac", "opus", "mp3"]} options={["copy", "aac", "opus", "mp3"]}
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, codec: value } }))} onChange={(value) => setSettings((current) => ({
...current,
audio: {...current.audio, codec: value}
}))}
/> />
<NumberField <NumberField
label="Bitrate (kbps)" label="Bitrate (kbps)"
value={settings.audio.bitrate_kbps ?? 160} value={settings.audio.bitrate_kbps ?? 160}
disabled={settings.remux_only || settings.audio.codec === "copy"} disabled={settings.remux_only || settings.audio.codec === "copy"}
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, bitrate_kbps: value } }))} onChange={(value) => setSettings((current) => ({
...current,
audio: {...current.audio, bitrate_kbps: value}
}))}
/> />
<SelectField <SelectField
label="Channels" label="Channels"
value={settings.audio.channels ?? "auto"} value={settings.audio.channels ?? "auto"}
disabled={settings.remux_only || settings.audio.codec === "copy"} disabled={settings.remux_only || settings.audio.codec === "copy"}
options={["auto", "stereo", "5.1"]} options={["auto", "stereo", "5.1"]}
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, channels: value } }))} onChange={(value) => setSettings((current) => ({
...current,
audio: {...current.audio, channels: value}
}))}
/> />
</div> </div>
</section> </section>
@@ -408,31 +459,36 @@ export default function ConversionTool() {
value={settings.subtitles.mode} value={settings.subtitles.mode}
disabled={settings.remux_only} disabled={settings.remux_only}
options={["copy", "burn", "remove"]} options={["copy", "burn", "remove"]}
onChange={(value) => setSettings((current) => ({ ...current, subtitles: { mode: value } }))} onChange={(value) => setSettings((current) => ({...current, subtitles: {mode: value}}))}
/> />
</section> </section>
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4"> <section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button onClick={() => void preview()} disabled={previewing} className="flex items-center gap-2 rounded-lg bg-helios-solar px-4 py-2 text-sm font-bold text-helios-main"> <button onClick={() => void preview()} disabled={previewing}
<Wand2 size={16} /> className="flex items-center gap-2 rounded-lg bg-helios-solar px-4 py-2 text-sm font-bold text-helios-main">
<Wand2 size={16}/>
{previewing ? "Previewing..." : "Preview Command"} {previewing ? "Previewing..." : "Preview Command"}
</button> </button>
<button onClick={() => void start()} disabled={starting || !commandPreview} className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink"> <button onClick={() => void start()} disabled={starting || !commandPreview}
<Play size={16} /> className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink">
<Play size={16}/>
{starting ? "Starting..." : "Start Job"} {starting ? "Starting..." : "Start Job"}
</button> </button>
<button onClick={() => void download()} disabled={!status?.download_ready} className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink disabled:opacity-50"> <button onClick={() => void download()} disabled={!status?.download_ready}
<Download size={16} /> className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink disabled:opacity-50">
<Download size={16}/>
Download Result Download Result
</button> </button>
<button onClick={() => void remove()} className="flex items-center gap-2 rounded-lg border border-red-500/20 px-4 py-2 text-sm font-semibold text-red-500"> <button onClick={() => void remove()}
<Trash2 size={16} /> className="flex items-center gap-2 rounded-lg border border-red-500/20 px-4 py-2 text-sm font-semibold text-red-500">
<Trash2 size={16}/>
Remove Remove
</button> </button>
</div> </div>
{commandPreview && ( {commandPreview && (
<pre className="overflow-x-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft p-4 text-xs text-helios-ink whitespace-pre-wrap"> <pre
className="overflow-x-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft p-4 text-xs text-helios-ink whitespace-pre-wrap">
{commandPreview} {commandPreview}
</pre> </pre>
)} )}
@@ -442,10 +498,11 @@ export default function ConversionTool() {
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-3"> <section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-3">
<h2 className="text-sm font-semibold text-helios-ink">Status</h2> <h2 className="text-sm font-semibold text-helios-ink">Status</h2>
<div className="grid gap-3 md:grid-cols-4 text-sm"> <div className="grid gap-3 md:grid-cols-4 text-sm">
<Stat label="State" value={status.status} /> <Stat label="State" value={status.status}/>
<Stat label="Progress" value={`${status.progress.toFixed(1)}%`} /> <Stat label="Progress" value={`${status.progress.toFixed(1)}%`}/>
<Stat label="Linked Job" value={status.linked_job_id ? `#${status.linked_job_id}` : "None"} /> <Stat label="Linked Job"
<Stat label="Download" value={status.download_ready ? "Ready" : "Pending"} /> value={status.linked_job_id ? `#${status.linked_job_id}` : "None"}/>
<Stat label="Download" value={status.download_ready ? "Ready" : "Pending"}/>
</div> </div>
</section> </section>
)} )}

View File

@@ -9,7 +9,6 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { apiJson, isApiError } from "../lib/api"; import { apiJson, isApiError } from "../lib/api";
import { withBasePath } from "../lib/basePath";
import { useSharedStats } from "../lib/statsStore"; import { useSharedStats } from "../lib/statsStore";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
import ResourceMonitor from "./ResourceMonitor"; import ResourceMonitor from "./ResourceMonitor";
@@ -145,7 +144,7 @@ function Dashboard() {
} }
if (setupComplete !== "true") { if (setupComplete !== "true") {
window.location.href = withBasePath("/setup"); window.location.href = "/setup";
} }
} }
} catch { } catch {
@@ -233,7 +232,7 @@ function Dashboard() {
<Activity size={16} className="text-helios-solar" /> <Activity size={16} className="text-helios-solar" />
Recent Activity Recent Activity
</h3> </h3>
<a href={withBasePath("/jobs")} className="text-xs font-medium text-helios-solar hover:underline"> <a href="/jobs" className="text-xs font-medium text-helios-solar hover:underline">
View all View all
</a> </a>
</div> </div>
@@ -249,7 +248,7 @@ function Dashboard() {
<span className="text-sm text-helios-slate/60"> <span className="text-sm text-helios-slate/60">
No recent activity. No recent activity.
</span> </span>
<a href={withBasePath("/settings")} className="text-xs text-helios-solar hover:underline"> <a href="/settings" className="text-xs text-helios-solar hover:underline">
Add a library folder Add a library folder
</a> </a>
</div> </div>

View File

@@ -3,7 +3,6 @@ import { Info, LogOut, Play, Square } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import AboutDialog from "./AboutDialog"; import AboutDialog from "./AboutDialog";
import { apiAction, apiJson } from "../lib/api"; import { apiAction, apiJson } from "../lib/api";
import { withBasePath } from "../lib/basePath";
import { useSharedStats } from "../lib/statsStore"; import { useSharedStats } from "../lib/statsStore";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
@@ -147,7 +146,7 @@ export default function HeaderActions() {
message: "Logout request failed. Redirecting to login.", message: "Logout request failed. Redirecting to login.",
}); });
} finally { } finally {
window.location.href = withBasePath("/login"); window.location.href = "/login";
} }
}; };

View File

@@ -5,7 +5,6 @@ import {
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal, ArrowDown, ArrowUp, AlertCircle Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal, ArrowDown, ArrowUp, AlertCircle
} from "lucide-react"; } from "lucide-react";
import { apiAction, apiJson, isApiError } from "../lib/api"; import { apiAction, apiJson, isApiError } from "../lib/api";
import { withBasePath } from "../lib/basePath";
import { useDebouncedValue } from "../lib/useDebouncedValue"; import { useDebouncedValue } from "../lib/useDebouncedValue";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog"; import ConfirmDialog from "./ui/ConfirmDialog";
@@ -502,6 +501,7 @@ function JobManager() {
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState<TabType>("all"); const [activeTab, setActiveTab] = useState<TabType>("all");
const [searchInput, setSearchInput] = useState(""); const [searchInput, setSearchInput] = useState("");
const [compactSearchOpen, setCompactSearchOpen] = useState(false);
const debouncedSearch = useDebouncedValue(searchInput, 350); const debouncedSearch = useDebouncedValue(searchInput, 350);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<SortField>("updated_at"); const [sortBy, setSortBy] = useState<SortField>("updated_at");
@@ -514,6 +514,8 @@ function JobManager() {
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
const detailDialogRef = useRef<HTMLDivElement | null>(null); const detailDialogRef = useRef<HTMLDivElement | null>(null);
const detailLastFocusedRef = useRef<HTMLElement | null>(null); const detailLastFocusedRef = useRef<HTMLElement | null>(null);
const compactSearchRef = useRef<HTMLDivElement | null>(null);
const compactSearchInputRef = useRef<HTMLInputElement | null>(null);
const confirmOpenRef = useRef(false); const confirmOpenRef = useRef(false);
const encodeStartTimes = useRef<Map<number, number>>(new Map()); const encodeStartTimes = useRef<Map<number, number>>(new Map());
const [confirmState, setConfirmState] = useState<{ const [confirmState, setConfirmState] = useState<{
@@ -530,6 +532,43 @@ function JobManager() {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
useEffect(() => {
if (searchInput.trim()) {
setCompactSearchOpen(true);
}
}, [searchInput]);
useEffect(() => {
if (!compactSearchOpen) {
return;
}
compactSearchInputRef.current?.focus();
const handlePointerDown = (event: MouseEvent) => {
if (
compactSearchRef.current &&
!compactSearchRef.current.contains(event.target as Node) &&
!searchInput.trim()
) {
setCompactSearchOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !searchInput.trim()) {
setCompactSearchOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [compactSearchOpen, searchInput]);
const isJobActive = (job: Job) => ["analyzing", "encoding", "remuxing", "resuming"].includes(job.status); const isJobActive = (job: Job) => ["analyzing", "encoding", "remuxing", "resuming"].includes(job.status);
const formatJobActionError = (error: unknown, fallback: string) => { const formatJobActionError = (error: unknown, fallback: string) => {
@@ -665,7 +704,7 @@ function JobManager() {
const connect = () => { const connect = () => {
if (cancelled) return; if (cancelled) return;
eventSource?.close(); eventSource?.close();
eventSource = new EventSource(withBasePath("/api/events")); eventSource = new EventSource("/api/events");
eventSource.onopen = () => { eventSource.onopen = () => {
// Reset reconnect attempts on successful connection // Reset reconnect attempts on successful connection
@@ -1084,8 +1123,8 @@ function JobManager() {
</div> </div>
{/* Toolbar */} {/* Toolbar */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-center bg-helios-surface/50 p-1 rounded-lg border border-helios-line/10"> <div className="flex flex-wrap items-start justify-between gap-4 rounded-xl border border-helios-line/10 bg-helios-surface/50 px-3 py-3 md:items-center">
<div className="flex gap-1 p-1 bg-helios-surface border border-helios-line/10 rounded-lg"> <div className="flex flex-wrap gap-1">
{(["all", "active", "queued", "completed", "failed", "skipped", "archived"] as TabType[]).map((tab) => ( {(["all", "active", "queued", "completed", "failed", "skipped", "archived"] as TabType[]).map((tab) => (
<button <button
key={tab} key={tab}
@@ -1102,8 +1141,8 @@ function JobManager() {
))} ))}
</div> </div>
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center md:w-auto"> <div className="ml-auto flex w-full min-w-0 flex-wrap items-center justify-end gap-2 md:w-auto md:flex-nowrap">
<div className="relative flex-1 md:w-64"> <div className="relative hidden xl:block xl:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate" size={14} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate" size={14} />
<input <input
type="text" type="text"
@@ -1113,7 +1152,7 @@ function JobManager() {
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-9 pr-4 py-2 text-sm text-helios-ink focus:border-helios-solar outline-none" className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-9 pr-4 py-2 text-sm text-helios-ink focus:border-helios-solar outline-none"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<select <select
value={sortBy} value={sortBy}
onChange={(e) => { onChange={(e) => {
@@ -1142,10 +1181,45 @@ function JobManager() {
</div> </div>
<button <button
onClick={() => void fetchJobs()} onClick={() => void fetchJobs()}
className={cn("p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft", refreshing && "animate-spin")} className="flex h-10 w-10 items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface text-helios-ink hover:bg-helios-surface-soft"
title="Refresh jobs"
aria-label="Refresh jobs"
> >
<RefreshCw size={16} /> <RefreshCw size={16} className={refreshing ? "animate-spin" : undefined} />
</button> </button>
<div ref={compactSearchRef} className="relative xl:hidden">
<div
className={cn(
"flex h-10 items-center overflow-hidden rounded-lg border border-helios-line/20 bg-helios-surface text-helios-ink transition-[width,box-shadow] duration-200 ease-out",
compactSearchOpen
? "w-[min(18rem,calc(100vw-4rem))] px-3 shadow-lg shadow-helios-main/20"
: "w-10 justify-center"
)}
>
<button
type="button"
onClick={() => setCompactSearchOpen((open) => (searchInput.trim() ? true : !open))}
className="flex h-10 w-10 shrink-0 items-center justify-center text-helios-ink"
title="Search files"
aria-label="Search files"
>
<Search size={16} />
</button>
<input
ref={compactSearchInputRef}
type="text"
placeholder="Search files..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className={cn(
"min-w-0 bg-transparent text-sm text-helios-ink outline-none placeholder:text-helios-slate transition-all duration-200",
compactSearchOpen
? "ml-1 w-full opacity-100"
: "w-0 opacity-0 pointer-events-none"
)}
/>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,6 @@ import { Terminal, Pause, Play, Trash2, RefreshCw, Search } from "lucide-react";
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { apiAction, apiJson, isApiError } from "../lib/api"; import { apiAction, apiJson, isApiError } from "../lib/api";
import { withBasePath } from "../lib/basePath";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog"; import ConfirmDialog from "./ui/ConfirmDialog";
@@ -73,7 +72,7 @@ export default function LogViewer() {
setStreamError(null); setStreamError(null);
eventSource?.close(); eventSource?.close();
eventSource = new EventSource(withBasePath("/api/events")); eventSource = new EventSource("/api/events");
const appendLog = (message: string, level: string, jobId?: number) => { const appendLog = (message: string, level: string, jobId?: number) => {
if (pausedRef.current) { if (pausedRef.current) {

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiAction, apiJson, isApiError } from "../lib/api"; import { apiAction, apiJson, isApiError } from "../lib/api";
import { withBasePath } from "../lib/basePath";
import AdminAccountStep from "./setup/AdminAccountStep"; import AdminAccountStep from "./setup/AdminAccountStep";
import LibraryStep from "./setup/LibraryStep"; import LibraryStep from "./setup/LibraryStep";
import ProcessingStep from "./setup/ProcessingStep"; import ProcessingStep from "./setup/ProcessingStep";
@@ -103,7 +102,7 @@ export default function SetupWizard() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "setup_complete", value: "true" }), body: JSON.stringify({ key: "setup_complete", value: "true" }),
}).catch(() => undefined); }).catch(() => undefined);
window.location.href = withBasePath("/"); window.location.href = "/";
} catch (err) { } catch (err) {
let message = "Failed to save setup configuration."; let message = "Failed to save setup configuration.";
if (isApiError(err)) { if (isApiError(err)) {
@@ -113,7 +112,7 @@ export default function SetupWizard() {
: "Setup configuration was rejected. Check that your username is at least 3 characters and password is at least 8 characters."; : "Setup configuration was rejected. Check that your username is at least 3 characters and password is at least 8 characters.";
} else if (err.status === 403) { } else if (err.status === 403) {
message = "Setup has already been completed. Redirecting to dashboard..."; message = "Setup has already been completed. Redirecting to dashboard...";
setTimeout(() => { window.location.href = withBasePath("/"); }, 1500); setTimeout(() => { window.location.href = "/"; }, 1500);
} else if (err.status >= 500) { } else if (err.status >= 500) {
message = `Server error during setup (${err.status}). Check the Alchemist logs for details.`; message = `Server error during setup (${err.status}). Check the Alchemist logs for details.`;
} else { } else {

View File

@@ -13,12 +13,7 @@ import {
import SystemStatus from "./SystemStatus.tsx"; import SystemStatus from "./SystemStatus.tsx";
const currentPath = Astro.url.pathname; const currentPath = Astro.url.pathname;
const basePath = "__ALCHEMIST_BASE_URL__"; const strippedPath = currentPath;
const withBase = (href: string) => `${basePath}${href === "/" ? "/" : href}`;
const strippedPath =
basePath && currentPath.startsWith(basePath)
? currentPath.slice(basePath.length) || "/"
: currentPath;
const navItems = [ const navItems = [
{ href: "/", label: "Dashboard", Icon: Activity }, { href: "/", label: "Dashboard", Icon: Activity },
@@ -33,7 +28,7 @@ const navItems = [
{/* Mobile top bar */} {/* Mobile top bar */}
<div class="lg:hidden flex items-center justify-between px-4 py-3 bg-helios-surface border-b border-helios-line/60"> <div class="lg:hidden flex items-center justify-between px-4 py-3 bg-helios-surface border-b border-helios-line/60">
<a href={withBase("/")} class="font-bold text-lg tracking-tight text-helios-ink">Alchemist</a> <a href="/" class="font-bold text-lg tracking-tight text-helios-ink">Alchemist</a>
<button <button
id="sidebar-hamburger" id="sidebar-hamburger"
aria-label="Open navigation" aria-label="Open navigation"
@@ -58,7 +53,7 @@ const navItems = [
transition-transform duration-200 lg:transition-none" transition-transform duration-200 lg:transition-none"
> >
<a <a
href={withBase("/")} href="/"
class="flex items-center px-3 pb-4 border-b border-helios-line/40" class="flex items-center px-3 pb-4 border-b border-helios-line/40"
> >
<span class="font-bold text-lg tracking-tight text-helios-ink"> <span class="font-bold text-lg tracking-tight text-helios-ink">
@@ -81,7 +76,7 @@ const navItems = [
(href !== "/" && strippedPath.startsWith(href)); (href !== "/" && strippedPath.startsWith(href));
return ( return (
<a <a
href={withBase(href)} href={href}
class:list={[ class:list={[
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap", "flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap",
isActive isActive

View File

@@ -9,7 +9,6 @@ interface Props {
} }
const { title } = Astro.props; const { title } = Astro.props;
const basePath = "__ALCHEMIST_BASE_URL__";
--- ---
<!doctype html> <!doctype html>
@@ -18,12 +17,9 @@ const basePath = "__ALCHEMIST_BASE_URL__";
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="description" content="Alchemist Media Transcoder" /> <meta name="description" content="Alchemist Media Transcoder" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href={`${basePath}/favicon.svg`} /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{title}</title> <title>{title}</title>
<script is:inline define:vars={{ basePath }}>
window.__ALCHEMIST_BASE_URL__ = basePath;
</script>
<ClientRouter /> <ClientRouter />
</head> </head>
<body> <body>

View File

@@ -1,5 +1,3 @@
import { stripBasePath, withBasePath } from "./basePath";
export interface ApiErrorShape { export interface ApiErrorShape {
status: number; status: number;
message: string; message: string;
@@ -85,7 +83,7 @@ export function isApiError(error: unknown): error is ApiError {
* Authenticated fetch utility using cookie auth. * Authenticated fetch utility using cookie auth.
*/ */
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> { export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
const resolvedUrl = withBasePath(url); const resolvedUrl = url;
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
if (!headers.has("Content-Type") && typeof options.body === "string") { if (!headers.has("Content-Type") && typeof options.body === "string") {
@@ -116,10 +114,10 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
}); });
if (response.status === 401 && typeof window !== "undefined") { if (response.status === 401 && typeof window !== "undefined") {
const path = stripBasePath(window.location.pathname); const path = window.location.pathname;
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup"); const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
if (!isAuthPage) { if (!isAuthPage) {
window.location.href = withBasePath("/login"); window.location.href = "/login";
return new Promise(() => {}); return new Promise(() => {});
} }
} }
@@ -136,7 +134,7 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
export async function apiJson<T>(url: string, options: RequestInit = {}): Promise<T> { export async function apiJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await apiFetch(url, options); const response = await apiFetch(url, options);
if (!response.ok) { if (!response.ok) {
throw await toApiError(withBasePath(url), response); throw await toApiError(url, response);
} }
return (await parseResponseBody(response)) as T; return (await parseResponseBody(response)) as T;
} }
@@ -144,7 +142,7 @@ export async function apiJson<T>(url: string, options: RequestInit = {}): Promis
export async function apiAction(url: string, options: RequestInit = {}): Promise<void> { export async function apiAction(url: string, options: RequestInit = {}): Promise<void> {
const response = await apiFetch(url, options); const response = await apiFetch(url, options);
if (!response.ok) { if (!response.ok) {
throw await toApiError(withBasePath(url), response); throw await toApiError(url, response);
} }
} }

View File

@@ -1,53 +0,0 @@
declare global {
interface Window {
__ALCHEMIST_BASE_URL__?: string;
}
}
const PLACEHOLDER = "__ALCHEMIST_BASE_URL__";
function normalize(value: string | undefined): string {
const raw = (value ?? "").trim();
if (!raw || raw === "/" || raw === PLACEHOLDER) {
return "";
}
return raw.replace(/\/+$/, "");
}
export function getBasePath(): string {
if (typeof window !== "undefined") {
return normalize(window.__ALCHEMIST_BASE_URL__);
}
return "";
}
export function withBasePath(path: string): string {
if (/^[a-z]+:\/\//i.test(path)) {
return path;
}
const basePath = getBasePath();
if (!path) {
return basePath || "/";
}
if (path.startsWith("/")) {
return `${basePath}${path}`;
}
return `${basePath}/${path}`;
}
export function stripBasePath(pathname: string): string {
const basePath = getBasePath();
if (!basePath) {
return pathname || "/";
}
if (pathname === basePath) {
return "/";
}
if (pathname.startsWith(`${basePath}/`)) {
return pathname.slice(basePath.length) || "/";
}
return pathname || "/";
}

View File

@@ -3,7 +3,6 @@ import { Home } from "lucide-react";
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro"; import Sidebar from "../components/Sidebar.astro";
import HeaderActions from "../components/HeaderActions.tsx"; import HeaderActions from "../components/HeaderActions.tsx";
const basePath = "__ALCHEMIST_BASE_URL__";
--- ---
<Layout title="Alchemist | Page Not Found"> <Layout title="Alchemist | Page Not Found">
@@ -24,7 +23,7 @@ const basePath = "__ALCHEMIST_BASE_URL__";
The page you're looking for couldn't be found. It may have moved or the URL might be wrong. The page you're looking for couldn't be found. It may have moved or the URL might be wrong.
</p> </p>
<a <a
href={`${basePath}/`} href="/"
class="inline-flex items-center gap-2 mt-6 bg-helios-solar text-helios-main rounded-lg px-5 py-2.5 text-sm font-semibold hover:opacity-90 transition-opacity" class="inline-flex items-center gap-2 mt-6 bg-helios-solar text-helios-main rounded-lg px-5 py-2.5 text-sm font-semibold hover:opacity-90 transition-opacity"
> >
<Home size={16} /> <Home size={16} />

View File

@@ -7,7 +7,6 @@ interface Props {
} }
const { error } = Astro.props; const { error } = Astro.props;
const basePath = "__ALCHEMIST_BASE_URL__";
--- ---
<Layout title="Alchemist | Server Error"> <Layout title="Alchemist | Server Error">
@@ -29,7 +28,7 @@ const basePath = "__ALCHEMIST_BASE_URL__";
) : null} ) : null}
<a <a
href={`${basePath}/`} href="/"
class="px-6 py-2.5 bg-helios-orange hover:bg-helios-orange/90 text-helios-main font-medium rounded-md transition-colors" class="px-6 py-2.5 bg-helios-orange hover:bg-helios-orange/90 text-helios-main font-medium rounded-md transition-colors"
> >
Return to Dashboard Return to Dashboard

View File

@@ -2,7 +2,7 @@
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro"; import Sidebar from "../components/Sidebar.astro";
import HeaderActions from "../components/HeaderActions.tsx"; import HeaderActions from "../components/HeaderActions.tsx";
import ConversionTool from "../components/ConversionTool.tsx"; import {ConversionTool} from "../components/ConversionTool.tsx";
--- ---
<Layout title="Alchemist | Convert"> <Layout title="Alchemist | Convert">

View File

@@ -68,12 +68,11 @@ import { ArrowRight } from "lucide-react";
<script> <script>
import { apiAction, apiJson, isApiError } from "../lib/api"; import { apiAction, apiJson, isApiError } from "../lib/api";
const basePath = "__ALCHEMIST_BASE_URL__";
void apiJson<{ setup_required: boolean }>("/api/setup/status") void apiJson<{ setup_required: boolean }>("/api/setup/status")
.then((data) => { .then((data) => {
if (data?.setup_required) { if (data?.setup_required) {
window.location.href = `${basePath}/setup`; window.location.href = "/setup";
} }
}) })
.catch(() => undefined); .catch(() => undefined);
@@ -96,7 +95,7 @@ import { ArrowRight } from "lucide-react";
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
window.location.href = `${basePath}/`; window.location.href = "/";
} catch (err) { } catch (err) {
console.error("Login failed", err); console.error("Login failed", err);
errorMsg?.classList.remove('hidden'); errorMsg?.classList.remove('hidden');

View File

@@ -15,11 +15,10 @@ import SetupSidebar from "../components/SetupSidebar.astro";
<script> <script>
import { apiJson } from "../lib/api"; import { apiJson } from "../lib/api";
const basePath = "__ALCHEMIST_BASE_URL__";
apiJson<{ setup_required: boolean }>("/api/setup/status") apiJson<{ setup_required: boolean }>("/api/setup/status")
.then((data) => { .then((data) => {
if (!data.setup_required) { if (!data.setup_required) {
window.location.href = `${basePath}/`; window.location.href = "/";
} }
}) })
.catch(() => undefined); .catch(() => undefined);