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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<module type="EMPTY_MODULE" version="4">
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="FacetManager">
|
||||||
|
<facet type="Python" name="Python facet">
|
||||||
|
<configuration sdkName="Python 3.14 (alchemist)" />
|
||||||
|
</facet>
|
||||||
|
</component>
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
@@ -8,5 +13,6 @@
|
|||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Python 3.14 (alchemist) interpreter library" level="application" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
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.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.3.1-rc.1] - 2026-04-08
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
#### Conversion & Library Workflows
|
||||||
|
- **Experimental Conversion / Remux page** — upload a single file, inspect streams, preview the generated FFmpeg command, run a remux/transcode job through Alchemist, and download the result when complete.
|
||||||
|
- **Expanded Library Intelligence** — duplicate detection now sits alongside storage-focused recommendation sections for remux-only opportunities, wasteful audio layouts, and commentary/descriptive-track cleanup candidates.
|
||||||
|
|
||||||
|
#### Authentication & Automation
|
||||||
|
- **Named API tokens** — create bearer tokens from Settings with `read_only` or `full_access` access classes. Tokens are only shown once at creation time and stored server-side as hashes.
|
||||||
|
- **OpenAPI contract** — hand-maintained OpenAPI spec added alongside expanded human API docs for auth, token management, and update-check behavior.
|
||||||
|
|
||||||
|
#### Notifications
|
||||||
|
- **Provider-specific notification targets** — notification settings now use provider-specific configuration payloads instead of the old shared endpoint/token shape.
|
||||||
|
- **Provider expansion** — Discord webhook, Discord bot, Gotify, generic webhook, Telegram, and SMTP email targets are supported.
|
||||||
|
- **Richer event model** — notification events now distinguish queue/start/completion/failure plus scan completion, engine idle, and daily summary delivery.
|
||||||
|
- **Daily summary scheduling** — notifications include a global `daily_summary_time_local` setting and per-target opt-in for digest delivery.
|
||||||
|
|
||||||
|
#### Deployment & Distribution
|
||||||
|
- **Windows update check** — the About dialog now checks GitHub Releases for the latest stable version and links directly to the release download page when an update is available.
|
||||||
|
- **Distribution metadata generation** — in-repo Homebrew and AUR packaging templates plus workflow rendering were added as the foundation for package-manager distribution.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Config path clarity** — docs now consistently describe `~/.config/alchemist/config.toml` as the default host-side config location on Linux/macOS, while Docker examples still use `/app/config/config.toml` inside the container.
|
||||||
|
- **Backlog realignment** — the backlog was rewritten around current repo reality, marking large newly implemented surfaces as “Implemented / In Progress” and keeping the roadmap automation-first.
|
||||||
|
|
||||||
## [0.3.0] - 2026-04-06
|
## [0.3.0] - 2026-04-06
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -74,6 +74,7 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then open [http://localhost:3000](http://localhost:3000) in your browser.
|
Then open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
First-time setup is only reachable from the local network.
|
||||||
|
|
||||||
On Linux and macOS, the default host-side config location is
|
On Linux and macOS, the default host-side config location is
|
||||||
`~/.config/alchemist/config.toml`. When you use Docker, the
|
`~/.config/alchemist/config.toml`. When you use Docker, the
|
||||||
@@ -132,10 +133,26 @@ just check
|
|||||||
|
|
||||||
The core contributor path is supported on Windows. Broader release and utility recipes remain Unix-first.
|
The core contributor path is supported on Windows. Broader release and utility recipes remain Unix-first.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Alchemist exposes explicit CLI subcommands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alchemist scan /path/to/media
|
||||||
|
alchemist run /path/to/media
|
||||||
|
alchemist plan /path/to/media
|
||||||
|
alchemist plan /path/to/media --json
|
||||||
|
```
|
||||||
|
|
||||||
|
- `scan` enqueues matching work and exits
|
||||||
|
- `run` scans, enqueues, and waits for processing to finish
|
||||||
|
- `plan` analyzes files and reports what Alchemist would do without enqueuing jobs
|
||||||
|
|
||||||
## First Run
|
## First Run
|
||||||
|
|
||||||
1. Open [http://localhost:3000](http://localhost:3000).
|
1. Open [http://localhost:3000](http://localhost:3000).
|
||||||
2. Complete the setup wizard. It takes about 2 minutes.
|
2. Complete the setup wizard. It takes about 2 minutes.
|
||||||
|
During first-time setup, the web UI is reachable only from the local network.
|
||||||
3. Add your media folders in Watch Folders.
|
3. Add your media folders in Watch Folders.
|
||||||
4. Alchemist scans and starts working automatically.
|
4. Alchemist scans and starts working automatically.
|
||||||
5. Check the Dashboard to see progress and savings.
|
5. Check the Dashboard to see progress and savings.
|
||||||
@@ -144,8 +161,6 @@ The core contributor path is supported on Windows. Broader release and utility r
|
|||||||
|
|
||||||
- API automation can use bearer tokens created in **Settings → API Tokens**.
|
- API automation can use bearer tokens created in **Settings → API Tokens**.
|
||||||
- Read-only tokens are limited to observability and monitoring routes.
|
- Read-only tokens are limited to observability and monitoring routes.
|
||||||
- Alchemist can also be served under a subpath such as `/alchemist`
|
|
||||||
using `ALCHEMIST_BASE_URL=/alchemist`.
|
|
||||||
|
|
||||||
## Supported Platforms
|
## Supported Platforms
|
||||||
|
|
||||||
|
|||||||
3
TODO.md
Normal file
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
|
- Token management endpoints and Settings UI
|
||||||
- Hand-maintained OpenAPI contract plus human API docs
|
- Hand-maintained OpenAPI contract plus human API docs
|
||||||
|
|
||||||
### Base URL / Subpath Support
|
|
||||||
- `ALCHEMIST_BASE_URL` and matching config support
|
|
||||||
- Router nesting under a configured path prefix
|
|
||||||
- Frontend fetches, redirects, navigation, and SSE path generation updated for subpaths
|
|
||||||
|
|
||||||
### Distribution Foundation
|
### Distribution Foundation
|
||||||
- In-repo distribution metadata sources for:
|
- In-repo distribution metadata sources for:
|
||||||
- Homebrew
|
- Homebrew
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ except:
|
|||||||
- `/api/auth/*`
|
- `/api/auth/*`
|
||||||
- `/api/health`
|
- `/api/health`
|
||||||
- `/api/ready`
|
- `/api/ready`
|
||||||
- setup-mode exceptions: `/api/setup/*`, `/api/fs/*`,
|
- during first-time setup, the setup UI and setup-related
|
||||||
`/api/settings/bundle`, `/api/system/hardware`
|
unauthenticated routes are only reachable from the local
|
||||||
|
network
|
||||||
|
|
||||||
Authentication is established by `POST /api/auth/login`.
|
Authentication is established by `POST /api/auth/login`.
|
||||||
The backend also accepts `Authorization: Bearer <token>`.
|
The backend also accepts `Authorization: Bearer <token>`.
|
||||||
|
|||||||
@@ -3,6 +3,32 @@ title: Changelog
|
|||||||
description: Release history for Alchemist.
|
description: Release history for Alchemist.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.3.1-rc.1] - 2026-04-08
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
#### Conversion & Library Workflows
|
||||||
|
- **Experimental Conversion / Remux page** — upload a single file, inspect streams, preview the generated FFmpeg command, run a remux/transcode job through Alchemist, and download the result when complete.
|
||||||
|
- **Expanded Library Intelligence** — duplicate detection now sits alongside storage-focused recommendation sections for remux-only opportunities, wasteful audio layouts, and commentary/descriptive-track cleanup candidates.
|
||||||
|
|
||||||
|
#### Authentication & Automation
|
||||||
|
- **Named API tokens** — create bearer tokens from Settings with `read_only` or `full_access` access classes. Tokens are only shown once at creation time and stored server-side as hashes.
|
||||||
|
- **OpenAPI contract** — hand-maintained OpenAPI spec added alongside expanded human API docs for auth, token management, and update-check behavior.
|
||||||
|
|
||||||
|
#### Notifications
|
||||||
|
- **Provider-specific notification targets** — notification settings now use provider-specific configuration payloads instead of the old shared endpoint/token shape.
|
||||||
|
- **Provider expansion** — Discord webhook, Discord bot, Gotify, generic webhook, Telegram, and SMTP email targets are supported.
|
||||||
|
- **Richer event model** — notification events now distinguish queue/start/completion/failure plus scan completion, engine idle, and daily summary delivery.
|
||||||
|
- **Daily summary scheduling** — notifications include a global `daily_summary_time_local` setting and per-target opt-in for digest delivery.
|
||||||
|
|
||||||
|
#### Deployment & Distribution
|
||||||
|
- **Windows update check** — the About dialog now checks GitHub Releases for the latest stable version and links directly to the release download page when an update is available.
|
||||||
|
- **Distribution metadata generation** — in-repo Homebrew and AUR packaging templates plus workflow rendering were added as the foundation for package-manager distribution.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Config path clarity** — docs now consistently describe `~/.config/alchemist/config.toml` as the default host-side config location on Linux/macOS, while Docker examples still use `/app/config/config.toml` inside the container.
|
||||||
|
- **Backlog realignment** — the backlog was rewritten around current repo reality, marking large newly implemented surfaces as “Implemented / In Progress” and keeping the roadmap automation-first.
|
||||||
|
|
||||||
## [0.3.0] - 2026-04-06
|
## [0.3.0] - 2026-04-06
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ requires at least one day in every window.
|
|||||||
| `enable_telemetry` | bool | `false` | Opt-in anonymous telemetry switch |
|
| `enable_telemetry` | bool | `false` | Opt-in anonymous telemetry switch |
|
||||||
| `log_retention_days` | int | `30` | Log retention period in days |
|
| `log_retention_days` | int | `30` | Log retention period in days |
|
||||||
| `engine_mode` | string | `"balanced"` | Runtime engine mode: `background`, `balanced`, or `throughput` |
|
| `engine_mode` | string | `"balanced"` | Runtime engine mode: `background`, `balanced`, or `throughput` |
|
||||||
| `base_url` | string | `""` | Path prefix for serving Alchemist under a subpath such as `/alchemist` |
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ description: All environment variables Alchemist reads at startup.
|
|||||||
| `ALCHEMIST_CONFIG` | (alias) | Alias for `ALCHEMIST_CONFIG_PATH` |
|
| `ALCHEMIST_CONFIG` | (alias) | Alias for `ALCHEMIST_CONFIG_PATH` |
|
||||||
| `ALCHEMIST_DB_PATH` | `~/.config/alchemist/alchemist.db` | Path to SQLite database |
|
| `ALCHEMIST_DB_PATH` | `~/.config/alchemist/alchemist.db` | Path to SQLite database |
|
||||||
| `ALCHEMIST_DATA_DIR` | (none) | Sets data dir; `alchemist.db` placed here |
|
| `ALCHEMIST_DATA_DIR` | (none) | Sets data dir; `alchemist.db` placed here |
|
||||||
| `ALCHEMIST_BASE_URL` | root (`/`) | Path prefix for serving Alchemist under a subpath such as `/alchemist` |
|
|
||||||
| `ALCHEMIST_CONFIG_MUTABLE` | `true` | Set `false` to block runtime config writes |
|
| `ALCHEMIST_CONFIG_MUTABLE` | `true` | Set `false` to block runtime config writes |
|
||||||
| `RUST_LOG` | `info` | Log level: `info`, `debug`, `alchemist=trace` |
|
| `RUST_LOG` | `info` | Log level: `info`, `debug`, `alchemist=trace` |
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ description: Getting through the setup wizard and starting your first scan.
|
|||||||
|
|
||||||
When you first open Alchemist at `http://localhost:3000`
|
When you first open Alchemist at `http://localhost:3000`
|
||||||
the setup wizard runs automatically. It takes about two
|
the setup wizard runs automatically. It takes about two
|
||||||
minutes.
|
minutes. Until the first account is created, setup is
|
||||||
|
reachable only from the local network.
|
||||||
|
|
||||||
## Wizard steps
|
## Wizard steps
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000). The
|
Open [http://localhost:3000](http://localhost:3000). The
|
||||||
setup wizard runs on first visit.
|
setup wizard runs on first visit and is only reachable
|
||||||
|
from the local network until the first account is created.
|
||||||
|
|
||||||
For GPU passthrough (NVIDIA, Intel, AMD) see
|
For GPU passthrough (NVIDIA, Intel, AMD) see
|
||||||
[GPU Passthrough](/gpu-passthrough).
|
[GPU Passthrough](/gpu-passthrough).
|
||||||
@@ -110,6 +111,19 @@ just dev
|
|||||||
Windows contributor support covers the core `install/dev/check` path.
|
Windows contributor support covers the core `install/dev/check` path.
|
||||||
Broader `just` release and utility recipes remain Unix-first.
|
Broader `just` release and utility recipes remain Unix-first.
|
||||||
|
|
||||||
|
## CLI subcommands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alchemist scan /path/to/media
|
||||||
|
alchemist run /path/to/media
|
||||||
|
alchemist plan /path/to/media
|
||||||
|
alchemist plan /path/to/media --json
|
||||||
|
```
|
||||||
|
|
||||||
|
- `scan` enqueues matching jobs and exits
|
||||||
|
- `run` scans, enqueues, and waits for processing to finish
|
||||||
|
- `plan` reports what Alchemist would do without enqueueing jobs
|
||||||
|
|
||||||
## Nightly builds
|
## Nightly builds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -682,8 +682,6 @@ pub struct SystemConfig {
|
|||||||
/// Enable HSTS header (only enable if running behind HTTPS)
|
/// Enable HSTS header (only enable if running behind HTTPS)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub https_only: bool,
|
pub https_only: bool,
|
||||||
#[serde(default)]
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -710,7 +708,6 @@ impl Default for SystemConfig {
|
|||||||
log_retention_days: default_log_retention_days(),
|
log_retention_days: default_log_retention_days(),
|
||||||
engine_mode: EngineMode::default(),
|
engine_mode: EngineMode::default(),
|
||||||
https_only: false,
|
https_only: false,
|
||||||
base_url: String::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -826,7 +823,6 @@ impl Default for Config {
|
|||||||
log_retention_days: default_log_retention_days(),
|
log_retention_days: default_log_retention_days(),
|
||||||
engine_mode: EngineMode::default(),
|
engine_mode: EngineMode::default(),
|
||||||
https_only: false,
|
https_only: false,
|
||||||
base_url: String::new(),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -923,7 +919,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validate_schedule_time(&self.notifications.daily_summary_time_local)?;
|
validate_schedule_time(&self.notifications.daily_summary_time_local)?;
|
||||||
normalize_base_url(&self.system.base_url)?;
|
|
||||||
for target in &self.notifications.targets {
|
for target in &self.notifications.targets {
|
||||||
target.validate()?;
|
target.validate()?;
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1021,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn canonicalize_for_save(&mut self) {
|
pub(crate) fn canonicalize_for_save(&mut self) {
|
||||||
self.system.base_url = normalize_base_url(&self.system.base_url).unwrap_or_default();
|
|
||||||
if !self.notifications.targets.is_empty() {
|
if !self.notifications.targets.is_empty() {
|
||||||
self.notifications.webhook_url = None;
|
self.notifications.webhook_url = None;
|
||||||
self.notifications.discord_webhook = None;
|
self.notifications.discord_webhook = None;
|
||||||
@@ -1046,33 +1040,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn apply_env_overrides(&mut self) {
|
pub(crate) fn apply_env_overrides(&mut self) {}
|
||||||
if let Ok(base_url) = std::env::var("ALCHEMIST_BASE_URL") {
|
|
||||||
self.system.base_url = base_url;
|
|
||||||
}
|
|
||||||
self.system.base_url = normalize_base_url(&self.system.base_url).unwrap_or_default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_base_url(value: &str) -> Result<String> {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() || trimmed == "/" {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
|
||||||
if trimmed.contains("://") {
|
|
||||||
anyhow::bail!("system.base_url must be a path prefix, not a full URL");
|
|
||||||
}
|
|
||||||
if !trimmed.starts_with('/') {
|
|
||||||
anyhow::bail!("system.base_url must start with '/'");
|
|
||||||
}
|
|
||||||
if trimmed.contains('?') || trimmed.contains('#') {
|
|
||||||
anyhow::bail!("system.base_url must not contain query or fragment components");
|
|
||||||
}
|
|
||||||
let normalized = trimmed.trim_end_matches('/');
|
|
||||||
if normalized.contains("//") {
|
|
||||||
anyhow::bail!("system.base_url must not contain repeated slashes");
|
|
||||||
}
|
|
||||||
Ok(normalized.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_schedule_time(value: &str) -> Result<()> {
|
fn validate_schedule_time(value: &str) -> Result<()> {
|
||||||
@@ -1158,65 +1126,4 @@ mod tests {
|
|||||||
assert_eq!(EngineMode::default(), EngineMode::Balanced);
|
assert_eq!(EngineMode::default(), EngineMode::Balanced);
|
||||||
assert_eq!(EngineMode::Balanced.concurrent_jobs_for_cpu_count(8), 4);
|
assert_eq!(EngineMode::Balanced.concurrent_jobs_for_cpu_count(8), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_base_url_accepts_root_or_empty() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_base_url("").unwrap_or_else(|err| panic!("empty base url: {err}")),
|
|
||||||
""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_base_url("/").unwrap_or_else(|err| panic!("root base url: {err}")),
|
|
||||||
""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_base_url("/alchemist/")
|
|
||||||
.unwrap_or_else(|err| panic!("trimmed base url: {err}")),
|
|
||||||
"/alchemist"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_base_url_rejects_invalid_values() {
|
|
||||||
assert!(normalize_base_url("alchemist").is_err());
|
|
||||||
assert!(normalize_base_url("https://example.com/alchemist").is_err());
|
|
||||||
assert!(normalize_base_url("/a//b").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_base_url_override_takes_priority_on_load() {
|
|
||||||
let config_path = std::env::temp_dir().join(format!(
|
|
||||||
"alchemist_base_url_override_{}.toml",
|
|
||||||
rand::random::<u64>()
|
|
||||||
));
|
|
||||||
std::fs::write(
|
|
||||||
&config_path,
|
|
||||||
r#"
|
|
||||||
[transcode]
|
|
||||||
size_reduction_threshold = 0.3
|
|
||||||
min_bpp_threshold = 0.1
|
|
||||||
min_file_size_mb = 50
|
|
||||||
concurrent_jobs = 1
|
|
||||||
|
|
||||||
[hardware]
|
|
||||||
preferred_vendor = "cpu"
|
|
||||||
allow_cpu_fallback = true
|
|
||||||
|
|
||||||
[scanner]
|
|
||||||
directories = []
|
|
||||||
|
|
||||||
[system]
|
|
||||||
base_url = "/from-config"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|err| panic!("failed to write temp config: {err}"));
|
|
||||||
|
|
||||||
// SAFETY: test-only environment mutation.
|
|
||||||
unsafe { std::env::set_var("ALCHEMIST_BASE_URL", "/from-env") };
|
|
||||||
let config =
|
|
||||||
Config::load(&config_path).unwrap_or_else(|err| panic!("failed to load config: {err}"));
|
|
||||||
assert_eq!(config.system.base_url, "/from-env");
|
|
||||||
unsafe { std::env::remove_var("ALCHEMIST_BASE_URL") };
|
|
||||||
let _ = std::fs::remove_file(config_path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
559
src/main.rs
559
src/main.rs
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
use alchemist::db::EventChannels;
|
use alchemist::db::EventChannels;
|
||||||
use alchemist::error::Result;
|
use alchemist::error::Result;
|
||||||
|
use alchemist::media::pipeline::{Analyzer as _, Planner as _};
|
||||||
use alchemist::system::hardware;
|
use alchemist::system::hardware;
|
||||||
use alchemist::version;
|
use alchemist::version;
|
||||||
use alchemist::{Agent, Transcoder, config, db, runtime};
|
use alchemist::{Agent, Transcoder, config, db, runtime};
|
||||||
use clap::Parser;
|
use clap::{Parser, Subcommand};
|
||||||
|
use serde::Serialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use tracing_subscriber::fmt::time::time;
|
||||||
|
|
||||||
use notify::{RecursiveMode, Watcher};
|
use notify::{RecursiveMode, Watcher};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@@ -19,21 +22,55 @@ use tokio::sync::broadcast;
|
|||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version = version::current(), about, long_about = None)]
|
#[command(author, version = version::current(), about, long_about = None)]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Run in CLI mode (process directories and exit)
|
|
||||||
#[arg(long)]
|
|
||||||
cli: bool,
|
|
||||||
|
|
||||||
/// Directories to scan for media files (CLI mode only)
|
|
||||||
#[arg(long, value_name = "DIR")]
|
|
||||||
directories: Vec<PathBuf>,
|
|
||||||
|
|
||||||
/// Dry run (don't actually transcode)
|
|
||||||
#[arg(short, long)]
|
|
||||||
dry_run: bool,
|
|
||||||
|
|
||||||
/// Reset admin user/password and sessions (forces setup mode)
|
/// Reset admin user/password and sessions (forces setup mode)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
reset_auth: bool,
|
reset_auth: bool,
|
||||||
|
|
||||||
|
/// Enable verbose terminal logging and default DEBUG filtering
|
||||||
|
#[arg(long)]
|
||||||
|
debug_flags: bool,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
enum Commands {
|
||||||
|
/// Scan directories and enqueue matching work, then exit
|
||||||
|
Scan {
|
||||||
|
#[arg(value_name = "DIR", required = true)]
|
||||||
|
directories: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Scan directories, enqueue work, and wait for processing to finish
|
||||||
|
Run {
|
||||||
|
#[arg(value_name = "DIR", required = true)]
|
||||||
|
directories: Vec<PathBuf>,
|
||||||
|
/// Don't actually transcode
|
||||||
|
#[arg(short, long)]
|
||||||
|
dry_run: bool,
|
||||||
|
},
|
||||||
|
/// Analyze files and report what Alchemist would do without enqueuing jobs
|
||||||
|
Plan {
|
||||||
|
#[arg(value_name = "DIR", required = true)]
|
||||||
|
directories: Vec<PathBuf>,
|
||||||
|
/// Emit machine-readable JSON instead of human-readable text
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CliPlanItem {
|
||||||
|
input_path: String,
|
||||||
|
output_path: Option<String>,
|
||||||
|
profile: Option<String>,
|
||||||
|
decision: String,
|
||||||
|
reason: String,
|
||||||
|
encoder: Option<String>,
|
||||||
|
backend: Option<String>,
|
||||||
|
rate_control: Option<String>,
|
||||||
|
fallback: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -160,76 +197,79 @@ fn should_enter_setup_mode_for_missing_users(is_server_mode: bool, has_users: bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run() -> Result<()> {
|
async fn run() -> Result<()> {
|
||||||
// Initialize logging
|
let args = Args::parse();
|
||||||
tracing_subscriber::fmt()
|
init_logging(args.debug_flags);
|
||||||
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
|
let is_server_mode = args.command.is_none();
|
||||||
.with_target(true)
|
|
||||||
.with_thread_ids(true)
|
|
||||||
.with_thread_names(true)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let boot_start = Instant::now();
|
let boot_start = Instant::now();
|
||||||
|
|
||||||
// Startup Banner
|
|
||||||
info!(
|
|
||||||
" ______ __ ______ __ __ ______ __ __ __ ______ ______ "
|
|
||||||
);
|
|
||||||
info!(
|
|
||||||
"/\\ __ \\ /\\ \\ /\\ ___\\ /\\ \\_\\ \\ /\\ ___\\ /\\ \"-./ \\ /\\ \\ /\\ ___\\ /\\__ _\\"
|
|
||||||
);
|
|
||||||
info!(
|
|
||||||
"\\ \\ __ \\ \\ \\ \\____ \\ \\ \\____ \\ \\ __ \\ \\ \\ __\\ \\ \\ \\-./\\ \\ \\ \\ \\ \\ \\___ \\ \\/_/\\ \\/"
|
|
||||||
);
|
|
||||||
info!(
|
|
||||||
" \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\_\\"
|
|
||||||
);
|
|
||||||
info!(
|
|
||||||
" \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_____/ \\/_/ \\/_/ \\/_/ \\/_____/ \\/_/"
|
|
||||||
);
|
|
||||||
info!("");
|
|
||||||
info!("");
|
|
||||||
let version = alchemist::version::current();
|
|
||||||
let build_info = option_env!("BUILD_INFO")
|
|
||||||
.or(option_env!("GIT_SHA"))
|
|
||||||
.or(option_env!("VERGEN_GIT_SHA"))
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
info!("Version: {}", version);
|
|
||||||
info!("Build: {}", build_info);
|
|
||||||
info!("");
|
|
||||||
info!("System Information:");
|
|
||||||
info!(
|
|
||||||
" OS: {} ({})",
|
|
||||||
std::env::consts::OS,
|
|
||||||
std::env::consts::ARCH
|
|
||||||
);
|
|
||||||
info!(" CPUs: {}", num_cpus::get());
|
|
||||||
info!("");
|
|
||||||
|
|
||||||
let args = Args::parse();
|
|
||||||
info!(
|
info!(
|
||||||
target: "startup",
|
target: "startup",
|
||||||
"Parsed CLI args: cli_mode={}, reset_auth={}, dry_run={}, directories={}",
|
"Parsed CLI args: command={:?}, reset_auth={}, debug_flags={}",
|
||||||
args.cli,
|
args.command,
|
||||||
args.reset_auth,
|
args.reset_auth,
|
||||||
args.dry_run,
|
args.debug_flags
|
||||||
args.directories.len()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ... rest of logic remains largely the same, just inside run()
|
if is_server_mode {
|
||||||
// Default to server mode unless CLI is explicitly requested.
|
info!(
|
||||||
let is_server_mode = !args.cli;
|
" ______ __ ______ __ __ ______ __ __ __ ______ ______ "
|
||||||
info!(target: "startup", "Resolved server mode: {}", is_server_mode);
|
);
|
||||||
if is_server_mode && !args.directories.is_empty() {
|
info!(
|
||||||
warn!("Directories were provided without --cli; ignoring CLI inputs.");
|
"/\\ __ \\ /\\ \\ /\\ ___\\ /\\ \\_\\ \\ /\\ ___\\ /\\ \"-./ \\ /\\ \\ /\\ ___\\ /\\__ _\\"
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
"\\ \\ __ \\ \\ \\ \\____ \\ \\ \\____ \\ \\ __ \\ \\ \\ __\\ \\ \\ \\-./\\ \\ \\ \\ \\ \\ \\___ \\ \\/_/\\ \\/"
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
" \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\_\\"
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
" \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_____/ \\/_/ \\/_/ \\/_/ \\/_____/ \\/_/"
|
||||||
|
);
|
||||||
|
info!("");
|
||||||
|
info!("");
|
||||||
|
let version = alchemist::version::current();
|
||||||
|
let build_info = option_env!("BUILD_INFO")
|
||||||
|
.or(option_env!("GIT_SHA"))
|
||||||
|
.or(option_env!("VERGEN_GIT_SHA"))
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
info!("Version: {}", version);
|
||||||
|
info!("Build: {}", build_info);
|
||||||
|
info!("");
|
||||||
|
info!("System Information:");
|
||||||
|
info!(
|
||||||
|
" OS: {} ({})",
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH
|
||||||
|
);
|
||||||
|
info!(" CPUs: {}", num_cpus::get());
|
||||||
|
info!("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!(target: "startup", "Resolved server mode: {}", is_server_mode);
|
||||||
|
|
||||||
// 0. Load Configuration
|
// 0. Load Configuration
|
||||||
let config_start = Instant::now();
|
let config_start = Instant::now();
|
||||||
let config_path = runtime::config_path();
|
let config_path = runtime::config_path();
|
||||||
let db_path = runtime::db_path();
|
let db_path = runtime::db_path();
|
||||||
let config_mutable = runtime::config_mutable();
|
let config_mutable = runtime::config_mutable();
|
||||||
let (config, mut setup_mode, config_exists) =
|
let (config, mut setup_mode, config_exists) = if is_server_mode {
|
||||||
load_startup_config(config_path.as_path(), is_server_mode);
|
load_startup_config(config_path.as_path(), true)
|
||||||
|
} else {
|
||||||
|
if !config_path.exists() {
|
||||||
|
error!(
|
||||||
|
"Configuration required. Run Alchemist in server mode to complete setup, or create {:?} manually.",
|
||||||
|
config_path
|
||||||
|
);
|
||||||
|
return Err(alchemist::error::AlchemistError::Config(
|
||||||
|
"Missing configuration".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let config = config::Config::load(config_path.as_path())
|
||||||
|
.map_err(|err| alchemist::error::AlchemistError::Config(err.to_string()))?;
|
||||||
|
(config, false, true)
|
||||||
|
};
|
||||||
info!(
|
info!(
|
||||||
target: "startup",
|
target: "startup",
|
||||||
"Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms",
|
"Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms",
|
||||||
@@ -371,9 +411,9 @@ async fn run() -> Result<()> {
|
|||||||
warn!("Auth reset requested. All users and sessions cleared.");
|
warn!("Auth reset requested. All users and sessions cleared.");
|
||||||
setup_mode = true;
|
setup_mode = true;
|
||||||
}
|
}
|
||||||
|
let has_users = db.has_users().await?;
|
||||||
if is_server_mode {
|
if is_server_mode {
|
||||||
let users_start = Instant::now();
|
let users_start = Instant::now();
|
||||||
let has_users = db.has_users().await?;
|
|
||||||
info!(
|
info!(
|
||||||
target: "startup",
|
target: "startup",
|
||||||
"User check completed (has_users={}) in {} ms",
|
"User check completed (has_users={}) in {} ms",
|
||||||
@@ -386,6 +426,13 @@ async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
setup_mode = true;
|
setup_mode = true;
|
||||||
}
|
}
|
||||||
|
} else if !has_users {
|
||||||
|
error!(
|
||||||
|
"Setup is not complete. Run Alchemist in server mode to finish creating the first account."
|
||||||
|
);
|
||||||
|
return Err(alchemist::error::AlchemistError::Config(
|
||||||
|
"Setup incomplete".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !setup_mode {
|
if !setup_mode {
|
||||||
@@ -518,7 +565,7 @@ async fn run() -> Result<()> {
|
|||||||
hardware_state.clone(),
|
hardware_state.clone(),
|
||||||
tx.clone(),
|
tx.clone(),
|
||||||
event_channels.clone(),
|
event_channels.clone(),
|
||||||
args.dry_run,
|
matches!(args.command, Some(Commands::Run { dry_run: true, .. })),
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
@@ -748,56 +795,307 @@ async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// CLI Mode
|
match args
|
||||||
if setup_mode {
|
.command
|
||||||
error!(
|
.clone()
|
||||||
"Configuration required. Run without --cli to use the web-based setup wizard, or create {:?} manually.",
|
.expect("CLI branch requires a subcommand")
|
||||||
config_path
|
{
|
||||||
);
|
Commands::Scan { directories } => {
|
||||||
|
agent.scan_and_enqueue(directories).await?;
|
||||||
// CLI early exit - error
|
info!("Scan complete. Matching files were enqueued.");
|
||||||
// (Caller will handle pause-on-exit if needed)
|
}
|
||||||
return Err(alchemist::error::AlchemistError::Config(
|
Commands::Run { directories, .. } => {
|
||||||
"Missing configuration".into(),
|
agent.scan_and_enqueue(directories).await?;
|
||||||
));
|
wait_for_cli_jobs(db.as_ref()).await?;
|
||||||
}
|
info!("All jobs processed.");
|
||||||
|
}
|
||||||
if args.directories.is_empty() {
|
Commands::Plan { directories, json } => {
|
||||||
error!("No directories provided. Usage: alchemist --cli --dir <DIR> [--dir <DIR> ...]");
|
let items =
|
||||||
return Err(alchemist::error::AlchemistError::Config(
|
build_cli_plan(db.as_ref(), config.clone(), &hardware_state, directories)
|
||||||
"Missing directories for CLI mode".into(),
|
.await?;
|
||||||
));
|
if json {
|
||||||
}
|
println!(
|
||||||
agent.scan_and_enqueue(args.directories).await?;
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string())
|
||||||
// Wait until all jobs are processed
|
);
|
||||||
info!("Waiting for jobs to complete...");
|
} else {
|
||||||
loop {
|
print_cli_plan(&items);
|
||||||
let stats = db.get_stats().await?;
|
}
|
||||||
let active = stats
|
|
||||||
.as_object()
|
|
||||||
.map(|m| {
|
|
||||||
m.iter()
|
|
||||||
.filter(|(k, _)| {
|
|
||||||
["encoding", "analyzing", "remuxing", "resuming"].contains(&k.as_str())
|
|
||||||
})
|
|
||||||
.map(|(_, v)| v.as_i64().unwrap_or(0))
|
|
||||||
.sum::<i64>()
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
let queued = stats.get("queued").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
||||||
|
|
||||||
if active + queued == 0 {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
||||||
}
|
}
|
||||||
info!("All jobs processed.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_cli_jobs(db: &db::Db) -> Result<()> {
|
||||||
|
info!("Waiting for jobs to complete...");
|
||||||
|
loop {
|
||||||
|
let stats = db.get_stats().await?;
|
||||||
|
let active = stats
|
||||||
|
.as_object()
|
||||||
|
.map(|m| {
|
||||||
|
m.iter()
|
||||||
|
.filter(|(k, _)| {
|
||||||
|
["encoding", "analyzing", "remuxing", "resuming"].contains(&k.as_str())
|
||||||
|
})
|
||||||
|
.map(|(_, v)| v.as_i64().unwrap_or(0))
|
||||||
|
.sum::<i64>()
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let queued = stats.get("queued").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
|
||||||
|
if active + queued == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_cli_plan(
|
||||||
|
db: &db::Db,
|
||||||
|
config_state: Arc<RwLock<config::Config>>,
|
||||||
|
hardware_state: &hardware::HardwareState,
|
||||||
|
directories: Vec<PathBuf>,
|
||||||
|
) -> Result<Vec<CliPlanItem>> {
|
||||||
|
let files = tokio::task::spawn_blocking(move || {
|
||||||
|
let scanner = alchemist::media::scanner::Scanner::new();
|
||||||
|
scanner.scan(directories)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| alchemist::error::AlchemistError::Unknown(format!("scan task failed: {err}")))?;
|
||||||
|
|
||||||
|
let file_settings = match db.get_file_settings().await {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to fetch file settings, using defaults: {}", err);
|
||||||
|
alchemist::media::pipeline::default_file_settings()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let config_snapshot = Arc::new(config_state.read().await.clone());
|
||||||
|
let hw_info = hardware_state.snapshot().await;
|
||||||
|
let planner = alchemist::media::planner::BasicPlanner::new(config_snapshot, hw_info);
|
||||||
|
let analyzer = alchemist::media::analyzer::FfmpegAnalyzer;
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for discovered in files {
|
||||||
|
let input_path = discovered.path.clone();
|
||||||
|
let input_path_string = input_path.display().to_string();
|
||||||
|
|
||||||
|
if let Some(reason) = alchemist::media::pipeline::skip_reason_for_discovered_path(
|
||||||
|
db,
|
||||||
|
&input_path,
|
||||||
|
&file_settings,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
items.push(CliPlanItem {
|
||||||
|
input_path: input_path_string,
|
||||||
|
output_path: None,
|
||||||
|
profile: None,
|
||||||
|
decision: "skip".to_string(),
|
||||||
|
reason: reason.to_string(),
|
||||||
|
encoder: None,
|
||||||
|
backend: None,
|
||||||
|
rate_control: None,
|
||||||
|
fallback: None,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_path =
|
||||||
|
file_settings.output_path_for_source(&input_path, discovered.source_root.as_deref());
|
||||||
|
if output_path.exists() && !file_settings.should_replace_existing_output() {
|
||||||
|
items.push(CliPlanItem {
|
||||||
|
input_path: input_path_string,
|
||||||
|
output_path: Some(output_path.display().to_string()),
|
||||||
|
profile: None,
|
||||||
|
decision: "skip".to_string(),
|
||||||
|
reason: "output exists and replace strategy is keep".to_string(),
|
||||||
|
encoder: None,
|
||||||
|
backend: None,
|
||||||
|
rate_control: None,
|
||||||
|
fallback: None,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let analysis = match analyzer.analyze(&input_path).await {
|
||||||
|
Ok(analysis) => analysis,
|
||||||
|
Err(err) => {
|
||||||
|
items.push(CliPlanItem {
|
||||||
|
input_path: input_path_string,
|
||||||
|
output_path: Some(output_path.display().to_string()),
|
||||||
|
profile: None,
|
||||||
|
decision: "error".to_string(),
|
||||||
|
reason: "analysis failed".to_string(),
|
||||||
|
encoder: None,
|
||||||
|
backend: None,
|
||||||
|
rate_control: None,
|
||||||
|
fallback: None,
|
||||||
|
error: Some(err.to_string()),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let profile = match db.get_profile_for_path(&input_path.to_string_lossy()).await {
|
||||||
|
Ok(profile) => profile,
|
||||||
|
Err(err) => {
|
||||||
|
items.push(CliPlanItem {
|
||||||
|
input_path: input_path_string,
|
||||||
|
output_path: Some(output_path.display().to_string()),
|
||||||
|
profile: None,
|
||||||
|
decision: "error".to_string(),
|
||||||
|
reason: "profile resolution failed".to_string(),
|
||||||
|
encoder: None,
|
||||||
|
backend: None,
|
||||||
|
rate_control: None,
|
||||||
|
fallback: None,
|
||||||
|
error: Some(err.to_string()),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let plan = match planner
|
||||||
|
.plan(&analysis, &output_path, profile.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(plan) => plan,
|
||||||
|
Err(err) => {
|
||||||
|
items.push(CliPlanItem {
|
||||||
|
input_path: input_path_string,
|
||||||
|
output_path: Some(output_path.display().to_string()),
|
||||||
|
profile: profile.as_ref().map(|p| p.name.clone()),
|
||||||
|
decision: "error".to_string(),
|
||||||
|
reason: "planning failed".to_string(),
|
||||||
|
encoder: None,
|
||||||
|
backend: None,
|
||||||
|
rate_control: None,
|
||||||
|
fallback: None,
|
||||||
|
error: Some(err.to_string()),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (decision, reason) = match &plan.decision {
|
||||||
|
alchemist::media::pipeline::TranscodeDecision::Skip { reason } => {
|
||||||
|
("skip".to_string(), reason.clone())
|
||||||
|
}
|
||||||
|
alchemist::media::pipeline::TranscodeDecision::Remux { reason } => {
|
||||||
|
("remux".to_string(), reason.clone())
|
||||||
|
}
|
||||||
|
alchemist::media::pipeline::TranscodeDecision::Transcode { reason } => {
|
||||||
|
("transcode".to_string(), reason.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(CliPlanItem {
|
||||||
|
input_path: input_path_string,
|
||||||
|
output_path: Some(output_path.display().to_string()),
|
||||||
|
profile: profile.as_ref().map(|p| p.name.clone()),
|
||||||
|
decision,
|
||||||
|
reason,
|
||||||
|
encoder: plan
|
||||||
|
.encoder
|
||||||
|
.map(|encoder| encoder.ffmpeg_encoder_name().to_string()),
|
||||||
|
backend: plan.backend.map(|backend| backend.as_str().to_string()),
|
||||||
|
rate_control: plan.rate_control.as_ref().map(format_rate_control),
|
||||||
|
fallback: plan
|
||||||
|
.fallback
|
||||||
|
.as_ref()
|
||||||
|
.map(|fallback| fallback.reason.clone()),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_rate_control(rate_control: &alchemist::media::pipeline::RateControl) -> String {
|
||||||
|
match rate_control {
|
||||||
|
alchemist::media::pipeline::RateControl::Crf { value } => format!("crf:{value}"),
|
||||||
|
alchemist::media::pipeline::RateControl::Cq { value } => format!("cq:{value}"),
|
||||||
|
alchemist::media::pipeline::RateControl::QsvQuality { value } => {
|
||||||
|
format!("qsv_quality:{value}")
|
||||||
|
}
|
||||||
|
alchemist::media::pipeline::RateControl::Bitrate { kbps } => format!("bitrate:{kbps}k"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_cli_plan(items: &[CliPlanItem]) {
|
||||||
|
for item in items {
|
||||||
|
println!("{}", item.input_path);
|
||||||
|
println!(" decision: {} — {}", item.decision, item.reason);
|
||||||
|
if let Some(output_path) = &item.output_path {
|
||||||
|
println!(" output: {}", output_path);
|
||||||
|
}
|
||||||
|
if let Some(profile) = &item.profile {
|
||||||
|
println!(" profile: {}", profile);
|
||||||
|
}
|
||||||
|
if let Some(encoder) = &item.encoder {
|
||||||
|
let backend = item.backend.as_deref().unwrap_or("unknown");
|
||||||
|
println!(" encoder: {} ({})", encoder, backend);
|
||||||
|
}
|
||||||
|
if let Some(rate_control) = &item.rate_control {
|
||||||
|
println!(" rate: {}", rate_control);
|
||||||
|
}
|
||||||
|
if let Some(fallback) = &item.fallback {
|
||||||
|
println!(" fallback: {}", fallback);
|
||||||
|
}
|
||||||
|
if let Some(error) = &item.error {
|
||||||
|
println!(" error: {}", error);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging(debug_flags: bool) {
|
||||||
|
let default_level = if debug_flags {
|
||||||
|
tracing::Level::DEBUG
|
||||||
|
} else {
|
||||||
|
tracing::Level::INFO
|
||||||
|
};
|
||||||
|
let env_filter = EnvFilter::from_default_env().add_directive(default_level.into());
|
||||||
|
|
||||||
|
if debug_flags {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.with_target(true)
|
||||||
|
.with_thread_ids(true)
|
||||||
|
.with_thread_names(true)
|
||||||
|
.with_timer(time())
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.without_time()
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_ids(false)
|
||||||
|
.with_thread_names(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod logging_tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_flags_arg_parses() {
|
||||||
|
let args = Args::try_parse_from(["alchemist", "--debug-flags"])
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse debug flag: {err}"));
|
||||||
|
assert!(args.debug_flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod version_cli_tests {
|
mod version_cli_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -836,6 +1134,41 @@ mod tests {
|
|||||||
assert!(Args::try_parse_from(["alchemist", "--output-dir", "/tmp/out"]).is_err());
|
assert!(Args::try_parse_from(["alchemist", "--output-dir", "/tmp/out"]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_reject_removed_cli_flag() {
|
||||||
|
assert!(Args::try_parse_from(["alchemist", "--cli"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_subcommand_parses() {
|
||||||
|
let args = Args::try_parse_from(["alchemist", "scan", "/tmp/media"])
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse scan subcommand: {err}"));
|
||||||
|
assert!(matches!(
|
||||||
|
args.command,
|
||||||
|
Some(Commands::Scan { directories }) if directories == vec![PathBuf::from("/tmp/media")]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_subcommand_parses_with_dry_run() {
|
||||||
|
let args = Args::try_parse_from(["alchemist", "run", "/tmp/media", "--dry-run"])
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse run subcommand: {err}"));
|
||||||
|
assert!(matches!(
|
||||||
|
args.command,
|
||||||
|
Some(Commands::Run { directories, dry_run }) if directories == vec![PathBuf::from("/tmp/media")] && dry_run
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_subcommand_parses_with_json() {
|
||||||
|
let args = Args::try_parse_from(["alchemist", "plan", "/tmp/media", "--json"])
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse plan subcommand: {err}"));
|
||||||
|
assert!(matches!(
|
||||||
|
args.command,
|
||||||
|
Some(Commands::Plan { directories, json }) if directories == vec![PathBuf::from("/tmp/media")] && json
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_reload_matches_create_modify_and_rename_events() {
|
fn config_reload_matches_create_modify_and_rename_events() {
|
||||||
let config_path = PathBuf::from("/tmp/alchemist-config.toml");
|
let config_path = PathBuf::from("/tmp/alchemist-config.toml");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub fn append_args(
|
|||||||
encoder: Encoder,
|
encoder: Encoder,
|
||||||
rate_control: Option<RateControl>,
|
rate_control: Option<RateControl>,
|
||||||
preset: Option<&str>,
|
preset: Option<&str>,
|
||||||
|
tag_hevc_as_hvc1: bool,
|
||||||
) {
|
) {
|
||||||
match encoder {
|
match encoder {
|
||||||
Encoder::Av1Svt => {
|
Encoder::Av1Svt => {
|
||||||
@@ -48,9 +49,10 @@ pub fn append_args(
|
|||||||
preset.unwrap_or(CpuPreset::Medium.as_str()).to_string(),
|
preset.unwrap_or(CpuPreset::Medium.as_str()).to_string(),
|
||||||
"-crf".to_string(),
|
"-crf".to_string(),
|
||||||
crf,
|
crf,
|
||||||
"-tag:v".to_string(),
|
|
||||||
"hvc1".to_string(),
|
|
||||||
]);
|
]);
|
||||||
|
if tag_hevc_as_hvc1 {
|
||||||
|
args.extend(["-tag:v".to_string(), "hvc1".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Encoder::H264X264 => {
|
Encoder::H264X264 => {
|
||||||
let crf = match rate_control {
|
let crf = match rate_control {
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rate_control = self.plan.rate_control.clone();
|
let rate_control = self.plan.rate_control.clone();
|
||||||
|
let tag_hevc_as_hvc1 = uses_quicktime_container(&self.plan.container);
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"-hide_banner".to_string(),
|
"-hide_banner".to_string(),
|
||||||
"-y".to_string(),
|
"-y".to_string(),
|
||||||
@@ -249,12 +250,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
|||||||
Encoder::Av1Videotoolbox
|
Encoder::Av1Videotoolbox
|
||||||
| Encoder::HevcVideotoolbox
|
| Encoder::HevcVideotoolbox
|
||||||
| Encoder::H264Videotoolbox => {
|
| Encoder::H264Videotoolbox => {
|
||||||
videotoolbox::append_args(
|
videotoolbox::append_args(&mut args, encoder, tag_hevc_as_hvc1);
|
||||||
&mut args,
|
|
||||||
encoder,
|
|
||||||
rate_control.clone(),
|
|
||||||
default_quality(&self.plan.rate_control, 65),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Encoder::Av1Svt | Encoder::Av1Aom | Encoder::HevcX265 | Encoder::H264X264 => {
|
Encoder::Av1Svt | Encoder::Av1Aom | Encoder::HevcX265 | Encoder::H264X264 => {
|
||||||
cpu::append_args(
|
cpu::append_args(
|
||||||
@@ -262,6 +258,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
|||||||
encoder,
|
encoder,
|
||||||
rate_control.clone(),
|
rate_control.clone(),
|
||||||
self.plan.encoder_preset.as_deref(),
|
self.plan.encoder_preset.as_deref(),
|
||||||
|
tag_hevc_as_hvc1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,7 +282,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
|||||||
apply_subtitle_plan(&mut args, &self.plan.subtitles);
|
apply_subtitle_plan(&mut args, &self.plan.subtitles);
|
||||||
apply_color_metadata(&mut args, self.metadata, &self.plan.filters);
|
apply_color_metadata(&mut args, self.metadata, &self.plan.filters);
|
||||||
|
|
||||||
if matches!(self.plan.container.as_str(), "mp4" | "m4v" | "mov") {
|
if uses_quicktime_container(&self.plan.container) {
|
||||||
args.push("-movflags".to_string());
|
args.push("-movflags".to_string());
|
||||||
args.push("+faststart".to_string());
|
args.push("+faststart".to_string());
|
||||||
}
|
}
|
||||||
@@ -483,6 +480,10 @@ fn output_format_name(container: &str) -> &str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn uses_quicktime_container(container: &str) -> bool {
|
||||||
|
matches!(container, "mp4" | "m4v" | "mov")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct FFmpegProgress {
|
pub struct FFmpegProgress {
|
||||||
pub frame: u64,
|
pub frame: u64,
|
||||||
@@ -1041,6 +1042,83 @@ mod tests {
|
|||||||
.build_args()
|
.build_args()
|
||||||
.unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}"));
|
.unwrap_or_else(|err| panic!("failed to build videotoolbox args: {err}"));
|
||||||
assert!(args.contains(&"hevc_videotoolbox".to_string()));
|
assert!(args.contains(&"hevc_videotoolbox".to_string()));
|
||||||
|
assert!(!args.contains(&"hvc1".to_string()));
|
||||||
|
assert!(!args.contains(&"-q:v".to_string()));
|
||||||
|
assert!(!args.contains(&"-b:v".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hevc_videotoolbox_mp4_adds_hvc1_tag() {
|
||||||
|
let metadata = metadata();
|
||||||
|
let mut plan = plan_for(Encoder::HevcVideotoolbox);
|
||||||
|
plan.container = "mp4".to_string();
|
||||||
|
let builder = FFmpegCommandBuilder::new(
|
||||||
|
Path::new("/tmp/in.mkv"),
|
||||||
|
Path::new("/tmp/out.mp4"),
|
||||||
|
&metadata,
|
||||||
|
&plan,
|
||||||
|
);
|
||||||
|
let args = builder
|
||||||
|
.build_args()
|
||||||
|
.unwrap_or_else(|err| panic!("failed to build mp4 videotoolbox args: {err}"));
|
||||||
|
assert!(args.contains(&"hevc_videotoolbox".to_string()));
|
||||||
|
assert!(args.contains(&"hvc1".to_string()));
|
||||||
|
assert!(!args.contains(&"-q:v".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hevc_videotoolbox_bitrate_mode_uses_generic_bitrate_flag() {
|
||||||
|
let metadata = metadata();
|
||||||
|
let mut plan = plan_for(Encoder::HevcVideotoolbox);
|
||||||
|
plan.rate_control = Some(RateControl::Bitrate { kbps: 2500 });
|
||||||
|
let builder = FFmpegCommandBuilder::new(
|
||||||
|
Path::new("/tmp/in.mkv"),
|
||||||
|
Path::new("/tmp/out.mkv"),
|
||||||
|
&metadata,
|
||||||
|
&plan,
|
||||||
|
);
|
||||||
|
let args = builder
|
||||||
|
.build_args()
|
||||||
|
.unwrap_or_else(|err| panic!("failed to build bitrate videotoolbox args: {err}"));
|
||||||
|
assert!(args.contains(&"hevc_videotoolbox".to_string()));
|
||||||
|
assert!(args.contains(&"-b:v".to_string()));
|
||||||
|
assert!(args.contains(&"2500k".to_string()));
|
||||||
|
assert!(!args.contains(&"-q:v".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hevc_x265_mkv_does_not_add_hvc1_tag() {
|
||||||
|
let metadata = metadata();
|
||||||
|
let plan = plan_for(Encoder::HevcX265);
|
||||||
|
let builder = FFmpegCommandBuilder::new(
|
||||||
|
Path::new("/tmp/in.mkv"),
|
||||||
|
Path::new("/tmp/out.mkv"),
|
||||||
|
&metadata,
|
||||||
|
&plan,
|
||||||
|
);
|
||||||
|
let args = builder
|
||||||
|
.build_args()
|
||||||
|
.unwrap_or_else(|err| panic!("failed to build mkv x265 args: {err}"));
|
||||||
|
assert!(args.contains(&"libx265".to_string()));
|
||||||
|
assert!(!args.contains(&"hvc1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hevc_x265_mp4_adds_hvc1_tag() {
|
||||||
|
let metadata = metadata();
|
||||||
|
let mut plan = plan_for(Encoder::HevcX265);
|
||||||
|
plan.container = "mp4".to_string();
|
||||||
|
let builder = FFmpegCommandBuilder::new(
|
||||||
|
Path::new("/tmp/in.mkv"),
|
||||||
|
Path::new("/tmp/out.mp4"),
|
||||||
|
&metadata,
|
||||||
|
&plan,
|
||||||
|
);
|
||||||
|
let args = builder
|
||||||
|
.build_args()
|
||||||
|
.unwrap_or_else(|err| panic!("failed to build mp4 x265 args: {err}"));
|
||||||
|
assert!(args.contains(&"libx265".to_string()));
|
||||||
|
assert!(args.contains(&"hvc1".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,51 +1,29 @@
|
|||||||
use crate::media::pipeline::{Encoder, RateControl};
|
use crate::media::pipeline::Encoder;
|
||||||
|
|
||||||
pub fn append_args(
|
|
||||||
args: &mut Vec<String>,
|
|
||||||
encoder: Encoder,
|
|
||||||
rate_control: Option<RateControl>,
|
|
||||||
default_quality: u8,
|
|
||||||
) {
|
|
||||||
let cq = match rate_control {
|
|
||||||
Some(RateControl::Cq { value }) => value,
|
|
||||||
_ => default_quality,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
pub fn append_args(args: &mut Vec<String>, encoder: Encoder, tag_hevc_as_hvc1: bool) {
|
||||||
|
// Current FFmpeg VideoToolbox encoders on macOS do not expose qscale-style
|
||||||
|
// quality controls, so bitrate mode is handled by the shared builder and
|
||||||
|
// CQ-style requests intentionally fall back to the encoder defaults.
|
||||||
match encoder {
|
match encoder {
|
||||||
Encoder::Av1Videotoolbox => {
|
Encoder::Av1Videotoolbox => {
|
||||||
args.extend([
|
args.extend([
|
||||||
"-c:v".to_string(),
|
"-c:v".to_string(),
|
||||||
"av1_videotoolbox".to_string(),
|
"av1_videotoolbox".to_string(),
|
||||||
"-b:v".to_string(),
|
|
||||||
"0".to_string(),
|
|
||||||
"-q:v".to_string(),
|
|
||||||
cq.to_string(),
|
|
||||||
"-allow_sw".to_string(),
|
"-allow_sw".to_string(),
|
||||||
"1".to_string(),
|
"1".to_string(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
Encoder::HevcVideotoolbox => {
|
Encoder::HevcVideotoolbox => {
|
||||||
args.extend([
|
args.extend(["-c:v".to_string(), "hevc_videotoolbox".to_string()]);
|
||||||
"-c:v".to_string(),
|
if tag_hevc_as_hvc1 {
|
||||||
"hevc_videotoolbox".to_string(),
|
args.extend(["-tag:v".to_string(), "hvc1".to_string()]);
|
||||||
"-b:v".to_string(),
|
}
|
||||||
"0".to_string(),
|
args.extend(["-allow_sw".to_string(), "1".to_string()]);
|
||||||
"-q:v".to_string(),
|
|
||||||
cq.to_string(),
|
|
||||||
"-tag:v".to_string(),
|
|
||||||
"hvc1".to_string(),
|
|
||||||
"-allow_sw".to_string(),
|
|
||||||
"1".to_string(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
Encoder::H264Videotoolbox => {
|
Encoder::H264Videotoolbox => {
|
||||||
args.extend([
|
args.extend([
|
||||||
"-c:v".to_string(),
|
"-c:v".to_string(),
|
||||||
"h264_videotoolbox".to_string(),
|
"h264_videotoolbox".to_string(),
|
||||||
"-b:v".to_string(),
|
|
||||||
"0".to_string(),
|
|
||||||
"-q:v".to_string(),
|
|
||||||
cq.to_string(),
|
|
||||||
"-allow_sw".to_string(),
|
"-allow_sw".to_string(),
|
||||||
"1".to_string(),
|
"1".to_string(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ pub async fn enqueue_discovered_with_db(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_file_settings() -> crate::db::FileSettings {
|
pub fn default_file_settings() -> crate::db::FileSettings {
|
||||||
crate::db::FileSettings {
|
crate::db::FileSettings {
|
||||||
id: 1,
|
id: 1,
|
||||||
delete_source: false,
|
delete_source: false,
|
||||||
@@ -525,7 +525,10 @@ fn default_file_settings() -> crate::db::FileSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matches_generated_output_pattern(path: &Path, settings: &crate::db::FileSettings) -> bool {
|
pub(crate) fn matches_generated_output_pattern(
|
||||||
|
path: &Path,
|
||||||
|
settings: &crate::db::FileSettings,
|
||||||
|
) -> bool {
|
||||||
let expected_extension = settings.output_extension.trim_start_matches('.');
|
let expected_extension = settings.output_extension.trim_start_matches('.');
|
||||||
if !expected_extension.is_empty() {
|
if !expected_extension.is_empty() {
|
||||||
let actual_extension = match path.extension().and_then(|extension| extension.to_str()) {
|
let actual_extension = match path.extension().and_then(|extension| extension.to_str()) {
|
||||||
@@ -548,7 +551,7 @@ fn matches_generated_output_pattern(path: &Path, settings: &crate::db::FileSetti
|
|||||||
.is_some_and(|stem| stem.ends_with(suffix))
|
.is_some_and(|stem| stem.ends_with(suffix))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn skip_reason_for_discovered_path(
|
pub async fn skip_reason_for_discovered_path(
|
||||||
db: &crate::db::Db,
|
db: &crate::db::Db,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
settings: &crate::db::FileSettings,
|
settings: &crate::db::FileSettings,
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ impl Agent {
|
|||||||
pub fn set_boot_analyzing(&self, value: bool) {
|
pub fn set_boot_analyzing(&self, value: bool) {
|
||||||
self.analyzing_boot.store(value, Ordering::SeqCst);
|
self.analyzing_boot.store(value, Ordering::SeqCst);
|
||||||
if value {
|
if value {
|
||||||
info!("Boot analysis started — engine claim loop paused.");
|
debug!("Boot analysis started — engine claim loop paused.");
|
||||||
} else {
|
} else {
|
||||||
info!("Boot analysis complete — engine claim loop resumed.");
|
debug!("Boot analysis complete — engine claim loop resumed.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ impl Agent {
|
|||||||
/// semaphore permit.
|
/// semaphore permit.
|
||||||
async fn _run_analysis_pass(&self) {
|
async fn _run_analysis_pass(&self) {
|
||||||
self.set_boot_analyzing(true);
|
self.set_boot_analyzing(true);
|
||||||
info!("Auto-analysis: starting pass...");
|
debug!("Auto-analysis: starting pass...");
|
||||||
|
|
||||||
// NOTE: reset_interrupted_jobs is intentionally
|
// NOTE: reset_interrupted_jobs is intentionally
|
||||||
// NOT called here. It is a one-time startup
|
// NOT called here. It is a one-time startup
|
||||||
@@ -244,7 +244,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let batch_len = batch.len();
|
let batch_len = batch.len();
|
||||||
info!("Auto-analysis: analyzing {} job(s)...", batch_len);
|
debug!("Auto-analysis: analyzing {} job(s)...", batch_len);
|
||||||
|
|
||||||
for job in batch {
|
for job in batch {
|
||||||
let pipeline = self.pipeline();
|
let pipeline = self.pipeline();
|
||||||
@@ -264,9 +264,9 @@ impl Agent {
|
|||||||
self.set_boot_analyzing(false);
|
self.set_boot_analyzing(false);
|
||||||
|
|
||||||
if total_analyzed == 0 {
|
if total_analyzed == 0 {
|
||||||
info!("Auto-analysis: no jobs pending analysis.");
|
debug!("Auto-analysis: no jobs pending analysis.");
|
||||||
} else {
|
} else {
|
||||||
info!(
|
debug!(
|
||||||
"Auto-analysis: complete. {} job(s) analyzed.",
|
"Auto-analysis: complete. {} job(s) analyzed.",
|
||||||
total_analyzed
|
total_analyzed
|
||||||
);
|
);
|
||||||
@@ -359,7 +359,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_loop(self: Arc<Self>) {
|
pub async fn run_loop(self: Arc<Self>) {
|
||||||
info!("Agent loop started.");
|
debug!("Agent loop started.");
|
||||||
loop {
|
loop {
|
||||||
// Block while paused OR while boot analysis runs
|
// Block while paused OR while boot analysis runs
|
||||||
if self.is_paused() || self.is_boot_analyzing() {
|
if self.is_paused() || self.is_boot_analyzing() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use rayon::prelude::*;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::media::pipeline::DiscoveredMedia;
|
use crate::media::pipeline::DiscoveredMedia;
|
||||||
@@ -45,7 +45,7 @@ impl Scanner {
|
|||||||
);
|
);
|
||||||
|
|
||||||
directories.into_par_iter().for_each(|(dir, recursive)| {
|
directories.into_par_iter().for_each(|(dir, recursive)| {
|
||||||
info!("Scanning directory: {:?} (recursive: {})", dir, recursive);
|
debug!("Scanning directory: {:?} (recursive: {})", dir, recursive);
|
||||||
let mut local_files = Vec::new();
|
let mut local_files = Vec::new();
|
||||||
let source_roots = source_roots.clone();
|
let source_roots = source_roots.clone();
|
||||||
let walker = if recursive {
|
let walker = if recursive {
|
||||||
@@ -90,7 +90,6 @@ impl Scanner {
|
|||||||
// Deterministic ordering
|
// Deterministic ordering
|
||||||
final_files.sort_by(|a, b| a.path.cmp(&b.path));
|
final_files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
|
||||||
info!("Found {} candidate media files", final_files.len());
|
|
||||||
final_files
|
final_files
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ pub(crate) async fn auth_middleware(
|
|||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
|
|
||||||
|
if state.setup_required.load(Ordering::Relaxed)
|
||||||
|
&& path != "/api/health"
|
||||||
|
&& path != "/api/ready"
|
||||||
|
&& !request_is_lan(&req)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Alchemist setup is only available from the local network",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// 1. API Protection: Only lock down /api routes
|
// 1. API Protection: Only lock down /api routes
|
||||||
if path.starts_with("/api") {
|
if path.starts_with("/api") {
|
||||||
// Public API endpoints
|
// Public API endpoints
|
||||||
@@ -92,28 +104,7 @@ pub(crate) async fn auth_middleware(
|
|||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
if state.setup_required.load(Ordering::Relaxed) && path.starts_with("/api/fs/") {
|
if state.setup_required.load(Ordering::Relaxed) && path.starts_with("/api/fs/") {
|
||||||
// Only allow filesystem browsing from localhost
|
return next.run(req).await;
|
||||||
// during setup — no account exists yet so we
|
|
||||||
// cannot authenticate the caller.
|
|
||||||
let connect_info = req.extensions().get::<ConnectInfo<SocketAddr>>();
|
|
||||||
let is_local = connect_info
|
|
||||||
.map(|ci| {
|
|
||||||
let ip = ci.0.ip();
|
|
||||||
ip.is_loopback()
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if is_local {
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
// Non-local request during setup -> 403
|
|
||||||
return Response::builder()
|
|
||||||
.status(StatusCode::FORBIDDEN)
|
|
||||||
.body(axum::body::Body::from(
|
|
||||||
"Filesystem browsing is only available \
|
|
||||||
from localhost during setup",
|
|
||||||
))
|
|
||||||
.unwrap_or_else(|_| StatusCode::FORBIDDEN.into_response());
|
|
||||||
}
|
}
|
||||||
if state.setup_required.load(Ordering::Relaxed) && path == "/api/settings/bundle" {
|
if state.setup_required.load(Ordering::Relaxed) && path == "/api/settings/bundle" {
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
@@ -157,6 +148,10 @@ pub(crate) async fn auth_middleware(
|
|||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_is_lan(req: &Request) -> bool {
|
||||||
|
request_ip(req).is_some_and(is_lan_ip)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_only_api_token_allows(method: &Method, path: &str) -> bool {
|
fn read_only_api_token_allows(method: &Method, path: &str) -> bool {
|
||||||
if *method != Method::GET && *method != Method::HEAD {
|
if *method != Method::GET && *method != Method::HEAD {
|
||||||
return false;
|
return false;
|
||||||
@@ -314,3 +309,10 @@ fn is_trusted_peer(ip: IpAddr) -> bool {
|
|||||||
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(),
|
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_lan_ip(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
|
||||||
|
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
http::{StatusCode, Uri, header},
|
http::{StatusCode, Uri, header},
|
||||||
middleware as axum_middleware,
|
middleware as axum_middleware,
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
#[cfg(feature = "embed-web")]
|
#[cfg(feature = "embed-web")]
|
||||||
@@ -81,7 +81,6 @@ pub struct AppState {
|
|||||||
pub library_scanner: Arc<crate::system::scanner::LibraryScanner>,
|
pub library_scanner: Arc<crate::system::scanner::LibraryScanner>,
|
||||||
pub config_path: PathBuf,
|
pub config_path: PathBuf,
|
||||||
pub config_mutable: bool,
|
pub config_mutable: bool,
|
||||||
pub base_url: String,
|
|
||||||
pub hardware_state: HardwareState,
|
pub hardware_state: HardwareState,
|
||||||
pub hardware_probe_log: Arc<tokio::sync::RwLock<HardwareProbeLog>>,
|
pub hardware_probe_log: Arc<tokio::sync::RwLock<HardwareProbeLog>>,
|
||||||
pub resources_cache: Arc<tokio::sync::Mutex<Option<(serde_json::Value, std::time::Instant)>>>,
|
pub resources_cache: Arc<tokio::sync::Mutex<Option<(serde_json::Value, std::time::Instant)>>>,
|
||||||
@@ -146,11 +145,6 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
|
|||||||
sys.refresh_cpu_usage();
|
sys.refresh_cpu_usage();
|
||||||
sys.refresh_memory();
|
sys.refresh_memory();
|
||||||
|
|
||||||
let base_url = {
|
|
||||||
let config = config.read().await;
|
|
||||||
config.system.base_url.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
@@ -168,7 +162,6 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
|
|||||||
library_scanner,
|
library_scanner,
|
||||||
config_path,
|
config_path,
|
||||||
config_mutable,
|
config_mutable,
|
||||||
base_url: base_url.clone(),
|
|
||||||
hardware_state,
|
hardware_state,
|
||||||
hardware_probe_log,
|
hardware_probe_log,
|
||||||
resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
|
resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
@@ -180,18 +173,7 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
|
|||||||
// Clone agent for shutdown handler before moving state into router
|
// Clone agent for shutdown handler before moving state into router
|
||||||
let shutdown_agent = state.agent.clone();
|
let shutdown_agent = state.agent.clone();
|
||||||
|
|
||||||
let inner_app = app_router(state.clone());
|
let app = app_router(state.clone());
|
||||||
let app = if base_url.is_empty() {
|
|
||||||
inner_app
|
|
||||||
} else {
|
|
||||||
let redirect_target = format!("{base_url}/");
|
|
||||||
Router::new()
|
|
||||||
.route(
|
|
||||||
"/",
|
|
||||||
get(move || async move { Redirect::permanent(&redirect_target) }),
|
|
||||||
)
|
|
||||||
.nest(&base_url, inner_app)
|
|
||||||
};
|
|
||||||
|
|
||||||
let port = std::env::var("ALCHEMIST_SERVER_PORT")
|
let port = std::env::var("ALCHEMIST_SERVER_PORT")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -828,7 +810,7 @@ async fn index_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse
|
|||||||
static_handler(State(state), Uri::from_static("/index.html")).await
|
static_handler(State(state), Uri::from_static("/index.html")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl IntoResponse {
|
async fn static_handler(State(_state): State<Arc<AppState>>, uri: Uri) -> impl IntoResponse {
|
||||||
let raw_path = uri.path().trim_start_matches('/');
|
let raw_path = uri.path().trim_start_matches('/');
|
||||||
let path = match sanitize_asset_path(raw_path) {
|
let path = match sanitize_asset_path(raw_path) {
|
||||||
Some(path) => path,
|
Some(path) => path,
|
||||||
@@ -837,11 +819,7 @@ async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl In
|
|||||||
|
|
||||||
if let Some(content) = load_static_asset(&path) {
|
if let Some(content) = load_static_asset(&path) {
|
||||||
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||||
return (
|
return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
|
||||||
[(header::CONTENT_TYPE, mime.as_ref())],
|
|
||||||
maybe_inject_base_url(content, mime.as_ref(), &state.base_url),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to serve index.html for directory paths (e.g. /jobs -> jobs/index.html)
|
// Attempt to serve index.html for directory paths (e.g. /jobs -> jobs/index.html)
|
||||||
@@ -849,11 +827,7 @@ async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl In
|
|||||||
let index_path = format!("{}/index.html", path);
|
let index_path = format!("{}/index.html", path);
|
||||||
if let Some(content) = load_static_asset(&index_path) {
|
if let Some(content) = load_static_asset(&index_path) {
|
||||||
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
|
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
|
||||||
return (
|
return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
|
||||||
[(header::CONTENT_TYPE, mime.as_ref())],
|
|
||||||
maybe_inject_base_url(content, mime.as_ref(), &state.base_url),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,14 +864,3 @@ async fn static_handler(State(state): State<Arc<AppState>>, uri: Uri) -> impl In
|
|||||||
// Default fallback to 404 for missing files.
|
// Default fallback to 404 for missing files.
|
||||||
StatusCode::NOT_FOUND.into_response()
|
StatusCode::NOT_FOUND.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_inject_base_url(content: Vec<u8>, mime: &str, base_url: &str) -> Vec<u8> {
|
|
||||||
if !mime.starts_with("text/html") {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
let Ok(text) = String::from_utf8(content.clone()) else {
|
|
||||||
return content;
|
|
||||||
};
|
|
||||||
text.replace("__ALCHEMIST_BASE_URL__", base_url)
|
|
||||||
.into_bytes()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ where
|
|||||||
library_scanner: Arc::new(crate::system::scanner::LibraryScanner::new(db, config)),
|
library_scanner: Arc::new(crate::system::scanner::LibraryScanner::new(db, config)),
|
||||||
config_path: config_path.clone(),
|
config_path: config_path.clone(),
|
||||||
config_mutable: true,
|
config_mutable: true,
|
||||||
base_url: String::new(),
|
|
||||||
hardware_state,
|
hardware_state,
|
||||||
hardware_probe_log,
|
hardware_probe_log,
|
||||||
resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
|
resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
@@ -211,6 +210,17 @@ fn remote_request(method: Method, uri: &str, body: Body) -> Request<Body> {
|
|||||||
request
|
request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lan_request(method: Method, uri: &str, body: Body) -> Request<Body> {
|
||||||
|
let mut request = match Request::builder().method(method).uri(uri).body(body) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(err) => panic!("failed to build LAN request: {err}"),
|
||||||
|
};
|
||||||
|
request
|
||||||
|
.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([192, 168, 1, 25], 3000))));
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
async fn body_text(response: axum::response::Response) -> String {
|
async fn body_text(response: axum::response::Response) -> String {
|
||||||
let bytes = match to_bytes(response.into_body(), usize::MAX).await {
|
let bytes = match to_bytes(response.into_body(), usize::MAX).await {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
@@ -740,32 +750,6 @@ async fn read_only_api_token_cannot_access_settings_config()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn nested_base_url_routes_engine_status_through_auth_middleware()
|
|
||||||
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let (state, _app, config_path, db_path) = build_test_app(false, 8, |config| {
|
|
||||||
config.system.base_url = "/alchemist".to_string();
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
let token = create_session(state.db.as_ref()).await?;
|
|
||||||
let app = Router::new().nest("/alchemist", app_router(state.clone()));
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(auth_request(
|
|
||||||
Method::GET,
|
|
||||||
"/alchemist/api/engine/status",
|
|
||||||
&token,
|
|
||||||
Body::empty(),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
drop(state);
|
|
||||||
let _ = std::fs::remove_file(config_path);
|
|
||||||
let _ = std::fs::remove_file(db_path);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hardware_probe_log_route_returns_runtime_log()
|
async fn hardware_probe_log_route_returns_runtime_log()
|
||||||
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -818,12 +802,11 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
|
|||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot({
|
||||||
Request::builder()
|
let mut request = localhost_request(
|
||||||
.method(Method::POST)
|
Method::POST,
|
||||||
.uri("/api/setup/complete")
|
"/api/setup/complete",
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
Body::from(
|
||||||
.body(Body::from(
|
|
||||||
json!({
|
json!({
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "password123",
|
"password": "password123",
|
||||||
@@ -838,9 +821,14 @@ async fn setup_complete_updates_runtime_hardware_without_mirroring_watch_dirs()
|
|||||||
"quality_profile": "balanced"
|
"quality_profile": "balanced"
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
))
|
),
|
||||||
.unwrap_or_else(|err| panic!("failed to build setup completion request: {err}")),
|
);
|
||||||
)
|
request.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
axum::http::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
request
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
@@ -932,23 +920,25 @@ async fn setup_complete_accepts_nested_settings_payload()
|
|||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot({
|
||||||
Request::builder()
|
let mut request = localhost_request(
|
||||||
.method(Method::POST)
|
Method::POST,
|
||||||
.uri("/api/setup/complete")
|
"/api/setup/complete",
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
Body::from(
|
||||||
.body(Body::from(
|
|
||||||
json!({
|
json!({
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "password123",
|
"password": "password123",
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
))
|
),
|
||||||
.unwrap_or_else(|err| {
|
);
|
||||||
panic!("failed to build nested setup completion request: {err}")
|
request.headers_mut().insert(
|
||||||
}),
|
header::CONTENT_TYPE,
|
||||||
)
|
axum::http::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
request
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -981,23 +971,25 @@ async fn setup_complete_rejects_nested_settings_without_library_directories()
|
|||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot({
|
||||||
Request::builder()
|
let mut request = localhost_request(
|
||||||
.method(Method::POST)
|
Method::POST,
|
||||||
.uri("/api/setup/complete")
|
"/api/setup/complete",
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
Body::from(
|
||||||
.body(Body::from(
|
|
||||||
json!({
|
json!({
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "password123",
|
"password": "password123",
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
))
|
),
|
||||||
.unwrap_or_else(|err| {
|
);
|
||||||
panic!("failed to build nested setup rejection request: {err}")
|
request.headers_mut().insert(
|
||||||
}),
|
header::CONTENT_TYPE,
|
||||||
)
|
axum::http::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
request
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let body = body_text(response).await;
|
let body = body_text(response).await;
|
||||||
@@ -1076,7 +1068,7 @@ async fn fs_endpoints_require_loopback_during_setup()
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(browse_response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(browse_response.status(), StatusCode::FORBIDDEN);
|
||||||
let browse_body = body_text(browse_response).await;
|
let browse_body = body_text(browse_response).await;
|
||||||
assert!(browse_body.contains("Filesystem browsing is only available"));
|
assert!(browse_body.contains("local network"));
|
||||||
|
|
||||||
let mut preview_request = remote_request(
|
let mut preview_request = remote_request(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
@@ -1096,12 +1088,78 @@ async fn fs_endpoints_require_loopback_during_setup()
|
|||||||
let preview_response = app.clone().oneshot(preview_request).await?;
|
let preview_response = app.clone().oneshot(preview_request).await?;
|
||||||
assert_eq!(preview_response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(preview_response.status(), StatusCode::FORBIDDEN);
|
||||||
let preview_body = body_text(preview_response).await;
|
let preview_body = body_text(preview_response).await;
|
||||||
assert!(preview_body.contains("Filesystem browsing is only available"));
|
assert!(preview_body.contains("local network"));
|
||||||
|
|
||||||
cleanup_paths(&[browse_root, config_path, db_path]);
|
cleanup_paths(&[browse_root, config_path, db_path]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn setup_html_routes_allow_lan_clients() -> std::result::Result<(), Box<dyn std::error::Error>>
|
||||||
|
{
|
||||||
|
let (_state, app, config_path, db_path) = build_test_app(true, 8, |_| {}).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(lan_request(Method::GET, "/setup", Body::empty()))
|
||||||
|
.await?;
|
||||||
|
assert_ne!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
cleanup_paths(&[config_path, db_path]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn setup_html_routes_reject_public_clients()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (_state, app, config_path, db_path) = build_test_app(true, 8, |_| {}).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(remote_request(Method::GET, "/setup", Body::empty()))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
let body = body_text(response).await;
|
||||||
|
assert!(body.contains("only available from the local network"));
|
||||||
|
|
||||||
|
cleanup_paths(&[config_path, db_path]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn setup_status_rejects_public_clients_during_setup()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (_state, app, config_path, db_path) = build_test_app(true, 8, |_| {}).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(remote_request(
|
||||||
|
Method::GET,
|
||||||
|
"/api/setup/status",
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
cleanup_paths(&[config_path, db_path]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_clients_can_reach_login_after_setup()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (_state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(remote_request(Method::GET, "/login", Body::empty()))
|
||||||
|
.await?;
|
||||||
|
assert_ne!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
cleanup_paths(&[config_path, db_path]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn settings_bundle_requires_auth_after_setup()
|
async fn settings_bundle_requires_auth_after_setup()
|
||||||
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { apiFetch, apiJson } from "../lib/api";
|
import { apiFetch, apiJson } from "../lib/api";
|
||||||
import { stripBasePath, withBasePath } from "../lib/basePath";
|
|
||||||
|
|
||||||
interface SetupStatus {
|
interface SetupStatus {
|
||||||
setup_required?: boolean;
|
setup_required?: boolean;
|
||||||
@@ -11,7 +10,7 @@ export default function AuthGuard() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
const path = stripBasePath(window.location.pathname);
|
const path = window.location.pathname;
|
||||||
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
|
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
|
||||||
if (isAuthPage) {
|
if (isAuthPage) {
|
||||||
return;
|
return;
|
||||||
@@ -28,9 +27,7 @@ export default function AuthGuard() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = setupStatus.setup_required
|
window.location.href = setupStatus.setup_required ? "/setup" : "/login";
|
||||||
? withBasePath("/setup")
|
|
||||||
: withBasePath("/login");
|
|
||||||
} catch {
|
} catch {
|
||||||
// Keep user on current page on transient backend/network failures.
|
// Keep user on current page on transient backend/network failures.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Upload, Wand2, Play, Download, Trash2 } from "lucide-react";
|
import { Upload, Wand2, Play, Download, Trash2 } from "lucide-react";
|
||||||
import { apiAction, apiFetch, apiJson, isApiError } from "../lib/api";
|
import { apiAction, apiFetch, apiJson, isApiError } from "../lib/api";
|
||||||
import { withBasePath } from "../lib/basePath";
|
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
interface SubtitleStreamMetadata {
|
interface SubtitleStreamMetadata {
|
||||||
@@ -105,7 +104,7 @@ const DEFAULT_SETTINGS: ConversionSettings = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ConversionTool() {
|
export function ConversionTool() {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [previewing, setPreviewing] = useState(false);
|
const [previewing, setPreviewing] = useState(false);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
@@ -121,13 +120,14 @@ export default function ConversionTool() {
|
|||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
void apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`)
|
void apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`)
|
||||||
.then(setStatus)
|
.then(setStatus)
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [conversionJobId]);
|
}, [conversionJobId]);
|
||||||
|
|
||||||
const updateSettings = (patch: Partial<ConversionSettings>) => {
|
const updateSettings = (patch: Partial<ConversionSettings>) => {
|
||||||
setSettings((current) => ({ ...current, ...patch }));
|
setSettings((current) => ({...current, ...patch}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
const uploadFile = async (file: File) => {
|
||||||
@@ -157,7 +157,7 @@ export default function ConversionTool() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Upload failed";
|
const message = err instanceof Error ? err.message : "Upload failed";
|
||||||
setError(message);
|
setError(message);
|
||||||
showToast({ kind: "error", title: "Conversion", message });
|
showToast({kind: "error", title: "Conversion", message});
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ export default function ConversionTool() {
|
|||||||
try {
|
try {
|
||||||
const payload = await apiJson<PreviewResponse>("/api/conversion/preview", {
|
const payload = await apiJson<PreviewResponse>("/api/conversion/preview", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
conversion_job_id: conversionJobId,
|
conversion_job_id: conversionJobId,
|
||||||
settings,
|
settings,
|
||||||
@@ -177,11 +177,11 @@ export default function ConversionTool() {
|
|||||||
});
|
});
|
||||||
setSettings(payload.normalized_settings);
|
setSettings(payload.normalized_settings);
|
||||||
setCommandPreview(payload.command_preview);
|
setCommandPreview(payload.command_preview);
|
||||||
showToast({ kind: "success", title: "Conversion", message: "Preview updated." });
|
showToast({kind: "success", title: "Conversion", message: "Preview updated."});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = isApiError(err) ? err.message : "Preview failed";
|
const message = isApiError(err) ? err.message : "Preview failed";
|
||||||
setError(message);
|
setError(message);
|
||||||
showToast({ kind: "error", title: "Conversion", message });
|
showToast({kind: "error", title: "Conversion", message});
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewing(false);
|
setPreviewing(false);
|
||||||
}
|
}
|
||||||
@@ -191,14 +191,14 @@ export default function ConversionTool() {
|
|||||||
if (!conversionJobId) return;
|
if (!conversionJobId) return;
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
try {
|
try {
|
||||||
await apiAction(`/api/conversion/jobs/${conversionJobId}/start`, { method: "POST" });
|
await apiAction(`/api/conversion/jobs/${conversionJobId}/start`, {method: "POST"});
|
||||||
const payload = await apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`);
|
const payload = await apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`);
|
||||||
setStatus(payload);
|
setStatus(payload);
|
||||||
showToast({ kind: "success", title: "Conversion", message: "Conversion job queued." });
|
showToast({kind: "success", title: "Conversion", message: "Conversion job queued."});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = isApiError(err) ? err.message : "Failed to start conversion";
|
const message = isApiError(err) ? err.message : "Failed to start conversion";
|
||||||
setError(message);
|
setError(message);
|
||||||
showToast({ kind: "error", title: "Conversion", message });
|
showToast({kind: "error", title: "Conversion", message});
|
||||||
} finally {
|
} finally {
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
}
|
}
|
||||||
@@ -207,23 +207,23 @@ export default function ConversionTool() {
|
|||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!conversionJobId) return;
|
if (!conversionJobId) return;
|
||||||
try {
|
try {
|
||||||
await apiAction(`/api/conversion/jobs/${conversionJobId}`, { method: "DELETE" });
|
await apiAction(`/api/conversion/jobs/${conversionJobId}`, {method: "DELETE"});
|
||||||
setConversionJobId(null);
|
setConversionJobId(null);
|
||||||
setProbe(null);
|
setProbe(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setSettings(DEFAULT_SETTINGS);
|
setSettings(DEFAULT_SETTINGS);
|
||||||
setCommandPreview("");
|
setCommandPreview("");
|
||||||
showToast({ kind: "success", title: "Conversion", message: "Conversion job removed." });
|
showToast({kind: "success", title: "Conversion", message: "Conversion job removed."});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = isApiError(err) ? err.message : "Failed to remove conversion job";
|
const message = isApiError(err) ? err.message : "Failed to remove conversion job";
|
||||||
setError(message);
|
setError(message);
|
||||||
showToast({ kind: "error", title: "Conversion", message });
|
showToast({kind: "error", title: "Conversion", message});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const download = async () => {
|
const download = async () => {
|
||||||
if (!conversionJobId) return;
|
if (!conversionJobId) return;
|
||||||
window.location.href = withBasePath(`/api/conversion/jobs/${conversionJobId}/download`);
|
window.location.href = `/api/conversion/jobs/${conversionJobId}/download`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -231,22 +231,26 @@ export default function ConversionTool() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-helios-ink">Conversion / Remux</h1>
|
<h1 className="text-xl font-bold text-helios-ink">Conversion / Remux</h1>
|
||||||
<p className="mt-1 text-sm text-helios-slate">
|
<p className="mt-1 text-sm text-helios-slate">
|
||||||
Upload a single file, inspect the streams, preview the generated FFmpeg command, and run it through Alchemist.
|
Upload a single file, inspect the streams, preview the generated FFmpeg command, and run it through
|
||||||
|
Alchemist.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-status-error/20 bg-status-error/10 px-4 py-3 text-sm text-status-error">
|
<div
|
||||||
|
className="rounded-lg border border-status-error/20 bg-status-error/10 px-4 py-3 text-sm text-status-error">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!probe && (
|
{!probe && (
|
||||||
<label className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-helios-line/30 bg-helios-surface p-10 text-center cursor-pointer hover:bg-helios-surface-soft transition-colors">
|
<label
|
||||||
<Upload size={28} className="text-helios-solar" />
|
className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-helios-line/30 bg-helios-surface p-10 text-center cursor-pointer hover:bg-helios-surface-soft transition-colors">
|
||||||
|
<Upload size={28} className="text-helios-solar"/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-helios-ink">Upload a source file</p>
|
<p className="text-sm font-semibold text-helios-ink">Upload a source file</p>
|
||||||
<p className="text-xs text-helios-slate mt-1">The uploaded file is stored temporarily under Alchemist-managed temp storage.</p>
|
<p className="text-xs text-helios-slate mt-1">You can select a couple options here to
|
||||||
|
convert/remux a video file.</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -270,16 +274,18 @@ export default function ConversionTool() {
|
|||||||
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
||||||
<h2 className="text-sm font-semibold text-helios-ink">Input</h2>
|
<h2 className="text-sm font-semibold text-helios-ink">Input</h2>
|
||||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||||
<Stat label="Container" value={probe.metadata.container} />
|
<Stat label="Container" value={probe.metadata.container}/>
|
||||||
<Stat label="Video" value={probe.metadata.codec_name} />
|
<Stat label="Video" value={probe.metadata.codec_name}/>
|
||||||
<Stat label="Resolution" value={`${probe.metadata.width}x${probe.metadata.height}`} />
|
<Stat label="Resolution" value={`${probe.metadata.width}x${probe.metadata.height}`}/>
|
||||||
<Stat label="Dynamic Range" value={probe.metadata.dynamic_range} />
|
<Stat label="Dynamic Range" value={probe.metadata.dynamic_range}/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
||||||
<h2 className="text-sm font-semibold text-helios-ink">Output Container</h2>
|
<h2 className="text-sm font-semibold text-helios-ink">Output Container</h2>
|
||||||
<select value={settings.output_container} onChange={(event) => updateSettings({ output_container: event.target.value })} className="w-full md:w-60 bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink">
|
<select value={settings.output_container}
|
||||||
|
onChange={(event) => updateSettings({output_container: event.target.value})}
|
||||||
|
className="w-full md:w-60 bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink">
|
||||||
{["mkv", "mp4", "webm", "mov"].map((option) => (
|
{["mkv", "mp4", "webm", "mov"].map((option) => (
|
||||||
<option key={option} value={option}>{option.toUpperCase()}</option>
|
<option key={option} value={option}>{option.toUpperCase()}</option>
|
||||||
))}
|
))}
|
||||||
@@ -293,7 +299,7 @@ export default function ConversionTool() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.remux_only}
|
checked={settings.remux_only}
|
||||||
onChange={(event) => updateSettings({ remux_only: event.target.checked })}
|
onChange={(event) => updateSettings({remux_only: event.target.checked})}
|
||||||
/>
|
/>
|
||||||
Remux only
|
Remux only
|
||||||
</label>
|
</label>
|
||||||
@@ -311,41 +317,59 @@ export default function ConversionTool() {
|
|||||||
value={settings.video.codec}
|
value={settings.video.codec}
|
||||||
disabled={settings.remux_only}
|
disabled={settings.remux_only}
|
||||||
options={["copy", "h264", "hevc", "av1"]}
|
options={["copy", "h264", "hevc", "av1"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, codec: value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {...current.video, codec: value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Mode"
|
label="Mode"
|
||||||
value={settings.video.mode}
|
value={settings.video.mode}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
options={["crf", "bitrate"]}
|
options={["crf", "bitrate"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, mode: value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {...current.video, mode: value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label={settings.video.mode === "bitrate" ? "Bitrate (kbps)" : "Quality Value"}
|
label={settings.video.mode === "bitrate" ? "Bitrate (kbps)" : "Quality Value"}
|
||||||
value={settings.video.value ?? 0}
|
value={settings.video.value ?? 0}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {...current.video, value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Preset"
|
label="Preset"
|
||||||
value={settings.video.preset ?? "medium"}
|
value={settings.video.preset ?? "medium"}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
options={["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"]}
|
options={["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, preset: value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {...current.video, preset: value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Resolution Mode"
|
label="Resolution Mode"
|
||||||
value={settings.video.resolution.mode}
|
value={settings.video.resolution.mode}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
options={["original", "custom", "scale_factor"]}
|
options={["original", "custom", "scale_factor"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, mode: value } } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {...current.video, resolution: {...current.video.resolution, mode: value}}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="HDR"
|
label="HDR"
|
||||||
value={settings.video.hdr_mode}
|
value={settings.video.hdr_mode}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
options={["preserve", "tonemap", "strip_metadata"]}
|
options={["preserve", "tonemap", "strip_metadata"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, hdr_mode: value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {...current.video, hdr_mode: value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
{settings.video.resolution.mode === "custom" && (
|
{settings.video.resolution.mode === "custom" && (
|
||||||
<>
|
<>
|
||||||
@@ -353,13 +377,25 @@ export default function ConversionTool() {
|
|||||||
label="Width"
|
label="Width"
|
||||||
value={settings.video.resolution.width ?? probe.metadata.width}
|
value={settings.video.resolution.width ?? probe.metadata.width}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, width: value } } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {
|
||||||
|
...current.video,
|
||||||
|
resolution: {...current.video.resolution, width: value}
|
||||||
|
}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Height"
|
label="Height"
|
||||||
value={settings.video.resolution.height ?? probe.metadata.height}
|
value={settings.video.resolution.height ?? probe.metadata.height}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, height: value } } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {
|
||||||
|
...current.video,
|
||||||
|
resolution: {...current.video.resolution, height: value}
|
||||||
|
}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -369,7 +405,13 @@ export default function ConversionTool() {
|
|||||||
value={settings.video.resolution.scale_factor ?? 1}
|
value={settings.video.resolution.scale_factor ?? 1}
|
||||||
disabled={settings.remux_only || settings.video.codec === "copy"}
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, scale_factor: value } } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
video: {
|
||||||
|
...current.video,
|
||||||
|
resolution: {...current.video.resolution, scale_factor: value}
|
||||||
|
}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -383,20 +425,29 @@ export default function ConversionTool() {
|
|||||||
value={settings.audio.codec}
|
value={settings.audio.codec}
|
||||||
disabled={settings.remux_only}
|
disabled={settings.remux_only}
|
||||||
options={["copy", "aac", "opus", "mp3"]}
|
options={["copy", "aac", "opus", "mp3"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, codec: value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
audio: {...current.audio, codec: value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Bitrate (kbps)"
|
label="Bitrate (kbps)"
|
||||||
value={settings.audio.bitrate_kbps ?? 160}
|
value={settings.audio.bitrate_kbps ?? 160}
|
||||||
disabled={settings.remux_only || settings.audio.codec === "copy"}
|
disabled={settings.remux_only || settings.audio.codec === "copy"}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, bitrate_kbps: value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
audio: {...current.audio, bitrate_kbps: value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Channels"
|
label="Channels"
|
||||||
value={settings.audio.channels ?? "auto"}
|
value={settings.audio.channels ?? "auto"}
|
||||||
disabled={settings.remux_only || settings.audio.codec === "copy"}
|
disabled={settings.remux_only || settings.audio.codec === "copy"}
|
||||||
options={["auto", "stereo", "5.1"]}
|
options={["auto", "stereo", "5.1"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, channels: value } }))}
|
onChange={(value) => setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
audio: {...current.audio, channels: value}
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -408,31 +459,36 @@ export default function ConversionTool() {
|
|||||||
value={settings.subtitles.mode}
|
value={settings.subtitles.mode}
|
||||||
disabled={settings.remux_only}
|
disabled={settings.remux_only}
|
||||||
options={["copy", "burn", "remove"]}
|
options={["copy", "burn", "remove"]}
|
||||||
onChange={(value) => setSettings((current) => ({ ...current, subtitles: { mode: value } }))}
|
onChange={(value) => setSettings((current) => ({...current, subtitles: {mode: value}}))}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button onClick={() => void preview()} disabled={previewing} className="flex items-center gap-2 rounded-lg bg-helios-solar px-4 py-2 text-sm font-bold text-helios-main">
|
<button onClick={() => void preview()} disabled={previewing}
|
||||||
<Wand2 size={16} />
|
className="flex items-center gap-2 rounded-lg bg-helios-solar px-4 py-2 text-sm font-bold text-helios-main">
|
||||||
|
<Wand2 size={16}/>
|
||||||
{previewing ? "Previewing..." : "Preview Command"}
|
{previewing ? "Previewing..." : "Preview Command"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => void start()} disabled={starting || !commandPreview} className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink">
|
<button onClick={() => void start()} disabled={starting || !commandPreview}
|
||||||
<Play size={16} />
|
className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink">
|
||||||
|
<Play size={16}/>
|
||||||
{starting ? "Starting..." : "Start Job"}
|
{starting ? "Starting..." : "Start Job"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => void download()} disabled={!status?.download_ready} className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink disabled:opacity-50">
|
<button onClick={() => void download()} disabled={!status?.download_ready}
|
||||||
<Download size={16} />
|
className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink disabled:opacity-50">
|
||||||
|
<Download size={16}/>
|
||||||
Download Result
|
Download Result
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => void remove()} className="flex items-center gap-2 rounded-lg border border-red-500/20 px-4 py-2 text-sm font-semibold text-red-500">
|
<button onClick={() => void remove()}
|
||||||
<Trash2 size={16} />
|
className="flex items-center gap-2 rounded-lg border border-red-500/20 px-4 py-2 text-sm font-semibold text-red-500">
|
||||||
|
<Trash2 size={16}/>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{commandPreview && (
|
{commandPreview && (
|
||||||
<pre className="overflow-x-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft p-4 text-xs text-helios-ink whitespace-pre-wrap">
|
<pre
|
||||||
|
className="overflow-x-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft p-4 text-xs text-helios-ink whitespace-pre-wrap">
|
||||||
{commandPreview}
|
{commandPreview}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -442,10 +498,11 @@ export default function ConversionTool() {
|
|||||||
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-3">
|
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-3">
|
||||||
<h2 className="text-sm font-semibold text-helios-ink">Status</h2>
|
<h2 className="text-sm font-semibold text-helios-ink">Status</h2>
|
||||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||||
<Stat label="State" value={status.status} />
|
<Stat label="State" value={status.status}/>
|
||||||
<Stat label="Progress" value={`${status.progress.toFixed(1)}%`} />
|
<Stat label="Progress" value={`${status.progress.toFixed(1)}%`}/>
|
||||||
<Stat label="Linked Job" value={status.linked_job_id ? `#${status.linked_job_id}` : "None"} />
|
<Stat label="Linked Job"
|
||||||
<Stat label="Download" value={status.download_ready ? "Ready" : "Pending"} />
|
value={status.linked_job_id ? `#${status.linked_job_id}` : "None"}/>
|
||||||
|
<Stat label="Download" value={status.download_ready ? "Ready" : "Pending"}/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiJson, isApiError } from "../lib/api";
|
import { apiJson, isApiError } from "../lib/api";
|
||||||
import { withBasePath } from "../lib/basePath";
|
|
||||||
import { useSharedStats } from "../lib/statsStore";
|
import { useSharedStats } from "../lib/statsStore";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
import ResourceMonitor from "./ResourceMonitor";
|
import ResourceMonitor from "./ResourceMonitor";
|
||||||
@@ -145,7 +144,7 @@ function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (setupComplete !== "true") {
|
if (setupComplete !== "true") {
|
||||||
window.location.href = withBasePath("/setup");
|
window.location.href = "/setup";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -233,7 +232,7 @@ function Dashboard() {
|
|||||||
<Activity size={16} className="text-helios-solar" />
|
<Activity size={16} className="text-helios-solar" />
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h3>
|
</h3>
|
||||||
<a href={withBasePath("/jobs")} className="text-xs font-medium text-helios-solar hover:underline">
|
<a href="/jobs" className="text-xs font-medium text-helios-solar hover:underline">
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +248,7 @@ function Dashboard() {
|
|||||||
<span className="text-sm text-helios-slate/60">
|
<span className="text-sm text-helios-slate/60">
|
||||||
No recent activity.
|
No recent activity.
|
||||||
</span>
|
</span>
|
||||||
<a href={withBasePath("/settings")} className="text-xs text-helios-solar hover:underline">
|
<a href="/settings" className="text-xs text-helios-solar hover:underline">
|
||||||
Add a library folder
|
Add a library folder
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Info, LogOut, Play, Square } from "lucide-react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
import { apiAction, apiJson } from "../lib/api";
|
import { apiAction, apiJson } from "../lib/api";
|
||||||
import { withBasePath } from "../lib/basePath";
|
|
||||||
import { useSharedStats } from "../lib/statsStore";
|
import { useSharedStats } from "../lib/statsStore";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
@@ -147,7 +146,7 @@ export default function HeaderActions() {
|
|||||||
message: "Logout request failed. Redirecting to login.",
|
message: "Logout request failed. Redirecting to login.",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
window.location.href = withBasePath("/login");
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal, ArrowDown, ArrowUp, AlertCircle
|
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal, ArrowDown, ArrowUp, AlertCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||||
import { withBasePath } from "../lib/basePath";
|
|
||||||
import { useDebouncedValue } from "../lib/useDebouncedValue";
|
import { useDebouncedValue } from "../lib/useDebouncedValue";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
import ConfirmDialog from "./ui/ConfirmDialog";
|
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||||
@@ -502,6 +501,7 @@ function JobManager() {
|
|||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("all");
|
const [activeTab, setActiveTab] = useState<TabType>("all");
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const [compactSearchOpen, setCompactSearchOpen] = useState(false);
|
||||||
const debouncedSearch = useDebouncedValue(searchInput, 350);
|
const debouncedSearch = useDebouncedValue(searchInput, 350);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortBy, setSortBy] = useState<SortField>("updated_at");
|
const [sortBy, setSortBy] = useState<SortField>("updated_at");
|
||||||
@@ -514,6 +514,8 @@ function JobManager() {
|
|||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const detailDialogRef = useRef<HTMLDivElement | null>(null);
|
const detailDialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
const detailLastFocusedRef = useRef<HTMLElement | null>(null);
|
const detailLastFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
|
const compactSearchRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const compactSearchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const confirmOpenRef = useRef(false);
|
const confirmOpenRef = useRef(false);
|
||||||
const encodeStartTimes = useRef<Map<number, number>>(new Map());
|
const encodeStartTimes = useRef<Map<number, number>>(new Map());
|
||||||
const [confirmState, setConfirmState] = useState<{
|
const [confirmState, setConfirmState] = useState<{
|
||||||
@@ -530,6 +532,43 @@ function JobManager() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchInput.trim()) {
|
||||||
|
setCompactSearchOpen(true);
|
||||||
|
}
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compactSearchOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
compactSearchInputRef.current?.focus();
|
||||||
|
|
||||||
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
compactSearchRef.current &&
|
||||||
|
!compactSearchRef.current.contains(event.target as Node) &&
|
||||||
|
!searchInput.trim()
|
||||||
|
) {
|
||||||
|
setCompactSearchOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !searchInput.trim()) {
|
||||||
|
setCompactSearchOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [compactSearchOpen, searchInput]);
|
||||||
|
|
||||||
const isJobActive = (job: Job) => ["analyzing", "encoding", "remuxing", "resuming"].includes(job.status);
|
const isJobActive = (job: Job) => ["analyzing", "encoding", "remuxing", "resuming"].includes(job.status);
|
||||||
|
|
||||||
const formatJobActionError = (error: unknown, fallback: string) => {
|
const formatJobActionError = (error: unknown, fallback: string) => {
|
||||||
@@ -665,7 +704,7 @@ function JobManager() {
|
|||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
eventSource = new EventSource(withBasePath("/api/events"));
|
eventSource = new EventSource("/api/events");
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
// Reset reconnect attempts on successful connection
|
// Reset reconnect attempts on successful connection
|
||||||
@@ -1084,8 +1123,8 @@ function JobManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-center bg-helios-surface/50 p-1 rounded-lg border border-helios-line/10">
|
<div className="flex flex-wrap items-start justify-between gap-4 rounded-xl border border-helios-line/10 bg-helios-surface/50 px-3 py-3 md:items-center">
|
||||||
<div className="flex gap-1 p-1 bg-helios-surface border border-helios-line/10 rounded-lg">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(["all", "active", "queued", "completed", "failed", "skipped", "archived"] as TabType[]).map((tab) => (
|
{(["all", "active", "queued", "completed", "failed", "skipped", "archived"] as TabType[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -1102,8 +1141,8 @@ function JobManager() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center md:w-auto">
|
<div className="ml-auto flex w-full min-w-0 flex-wrap items-center justify-end gap-2 md:w-auto md:flex-nowrap">
|
||||||
<div className="relative flex-1 md:w-64">
|
<div className="relative hidden xl:block xl:w-64">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate" size={14} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate" size={14} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1113,7 +1152,7 @@ function JobManager() {
|
|||||||
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-9 pr-4 py-2 text-sm text-helios-ink focus:border-helios-solar outline-none"
|
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-9 pr-4 py-2 text-sm text-helios-ink focus:border-helios-solar outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -1142,10 +1181,45 @@ function JobManager() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => void fetchJobs()}
|
onClick={() => void fetchJobs()}
|
||||||
className={cn("p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft", refreshing && "animate-spin")}
|
className="flex h-10 w-10 items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface text-helios-ink hover:bg-helios-surface-soft"
|
||||||
|
title="Refresh jobs"
|
||||||
|
aria-label="Refresh jobs"
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} className={refreshing ? "animate-spin" : undefined} />
|
||||||
</button>
|
</button>
|
||||||
|
<div ref={compactSearchRef} className="relative xl:hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-center overflow-hidden rounded-lg border border-helios-line/20 bg-helios-surface text-helios-ink transition-[width,box-shadow] duration-200 ease-out",
|
||||||
|
compactSearchOpen
|
||||||
|
? "w-[min(18rem,calc(100vw-4rem))] px-3 shadow-lg shadow-helios-main/20"
|
||||||
|
: "w-10 justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCompactSearchOpen((open) => (searchInput.trim() ? true : !open))}
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center text-helios-ink"
|
||||||
|
title="Search files"
|
||||||
|
aria-label="Search files"
|
||||||
|
>
|
||||||
|
<Search size={16} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={compactSearchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search files..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 bg-transparent text-sm text-helios-ink outline-none placeholder:text-helios-slate transition-all duration-200",
|
||||||
|
compactSearchOpen
|
||||||
|
? "ml-1 w-full opacity-100"
|
||||||
|
: "w-0 opacity-0 pointer-events-none"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Terminal, Pause, Play, Trash2, RefreshCw, Search } from "lucide-react";
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||||
import { withBasePath } from "../lib/basePath";
|
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
import ConfirmDialog from "./ui/ConfirmDialog";
|
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ export default function LogViewer() {
|
|||||||
|
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
eventSource = new EventSource(withBasePath("/api/events"));
|
eventSource = new EventSource("/api/events");
|
||||||
|
|
||||||
const appendLog = (message: string, level: string, jobId?: number) => {
|
const appendLog = (message: string, level: string, jobId?: number) => {
|
||||||
if (pausedRef.current) {
|
if (pausedRef.current) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||||
import { withBasePath } from "../lib/basePath";
|
|
||||||
import AdminAccountStep from "./setup/AdminAccountStep";
|
import AdminAccountStep from "./setup/AdminAccountStep";
|
||||||
import LibraryStep from "./setup/LibraryStep";
|
import LibraryStep from "./setup/LibraryStep";
|
||||||
import ProcessingStep from "./setup/ProcessingStep";
|
import ProcessingStep from "./setup/ProcessingStep";
|
||||||
@@ -103,7 +102,7 @@ export default function SetupWizard() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ key: "setup_complete", value: "true" }),
|
body: JSON.stringify({ key: "setup_complete", value: "true" }),
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
window.location.href = withBasePath("/");
|
window.location.href = "/";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message = "Failed to save setup configuration.";
|
let message = "Failed to save setup configuration.";
|
||||||
if (isApiError(err)) {
|
if (isApiError(err)) {
|
||||||
@@ -113,7 +112,7 @@ export default function SetupWizard() {
|
|||||||
: "Setup configuration was rejected. Check that your username is at least 3 characters and password is at least 8 characters.";
|
: "Setup configuration was rejected. Check that your username is at least 3 characters and password is at least 8 characters.";
|
||||||
} else if (err.status === 403) {
|
} else if (err.status === 403) {
|
||||||
message = "Setup has already been completed. Redirecting to dashboard...";
|
message = "Setup has already been completed. Redirecting to dashboard...";
|
||||||
setTimeout(() => { window.location.href = withBasePath("/"); }, 1500);
|
setTimeout(() => { window.location.href = "/"; }, 1500);
|
||||||
} else if (err.status >= 500) {
|
} else if (err.status >= 500) {
|
||||||
message = `Server error during setup (${err.status}). Check the Alchemist logs for details.`;
|
message = `Server error during setup (${err.status}). Check the Alchemist logs for details.`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ import {
|
|||||||
import SystemStatus from "./SystemStatus.tsx";
|
import SystemStatus from "./SystemStatus.tsx";
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
const strippedPath = currentPath;
|
||||||
const withBase = (href: string) => `${basePath}${href === "/" ? "/" : href}`;
|
|
||||||
const strippedPath =
|
|
||||||
basePath && currentPath.startsWith(basePath)
|
|
||||||
? currentPath.slice(basePath.length) || "/"
|
|
||||||
: currentPath;
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Dashboard", Icon: Activity },
|
{ href: "/", label: "Dashboard", Icon: Activity },
|
||||||
@@ -33,7 +28,7 @@ const navItems = [
|
|||||||
|
|
||||||
{/* Mobile top bar */}
|
{/* Mobile top bar */}
|
||||||
<div class="lg:hidden flex items-center justify-between px-4 py-3 bg-helios-surface border-b border-helios-line/60">
|
<div class="lg:hidden flex items-center justify-between px-4 py-3 bg-helios-surface border-b border-helios-line/60">
|
||||||
<a href={withBase("/")} class="font-bold text-lg tracking-tight text-helios-ink">Alchemist</a>
|
<a href="/" class="font-bold text-lg tracking-tight text-helios-ink">Alchemist</a>
|
||||||
<button
|
<button
|
||||||
id="sidebar-hamburger"
|
id="sidebar-hamburger"
|
||||||
aria-label="Open navigation"
|
aria-label="Open navigation"
|
||||||
@@ -58,7 +53,7 @@ const navItems = [
|
|||||||
transition-transform duration-200 lg:transition-none"
|
transition-transform duration-200 lg:transition-none"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={withBase("/")}
|
href="/"
|
||||||
class="flex items-center px-3 pb-4 border-b border-helios-line/40"
|
class="flex items-center px-3 pb-4 border-b border-helios-line/40"
|
||||||
>
|
>
|
||||||
<span class="font-bold text-lg tracking-tight text-helios-ink">
|
<span class="font-bold text-lg tracking-tight text-helios-ink">
|
||||||
@@ -81,7 +76,7 @@ const navItems = [
|
|||||||
(href !== "/" && strippedPath.startsWith(href));
|
(href !== "/" && strippedPath.startsWith(href));
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={withBase(href)}
|
href={href}
|
||||||
class:list={[
|
class:list={[
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap",
|
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap",
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { title } = Astro.props;
|
const { title } = Astro.props;
|
||||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -18,12 +17,9 @@ const basePath = "__ALCHEMIST_BASE_URL__";
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="description" content="Alchemist Media Transcoder" />
|
<meta name="description" content="Alchemist Media Transcoder" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<link rel="icon" type="image/svg+xml" href={`${basePath}/favicon.svg`} />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<script is:inline define:vars={{ basePath }}>
|
|
||||||
window.__ALCHEMIST_BASE_URL__ = basePath;
|
|
||||||
</script>
|
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { stripBasePath, withBasePath } from "./basePath";
|
|
||||||
|
|
||||||
export interface ApiErrorShape {
|
export interface ApiErrorShape {
|
||||||
status: number;
|
status: number;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -85,7 +83,7 @@ export function isApiError(error: unknown): error is ApiError {
|
|||||||
* Authenticated fetch utility using cookie auth.
|
* Authenticated fetch utility using cookie auth.
|
||||||
*/
|
*/
|
||||||
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
const resolvedUrl = withBasePath(url);
|
const resolvedUrl = url;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|
||||||
if (!headers.has("Content-Type") && typeof options.body === "string") {
|
if (!headers.has("Content-Type") && typeof options.body === "string") {
|
||||||
@@ -116,10 +114,10 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 && typeof window !== "undefined") {
|
if (response.status === 401 && typeof window !== "undefined") {
|
||||||
const path = stripBasePath(window.location.pathname);
|
const path = window.location.pathname;
|
||||||
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
|
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
|
||||||
if (!isAuthPage) {
|
if (!isAuthPage) {
|
||||||
window.location.href = withBasePath("/login");
|
window.location.href = "/login";
|
||||||
return new Promise(() => {});
|
return new Promise(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +134,7 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
|
|||||||
export async function apiJson<T>(url: string, options: RequestInit = {}): Promise<T> {
|
export async function apiJson<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await apiFetch(url, options);
|
const response = await apiFetch(url, options);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await toApiError(withBasePath(url), response);
|
throw await toApiError(url, response);
|
||||||
}
|
}
|
||||||
return (await parseResponseBody(response)) as T;
|
return (await parseResponseBody(response)) as T;
|
||||||
}
|
}
|
||||||
@@ -144,7 +142,7 @@ export async function apiJson<T>(url: string, options: RequestInit = {}): Promis
|
|||||||
export async function apiAction(url: string, options: RequestInit = {}): Promise<void> {
|
export async function apiAction(url: string, options: RequestInit = {}): Promise<void> {
|
||||||
const response = await apiFetch(url, options);
|
const response = await apiFetch(url, options);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await toApiError(withBasePath(url), response);
|
throw await toApiError(url, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 Layout from "../layouts/Layout.astro";
|
||||||
import Sidebar from "../components/Sidebar.astro";
|
import Sidebar from "../components/Sidebar.astro";
|
||||||
import HeaderActions from "../components/HeaderActions.tsx";
|
import HeaderActions from "../components/HeaderActions.tsx";
|
||||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Alchemist | Page Not Found">
|
<Layout title="Alchemist | Page Not Found">
|
||||||
@@ -24,7 +23,7 @@ const basePath = "__ALCHEMIST_BASE_URL__";
|
|||||||
The page you're looking for couldn't be found. It may have moved or the URL might be wrong.
|
The page you're looking for couldn't be found. It may have moved or the URL might be wrong.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`${basePath}/`}
|
href="/"
|
||||||
class="inline-flex items-center gap-2 mt-6 bg-helios-solar text-helios-main rounded-lg px-5 py-2.5 text-sm font-semibold hover:opacity-90 transition-opacity"
|
class="inline-flex items-center gap-2 mt-6 bg-helios-solar text-helios-main rounded-lg px-5 py-2.5 text-sm font-semibold hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
<Home size={16} />
|
<Home size={16} />
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { error } = Astro.props;
|
const { error } = Astro.props;
|
||||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Alchemist | Server Error">
|
<Layout title="Alchemist | Server Error">
|
||||||
@@ -29,7 +28,7 @@ const basePath = "__ALCHEMIST_BASE_URL__";
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`${basePath}/`}
|
href="/"
|
||||||
class="px-6 py-2.5 bg-helios-orange hover:bg-helios-orange/90 text-helios-main font-medium rounded-md transition-colors"
|
class="px-6 py-2.5 bg-helios-orange hover:bg-helios-orange/90 text-helios-main font-medium rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
Return to Dashboard
|
Return to Dashboard
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import Sidebar from "../components/Sidebar.astro";
|
import Sidebar from "../components/Sidebar.astro";
|
||||||
import HeaderActions from "../components/HeaderActions.tsx";
|
import HeaderActions from "../components/HeaderActions.tsx";
|
||||||
import ConversionTool from "../components/ConversionTool.tsx";
|
import {ConversionTool} from "../components/ConversionTool.tsx";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Alchemist | Convert">
|
<Layout title="Alchemist | Convert">
|
||||||
|
|||||||
@@ -68,12 +68,11 @@ import { ArrowRight } from "lucide-react";
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
|
||||||
|
|
||||||
void apiJson<{ setup_required: boolean }>("/api/setup/status")
|
void apiJson<{ setup_required: boolean }>("/api/setup/status")
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data?.setup_required) {
|
if (data?.setup_required) {
|
||||||
window.location.href = `${basePath}/setup`;
|
window.location.href = "/setup";
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -96,7 +95,7 @@ import { ArrowRight } from "lucide-react";
|
|||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
window.location.href = `${basePath}/`;
|
window.location.href = "/";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Login failed", err);
|
console.error("Login failed", err);
|
||||||
errorMsg?.classList.remove('hidden');
|
errorMsg?.classList.remove('hidden');
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ import SetupSidebar from "../components/SetupSidebar.astro";
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { apiJson } from "../lib/api";
|
import { apiJson } from "../lib/api";
|
||||||
const basePath = "__ALCHEMIST_BASE_URL__";
|
|
||||||
apiJson<{ setup_required: boolean }>("/api/setup/status")
|
apiJson<{ setup_required: boolean }>("/api/setup/status")
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.setup_required) {
|
if (!data.setup_required) {
|
||||||
window.location.href = `${basePath}/`;
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user