mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
fix bug with set up causing unreachable set up page
This commit is contained in:
6
.idea/alchemist.iml
generated
6
.idea/alchemist.iml
generated
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
@@ -8,5 +13,6 @@
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="Python 3.14 (alchemist) interpreter library" level="application" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
6
.idea/misc.xml
generated
Normal 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>
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,32 @@
|
||||
|
||||
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
|
||||
|
||||
### Security
|
||||
|
||||
19
README.md
19
README.md
@@ -74,6 +74,7 @@ services:
|
||||
```
|
||||
|
||||
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
|
||||
`~/.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.
|
||||
|
||||
## 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
|
||||
|
||||
1. Open [http://localhost:3000](http://localhost:3000).
|
||||
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.
|
||||
4. Alchemist scans and starts working automatically.
|
||||
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**.
|
||||
- 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
|
||||
|
||||
|
||||
3
TODO.md
Normal file
3
TODO.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Todo List
|
||||
|
||||
Remove `src/wizard.rs` from the project, the web setup handles it.. maybe keep for CLI users?
|
||||
@@ -45,11 +45,6 @@ documentation, or iteration.
|
||||
- Token management endpoints and Settings UI
|
||||
- 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
|
||||
- In-repo distribution metadata sources for:
|
||||
- Homebrew
|
||||
|
||||
@@ -9,8 +9,9 @@ except:
|
||||
- `/api/auth/*`
|
||||
- `/api/health`
|
||||
- `/api/ready`
|
||||
- setup-mode exceptions: `/api/setup/*`, `/api/fs/*`,
|
||||
`/api/settings/bundle`, `/api/system/hardware`
|
||||
- during first-time setup, the setup UI and setup-related
|
||||
unauthenticated routes are only reachable from the local
|
||||
network
|
||||
|
||||
Authentication is established by `POST /api/auth/login`.
|
||||
The backend also accepts `Authorization: Bearer <token>`.
|
||||
|
||||
@@ -3,6 +3,32 @@ title: Changelog
|
||||
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
|
||||
|
||||
### Security
|
||||
|
||||
@@ -97,7 +97,6 @@ requires at least one day in every window.
|
||||
| `enable_telemetry` | bool | `false` | Opt-in anonymous telemetry switch |
|
||||
| `log_retention_days` | int | `30` | Log retention period in days |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ description: All environment variables Alchemist reads at startup.
|
||||
| `ALCHEMIST_CONFIG` | (alias) | Alias for `ALCHEMIST_CONFIG_PATH` |
|
||||
| `ALCHEMIST_DB_PATH` | `~/.config/alchemist/alchemist.db` | Path to SQLite database |
|
||||
| `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 |
|
||||
| `RUST_LOG` | `info` | Log level: `info`, `debug`, `alchemist=trace` |
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ description: Getting through the setup wizard and starting your first scan.
|
||||
|
||||
When you first open Alchemist at `http://localhost:3000`
|
||||
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
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ docker compose up -d
|
||||
```
|
||||
|
||||
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
|
||||
[GPU Passthrough](/gpu-passthrough).
|
||||
@@ -110,6 +111,19 @@ just dev
|
||||
Windows contributor support covers the core `install/dev/check` path.
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -682,8 +682,6 @@ pub struct SystemConfig {
|
||||
/// Enable HSTS header (only enable if running behind HTTPS)
|
||||
#[serde(default)]
|
||||
pub https_only: bool,
|
||||
#[serde(default)]
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -710,7 +708,6 @@ impl Default for SystemConfig {
|
||||
log_retention_days: default_log_retention_days(),
|
||||
engine_mode: EngineMode::default(),
|
||||
https_only: false,
|
||||
base_url: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -826,7 +823,6 @@ impl Default for Config {
|
||||
log_retention_days: default_log_retention_days(),
|
||||
engine_mode: EngineMode::default(),
|
||||
https_only: false,
|
||||
base_url: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -923,7 +919,6 @@ impl Config {
|
||||
}
|
||||
|
||||
validate_schedule_time(&self.notifications.daily_summary_time_local)?;
|
||||
normalize_base_url(&self.system.base_url)?;
|
||||
for target in &self.notifications.targets {
|
||||
target.validate()?;
|
||||
}
|
||||
@@ -1026,7 +1021,6 @@ impl Config {
|
||||
}
|
||||
|
||||
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() {
|
||||
self.notifications.webhook_url = None;
|
||||
self.notifications.discord_webhook = None;
|
||||
@@ -1046,33 +1040,7 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
pub(crate) fn apply_env_overrides(&mut self) {}
|
||||
}
|
||||
|
||||
fn validate_schedule_time(value: &str) -> Result<()> {
|
||||
@@ -1158,65 +1126,4 @@ mod tests {
|
||||
assert_eq!(EngineMode::default(), EngineMode::Balanced);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
457
src/main.rs
457
src/main.rs
@@ -2,15 +2,18 @@
|
||||
|
||||
use alchemist::db::EventChannels;
|
||||
use alchemist::error::Result;
|
||||
use alchemist::media::pipeline::{Analyzer as _, Planner as _};
|
||||
use alchemist::system::hardware;
|
||||
use alchemist::version;
|
||||
use alchemist::{Agent, Transcoder, config, db, runtime};
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt::time::time;
|
||||
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -19,21 +22,55 @@ use tokio::sync::broadcast;
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version = version::current(), about, long_about = None)]
|
||||
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)
|
||||
#[arg(long)]
|
||||
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]
|
||||
@@ -160,17 +197,21 @@ fn should_enter_setup_mode_for_missing_users(is_server_mode: bool, has_users: bo
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_thread_names(true)
|
||||
.init();
|
||||
let args = Args::parse();
|
||||
init_logging(args.debug_flags);
|
||||
let is_server_mode = args.command.is_none();
|
||||
|
||||
let boot_start = Instant::now();
|
||||
|
||||
// Startup Banner
|
||||
info!(
|
||||
target: "startup",
|
||||
"Parsed CLI args: command={:?}, reset_auth={}, debug_flags={}",
|
||||
args.command,
|
||||
args.reset_auth,
|
||||
args.debug_flags
|
||||
);
|
||||
|
||||
if is_server_mode {
|
||||
info!(
|
||||
" ______ __ ______ __ __ ______ __ __ __ ______ ______ "
|
||||
);
|
||||
@@ -204,32 +245,31 @@ async fn run() -> Result<()> {
|
||||
);
|
||||
info!(" CPUs: {}", num_cpus::get());
|
||||
info!("");
|
||||
|
||||
let args = Args::parse();
|
||||
info!(
|
||||
target: "startup",
|
||||
"Parsed CLI args: cli_mode={}, reset_auth={}, dry_run={}, directories={}",
|
||||
args.cli,
|
||||
args.reset_auth,
|
||||
args.dry_run,
|
||||
args.directories.len()
|
||||
);
|
||||
|
||||
// ... rest of logic remains largely the same, just inside run()
|
||||
// Default to server mode unless CLI is explicitly requested.
|
||||
let is_server_mode = !args.cli;
|
||||
info!(target: "startup", "Resolved server mode: {}", is_server_mode);
|
||||
if is_server_mode && !args.directories.is_empty() {
|
||||
warn!("Directories were provided without --cli; ignoring CLI inputs.");
|
||||
}
|
||||
|
||||
info!(target: "startup", "Resolved server mode: {}", is_server_mode);
|
||||
|
||||
// 0. Load Configuration
|
||||
let config_start = Instant::now();
|
||||
let config_path = runtime::config_path();
|
||||
let db_path = runtime::db_path();
|
||||
let config_mutable = runtime::config_mutable();
|
||||
let (config, mut setup_mode, config_exists) =
|
||||
load_startup_config(config_path.as_path(), is_server_mode);
|
||||
let (config, mut setup_mode, config_exists) = if 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!(
|
||||
target: "startup",
|
||||
"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.");
|
||||
setup_mode = true;
|
||||
}
|
||||
let has_users = db.has_users().await?;
|
||||
if is_server_mode {
|
||||
let users_start = Instant::now();
|
||||
let has_users = db.has_users().await?;
|
||||
info!(
|
||||
target: "startup",
|
||||
"User check completed (has_users={}) in {} ms",
|
||||
@@ -386,6 +426,13 @@ async fn run() -> Result<()> {
|
||||
}
|
||||
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 {
|
||||
@@ -518,7 +565,7 @@ async fn run() -> Result<()> {
|
||||
hardware_state.clone(),
|
||||
tx.clone(),
|
||||
event_channels.clone(),
|
||||
args.dry_run,
|
||||
matches!(args.command, Some(Commands::Run { dry_run: true, .. })),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
@@ -748,29 +795,40 @@ async fn run() -> Result<()> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// CLI Mode
|
||||
if setup_mode {
|
||||
error!(
|
||||
"Configuration required. Run without --cli to use the web-based setup wizard, or create {:?} manually.",
|
||||
config_path
|
||||
match args
|
||||
.command
|
||||
.clone()
|
||||
.expect("CLI branch requires a subcommand")
|
||||
{
|
||||
Commands::Scan { directories } => {
|
||||
agent.scan_and_enqueue(directories).await?;
|
||||
info!("Scan complete. Matching files were enqueued.");
|
||||
}
|
||||
Commands::Run { directories, .. } => {
|
||||
agent.scan_and_enqueue(directories).await?;
|
||||
wait_for_cli_jobs(db.as_ref()).await?;
|
||||
info!("All jobs processed.");
|
||||
}
|
||||
Commands::Plan { directories, json } => {
|
||||
let items =
|
||||
build_cli_plan(db.as_ref(), config.clone(), &hardware_state, directories)
|
||||
.await?;
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string())
|
||||
);
|
||||
|
||||
// CLI early exit - error
|
||||
// (Caller will handle pause-on-exit if needed)
|
||||
return Err(alchemist::error::AlchemistError::Config(
|
||||
"Missing configuration".into(),
|
||||
));
|
||||
} else {
|
||||
print_cli_plan(&items);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if args.directories.is_empty() {
|
||||
error!("No directories provided. Usage: alchemist --cli --dir <DIR> [--dir <DIR> ...]");
|
||||
return Err(alchemist::error::AlchemistError::Config(
|
||||
"Missing directories for CLI mode".into(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
agent.scan_and_enqueue(args.directories).await?;
|
||||
|
||||
// Wait until all jobs are processed
|
||||
async fn wait_for_cli_jobs(db: &db::Db) -> Result<()> {
|
||||
info!("Waiting for jobs to complete...");
|
||||
loop {
|
||||
let stats = db.get_stats().await?;
|
||||
@@ -792,10 +850,250 @@ async fn run() -> Result<()> {
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
info!("All jobs processed.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -836,6 +1134,41 @@ mod tests {
|
||||
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]
|
||||
fn config_reload_matches_create_modify_and_rename_events() {
|
||||
let config_path = PathBuf::from("/tmp/alchemist-config.toml");
|
||||
|
||||
@@ -6,6 +6,7 @@ pub fn append_args(
|
||||
encoder: Encoder,
|
||||
rate_control: Option<RateControl>,
|
||||
preset: Option<&str>,
|
||||
tag_hevc_as_hvc1: bool,
|
||||
) {
|
||||
match encoder {
|
||||
Encoder::Av1Svt => {
|
||||
@@ -48,9 +49,10 @@ pub fn append_args(
|
||||
preset.unwrap_or(CpuPreset::Medium.as_str()).to_string(),
|
||||
"-crf".to_string(),
|
||||
crf,
|
||||
"-tag:v".to_string(),
|
||||
"hvc1".to_string(),
|
||||
]);
|
||||
if tag_hevc_as_hvc1 {
|
||||
args.extend(["-tag:v".to_string(), "hvc1".to_string()]);
|
||||
}
|
||||
}
|
||||
Encoder::H264X264 => {
|
||||
let crf = match rate_control {
|
||||
|
||||
@@ -182,6 +182,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
}
|
||||
|
||||
let rate_control = self.plan.rate_control.clone();
|
||||
let tag_hevc_as_hvc1 = uses_quicktime_container(&self.plan.container);
|
||||
let mut args = vec![
|
||||
"-hide_banner".to_string(),
|
||||
"-y".to_string(),
|
||||
@@ -249,12 +250,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
Encoder::Av1Videotoolbox
|
||||
| Encoder::HevcVideotoolbox
|
||||
| Encoder::H264Videotoolbox => {
|
||||
videotoolbox::append_args(
|
||||
&mut args,
|
||||
encoder,
|
||||
rate_control.clone(),
|
||||
default_quality(&self.plan.rate_control, 65),
|
||||
);
|
||||
videotoolbox::append_args(&mut args, encoder, tag_hevc_as_hvc1);
|
||||
}
|
||||
Encoder::Av1Svt | Encoder::Av1Aom | Encoder::HevcX265 | Encoder::H264X264 => {
|
||||
cpu::append_args(
|
||||
@@ -262,6 +258,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
encoder,
|
||||
rate_control.clone(),
|
||||
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_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("+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)]
|
||||
pub struct FFmpegProgress {
|
||||
pub frame: u64,
|
||||
@@ -1041,6 +1042,83 @@ mod tests {
|
||||
.build_args()
|
||||
.unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}"));
|
||||
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]
|
||||
|
||||
@@ -1,51 +1,29 @@
|
||||
use crate::media::pipeline::{Encoder, RateControl};
|
||||
|
||||
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,
|
||||
};
|
||||
use crate::media::pipeline::Encoder;
|
||||
|
||||
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 {
|
||||
Encoder::Av1Videotoolbox => {
|
||||
args.extend([
|
||||
"-c:v".to_string(),
|
||||
"av1_videotoolbox".to_string(),
|
||||
"-b:v".to_string(),
|
||||
"0".to_string(),
|
||||
"-q:v".to_string(),
|
||||
cq.to_string(),
|
||||
"-allow_sw".to_string(),
|
||||
"1".to_string(),
|
||||
]);
|
||||
}
|
||||
Encoder::HevcVideotoolbox => {
|
||||
args.extend([
|
||||
"-c:v".to_string(),
|
||||
"hevc_videotoolbox".to_string(),
|
||||
"-b:v".to_string(),
|
||||
"0".to_string(),
|
||||
"-q:v".to_string(),
|
||||
cq.to_string(),
|
||||
"-tag:v".to_string(),
|
||||
"hvc1".to_string(),
|
||||
"-allow_sw".to_string(),
|
||||
"1".to_string(),
|
||||
]);
|
||||
args.extend(["-c:v".to_string(), "hevc_videotoolbox".to_string()]);
|
||||
if tag_hevc_as_hvc1 {
|
||||
args.extend(["-tag:v".to_string(), "hvc1".to_string()]);
|
||||
}
|
||||
args.extend(["-allow_sw".to_string(), "1".to_string()]);
|
||||
}
|
||||
Encoder::H264Videotoolbox => {
|
||||
args.extend([
|
||||
"-c:v".to_string(),
|
||||
"h264_videotoolbox".to_string(),
|
||||
"-b:v".to_string(),
|
||||
"0".to_string(),
|
||||
"-q:v".to_string(),
|
||||
cq.to_string(),
|
||||
"-allow_sw".to_string(),
|
||||
"1".to_string(),
|
||||
]);
|
||||
|
||||
@@ -514,7 +514,7 @@ pub async fn enqueue_discovered_with_db(
|
||||
.await
|
||||
}
|
||||
|
||||
fn default_file_settings() -> crate::db::FileSettings {
|
||||
pub fn default_file_settings() -> crate::db::FileSettings {
|
||||
crate::db::FileSettings {
|
||||
id: 1,
|
||||
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('.');
|
||||
if !expected_extension.is_empty() {
|
||||
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))
|
||||
}
|
||||
|
||||
async fn skip_reason_for_discovered_path(
|
||||
pub async fn skip_reason_for_discovered_path(
|
||||
db: &crate::db::Db,
|
||||
path: &Path,
|
||||
settings: &crate::db::FileSettings,
|
||||
|
||||
@@ -170,9 +170,9 @@ impl Agent {
|
||||
pub fn set_boot_analyzing(&self, value: bool) {
|
||||
self.analyzing_boot.store(value, Ordering::SeqCst);
|
||||
if value {
|
||||
info!("Boot analysis started — engine claim loop paused.");
|
||||
debug!("Boot analysis started — engine claim loop paused.");
|
||||
} 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.
|
||||
async fn _run_analysis_pass(&self) {
|
||||
self.set_boot_analyzing(true);
|
||||
info!("Auto-analysis: starting pass...");
|
||||
debug!("Auto-analysis: starting pass...");
|
||||
|
||||
// NOTE: reset_interrupted_jobs is intentionally
|
||||
// NOT called here. It is a one-time startup
|
||||
@@ -244,7 +244,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
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 {
|
||||
let pipeline = self.pipeline();
|
||||
@@ -264,9 +264,9 @@ impl Agent {
|
||||
self.set_boot_analyzing(false);
|
||||
|
||||
if total_analyzed == 0 {
|
||||
info!("Auto-analysis: no jobs pending analysis.");
|
||||
debug!("Auto-analysis: no jobs pending analysis.");
|
||||
} else {
|
||||
info!(
|
||||
debug!(
|
||||
"Auto-analysis: complete. {} job(s) analyzed.",
|
||||
total_analyzed
|
||||
);
|
||||
@@ -359,7 +359,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
pub async fn run_loop(self: Arc<Self>) {
|
||||
info!("Agent loop started.");
|
||||
debug!("Agent loop started.");
|
||||
loop {
|
||||
// Block while paused OR while boot analysis runs
|
||||
if self.is_paused() || self.is_boot_analyzing() {
|
||||
|
||||
@@ -2,7 +2,7 @@ use rayon::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{debug, error};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::media::pipeline::DiscoveredMedia;
|
||||
@@ -45,7 +45,7 @@ impl Scanner {
|
||||
);
|
||||
|
||||
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 source_roots = source_roots.clone();
|
||||
let walker = if recursive {
|
||||
@@ -90,7 +90,6 @@ impl Scanner {
|
||||
// Deterministic ordering
|
||||
final_files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
info!("Found {} candidate media files", final_files.len());
|
||||
final_files
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@ pub(crate) async fn auth_middleware(
|
||||
let path = req.uri().path();
|
||||
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
|
||||
if path.starts_with("/api") {
|
||||
// Public API endpoints
|
||||
@@ -92,29 +104,8 @@ pub(crate) async fn auth_middleware(
|
||||
return next.run(req).await;
|
||||
}
|
||||
if state.setup_required.load(Ordering::Relaxed) && path.starts_with("/api/fs/") {
|
||||
// Only allow filesystem browsing from localhost
|
||||
// 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" {
|
||||
return next.run(req).await;
|
||||
}
|
||||
@@ -157,6 +148,10 @@ pub(crate) async fn auth_middleware(
|
||||
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 {
|
||||
if *method != Method::GET && *method != Method::HEAD {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use axum::{
|
||||
extract::State,
|
||||
http::{StatusCode, Uri, header},
|
||||
middleware as axum_middleware,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
#[cfg(feature = "embed-web")]
|
||||
@@ -81,7 +81,6 @@ pub struct AppState {
|
||||
pub library_scanner: Arc<crate::system::scanner::LibraryScanner>,
|
||||
pub config_path: PathBuf,
|
||||
pub config_mutable: bool,
|
||||
pub base_url: String,
|
||||
pub hardware_state: HardwareState,
|
||||
pub hardware_probe_log: Arc<tokio::sync::RwLock<HardwareProbeLog>>,
|
||||
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_memory();
|
||||
|
||||
let base_url = {
|
||||
let config = config.read().await;
|
||||
config.system.base_url.clone()
|
||||
};
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
config,
|
||||
@@ -168,7 +162,6 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
|
||||
library_scanner,
|
||||
config_path,
|
||||
config_mutable,
|
||||
base_url: base_url.clone(),
|
||||
hardware_state,
|
||||
hardware_probe_log,
|
||||
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
|
||||
let shutdown_agent = state.agent.clone();
|
||||
|
||||
let inner_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 app = app_router(state.clone());
|
||||
|
||||
let port = std::env::var("ALCHEMIST_SERVER_PORT")
|
||||
.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
|
||||
}
|
||||
|
||||
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 path = match sanitize_asset_path(raw_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) {
|
||||
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||
return (
|
||||
[(header::CONTENT_TYPE, mime.as_ref())],
|
||||
maybe_inject_base_url(content, mime.as_ref(), &state.base_url),
|
||||
)
|
||||
.into_response();
|
||||
return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
|
||||
}
|
||||
|
||||
// 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);
|
||||
if let Some(content) = load_static_asset(&index_path) {
|
||||
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
|
||||
return (
|
||||
[(header::CONTENT_TYPE, mime.as_ref())],
|
||||
maybe_inject_base_url(content, mime.as_ref(), &state.base_url),
|
||||
)
|
||||
.into_response();
|
||||
return ([(header::CONTENT_TYPE, mime.as_ref())], content).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.
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ where
|
||||
library_scanner: Arc::new(crate::system::scanner::LibraryScanner::new(db, config)),
|
||||
config_path: config_path.clone(),
|
||||
config_mutable: true,
|
||||
base_url: String::new(),
|
||||
hardware_state,
|
||||
hardware_probe_log,
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
let bytes = match to_bytes(response.into_body(), usize::MAX).await {
|
||||
Ok(bytes) => bytes,
|
||||
@@ -740,32 +750,6 @@ async fn read_only_api_token_cannot_access_settings_config()
|
||||
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]
|
||||
async fn hardware_probe_log_route_returns_runtime_log()
|
||||
-> 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
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/setup/complete")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
.oneshot({
|
||||
let mut request = localhost_request(
|
||||
Method::POST,
|
||||
"/api/setup/complete",
|
||||
Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
@@ -838,9 +821,14 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
|
||||
"quality_profile": "balanced"
|
||||
})
|
||||
.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?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
@@ -932,23 +920,25 @@ async fn setup_complete_accepts_nested_settings_payload()
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/setup/complete")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
.oneshot({
|
||||
let mut request = localhost_request(
|
||||
Method::POST,
|
||||
"/api/setup/complete",
|
||||
Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"settings": settings,
|
||||
})
|
||||
.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?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
@@ -981,23 +971,25 @@ async fn setup_complete_rejects_nested_settings_without_library_directories()
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/setup/complete")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
.oneshot({
|
||||
let mut request = localhost_request(
|
||||
Method::POST,
|
||||
"/api/setup/complete",
|
||||
Body::from(
|
||||
json!({
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"settings": settings,
|
||||
})
|
||||
.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?;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = body_text(response).await;
|
||||
@@ -1076,7 +1068,7 @@ async fn fs_endpoints_require_loopback_during_setup()
|
||||
.await?;
|
||||
assert_eq!(browse_response.status(), StatusCode::FORBIDDEN);
|
||||
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(
|
||||
Method::POST,
|
||||
@@ -1096,12 +1088,78 @@ async fn fs_endpoints_require_loopback_during_setup()
|
||||
let preview_response = app.clone().oneshot(preview_request).await?;
|
||||
assert_eq!(preview_response.status(), StatusCode::FORBIDDEN);
|
||||
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]);
|
||||
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]
|
||||
async fn settings_bundle_requires_auth_after_setup()
|
||||
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { apiFetch, apiJson } from "../lib/api";
|
||||
import { stripBasePath, withBasePath } from "../lib/basePath";
|
||||
|
||||
interface SetupStatus {
|
||||
setup_required?: boolean;
|
||||
@@ -11,7 +10,7 @@ export default function AuthGuard() {
|
||||
let cancelled = false;
|
||||
|
||||
const checkAuth = async () => {
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
const path = window.location.pathname;
|
||||
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
|
||||
if (isAuthPage) {
|
||||
return;
|
||||
@@ -28,9 +27,7 @@ export default function AuthGuard() {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = setupStatus.setup_required
|
||||
? withBasePath("/setup")
|
||||
: withBasePath("/login");
|
||||
window.location.href = setupStatus.setup_required ? "/setup" : "/login";
|
||||
} catch {
|
||||
// Keep user on current page on transient backend/network failures.
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Upload, Wand2, Play, Download, Trash2 } from "lucide-react";
|
||||
import { apiAction, apiFetch, apiJson, isApiError } from "../lib/api";
|
||||
import { withBasePath } from "../lib/basePath";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
interface SubtitleStreamMetadata {
|
||||
@@ -105,7 +104,7 @@ const DEFAULT_SETTINGS: ConversionSettings = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function ConversionTool() {
|
||||
export function ConversionTool() {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
@@ -121,7 +120,8 @@ export default function ConversionTool() {
|
||||
const id = window.setInterval(() => {
|
||||
void apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`)
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
});
|
||||
}, 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [conversionJobId]);
|
||||
@@ -223,7 +223,7 @@ export default function ConversionTool() {
|
||||
|
||||
const download = async () => {
|
||||
if (!conversionJobId) return;
|
||||
window.location.href = withBasePath(`/api/conversion/jobs/${conversionJobId}/download`);
|
||||
window.location.href = `/api/conversion/jobs/${conversionJobId}/download`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -231,22 +231,26 @@ export default function ConversionTool() {
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-helios-ink">Conversion / Remux</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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
|
||||
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>
|
||||
<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>
|
||||
<input
|
||||
type="file"
|
||||
@@ -279,7 +283,9 @@ export default function ConversionTool() {
|
||||
|
||||
<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>
|
||||
<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) => (
|
||||
<option key={option} value={option}>{option.toUpperCase()}</option>
|
||||
))}
|
||||
@@ -311,41 +317,59 @@ export default function ConversionTool() {
|
||||
value={settings.video.codec}
|
||||
disabled={settings.remux_only}
|
||||
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
|
||||
label="Mode"
|
||||
value={settings.video.mode}
|
||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||
options={["crf", "bitrate"]}
|
||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, mode: value } }))}
|
||||
onChange={(value) => setSettings((current) => ({
|
||||
...current,
|
||||
video: {...current.video, mode: value}
|
||||
}))}
|
||||
/>
|
||||
<NumberField
|
||||
label={settings.video.mode === "bitrate" ? "Bitrate (kbps)" : "Quality Value"}
|
||||
value={settings.video.value ?? 0}
|
||||
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
|
||||
label="Preset"
|
||||
value={settings.video.preset ?? "medium"}
|
||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||
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
|
||||
label="Resolution Mode"
|
||||
value={settings.video.resolution.mode}
|
||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||
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
|
||||
label="HDR"
|
||||
value={settings.video.hdr_mode}
|
||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||
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" && (
|
||||
<>
|
||||
@@ -353,13 +377,25 @@ export default function ConversionTool() {
|
||||
label="Width"
|
||||
value={settings.video.resolution.width ?? probe.metadata.width}
|
||||
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
|
||||
label="Height"
|
||||
value={settings.video.resolution.height ?? probe.metadata.height}
|
||||
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}
|
||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||
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>
|
||||
@@ -383,20 +425,29 @@ export default function ConversionTool() {
|
||||
value={settings.audio.codec}
|
||||
disabled={settings.remux_only}
|
||||
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
|
||||
label="Bitrate (kbps)"
|
||||
value={settings.audio.bitrate_kbps ?? 160}
|
||||
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
|
||||
label="Channels"
|
||||
value={settings.audio.channels ?? "auto"}
|
||||
disabled={settings.remux_only || settings.audio.codec === "copy"}
|
||||
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>
|
||||
</section>
|
||||
@@ -414,25 +465,30 @@ export default function ConversionTool() {
|
||||
|
||||
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
||||
<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}
|
||||
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"}
|
||||
</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}
|
||||
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"}
|
||||
</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}
|
||||
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
|
||||
</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()}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
{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}
|
||||
</pre>
|
||||
)}
|
||||
@@ -444,7 +500,8 @@ export default function ConversionTool() {
|
||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||
<Stat label="State" value={status.status}/>
|
||||
<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"
|
||||
value={status.linked_job_id ? `#${status.linked_job_id}` : "None"}/>
|
||||
<Stat label="Download" value={status.download_ready ? "Ready" : "Pending"}/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { apiJson, isApiError } from "../lib/api";
|
||||
import { withBasePath } from "../lib/basePath";
|
||||
import { useSharedStats } from "../lib/statsStore";
|
||||
import { showToast } from "../lib/toast";
|
||||
import ResourceMonitor from "./ResourceMonitor";
|
||||
@@ -145,7 +144,7 @@ function Dashboard() {
|
||||
}
|
||||
|
||||
if (setupComplete !== "true") {
|
||||
window.location.href = withBasePath("/setup");
|
||||
window.location.href = "/setup";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -233,7 +232,7 @@ function Dashboard() {
|
||||
<Activity size={16} className="text-helios-solar" />
|
||||
Recent Activity
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
@@ -249,7 +248,7 @@ function Dashboard() {
|
||||
<span className="text-sm text-helios-slate/60">
|
||||
No recent activity.
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Info, LogOut, Play, Square } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
import { apiAction, apiJson } from "../lib/api";
|
||||
import { withBasePath } from "../lib/basePath";
|
||||
import { useSharedStats } from "../lib/statsStore";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
@@ -147,7 +146,7 @@ export default function HeaderActions() {
|
||||
message: "Logout request failed. Redirecting to login.",
|
||||
});
|
||||
} finally {
|
||||
window.location.href = withBasePath("/login");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal, ArrowDown, ArrowUp, AlertCircle
|
||||
} from "lucide-react";
|
||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||
import { withBasePath } from "../lib/basePath";
|
||||
import { useDebouncedValue } from "../lib/useDebouncedValue";
|
||||
import { showToast } from "../lib/toast";
|
||||
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||
@@ -502,6 +501,7 @@ function JobManager() {
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<TabType>("all");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [compactSearchOpen, setCompactSearchOpen] = useState(false);
|
||||
const debouncedSearch = useDebouncedValue(searchInput, 350);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<SortField>("updated_at");
|
||||
@@ -514,6 +514,8 @@ function JobManager() {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const detailDialogRef = useRef<HTMLDivElement | 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 encodeStartTimes = useRef<Map<number, number>>(new Map());
|
||||
const [confirmState, setConfirmState] = useState<{
|
||||
@@ -530,6 +532,43 @@ function JobManager() {
|
||||
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 formatJobActionError = (error: unknown, fallback: string) => {
|
||||
@@ -665,7 +704,7 @@ function JobManager() {
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
eventSource?.close();
|
||||
eventSource = new EventSource(withBasePath("/api/events"));
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.onopen = () => {
|
||||
// Reset reconnect attempts on successful connection
|
||||
@@ -1084,8 +1123,8 @@ function JobManager() {
|
||||
</div>
|
||||
|
||||
{/* 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 gap-1 p-1 bg-helios-surface border border-helios-line/10 rounded-lg">
|
||||
<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 flex-wrap gap-1">
|
||||
{(["all", "active", "queued", "completed", "failed", "skipped", "archived"] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -1102,8 +1141,8 @@ function JobManager() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center md:w-auto">
|
||||
<div className="relative flex-1 md:w-64">
|
||||
<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 hidden xl:block xl:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate" size={14} />
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
@@ -1142,10 +1181,45 @@ function JobManager() {
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Terminal, Pause, Play, Trash2, RefreshCw, Search } from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||
import { withBasePath } from "../lib/basePath";
|
||||
import { showToast } from "../lib/toast";
|
||||
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||
|
||||
@@ -73,7 +72,7 @@ export default function LogViewer() {
|
||||
|
||||
setStreamError(null);
|
||||
eventSource?.close();
|
||||
eventSource = new EventSource(withBasePath("/api/events"));
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
const appendLog = (message: string, level: string, jobId?: number) => {
|
||||
if (pausedRef.current) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||
import { withBasePath } from "../lib/basePath";
|
||||
import AdminAccountStep from "./setup/AdminAccountStep";
|
||||
import LibraryStep from "./setup/LibraryStep";
|
||||
import ProcessingStep from "./setup/ProcessingStep";
|
||||
@@ -103,7 +102,7 @@ export default function SetupWizard() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: "setup_complete", value: "true" }),
|
||||
}).catch(() => undefined);
|
||||
window.location.href = withBasePath("/");
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
let message = "Failed to save setup configuration.";
|
||||
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.";
|
||||
} else if (err.status === 403) {
|
||||
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) {
|
||||
message = `Server error during setup (${err.status}). Check the Alchemist logs for details.`;
|
||||
} else {
|
||||
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
import SystemStatus from "./SystemStatus.tsx";
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
const withBase = (href: string) => `${basePath}${href === "/" ? "/" : href}`;
|
||||
const strippedPath =
|
||||
basePath && currentPath.startsWith(basePath)
|
||||
? currentPath.slice(basePath.length) || "/"
|
||||
: currentPath;
|
||||
const strippedPath = currentPath;
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard", Icon: Activity },
|
||||
@@ -33,7 +28,7 @@ const navItems = [
|
||||
|
||||
{/* 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">
|
||||
<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
|
||||
id="sidebar-hamburger"
|
||||
aria-label="Open navigation"
|
||||
@@ -58,7 +53,7 @@ const navItems = [
|
||||
transition-transform duration-200 lg:transition-none"
|
||||
>
|
||||
<a
|
||||
href={withBase("/")}
|
||||
href="/"
|
||||
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">
|
||||
@@ -81,7 +76,7 @@ const navItems = [
|
||||
(href !== "/" && strippedPath.startsWith(href));
|
||||
return (
|
||||
<a
|
||||
href={withBase(href)}
|
||||
href={href}
|
||||
class:list={[
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap",
|
||||
isActive
|
||||
|
||||
@@ -9,7 +9,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -18,12 +17,9 @@ const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="Alchemist Media Transcoder" />
|
||||
<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} />
|
||||
<title>{title}</title>
|
||||
<script is:inline define:vars={{ basePath }}>
|
||||
window.__ALCHEMIST_BASE_URL__ = basePath;
|
||||
</script>
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { stripBasePath, withBasePath } from "./basePath";
|
||||
|
||||
export interface ApiErrorShape {
|
||||
status: number;
|
||||
message: string;
|
||||
@@ -85,7 +83,7 @@ export function isApiError(error: unknown): error is ApiError {
|
||||
* Authenticated fetch utility using cookie auth.
|
||||
*/
|
||||
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const resolvedUrl = withBasePath(url);
|
||||
const resolvedUrl = url;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
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") {
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
const path = window.location.pathname;
|
||||
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
|
||||
if (!isAuthPage) {
|
||||
window.location.href = withBasePath("/login");
|
||||
window.location.href = "/login";
|
||||
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> {
|
||||
const response = await apiFetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw await toApiError(withBasePath(url), response);
|
||||
throw await toApiError(url, response);
|
||||
}
|
||||
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> {
|
||||
const response = await apiFetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw await toApiError(withBasePath(url), response);
|
||||
throw await toApiError(url, response);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || "/";
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { Home } from "lucide-react";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
---
|
||||
|
||||
<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.
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
<Home size={16} />
|
||||
|
||||
@@ -7,7 +7,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const { error } = Astro.props;
|
||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Server Error">
|
||||
@@ -29,7 +28,7 @@ const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
) : null}
|
||||
|
||||
<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"
|
||||
>
|
||||
Return to Dashboard
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
import ConversionTool from "../components/ConversionTool.tsx";
|
||||
import {ConversionTool} from "../components/ConversionTool.tsx";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Convert">
|
||||
|
||||
@@ -68,12 +68,11 @@ import { ArrowRight } from "lucide-react";
|
||||
|
||||
<script>
|
||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
|
||||
void apiJson<{ setup_required: boolean }>("/api/setup/status")
|
||||
.then((data) => {
|
||||
if (data?.setup_required) {
|
||||
window.location.href = `${basePath}/setup`;
|
||||
window.location.href = "/setup";
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@@ -96,7 +95,7 @@ import { ArrowRight } from "lucide-react";
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
window.location.href = `${basePath}/`;
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
console.error("Login failed", err);
|
||||
errorMsg?.classList.remove('hidden');
|
||||
|
||||
@@ -15,11 +15,10 @@ import SetupSidebar from "../components/SetupSidebar.astro";
|
||||
|
||||
<script>
|
||||
import { apiJson } from "../lib/api";
|
||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
||||
apiJson<{ setup_required: boolean }>("/api/setup/status")
|
||||
.then((data) => {
|
||||
if (!data.setup_required) {
|
||||
window.location.href = `${basePath}/`;
|
||||
window.location.href = "/";
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user