mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
chore: release v0.3.1-rc.1
This commit is contained in:
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -495,6 +495,17 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
path: release-assets
|
path: release-assets
|
||||||
|
|
||||||
|
- name: Render distribution metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ inputs.release_tag }}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
python3 scripts/render_distribution.py \
|
||||||
|
--version "${VERSION}" \
|
||||||
|
--assets-dir release-assets \
|
||||||
|
--output-dir release-assets/distribution
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
96
Cargo.lock
generated
96
Cargo.lock
generated
@@ -13,7 +13,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alchemist"
|
name = "alchemist"
|
||||||
version = "0.3.0"
|
version = "0.3.1-rc.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"inquire",
|
"inquire",
|
||||||
|
"lettre",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"notify",
|
"notify",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
@@ -38,6 +39,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -182,6 +184,7 @@ dependencies = [
|
|||||||
"matchit 0.7.3",
|
"matchit 0.7.3",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -598,6 +601,31 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-encoding"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -636,6 +664,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.27"
|
version = "0.2.27"
|
||||||
@@ -1289,6 +1323,33 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lettre"
|
||||||
|
version = "0.11.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"email-encoding",
|
||||||
|
"email_address",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"httpdate",
|
||||||
|
"idna",
|
||||||
|
"mime",
|
||||||
|
"nom",
|
||||||
|
"percent-encoding",
|
||||||
|
"quoted_printable",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"url",
|
||||||
|
"webpki-roots 1.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.183"
|
version = "0.2.183"
|
||||||
@@ -1427,6 +1488,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "newline-converter"
|
name = "newline-converter"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1436,6 +1514,15 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify"
|
name = "notify"
|
||||||
version = "6.1.1"
|
version = "6.1.1"
|
||||||
@@ -1742,6 +1829,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
@@ -1987,6 +2080,7 @@ version = "0.23.37"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "alchemist"
|
name = "alchemist"
|
||||||
version = "0.3.0"
|
version = "0.3.1-rc.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
@@ -32,7 +32,7 @@ num_cpus = "1.16"
|
|||||||
inquire = { version = "0.7" }
|
inquire = { version = "0.7" }
|
||||||
futures = { version = "0.3" }
|
futures = { version = "0.3" }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros", "multipart"] }
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
@@ -46,6 +46,8 @@ sysinfo = "0.32"
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
trait-variant = "0.1.2"
|
trait-variant = "0.1.2"
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -21,10 +21,12 @@ Everything is visible in the web dashboard. You can see what is running, what wa
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Give movies, TV, and home videos different behavior with per-library profiles.
|
- Give movies, TV, and home videos different behavior with per-library profiles.
|
||||||
|
- Convert or remux a single uploaded file from the **Convert** page using the same pipeline Alchemist uses for library jobs. Experimental.
|
||||||
- Catch corrupt or broken files before they surprise you with Library Doctor.
|
- Catch corrupt or broken files before they surprise you with Library Doctor.
|
||||||
- See exactly how much storage you have recovered in the savings dashboard.
|
- See exactly how much storage you have recovered in the savings dashboard.
|
||||||
- Understand every skipped file immediately with plain-English explanations.
|
- Understand every skipped file immediately with plain-English explanations.
|
||||||
- Get a ping when work finishes through Discord, Gotify, or a webhook.
|
- Get a ping when work finishes through Discord, Gotify, Telegram, email, or a webhook.
|
||||||
|
- Create named API tokens for automation, with `read_only` and `full_access` access classes.
|
||||||
- Keep heavy jobs out of the way with a scheduler for off-peak hours.
|
- Keep heavy jobs out of the way with a scheduler for off-peak hours.
|
||||||
- Push urgent files to the front with the priority queue.
|
- Push urgent files to the front with the priority queue.
|
||||||
- Switch the engine between background, balanced, and throughput modes without restarting the app.
|
- Switch the engine between background, balanced, and throughput modes without restarting the app.
|
||||||
@@ -32,6 +34,7 @@ Everything is visible in the web dashboard. You can see what is running, what wa
|
|||||||
- Preserve HDR metadata or tonemap to SDR depending on what you need.
|
- Preserve HDR metadata or tonemap to SDR depending on what you need.
|
||||||
- Add folders once and let watch folders keep monitoring them automatically.
|
- Add folders once and let watch folders keep monitoring them automatically.
|
||||||
- Shape audio output with stream rules for commentary stripping, language filtering, and default-track retention.
|
- Shape audio output with stream rules for commentary stripping, language filtering, and default-track retention.
|
||||||
|
- Surface storage-focused recommendations through Library Intelligence, including remux opportunities and commentary cleanup candidates.
|
||||||
|
|
||||||
## Hardware Support
|
## Hardware Support
|
||||||
|
|
||||||
@@ -61,8 +64,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
environment:
|
environment:
|
||||||
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
||||||
@@ -72,10 +75,15 @@ services:
|
|||||||
|
|
||||||
Then open [http://localhost:3000](http://localhost:3000) in your browser.
|
Then open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
On Linux and macOS, the default host-side config location is
|
||||||
|
`~/.config/alchemist/config.toml`. When you use Docker, the
|
||||||
|
recommended bind mount is still `~/.config/alchemist`, mapped
|
||||||
|
into `/app/config` and `/app/data` inside the container.
|
||||||
|
|
||||||
If you prefer `docker run`, this is the trimmed equivalent:
|
If you prefer `docker run`, this is the trimmed equivalent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name alchemist -p 3000:3000 -v /path/to/config:/app/config -v /path/to/data:/app/data -v /path/to/media:/media -e ALCHEMIST_CONFIG_PATH=/app/config/config.toml -e ALCHEMIST_DB_PATH=/app/data/alchemist.db --restart unless-stopped ghcr.io/bybrooklyn/alchemist:latest
|
docker run -d --name alchemist -p 3000:3000 -v ~/.config/alchemist:/app/config -v ~/.config/alchemist:/app/data -v /path/to/media:/media -e ALCHEMIST_CONFIG_PATH=/app/config/config.toml -e ALCHEMIST_DB_PATH=/app/data/alchemist.db --restart unless-stopped ghcr.io/bybrooklyn/alchemist:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
@@ -132,6 +140,13 @@ The core contributor path is supported on Windows. Broader release and utility r
|
|||||||
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.
|
||||||
|
|
||||||
|
## Automation + Subpath Notes
|
||||||
|
|
||||||
|
- API automation can use bearer tokens created in **Settings → API Tokens**.
|
||||||
|
- Read-only tokens are limited to observability and monitoring routes.
|
||||||
|
- Alchemist can also be served under a subpath such as `/alchemist`
|
||||||
|
using `ALCHEMIST_BASE_URL=/alchemist`.
|
||||||
|
|
||||||
## Supported Platforms
|
## Supported Platforms
|
||||||
|
|
||||||
| Platform | Status |
|
| Platform | Status |
|
||||||
|
|||||||
563
backlog.md
563
backlog.md
@@ -1,476 +1,155 @@
|
|||||||
# Alchemist Backlog
|
# Alchemist Backlog
|
||||||
|
|
||||||
Future improvements and features to consider for the project.
|
Current and future work for Alchemist, organized around the
|
||||||
|
actual repo state rather than historical priorities.
|
||||||
|
|
||||||
|
Alchemist should remain an automation-first media
|
||||||
|
optimization tool, not drift into a general-purpose media
|
||||||
|
workbench.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Out of Scope — Explicitly Not Planned
|
## Implemented / In Progress
|
||||||
|
|
||||||
These are deliberate design decisions, not omissions. Do not add them.
|
These items now exist in the repo and should be treated as
|
||||||
|
current product surface that still needs hardening,
|
||||||
|
documentation, or iteration.
|
||||||
|
|
||||||
- **Custom FFmpeg flags / raw flag injection** — Alchemist is designed to be approachable and safe. Exposing raw FFmpeg arguments (whether per-profile, per-job, or in the conversion sandbox) would make it a footgun and undermine the beginner-first design. The encoding pipeline is the abstraction; users configure outcomes, not commands.
|
### Conversion / Remux Workflow
|
||||||
- **Distributed encoding across multiple machines** — Not a goal. Alchemist is a single-host tool. Multi-node orchestration is a different product.
|
- Dedicated **Convert** page for single-file upload-driven conversion
|
||||||
|
- Probe-driven UI with container, video, audio, subtitle, and remux-only controls
|
||||||
|
- FFmpeg command preview
|
||||||
|
- Temporary upload/output lifecycle under `~/.config/alchemist/temp`
|
||||||
|
- Reuse of the existing queue and worker system
|
||||||
|
- Status polling and download flow
|
||||||
|
- Treat this as an experimental utility, not a second core
|
||||||
|
product track
|
||||||
|
|
||||||
|
### Notification Platform Expansion
|
||||||
|
- Provider-specific notification target model backed by `config_json`
|
||||||
|
- Discord webhook, Discord bot, Gotify, generic webhook, Telegram, and email targets
|
||||||
|
- Richer event taxonomy:
|
||||||
|
- `encode.queued`
|
||||||
|
- `encode.started`
|
||||||
|
- `encode.completed`
|
||||||
|
- `encode.failed`
|
||||||
|
- `scan.completed`
|
||||||
|
- `engine.idle`
|
||||||
|
- `daily.summary`
|
||||||
|
- Per-target event filtering
|
||||||
|
- Daily summary scheduling via `daily_summary_time_local`
|
||||||
|
|
||||||
|
### API Token Authentication + API Docs
|
||||||
|
- Named static API tokens with `read_only` and `full_access` classes
|
||||||
|
- Hash-only token storage, plaintext shown once at creation
|
||||||
|
- Token management endpoints and Settings UI
|
||||||
|
- Hand-maintained OpenAPI contract plus human API docs
|
||||||
|
|
||||||
|
### Base URL / Subpath Support
|
||||||
|
- `ALCHEMIST_BASE_URL` and matching config support
|
||||||
|
- Router nesting under a configured path prefix
|
||||||
|
- Frontend fetches, redirects, navigation, and SSE path generation updated for subpaths
|
||||||
|
|
||||||
|
### Distribution Foundation
|
||||||
|
- In-repo distribution metadata sources for:
|
||||||
|
- Homebrew
|
||||||
|
- AUR
|
||||||
|
- Windows update-check metadata
|
||||||
|
- Release workflow renders package metadata from release assets/checksums
|
||||||
|
- Windows in-app update check against GitHub Releases
|
||||||
|
|
||||||
|
### Expanded Library Intelligence
|
||||||
|
- Duplicate groups remain
|
||||||
|
- Storage-focused recommendation categories added:
|
||||||
|
- remux-only opportunities
|
||||||
|
- wasteful audio layouts
|
||||||
|
- commentary/descriptive-track cleanup candidates
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## High Priority
|
## Active Priorities
|
||||||
|
|
||||||
Testing policy for this section:
|
### Engine Lifecycle Controls
|
||||||
|
- Finish and harden restart/shutdown semantics from the About/header surface
|
||||||
|
- Restart must reset the engine loop without re-execing the process
|
||||||
|
- Shutdown must cancel active jobs and exit cleanly
|
||||||
|
- Add final backend and Playwright coverage for lifecycle transitions
|
||||||
|
|
||||||
- Backend/unit/integration coverage and Playwright coverage are exit criteria for each item below.
|
### Planner and Lifecycle Documentation
|
||||||
- Do not treat "more tests" as a standalone product track; attach the required coverage to the feature or refactor that needs it.
|
- Document planner heuristics and stable skip/transcode/remux decision boundaries
|
||||||
|
- Document hardware fallback rules and backend selection semantics
|
||||||
|
- Document pause, drain, restart, cancel, and shutdown semantics from actual behavior
|
||||||
|
|
||||||
### 1. Engine Lifecycle Controls
|
### Per-File Encode History
|
||||||
|
- Show full attempt history in job detail, grouped by canonical file identity
|
||||||
|
- Include outcome, encode stats, and failure reason where available
|
||||||
|
- Make retries, reruns, and settings-driven requeues legible
|
||||||
|
|
||||||
#### Goal
|
### Behavior-Preserving Refactor Pass
|
||||||
- Make engine lifecycle controls real, explicit, and operator-safe from the header/About surface.
|
- Decompose `web/src/components/JobManager.tsx` without changing current behavior
|
||||||
|
- Extract shared formatting logic
|
||||||
|
- Clarify SSE vs polling ownership
|
||||||
|
- Add regression coverage before deeper structural cleanup
|
||||||
|
|
||||||
#### Scope
|
### AMD AV1 Validation
|
||||||
- Redesign the About screen so it fits the current visual language.
|
- Validate Linux VAAPI and Windows AMF AV1 paths on real hardware
|
||||||
- Add a **Restart Engine** action that restarts the engine loop without killing the Alchemist process.
|
- Confirm encoder selection, fallback behavior, and defaults
|
||||||
- Add a **Shutdown Alchemist** action that cancels active jobs immediately and exits the process cleanly.
|
- Keep support claims conservative until validation is real
|
||||||
- Define and surface the lifecycle states needed to make restart and shutdown understandable in the UI.
|
|
||||||
|
|
||||||
#### Non-Goals
|
|
||||||
- Do not re-exec the whole app process to implement restart.
|
|
||||||
- Do not drain active jobs to completion on shutdown; shutdown means cancel and exit.
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- Backend lifecycle endpoints and orchestration semantics for restart and shutdown.
|
|
||||||
- Reliable event/state propagation so the UI can reflect transient lifecycle states without stale polling or SSE behavior.
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
- Restart tears down and reinitializes the engine loop while the binary stays alive.
|
|
||||||
- Shutdown stops accepting new work, cancels active jobs, persists the right terminal states, and exits cleanly.
|
|
||||||
- Job rows, logs, and toasts clearly distinguish pause, drain, restart, cancellation, and shutdown.
|
|
||||||
- The About surface exposes restart and shutdown with confirmation and clear failure handling.
|
|
||||||
|
|
||||||
#### Required Tests
|
|
||||||
- Backend tests for restart/shutdown semantics and lifecycle state transitions.
|
|
||||||
- Playwright coverage for About screen controls, confirmations, success states, and failure states.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
- Add a dedicated engine lifecycle API instead of overloading pause/drain:
|
|
||||||
- Add authenticated lifecycle routes for `restart engine` and `shutdown app`.
|
|
||||||
- Keep restart scoped to the engine loop only; do not re-exec the binary.
|
|
||||||
- Keep shutdown as cancel-all-and-exit; do not reuse drain semantics.
|
|
||||||
- Introduce a server-owned shutdown trigger so HTTP-initiated shutdown uses the same shutdown path as Ctrl+C and SIGTERM:
|
|
||||||
- Extend `RunServerArgs` and `AppState` with a shutdown signal sender.
|
|
||||||
- Update `axum::serve(...).with_graceful_shutdown(...)` to also listen for an internal shutdown signal.
|
|
||||||
- Add an explicit lifecycle transition guard:
|
|
||||||
- Reject overlapping restart/shutdown requests while a lifecycle action is already in progress.
|
|
||||||
- Surface lifecycle state through `/api/engine/status` so the UI can render restarting/shutting-down states cleanly.
|
|
||||||
- Implement restart as an engine-loop reset, not a process restart:
|
|
||||||
- Pause new intake.
|
|
||||||
- Cancel active jobs immediately through the orchestrator.
|
|
||||||
- Clear drain state and any temporary lifecycle flags.
|
|
||||||
- Reinitialize the engine loop state needed to resume normal processing.
|
|
||||||
- Resume only if the scheduler is not actively pausing the engine.
|
|
||||||
- Implement shutdown as a process-level cancel-and-exit flow:
|
|
||||||
- Pause intake.
|
|
||||||
- Cancel all active jobs immediately.
|
|
||||||
- Give cancellation and persistence a short bounded window to flush terminal state.
|
|
||||||
- Trigger the internal shutdown signal so the server exits through the same top-level path already used for signals.
|
|
||||||
- Split the backend work by file responsibility:
|
|
||||||
- `src/media/processor.rs`: add restart/shutdown lifecycle methods and transient lifecycle state.
|
|
||||||
- `src/server/mod.rs`: wire new lifecycle routes and internal shutdown signaling into `AppState` and server startup.
|
|
||||||
- `src/server/jobs.rs` or a new dedicated engine/server lifecycle module: implement authenticated handlers for restart/shutdown.
|
|
||||||
- `src/main.rs`: keep the top-level exit behavior but make sure HTTP-triggered shutdown lands in the same path as signal-triggered shutdown.
|
|
||||||
- Update the UI in two passes:
|
|
||||||
- Redesign `web/src/components/AboutDialog.tsx` to match the current visual system and include restart/shutdown actions plus confirmation UX.
|
|
||||||
- Update `web/src/components/HeaderActions.tsx` and any engine-status consumers to understand the new lifecycle states.
|
|
||||||
- Add coverage before shipping:
|
|
||||||
- Backend tests for restart, shutdown, overlapping request rejection, and status payload transitions.
|
|
||||||
- Playwright tests for About modal actions, confirmation dialogs, success flows, disabled/loading states, and failure toasts.
|
|
||||||
|
|
||||||
### 2. Planner and Lifecycle Documentation
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
- Lock down current behavior before deeper refactors by documenting planner heuristics, hardware fallback rules, and engine lifecycle semantics.
|
|
||||||
|
|
||||||
#### Scope
|
|
||||||
- Document the current planner heuristics and stable skip/transcode/remux decision boundaries.
|
|
||||||
- Document hardware fallback rules and vendor/backend selection semantics.
|
|
||||||
- Document lifecycle semantics for pause, drain, restart, cancel, and shutdown.
|
|
||||||
|
|
||||||
#### Non-Goals
|
|
||||||
- No product behavior changes.
|
|
||||||
- No speculative redesign of the planner or lifecycle model.
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- Cross-check against the existing backend behavior and tests, not just intended behavior.
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
- Future cleanup work has a single documented source of truth for planner and lifecycle behavior.
|
|
||||||
- The docs are specific enough to catch accidental behavior changes during refactors.
|
|
||||||
|
|
||||||
#### Required Tests
|
|
||||||
- Add or tighten assertions where documentation work uncovers missing coverage around planner decisions, hardware fallback, or lifecycle states.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### 3. Per-File Encode History
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
- Show a complete attempt history in the job detail panel for files that have been processed more than once.
|
|
||||||
|
|
||||||
#### Scope
|
|
||||||
- Group history by canonical file identity rather than path-only matching.
|
|
||||||
- Show date, outcome, encode stats where applicable, and failure reason where applicable.
|
|
||||||
- Make repeated retries, re-queues after settings changes, and manual reruns understandable at a glance.
|
|
||||||
|
|
||||||
#### Non-Goals
|
|
||||||
- Do not turn this into a general media-management timeline.
|
|
||||||
- Do not rely on path-only grouping when a canonical identity is available.
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- Query shaping across `jobs`, `encode_stats`, and `job_failure_explanations`.
|
|
||||||
- A stable canonical file identity strategy that survives path changes better than naive path matching.
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
- Job detail shows prior attempts for the same canonical file identity with enough detail to explain repeated outcomes.
|
|
||||||
- Operators can distinguish retry noise from truly separate processing attempts.
|
|
||||||
|
|
||||||
#### Required Tests
|
|
||||||
- Backend coverage for history lookup and canonical identity grouping.
|
|
||||||
- UI coverage for rendering mixed completed/failed/skipped histories.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### 4. Behavior-Preserving Refactor Pass
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
- Improve internal structure without changing visible product behavior.
|
|
||||||
|
|
||||||
#### Scope
|
|
||||||
- Refactor `web/src/components/JobManager.tsx` into smaller components and hooks without changing screens, filters, polling, SSE updates, or job actions.
|
|
||||||
- Centralize duplicated byte/time/reduction formatting logic into shared utilities while preserving current output formatting.
|
|
||||||
- Preserve the current realtime model, but make ownership clearer: job/config/system events via SSE, resource metrics via polling.
|
|
||||||
- Add regression coverage around planner decisions, watcher behavior, job lifecycle transitions, and decision explanation rendering before deeper refactors.
|
|
||||||
|
|
||||||
#### Non-Goals
|
|
||||||
- No new screens, filters, realtime behaviors, or job actions.
|
|
||||||
- No opportunistic product changes hidden inside the refactor.
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- Planner/lifecycle documentation and regression coverage should land before deeper structural work.
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
- Existing behavior, strings, filters, and action flows remain stable.
|
|
||||||
- `JobManager` is decomposed enough that future feature work does not require editing a single monolithic file for unrelated changes.
|
|
||||||
- Realtime ownership is easier to reason about and less likely to regress.
|
|
||||||
|
|
||||||
#### Required Tests
|
|
||||||
- Keep current backend and Playwright suites green.
|
|
||||||
- Add targeted regression coverage before extracting behavior into hooks/components.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### 5. AMD AV1 Validation
|
|
||||||
|
|
||||||
#### Goal
|
|
||||||
- Validate and tune the existing AMD AV1 paths on real hardware.
|
|
||||||
|
|
||||||
#### Scope
|
|
||||||
- Cover Linux VAAPI and Windows AMF separately.
|
|
||||||
- Verify encoder selection, fallback behavior, and quality/performance defaults.
|
|
||||||
- Treat this as validation/tuning of existing wiring, not support-from-scratch.
|
|
||||||
|
|
||||||
#### Non-Goals
|
|
||||||
- Do not expand the stable support promise before validation is complete.
|
|
||||||
- Do not invent a fake validation story without real hardware runs.
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- Access to representative Linux VAAPI and Windows AMF hardware.
|
|
||||||
- Repeatable manual verification notes and any scripted checks that can be automated.
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
- AMD AV1 is either validated with documented defaults and caveats, or explicitly left outside the supported matrix with clearer docs.
|
|
||||||
- Linux and Windows results are documented separately.
|
|
||||||
|
|
||||||
#### Required Tests
|
|
||||||
- Scripted verification where possible, plus recorded manual validation runs on real hardware.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Medium Priority
|
## Later
|
||||||
|
|
||||||
### Power User Conversion / Remux Mode
|
### Documentation
|
||||||
**Target: 0.3.1**
|
- Architecture diagrams
|
||||||
|
- Contributor walkthrough improvements
|
||||||
|
- Video tutorials for common workflows
|
||||||
|
|
||||||
#### Overview
|
### Code Quality
|
||||||
- Introduce a conversion mode that allows users to upload a single file and perform customizable transcoding or remuxing operations using Alchemist's existing pipeline
|
- Increase coverage for edge cases
|
||||||
- Exposes the same encoding parameters Alchemist uses internally — no raw flag injection
|
- Add property-based tests for codec parameter generation
|
||||||
- Clear separation between remux mode (container-only, lossless) and transcode mode (re-encode)
|
- Add fuzzing for FFprobe parsing
|
||||||
|
|
||||||
#### Goals
|
|
||||||
- Provide a fast, interactive way to process single files
|
|
||||||
- Reuse Alchemist's existing job queue and worker system
|
|
||||||
- Avoid becoming a HandBrake clone; prioritize clarity over exhaustive configurability
|
|
||||||
|
|
||||||
#### Storage Structure
|
|
||||||
- Store temporary files under `~/.alchemist/temp/`
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.alchemist/
|
|
||||||
temp/
|
|
||||||
uploads/ # raw uploaded files
|
|
||||||
outputs/ # processed outputs
|
|
||||||
jobs/ # job metadata (JSON)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Each job gets a unique ID (UUID or short hash)
|
|
||||||
- Files stored per job:
|
|
||||||
`uploads/{job_id}/input.ext`
|
|
||||||
`outputs/{job_id}/output.ext`
|
|
||||||
`jobs/{job_id}.json`
|
|
||||||
|
|
||||||
#### Core Workflow
|
|
||||||
1. User uploads file (drag-and-drop or file picker)
|
|
||||||
2. File is stored in `~/.alchemist/temp/uploads/{job_id}/`
|
|
||||||
3. Media is probed (`ffprobe`) and stream info is displayed
|
|
||||||
4. User configures conversion settings
|
|
||||||
5. User submits job
|
|
||||||
6. Job is added to Alchemist queue
|
|
||||||
7. Worker processes job using standard pipeline
|
|
||||||
8. Output is saved to `~/.alchemist/temp/outputs/{job_id}/`
|
|
||||||
9. User downloads result
|
|
||||||
|
|
||||||
#### UI Design Principles
|
|
||||||
- Must feel like a visual encoding editor
|
|
||||||
- No oversimplified presets as the primary UX
|
|
||||||
- All major encoding options exposed
|
|
||||||
- Clear separation between remux and transcode modes
|
|
||||||
|
|
||||||
#### UI Sections
|
|
||||||
##### 1. Input
|
|
||||||
- File upload (drag-and-drop)
|
|
||||||
- Display:
|
|
||||||
- container format
|
|
||||||
- video streams (codec, resolution, HDR info)
|
|
||||||
- audio streams (codec, channels)
|
|
||||||
- subtitle streams
|
|
||||||
|
|
||||||
##### 2. Output Container
|
|
||||||
- Options: `mkv`, `mp4`, `webm`, `mov`
|
|
||||||
|
|
||||||
##### 3. Video Settings
|
|
||||||
- Codec: `copy`, `h264`, `hevc`, `av1`
|
|
||||||
- Mode: CRF (quality-based) or Bitrate (kbps)
|
|
||||||
- Preset: `ultrafast` to `veryslow`
|
|
||||||
- Resolution: original, custom (width/height), scale factor
|
|
||||||
- HDR: preserve, tonemap to SDR, strip metadata
|
|
||||||
|
|
||||||
##### 4. Audio Settings
|
|
||||||
- Codec: `copy`, `aac`, `opus`, `mp3`
|
|
||||||
- Bitrate
|
|
||||||
- Channels (`auto`, stereo, 5.1, etc.)
|
|
||||||
|
|
||||||
##### 5. Subtitle Settings
|
|
||||||
- Options: `copy`, burn-in, remove
|
|
||||||
|
|
||||||
##### 6. Remux Mode
|
|
||||||
- Toggle: `[ ] Remux only (no re-encode)`
|
|
||||||
- Forces stream copy, disables all encoding options
|
|
||||||
- Use cases: container changes, stream compatibility fixes, zero quality loss operations
|
|
||||||
|
|
||||||
##### 7. Command Preview
|
|
||||||
- Display the generated FFmpeg command before execution
|
|
||||||
- Example: `ffmpeg -i input.mkv -c:v libaom-av1 -crf 28 -b:v 0 -c:a opus output.mkv`
|
|
||||||
- Read-only — for transparency and debugging, not for editing
|
|
||||||
|
|
||||||
#### Job System Integration
|
|
||||||
- Use the existing Alchemist job queue
|
|
||||||
- Treat each conversion as a standard job
|
|
||||||
- Stream logs live to the UI
|
|
||||||
|
|
||||||
#### Job Metadata Example
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "abc123",
|
|
||||||
"input_path": "...",
|
|
||||||
"output_path": "...",
|
|
||||||
"mode": "transcode | remux",
|
|
||||||
"video": { "codec": "av1", "crf": 28, "preset": "slow" },
|
|
||||||
"audio": { "codec": "opus", "bitrate": 128 },
|
|
||||||
"container": "mkv",
|
|
||||||
"status": "queued"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Cleanup Strategy
|
|
||||||
- Auto-delete uploads after X hours
|
|
||||||
- Auto-delete outputs after download or timeout
|
|
||||||
- Enforce a max file size limit
|
|
||||||
- Run a periodic cleanup job that scans the temp directory
|
|
||||||
|
|
||||||
#### Security Considerations
|
|
||||||
- Sanitize filenames
|
|
||||||
- Prevent path traversal
|
|
||||||
- Validate file types via probing, not extension
|
|
||||||
- Isolate the temp directory
|
|
||||||
- Do not allow arbitrary file path input
|
|
||||||
|
|
||||||
#### Non-Goals
|
|
||||||
- Not a beginner-focused tool
|
|
||||||
- Not a replacement for full automation workflows
|
|
||||||
- Not a cloud encoding service; no public hosting assumed
|
|
||||||
- No raw FFmpeg flag injection (see Out of Scope)
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Library Intelligence
|
|
||||||
- Expand recommendations beyond duplicate detection into remux-only opportunities, wasteful audio layouts, commentary/descriptive-track cleanup, and duplicate-ish title variants
|
|
||||||
- Keep the feature focused on storage and library quality, not general media management
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Auto-Priority Rules
|
|
||||||
- Define rules that automatically assign queue priority based on file attributes
|
|
||||||
- Rule conditions: file path pattern (glob), file age, file size, source watch folder
|
|
||||||
- Example: "anything under `/movies/` gets priority 2", "files over 20 GB get priority 1"
|
|
||||||
- Rules evaluated at enqueue time; manual priority overrides still win
|
|
||||||
- Configured in Settings alongside other library behavior
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Performance Optimizations
|
|
||||||
- Profile scanner/analyzer hot paths before changing behavior
|
|
||||||
- Only tune connection pooling after measuring database contention under load
|
|
||||||
- Consider caching repeated FFprobe calls on identical files if profiling shows probe churn is material
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Audio Normalization
|
|
||||||
- Apply EBU R128 loudness normalization to audio streams during transcode
|
|
||||||
- Target: -23 LUFS integrated, -1 dBTP true peak (broadcast standard)
|
|
||||||
- Opt-in per library profile, disabled by default
|
|
||||||
- Implemented via `loudnorm` FFmpeg filter — no new dependencies
|
|
||||||
- Two-pass mode for accurate results; single-pass for speed
|
|
||||||
- Should surface loudness stats (measured LUFS, correction applied) in
|
|
||||||
the job detail panel alongside existing encode stats
|
|
||||||
- Do not normalize if audio is being copied (copy mode bypasses this)
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### UI Improvements
|
|
||||||
- Add keyboard shortcuts for common actions
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Notification Improvements
|
|
||||||
- **Granular event types** — current events are too coarse. Add:
|
|
||||||
- `encode.started` — job moved from queued to encoding
|
|
||||||
- `encode.completed` — with savings summary (size before/after)
|
|
||||||
- `encode.failed` — with failure reason included in payload
|
|
||||||
- `scan.completed` — N files discovered, M queued
|
|
||||||
- `engine.idle` — queue drained, nothing left to process
|
|
||||||
- `daily.summary` — opt-in digest of the day's activity
|
|
||||||
- **Per-target event filtering** — each notification target should
|
|
||||||
independently choose which events it receives. Currently, all targets
|
|
||||||
get the same events. A Discord webhook might want everything; a
|
|
||||||
phone webhook might only want failures.
|
|
||||||
- **Richer payloads** — completed job notifications should include
|
|
||||||
filename, input size, output size, space saved, and encode time.
|
|
||||||
Currently, the payload is minimal.
|
|
||||||
- **Add Telegram integration** — bot token + chat ID, same event
|
|
||||||
model as Discord. No new dependencies needed (reqwest already present).
|
|
||||||
- **Improve Discord notifications** — add bot token support where it meaningfully improves delivery or richer messaging.
|
|
||||||
- **Add email support** — SMTP with TLS. Lower priority than Telegram.
|
|
||||||
Most self-hosters already have Discord or Telegram.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Low Priority
|
|
||||||
|
|
||||||
### Planning / Simulation Mode
|
### Planning / Simulation Mode
|
||||||
- Not a current focus. If revisited, start with a single current-config dry-run before attempting comparison mode.
|
- Promote this only after the current Active Priorities are done
|
||||||
- Add a first-class simulation flow that answers what Alchemist would transcode, remux, or skip without mutating the library.
|
- Single-config dry run first
|
||||||
- Show estimated total bytes recoverable, action counts, top skip reasons, and per-file predicted actions.
|
- No comparison matrix or scenario planner until the first simple flow proves useful
|
||||||
- Reuse the scanner, analyzer, and planner, but stop before executor and promotion stages.
|
|
||||||
- Only add profile/codec/threshold comparison snapshots after the simple single-config flow proves useful.
|
|
||||||
|
|
||||||
#### Solution
|
### Audio Normalization
|
||||||
|
- Add opt-in EBU R128 loudness normalization during transcode
|
||||||
|
- Surface loudness metrics in job detail
|
||||||
|
- Keep copy-mode bypass behavior explicit
|
||||||
|
- Keep this secondary unless it clearly supports the automation-first mission
|
||||||
|
|
||||||
### API Token Authentication + API Documentation
|
### Auto-Priority Rules
|
||||||
- Add support for static bearer tokens as an alternative to session cookies
|
- Add explainable enqueue-time priority automation
|
||||||
- Enables programmatic access from scripts, home automation (Home Assistant, n8n), and CLI tools without managing session state
|
- Manual priority overrides must still win
|
||||||
- Tokens generated and revoked from Settings; no expiry by default, revocable any time
|
- Matched rules must be visible in the UI to keep queue behavior trustworthy
|
||||||
- Expand API documentation to cover all endpoints with request/response examples
|
|
||||||
|
|
||||||
#### Solution
|
### UI Improvements
|
||||||
|
- Tighten settings and detail-panel consistency
|
||||||
|
- Improve dense forms, empty states, and action affordances
|
||||||
|
- Keep this narrowly scoped to automation-supporting UX problems
|
||||||
|
|
||||||
### Passthrough Mode
|
### Keyboard Shortcuts
|
||||||
- A toggle that keeps all watch folders and watcher active but prevents the planner from queuing new jobs
|
- Add a concrete shortcut set for common jobs/logs/conversion actions
|
||||||
- Different from Pause — Pause stops active encodes; Passthrough lets the system observe and index the library without touching anything
|
- Avoid a vague “shortcut layer everywhere” rollout
|
||||||
- Useful when testing settings or onboarding a new library without triggering encodes immediately
|
- First likely cut if scope pressure appears
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Base URL / Subpath Configuration
|
|
||||||
- Allow Alchemist to be served at a non-root path (e.g. `/alchemist/`) via `ALCHEMIST_BASE_URL`
|
|
||||||
- Common self-hosting pattern for reverse proxy setups running multiple services on one domain
|
|
||||||
- Low urgency — most users run Alchemist on a dedicated subdomain or port
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Features from DESIGN_PHILOSOPHY.md
|
### Features from DESIGN_PHILOSOPHY.md
|
||||||
- Add batch job templates
|
- Add batch job templates
|
||||||
|
|
||||||
#### Solution
|
### Distribution Follow-Ons
|
||||||
|
- Flatpak / Snap packaging
|
||||||
### Code Quality
|
- Additional installer polish beyond the current Windows update-check flow
|
||||||
- Increase test coverage for edge cases
|
- Only promote these if they become strategically important
|
||||||
- Add property-based testing for codec parameter generation
|
|
||||||
- Add fuzzing for FFprobe output parsing
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Add architecture diagrams
|
|
||||||
- Add contributor guide with development setup
|
|
||||||
- Video tutorials for common workflows
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
### Distribution
|
|
||||||
- Add Homebrew formula
|
|
||||||
- Add AUR package
|
|
||||||
- Add Flatpak/Snap packages
|
|
||||||
- Improve Windows installer (WiX) with auto-updates
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Completed (Recent)
|
## Out of Scope
|
||||||
|
|
||||||
- [x] Split server.rs into modules
|
- Custom FFmpeg flags / raw flag injection
|
||||||
- [x] Add typed broadcast channels
|
- Distributed encoding across multiple machines
|
||||||
- [x] Add security headers middleware
|
- Features that turn Alchemist into a general-purpose media
|
||||||
- [x] Add database query timeouts
|
workbench
|
||||||
- [x] Add config file permission check
|
- Fuzzy media-management intelligence that drifts away from storage quality and encode operations
|
||||||
- [x] Handle SSE lagged events in frontend
|
|
||||||
- [x] Create FFmpeg integration tests
|
|
||||||
- [x] Expand documentation site
|
|
||||||
- [x] Pin MSRV in Cargo.toml
|
|
||||||
- [x] Add schema versioning for migrations
|
|
||||||
- [x] Enable SQLite WAL mode
|
|
||||||
- [x] Add theme persistence and selection
|
|
||||||
- [x] Add job history filtering and search
|
|
||||||
- [x] Add subtitle extraction sidecars
|
|
||||||
- [x] Decision clarity — structured skip/failure explanations with codes, plain-English summaries, measured values, and operator guidance
|
|
||||||
- [x] Retry backoff visibility — countdown on failed jobs, attempt count in job detail
|
|
||||||
- [x] Per-library profiles (Space Saver, Quality First, Balanced, Streaming)
|
|
||||||
- [x] Engine runtime modes (Background / Balanced / Throughput) with drain support
|
|
||||||
- [x] Container remuxing (MP4 → MKV lossless)
|
|
||||||
- [x] Stream rules (commentary stripping, language filtering, default-only audio)
|
|
||||||
- [x] VMAF quality gating
|
|
||||||
- [x] Library Intelligence duplicate detection
|
|
||||||
- [x] Library Doctor health scanning
|
|
||||||
- [x] Boot auto-analysis
|
|
||||||
- [x] Mobile layout
|
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
# Configuration file
|
||||||
- ./config.toml:/app/config/config.toml:ro
|
- ${HOME}/.config/alchemist/config.toml:/app/config/config.toml:ro
|
||||||
# Media directories (adjust paths as needed)
|
# Media directories (adjust paths as needed)
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
- /path/to/output:/output
|
- /path/to/output:/output
|
||||||
# Persistent database
|
# Persistent database
|
||||||
- alchemist_data:/app/data
|
- ${HOME}/.config/alchemist:/app/data
|
||||||
environment:
|
environment:
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
- TZ=America/New_York
|
- TZ=America/New_York
|
||||||
@@ -29,6 +29,3 @@ services:
|
|||||||
- driver: nvidia
|
- driver: nvidia
|
||||||
count: 1
|
count: 1
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
|
|
||||||
volumes:
|
|
||||||
alchemist_data:
|
|
||||||
|
|||||||
@@ -13,11 +13,66 @@ except:
|
|||||||
`/api/settings/bundle`, `/api/system/hardware`
|
`/api/settings/bundle`, `/api/system/hardware`
|
||||||
|
|
||||||
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>`.
|
||||||
but the web UI uses the session cookie.
|
Bearer tokens now come in two classes:
|
||||||
|
|
||||||
|
- `read_only` — observability-only routes
|
||||||
|
- `full_access` — same route access as an authenticated session
|
||||||
|
|
||||||
|
The web UI still uses the session cookie.
|
||||||
|
|
||||||
|
Machine-readable contract:
|
||||||
|
|
||||||
|
- [OpenAPI spec](/openapi.yaml)
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
|
### API tokens
|
||||||
|
|
||||||
|
API tokens are created in **Settings → API Tokens**.
|
||||||
|
|
||||||
|
- token values are only shown once at creation time
|
||||||
|
- only hashed token material is stored server-side
|
||||||
|
- revoked tokens stop working immediately
|
||||||
|
|
||||||
|
Read-only tokens are intentionally limited to observability
|
||||||
|
routes such as stats, jobs, logs history, SSE, system info,
|
||||||
|
hardware info, library intelligence, and health/readiness.
|
||||||
|
|
||||||
|
### `GET /api/settings/api-tokens`
|
||||||
|
|
||||||
|
Lists token metadata only. Plaintext token values are never
|
||||||
|
returned after creation.
|
||||||
|
|
||||||
|
### `POST /api/settings/api-tokens`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Prometheus",
|
||||||
|
"access_level": "read_only"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Prometheus",
|
||||||
|
"access_level": "read_only"
|
||||||
|
},
|
||||||
|
"plaintext_token": "alc_tok_..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/settings/api-tokens/:id`
|
||||||
|
|
||||||
|
Revokes a token in place. Existing automations using it will
|
||||||
|
begin receiving `401` or `403` depending on route class.
|
||||||
|
|
||||||
### `POST /api/auth/login`
|
### `POST /api/auth/login`
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ Default config file location:
|
|||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
|------|------|---------|-------------|
|
||||||
| `enabled` | bool | `false` | Master switch for notifications |
|
| `enabled` | bool | `false` | Master switch for notifications |
|
||||||
| `targets` | list | `[]` | Notification target objects with `name`, `target_type`, `endpoint_url`, `auth_token`, `events`, and `enabled` |
|
| `daily_summary_time_local` | string | `"09:00"` | Global local-time send window for daily summary notifications |
|
||||||
|
| `targets` | list | `[]` | Notification target objects with `name`, `target_type`, `config_json`, `events`, and `enabled` |
|
||||||
|
|
||||||
## `[files]`
|
## `[files]`
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
- /tmp/alchemist:/tmp # optional: fast SSD for temp files
|
- /tmp/alchemist:/tmp # optional: fast SSD for temp files
|
||||||
environment:
|
environment:
|
||||||
@@ -27,8 +27,7 @@ services:
|
|||||||
|
|
||||||
| Mount | Purpose |
|
| Mount | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `/app/config` | `config.toml` — persists across restarts |
|
| `~/.config/alchemist` on the host | Mounted into `/app/config` and `/app/data` so `config.toml` and `alchemist.db` persist across restarts |
|
||||||
| `/app/data` | `alchemist.db` (SQLite) — persists across restarts |
|
|
||||||
| `/media` | Your media library — mount read-write |
|
| `/media` | Your media library — mount read-write |
|
||||||
| `/tmp` (optional) | Temp dir for in-progress encodes — use a fast SSD |
|
| `/tmp` (optional) | Temp dir for in-progress encodes — use a fast SSD |
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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` |
|
||||||
|
|
||||||
@@ -26,3 +27,11 @@ environment:
|
|||||||
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
||||||
- ALCHEMIST_DB_PATH=/app/data/alchemist.db
|
- ALCHEMIST_DB_PATH=/app/data/alchemist.db
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Recommended host bind mount:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ~/.config/alchemist:/app/config
|
||||||
|
- ~/.config/alchemist:/app/data
|
||||||
|
```
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
environment:
|
environment:
|
||||||
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
||||||
@@ -58,8 +58,8 @@ docker run -d \
|
|||||||
--name alchemist \
|
--name alchemist \
|
||||||
--gpus all \
|
--gpus all \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /path/to/config:/app/config \
|
-v ~/.config/alchemist:/app/config \
|
||||||
-v /path/to/data:/app/data \
|
-v ~/.config/alchemist:/app/data \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
||||||
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
||||||
@@ -99,8 +99,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
@@ -123,8 +123,8 @@ docker run -d \
|
|||||||
--group-add video \
|
--group-add video \
|
||||||
--group-add render \
|
--group-add render \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /path/to/config:/app/config \
|
-v ~/.config/alchemist:/app/config \
|
||||||
-v /path/to/data:/app/data \
|
-v ~/.config/alchemist:/app/data \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
||||||
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
||||||
@@ -159,8 +159,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
@@ -183,8 +183,8 @@ docker run -d \
|
|||||||
--group-add video \
|
--group-add video \
|
||||||
--group-add render \
|
--group-add render \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /path/to/config:/app/config \
|
-v ~/.config/alchemist:/app/config \
|
||||||
-v /path/to/data:/app/data \
|
-v ~/.config/alchemist:/app/data \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
||||||
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
@@ -71,8 +71,8 @@ docker run -d \
|
|||||||
--group-add video \
|
--group-add video \
|
||||||
--group-add render \
|
--group-add render \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /path/to/config:/app/config \
|
-v ~/.config/alchemist:/app/config \
|
||||||
-v /path/to/data:/app/data \
|
-v ~/.config/alchemist:/app/data \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
||||||
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
@@ -77,8 +77,8 @@ docker run -d \
|
|||||||
--group-add video \
|
--group-add video \
|
||||||
--group-add render \
|
--group-add render \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /path/to/config:/app/config \
|
-v ~/.config/alchemist:/app/config \
|
||||||
-v /path/to/data:/app/data \
|
-v ~/.config/alchemist:/app/data \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
||||||
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
environment:
|
environment:
|
||||||
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
||||||
@@ -64,8 +64,8 @@ docker run -d \
|
|||||||
--name alchemist \
|
--name alchemist \
|
||||||
--gpus all \
|
--gpus all \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /path/to/config:/app/config \
|
-v ~/.config/alchemist:/app/config \
|
||||||
-v /path/to/data:/app/data \
|
-v ~/.config/alchemist:/app/data \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
||||||
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config
|
- ~/.config/alchemist:/app/config
|
||||||
- /path/to/data:/app/data
|
- ~/.config/alchemist:/app/data
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
environment:
|
environment:
|
||||||
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
||||||
@@ -43,8 +43,8 @@ For GPU passthrough (NVIDIA, Intel, AMD) see
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name alchemist \
|
--name alchemist \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v /path/to/config:/app/config \
|
-v ~/.config/alchemist:/app/config \
|
||||||
-v /path/to/data:/app/data \
|
-v ~/.config/alchemist:/app/data \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
|
||||||
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
|
||||||
@@ -58,6 +58,14 @@ Download from [GitHub Releases](https://github.com/bybrooklyn/alchemist/releases
|
|||||||
Available for Linux x86_64, Linux ARM64, Windows x86_64,
|
Available for Linux x86_64, Linux ARM64, Windows x86_64,
|
||||||
macOS Apple Silicon, and macOS Intel.
|
macOS Apple Silicon, and macOS Intel.
|
||||||
|
|
||||||
|
### Package-manager metadata
|
||||||
|
|
||||||
|
Release packaging metadata is generated from this repo’s
|
||||||
|
`packaging/` templates during release publication.
|
||||||
|
|
||||||
|
- Homebrew formula source lives under `packaging/homebrew/`
|
||||||
|
- AUR metadata source lives under `packaging/aur/`
|
||||||
|
|
||||||
FFmpeg must be installed separately:
|
FFmpeg must be installed separately:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -73,6 +81,11 @@ winget install Gyan.FFmpeg # Windows
|
|||||||
alchemist.exe # Windows
|
alchemist.exe # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On Windows, Alchemist now exposes an in-app update check in
|
||||||
|
the About dialog that compares the running version against
|
||||||
|
the latest stable GitHub Release and links directly to the
|
||||||
|
download page when an update is available.
|
||||||
|
|
||||||
## From source
|
## From source
|
||||||
|
|
||||||
For macOS and Linux:
|
For macOS and Linux:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Notifications
|
title: Notifications
|
||||||
description: Configure Discord, Gotify, and webhook alerts.
|
description: Configure Discord, Gotify, Telegram, email, and webhook alerts.
|
||||||
---
|
---
|
||||||
|
|
||||||
Configure notification targets in **Settings → Notifications**.
|
Configure notification targets in **Settings → Notifications**.
|
||||||
@@ -13,15 +13,33 @@ Create a webhook in your Discord channel settings
|
|||||||
(channel → Integrations → Webhooks). Paste the URL into
|
(channel → Integrations → Webhooks). Paste the URL into
|
||||||
Alchemist.
|
Alchemist.
|
||||||
|
|
||||||
|
### Discord bot
|
||||||
|
|
||||||
|
Provide a bot token and target channel ID. This is useful
|
||||||
|
when you want a single bot identity instead of per-channel
|
||||||
|
webhooks.
|
||||||
|
|
||||||
### Gotify
|
### Gotify
|
||||||
|
|
||||||
Enter your Gotify server URL and app token.
|
Enter your Gotify server URL and app token. Gotify supports
|
||||||
|
the same event filtering model as the other providers.
|
||||||
|
|
||||||
### Generic webhook
|
### Generic webhook
|
||||||
|
|
||||||
Alchemist sends a JSON POST to any URL you configure.
|
Alchemist sends a JSON POST to any URL you configure.
|
||||||
Works with Home Assistant, ntfy, Apprise, and custom scripts.
|
Works with Home Assistant, ntfy, Apprise, and custom scripts.
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
|
||||||
|
Provide a bot token and chat ID. Alchemist posts the same
|
||||||
|
human-readable event summaries it uses for Discord and
|
||||||
|
Gotify.
|
||||||
|
|
||||||
|
### Email
|
||||||
|
|
||||||
|
Configure an SMTP host, port, sender address, recipient
|
||||||
|
addresses, and security mode (`STARTTLS`, `TLS`, or `None`).
|
||||||
|
|
||||||
Webhook payloads now include structured explanation data
|
Webhook payloads now include structured explanation data
|
||||||
when relevant:
|
when relevant:
|
||||||
|
|
||||||
@@ -32,11 +50,27 @@ Discord and Gotify targets use the same structured
|
|||||||
summary/detail/guidance internally, but render them as
|
summary/detail/guidance internally, but render them as
|
||||||
human-readable message text instead of raw JSON.
|
human-readable message text instead of raw JSON.
|
||||||
|
|
||||||
|
## Event types
|
||||||
|
|
||||||
|
Targets can subscribe independently to:
|
||||||
|
|
||||||
|
- `encode.queued`
|
||||||
|
- `encode.started`
|
||||||
|
- `encode.completed`
|
||||||
|
- `encode.failed`
|
||||||
|
- `scan.completed`
|
||||||
|
- `engine.idle`
|
||||||
|
- `daily.summary`
|
||||||
|
|
||||||
|
Daily summaries are opt-in per target and use the global
|
||||||
|
local-time send window configured in **Settings →
|
||||||
|
Notifications**.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If notifications aren't arriving:
|
If notifications aren't arriving:
|
||||||
|
|
||||||
1. Check the URL or token for extra whitespace
|
1. Check the URL, token, SMTP host, or chat ID for extra whitespace
|
||||||
2. Check **Logs** — Alchemist logs notification failures
|
2. Check **Logs** — Alchemist logs notification failures
|
||||||
with response code and body
|
with response code and body
|
||||||
3. Verify the server has network access to the target
|
3. Verify the server has network access to the target
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ quality validation. Nothing is deleted until you say so.
|
|||||||
- Encodes to AV1, HEVC, or H.264 based on your configured target
|
- Encodes to AV1, HEVC, or H.264 based on your configured target
|
||||||
- Validates output quality (optional VMAF scoring) before promoting the result
|
- Validates output quality (optional VMAF scoring) before promoting the result
|
||||||
- Tells you exactly why every skipped file was skipped
|
- Tells you exactly why every skipped file was skipped
|
||||||
|
- Supports named API tokens for automation clients and external observability
|
||||||
|
- Can be served under a path prefix such as `/alchemist`
|
||||||
|
- Includes an experimental single-file Conversion / Remux workflow
|
||||||
|
- Expands Library Intelligence beyond duplicate detection into storage-focused recommendations
|
||||||
|
|
||||||
## What it is not
|
## What it is not
|
||||||
|
|
||||||
@@ -48,6 +52,7 @@ FFmpeg expert.
|
|||||||
| Get it running | [Installation](/installation) |
|
| Get it running | [Installation](/installation) |
|
||||||
| Docker setup | [Docker](/docker) |
|
| Docker setup | [Docker](/docker) |
|
||||||
| Get your GPU working | [Hardware](/hardware) |
|
| Get your GPU working | [Hardware](/hardware) |
|
||||||
|
| Automate with tokens | [API](/api) |
|
||||||
| Understand skip decisions | [Skip Decisions](/skip-decisions) |
|
| Understand skip decisions | [Skip Decisions](/skip-decisions) |
|
||||||
| Tune per-library behavior | [Profiles](/profiles) |
|
| Tune per-library behavior | [Profiles](/profiles) |
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ Served by the same binary as the backend. Default:
|
|||||||
## Header bar
|
## Header bar
|
||||||
|
|
||||||
Visible on every page. Shows engine state and provides
|
Visible on every page. Shows engine state and provides
|
||||||
**Start**, **Pause**, and **Stop** controls.
|
**Start** and **Stop** controls plus About and Logout.
|
||||||
|
|
||||||
- **Start** — begins processing
|
- **Start** — begins processing
|
||||||
- **Pause** — freezes active jobs mid-encode, stops new jobs
|
|
||||||
- **Stop** — drain mode: active jobs finish, no new jobs start
|
- **Stop** — drain mode: active jobs finish, no new jobs start
|
||||||
|
- **About** — version info, environment info, and update-check status
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
@@ -44,6 +44,23 @@ Filterable by level, searchable.
|
|||||||
Space savings area chart, per-codec breakdown, aggregate
|
Space savings area chart, per-codec breakdown, aggregate
|
||||||
totals. Fills in as jobs complete.
|
totals. Fills in as jobs complete.
|
||||||
|
|
||||||
|
## Intelligence
|
||||||
|
|
||||||
|
- Duplicate groups by basename
|
||||||
|
- Remux-only opportunities
|
||||||
|
- Wasteful audio layout recommendations
|
||||||
|
- Commentary / descriptive-track cleanup candidates
|
||||||
|
|
||||||
|
## Convert
|
||||||
|
|
||||||
|
Experimental single-file workflow:
|
||||||
|
|
||||||
|
- Upload a file
|
||||||
|
- Probe streams and metadata
|
||||||
|
- Configure transcode or remux settings
|
||||||
|
- Preview the generated FFmpeg command
|
||||||
|
- Queue the job and download the result when complete
|
||||||
|
|
||||||
## Settings tabs
|
## Settings tabs
|
||||||
|
|
||||||
| Tab | Controls |
|
| Tab | Controls |
|
||||||
@@ -54,7 +71,8 @@ totals. Fills in as jobs complete.
|
|||||||
| Hardware | GPU vendor, device path, fallback |
|
| Hardware | GPU vendor, device path, fallback |
|
||||||
| File Settings | Output extension, suffix, output root, replace strategy |
|
| File Settings | Output extension, suffix, output root, replace strategy |
|
||||||
| Quality | VMAF scoring, minimum score, revert on failure |
|
| Quality | VMAF scoring, minimum score, revert on failure |
|
||||||
| Notifications | Discord, Gotify, webhook targets |
|
| Notifications | Discord webhook, Discord bot, Gotify, Telegram, email, webhook targets, daily summary time |
|
||||||
|
| API Tokens | Named bearer tokens with `read_only` and `full_access` classes |
|
||||||
| Schedule | Time windows |
|
| Schedule | Time windows |
|
||||||
| Runtime | Engine mode, concurrent jobs override, Library Doctor |
|
| Runtime | Engine mode, concurrent jobs override, Library Doctor |
|
||||||
| Appearance | Color theme (35+ themes) |
|
| Appearance | Color theme (35+ themes) |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "alchemist-docs",
|
"name": "alchemist-docs",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
163
docs/static/openapi.yaml
vendored
Normal file
163
docs/static/openapi.yaml
vendored
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Alchemist API
|
||||||
|
version: 0.3.0
|
||||||
|
description: >
|
||||||
|
Hand-maintained API contract for Alchemist. Authentication may use the
|
||||||
|
alchemist_session cookie or a bearer token. Bearer tokens support
|
||||||
|
read_only and full_access classes.
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
sessionCookie:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: alchemist_session
|
||||||
|
bearerToken:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: opaque
|
||||||
|
schemas:
|
||||||
|
ApiToken:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
access_level:
|
||||||
|
type: string
|
||||||
|
enum: [read_only, full_access]
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_used_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
revoked_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
paths:
|
||||||
|
/api/auth/login:
|
||||||
|
post:
|
||||||
|
summary: Create an authenticated session cookie
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Session created
|
||||||
|
/api/settings/api-tokens:
|
||||||
|
get:
|
||||||
|
summary: List API token metadata
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Token metadata list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ApiToken"
|
||||||
|
post:
|
||||||
|
summary: Create an API token
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name, access_level]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
access_level:
|
||||||
|
type: string
|
||||||
|
enum: [read_only, full_access]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Token created; plaintext token shown once
|
||||||
|
/api/settings/api-tokens/{id}:
|
||||||
|
delete:
|
||||||
|
summary: Revoke an API token
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Token revoked
|
||||||
|
/api/system/info:
|
||||||
|
get:
|
||||||
|
summary: Get runtime version and environment information
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Runtime info
|
||||||
|
/api/system/update:
|
||||||
|
get:
|
||||||
|
summary: Check GitHub Releases for the latest stable version
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Update status
|
||||||
|
/api/jobs:
|
||||||
|
get:
|
||||||
|
summary: List jobs
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Job list
|
||||||
|
/api/jobs/{id}/details:
|
||||||
|
get:
|
||||||
|
summary: Get a single job detail record
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Job detail
|
||||||
|
/api/engine/status:
|
||||||
|
get:
|
||||||
|
summary: Get current engine status
|
||||||
|
security:
|
||||||
|
- sessionCookie: []
|
||||||
|
- bearerToken: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Engine status
|
||||||
6
justfile
6
justfile
@@ -242,9 +242,9 @@ release-verify:
|
|||||||
@echo "── Actionlint ──"
|
@echo "── Actionlint ──"
|
||||||
actionlint .github/workflows/*.yml
|
actionlint .github/workflows/*.yml
|
||||||
@echo "── Web verify ──"
|
@echo "── Web verify ──"
|
||||||
cd web && bun install --frozen-lockfile && bun run verify && bun audit
|
cd web && bun install --frozen-lockfile && bun run verify && python3 ../scripts/run_bun_audit.py .
|
||||||
@echo "── Docs verify ──"
|
@echo "── Docs verify ──"
|
||||||
cd docs && bun install --frozen-lockfile && bun run build && bun audit
|
cd docs && bun install --frozen-lockfile && bun run build && python3 ../scripts/run_bun_audit.py .
|
||||||
@echo "── E2E backend build ──"
|
@echo "── E2E backend build ──"
|
||||||
rm -rf target/debug/incremental
|
rm -rf target/debug/incremental
|
||||||
CARGO_INCREMENTAL=0 cargo build --locked --no-default-features
|
CARGO_INCREMENTAL=0 cargo build --locked --no-default-features
|
||||||
@@ -403,7 +403,7 @@ fmt:
|
|||||||
# Clean all build artifacts
|
# Clean all build artifacts
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
rm -rf web/dist web/node_modules web-e2e/node_modules
|
rm -rf web/dist web/node_modules web-e2e/node_modules docs/node_modules docs/build
|
||||||
|
|
||||||
# Count lines of source code
|
# Count lines of source code
|
||||||
loc:
|
loc:
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS notification_targets_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target_type TEXT CHECK(target_type IN ('discord_webhook', 'discord_bot', 'gotify', 'webhook', 'telegram', 'email')) NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
events TEXT NOT NULL DEFAULT '["encode.failed","encode.completed"]',
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO notification_targets_new (id, name, target_type, config_json, events, enabled, created_at)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
CASE target_type
|
||||||
|
WHEN 'discord' THEN 'discord_webhook'
|
||||||
|
WHEN 'gotify' THEN 'gotify'
|
||||||
|
ELSE 'webhook'
|
||||||
|
END,
|
||||||
|
CASE target_type
|
||||||
|
WHEN 'discord' THEN json_object('webhook_url', endpoint_url)
|
||||||
|
WHEN 'gotify' THEN json_object('server_url', endpoint_url, 'app_token', COALESCE(auth_token, ''))
|
||||||
|
ELSE json_object('url', endpoint_url, 'auth_token', auth_token)
|
||||||
|
END,
|
||||||
|
COALESCE(events, '["failed","completed"]'),
|
||||||
|
enabled,
|
||||||
|
created_at
|
||||||
|
FROM notification_targets;
|
||||||
|
|
||||||
|
DROP TABLE notification_targets;
|
||||||
|
ALTER TABLE notification_targets_new RENAME TO notification_targets;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_targets_enabled
|
||||||
|
ON notification_targets(enabled);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conversion_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
upload_path TEXT NOT NULL,
|
||||||
|
output_path TEXT,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
settings_json TEXT NOT NULL,
|
||||||
|
probe_json TEXT,
|
||||||
|
linked_job_id INTEGER REFERENCES jobs(id) ON DELETE SET NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'uploaded',
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
downloaded_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversion_jobs_status_updated_at
|
||||||
|
ON conversion_jobs(status, updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversion_jobs_expires_at
|
||||||
|
ON conversion_jobs(expires_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversion_jobs_linked_job_id
|
||||||
|
ON conversion_jobs(linked_job_id);
|
||||||
|
|
||||||
|
INSERT OR REPLACE INTO schema_info (key, value) VALUES
|
||||||
|
('schema_version', '7'),
|
||||||
|
('min_compatible_version', '0.2.5'),
|
||||||
|
('last_updated', datetime('now'));
|
||||||
17
migrations/20260407190000_api_tokens.sql
Normal file
17
migrations/20260407190000_api_tokens.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
access_level TEXT CHECK(access_level IN ('read_only', 'full_access')) NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME,
|
||||||
|
revoked_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_active
|
||||||
|
ON api_tokens(revoked_at, access_level);
|
||||||
|
|
||||||
|
INSERT OR REPLACE INTO schema_info (key, value) VALUES
|
||||||
|
('schema_version', '8'),
|
||||||
|
('min_compatible_version', '0.2.5'),
|
||||||
|
('last_updated', datetime('now'));
|
||||||
14
packaging/aur/PKGBUILD.tmpl
Normal file
14
packaging/aur/PKGBUILD.tmpl
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
pkgname=alchemist-bin
|
||||||
|
pkgver={{VERSION}}
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Self-hosted media transcoding pipeline with a web UI"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/bybrooklyn/alchemist"
|
||||||
|
license=('GPL3')
|
||||||
|
depends=('ffmpeg')
|
||||||
|
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/bybrooklyn/alchemist/releases/download/v${pkgver}/alchemist-linux-x86_64.tar.gz")
|
||||||
|
sha256sums=('{{LINUX_X86_64_SHA256}}')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -Dm755 "${srcdir}/alchemist" "${pkgdir}/usr/bin/alchemist"
|
||||||
|
}
|
||||||
29
packaging/homebrew/alchemist.rb.tmpl
Normal file
29
packaging/homebrew/alchemist.rb.tmpl
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class Alchemist < Formula
|
||||||
|
desc "Self-hosted media transcoding pipeline with a web UI"
|
||||||
|
homepage "https://github.com/bybrooklyn/alchemist"
|
||||||
|
license "GPL-3.0"
|
||||||
|
version "{{VERSION}}"
|
||||||
|
|
||||||
|
on_macos do
|
||||||
|
if Hardware::CPU.arm?
|
||||||
|
url "https://github.com/bybrooklyn/alchemist/releases/download/v{{VERSION}}/alchemist-macos-arm64.tar.gz"
|
||||||
|
sha256 "{{MACOS_ARM64_SHA256}}"
|
||||||
|
else
|
||||||
|
url "https://github.com/bybrooklyn/alchemist/releases/download/v{{VERSION}}/alchemist-macos-x86_64.tar.gz"
|
||||||
|
sha256 "{{MACOS_X86_64_SHA256}}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
on_linux do
|
||||||
|
url "https://github.com/bybrooklyn/alchemist/releases/download/v{{VERSION}}/alchemist-linux-x86_64.tar.gz"
|
||||||
|
sha256 "{{LINUX_X86_64_SHA256}}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
bin.install "alchemist"
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
assert_match version.to_s, shell_output("#{bin}/alchemist --version")
|
||||||
|
end
|
||||||
|
end
|
||||||
5
packaging/windows/update-check.json
Normal file
5
packaging/windows/update-check.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"github_repo": "bybrooklyn/alchemist",
|
||||||
|
"channel": "stable",
|
||||||
|
"release_page": "https://github.com/bybrooklyn/alchemist/releases"
|
||||||
|
}
|
||||||
66
scripts/render_distribution.py
Normal file
66
scripts/render_distribution.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
hasher.update(chunk)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template: str, replacements: dict[str, str]) -> str:
|
||||||
|
rendered = template
|
||||||
|
for key, value in replacements.items():
|
||||||
|
rendered = rendered.replace(f"{{{{{key}}}}}", value)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--version", required=True)
|
||||||
|
parser.add_argument("--assets-dir", required=True)
|
||||||
|
parser.add_argument("--output-dir", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
assets_dir = Path(args.assets_dir).resolve()
|
||||||
|
output_dir = Path(args.output_dir).resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
replacements = {
|
||||||
|
"VERSION": args.version,
|
||||||
|
"LINUX_X86_64_SHA256": sha256(assets_dir / "alchemist-linux-x86_64.tar.gz"),
|
||||||
|
"MACOS_X86_64_SHA256": sha256(assets_dir / "alchemist-macos-x86_64.tar.gz"),
|
||||||
|
"MACOS_ARM64_SHA256": sha256(assets_dir / "alchemist-macos-arm64.tar.gz"),
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = [
|
||||||
|
(
|
||||||
|
root / "packaging/homebrew/alchemist.rb.tmpl",
|
||||||
|
output_dir / "homebrew/alchemist.rb",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
root / "packaging/aur/PKGBUILD.tmpl",
|
||||||
|
output_dir / "aur/PKGBUILD",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for template_path, output_path in templates:
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(
|
||||||
|
render_template(template_path.read_text(), replacements),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
32
scripts/run_bun_audit.py
Normal file
32
scripts/run_bun_audit.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("usage: run_bun_audit.py <cwd>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
cwd = pathlib.Path(sys.argv[1]).resolve()
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["bun", "audit"],
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(
|
||||||
|
f"warning: bun audit timed out after 60s in {cwd}; continuing release-check",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return completed.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
437
src/config.rs
437
src/config.rs
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
@@ -363,13 +364,15 @@ pub(crate) fn default_tonemap_desat() -> f32 {
|
|||||||
0.2
|
0.2
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct NotificationsConfig {
|
pub struct NotificationsConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allow_local_notifications: bool,
|
pub allow_local_notifications: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub targets: Vec<NotificationTargetConfig>,
|
pub targets: Vec<NotificationTargetConfig>,
|
||||||
|
#[serde(default = "default_daily_summary_time_local")]
|
||||||
|
pub daily_summary_time_local: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub webhook_url: Option<String>,
|
pub webhook_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -380,12 +383,15 @@ pub struct NotificationsConfig {
|
|||||||
pub notify_on_failure: bool,
|
pub notify_on_failure: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
pub struct NotificationTargetConfig {
|
pub struct NotificationTargetConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub target_type: String,
|
pub target_type: String,
|
||||||
pub endpoint_url: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub config_json: JsonValue,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub endpoint_url: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub auth_token: Option<String>,
|
pub auth_token: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub events: Vec<String>,
|
pub events: Vec<String>,
|
||||||
@@ -393,6 +399,221 @@ pub struct NotificationTargetConfig {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for NotificationsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
allow_local_notifications: false,
|
||||||
|
targets: Vec::new(),
|
||||||
|
daily_summary_time_local: default_daily_summary_time_local(),
|
||||||
|
webhook_url: None,
|
||||||
|
discord_webhook: None,
|
||||||
|
notify_on_complete: false,
|
||||||
|
notify_on_failure: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_daily_summary_time_local() -> String {
|
||||||
|
"09:00".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NOTIFICATION_EVENT_ENCODE_QUEUED: &str = "encode.queued";
|
||||||
|
pub const NOTIFICATION_EVENT_ENCODE_STARTED: &str = "encode.started";
|
||||||
|
pub const NOTIFICATION_EVENT_ENCODE_COMPLETED: &str = "encode.completed";
|
||||||
|
pub const NOTIFICATION_EVENT_ENCODE_FAILED: &str = "encode.failed";
|
||||||
|
pub const NOTIFICATION_EVENT_SCAN_COMPLETED: &str = "scan.completed";
|
||||||
|
pub const NOTIFICATION_EVENT_ENGINE_IDLE: &str = "engine.idle";
|
||||||
|
pub const NOTIFICATION_EVENT_DAILY_SUMMARY: &str = "daily.summary";
|
||||||
|
|
||||||
|
pub const NOTIFICATION_EVENTS: [&str; 7] = [
|
||||||
|
NOTIFICATION_EVENT_ENCODE_QUEUED,
|
||||||
|
NOTIFICATION_EVENT_ENCODE_STARTED,
|
||||||
|
NOTIFICATION_EVENT_ENCODE_COMPLETED,
|
||||||
|
NOTIFICATION_EVENT_ENCODE_FAILED,
|
||||||
|
NOTIFICATION_EVENT_SCAN_COMPLETED,
|
||||||
|
NOTIFICATION_EVENT_ENGINE_IDLE,
|
||||||
|
NOTIFICATION_EVENT_DAILY_SUMMARY,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn normalize_notification_event(event: &str) -> Option<&'static str> {
|
||||||
|
match event.trim() {
|
||||||
|
"queued" | "encode.queued" => Some(NOTIFICATION_EVENT_ENCODE_QUEUED),
|
||||||
|
"encoding" | "remuxing" | "encode.started" => Some(NOTIFICATION_EVENT_ENCODE_STARTED),
|
||||||
|
"completed" | "encode.completed" => Some(NOTIFICATION_EVENT_ENCODE_COMPLETED),
|
||||||
|
"failed" | "encode.failed" => Some(NOTIFICATION_EVENT_ENCODE_FAILED),
|
||||||
|
"scan.completed" => Some(NOTIFICATION_EVENT_SCAN_COMPLETED),
|
||||||
|
"engine.idle" => Some(NOTIFICATION_EVENT_ENGINE_IDLE),
|
||||||
|
"daily.summary" => Some(NOTIFICATION_EVENT_DAILY_SUMMARY),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_notification_events(events: &[String]) -> Vec<String> {
|
||||||
|
let mut normalized = Vec::new();
|
||||||
|
for event in events {
|
||||||
|
if let Some(value) = normalize_notification_event(event) {
|
||||||
|
if !normalized.iter().any(|candidate| candidate == value) {
|
||||||
|
normalized.push(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_json_string(config_json: &JsonValue, key: &str) -> Option<String> {
|
||||||
|
config_json
|
||||||
|
.get(key)
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationTargetConfig {
|
||||||
|
pub fn migrate_legacy_shape(&mut self) {
|
||||||
|
self.target_type = match self.target_type.as_str() {
|
||||||
|
"discord" => "discord_webhook".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.config_json.is_object() {
|
||||||
|
self.config_json = JsonValue::Object(JsonMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config_map = self
|
||||||
|
.config_json
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(JsonMap::new);
|
||||||
|
|
||||||
|
match self.target_type.as_str() {
|
||||||
|
"discord_webhook" => {
|
||||||
|
if !config_map.contains_key("webhook_url") {
|
||||||
|
if let Some(endpoint_url) = self.endpoint_url.clone() {
|
||||||
|
config_map
|
||||||
|
.insert("webhook_url".to_string(), JsonValue::String(endpoint_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gotify" => {
|
||||||
|
if !config_map.contains_key("server_url") {
|
||||||
|
if let Some(endpoint_url) = self.endpoint_url.clone() {
|
||||||
|
config_map
|
||||||
|
.insert("server_url".to_string(), JsonValue::String(endpoint_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !config_map.contains_key("app_token") {
|
||||||
|
if let Some(auth_token) = self.auth_token.clone() {
|
||||||
|
config_map.insert("app_token".to_string(), JsonValue::String(auth_token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"webhook" => {
|
||||||
|
if !config_map.contains_key("url") {
|
||||||
|
if let Some(endpoint_url) = self.endpoint_url.clone() {
|
||||||
|
config_map.insert("url".to_string(), JsonValue::String(endpoint_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !config_map.contains_key("auth_token") {
|
||||||
|
if let Some(auth_token) = self.auth_token.clone() {
|
||||||
|
config_map.insert("auth_token".to_string(), JsonValue::String(auth_token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.config_json = JsonValue::Object(config_map);
|
||||||
|
self.events = normalize_notification_events(&self.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn canonicalize_for_save(&mut self) {
|
||||||
|
self.endpoint_url = None;
|
||||||
|
self.auth_token = None;
|
||||||
|
self.events = normalize_notification_events(&self.events);
|
||||||
|
if !self.config_json.is_object() {
|
||||||
|
self.config_json = JsonValue::Object(JsonMap::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
anyhow::bail!("notification target name must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.config_json.is_object() {
|
||||||
|
anyhow::bail!("notification target config_json must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.events.is_empty() {
|
||||||
|
anyhow::bail!("notification target events must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in &self.events {
|
||||||
|
if normalize_notification_event(event).is_none() {
|
||||||
|
anyhow::bail!("unsupported notification event '{}'", event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.target_type.as_str() {
|
||||||
|
"discord_webhook" => {
|
||||||
|
if config_json_string(&self.config_json, "webhook_url").is_none() {
|
||||||
|
anyhow::bail!("discord_webhook target requires config_json.webhook_url");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"discord_bot" => {
|
||||||
|
if config_json_string(&self.config_json, "bot_token").is_none() {
|
||||||
|
anyhow::bail!("discord_bot target requires config_json.bot_token");
|
||||||
|
}
|
||||||
|
if config_json_string(&self.config_json, "channel_id").is_none() {
|
||||||
|
anyhow::bail!("discord_bot target requires config_json.channel_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gotify" => {
|
||||||
|
if config_json_string(&self.config_json, "server_url").is_none() {
|
||||||
|
anyhow::bail!("gotify target requires config_json.server_url");
|
||||||
|
}
|
||||||
|
if config_json_string(&self.config_json, "app_token").is_none() {
|
||||||
|
anyhow::bail!("gotify target requires config_json.app_token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"webhook" => {
|
||||||
|
if config_json_string(&self.config_json, "url").is_none() {
|
||||||
|
anyhow::bail!("webhook target requires config_json.url");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"telegram" => {
|
||||||
|
if config_json_string(&self.config_json, "bot_token").is_none() {
|
||||||
|
anyhow::bail!("telegram target requires config_json.bot_token");
|
||||||
|
}
|
||||||
|
if config_json_string(&self.config_json, "chat_id").is_none() {
|
||||||
|
anyhow::bail!("telegram target requires config_json.chat_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"email" => {
|
||||||
|
if config_json_string(&self.config_json, "smtp_host").is_none() {
|
||||||
|
anyhow::bail!("email target requires config_json.smtp_host");
|
||||||
|
}
|
||||||
|
if config_json_string(&self.config_json, "from_address").is_none() {
|
||||||
|
anyhow::bail!("email target requires config_json.from_address");
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.config_json
|
||||||
|
.get("to_addresses")
|
||||||
|
.and_then(JsonValue::as_array)
|
||||||
|
.map(|values| !values.is_empty())
|
||||||
|
!= Some(true)
|
||||||
|
{
|
||||||
|
anyhow::bail!("email target requires non-empty config_json.to_addresses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => anyhow::bail!("unsupported notification target type '{}'", other),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct FileSettingsConfig {
|
pub struct FileSettingsConfig {
|
||||||
pub delete_source: bool,
|
pub delete_source: bool,
|
||||||
@@ -461,6 +682,8 @@ 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 {
|
||||||
@@ -487,6 +710,7 @@ impl Default for SystemConfig {
|
|||||||
log_retention_days: default_log_retention_days(),
|
log_retention_days: default_log_retention_days(),
|
||||||
engine_mode: EngineMode::default(),
|
engine_mode: EngineMode::default(),
|
||||||
https_only: false,
|
https_only: false,
|
||||||
|
base_url: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,6 +826,7 @@ impl Default for Config {
|
|||||||
log_retention_days: default_log_retention_days(),
|
log_retention_days: default_log_retention_days(),
|
||||||
engine_mode: EngineMode::default(),
|
engine_mode: EngineMode::default(),
|
||||||
https_only: false,
|
https_only: false,
|
||||||
|
base_url: String::new(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,6 +840,7 @@ impl Config {
|
|||||||
let content = std::fs::read_to_string(path)?;
|
let content = std::fs::read_to_string(path)?;
|
||||||
let mut config: Config = toml::from_str(&content)?;
|
let mut config: Config = toml::from_str(&content)?;
|
||||||
config.migrate_legacy_notifications();
|
config.migrate_legacy_notifications();
|
||||||
|
config.apply_env_overrides();
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -696,6 +922,12 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_schedule_time(&self.notifications.daily_summary_time_local)?;
|
||||||
|
normalize_base_url(&self.system.base_url)?;
|
||||||
|
for target in &self.notifications.targets {
|
||||||
|
target.validate()?;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate VMAF threshold
|
// Validate VMAF threshold
|
||||||
if self.quality.min_vmaf_score < 0.0 || self.quality.min_vmaf_score > 100.0 {
|
if self.quality.min_vmaf_score < 0.0 || self.quality.min_vmaf_score > 100.0 {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@@ -737,56 +969,110 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn migrate_legacy_notifications(&mut self) {
|
pub(crate) fn migrate_legacy_notifications(&mut self) {
|
||||||
if !self.notifications.targets.is_empty() {
|
if self.notifications.targets.is_empty() {
|
||||||
return;
|
let mut targets = Vec::new();
|
||||||
|
let events = normalize_notification_events(
|
||||||
|
&[
|
||||||
|
self.notifications
|
||||||
|
.notify_on_complete
|
||||||
|
.then_some("completed".to_string()),
|
||||||
|
self.notifications
|
||||||
|
.notify_on_failure
|
||||||
|
.then_some("failed".to_string()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(discord_webhook) = self.notifications.discord_webhook.clone() {
|
||||||
|
targets.push(NotificationTargetConfig {
|
||||||
|
name: "Discord".to_string(),
|
||||||
|
target_type: "discord_webhook".to_string(),
|
||||||
|
config_json: serde_json::json!({ "webhook_url": discord_webhook }),
|
||||||
|
endpoint_url: None,
|
||||||
|
auth_token: None,
|
||||||
|
events: events.clone(),
|
||||||
|
enabled: self.notifications.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(webhook_url) = self.notifications.webhook_url.clone() {
|
||||||
|
targets.push(NotificationTargetConfig {
|
||||||
|
name: "Webhook".to_string(),
|
||||||
|
target_type: "webhook".to_string(),
|
||||||
|
config_json: serde_json::json!({ "url": webhook_url }),
|
||||||
|
endpoint_url: None,
|
||||||
|
auth_token: None,
|
||||||
|
events,
|
||||||
|
enabled: self.notifications.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.notifications.targets = targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut targets = Vec::new();
|
for target in &mut self.notifications.targets {
|
||||||
let events = [
|
target.migrate_legacy_shape();
|
||||||
self.notifications
|
|
||||||
.notify_on_complete
|
|
||||||
.then_some("completed".to_string()),
|
|
||||||
self.notifications
|
|
||||||
.notify_on_failure
|
|
||||||
.then_some("failed".to_string()),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
if let Some(discord_webhook) = self.notifications.discord_webhook.clone() {
|
|
||||||
targets.push(NotificationTargetConfig {
|
|
||||||
name: "Discord".to_string(),
|
|
||||||
target_type: "discord".to_string(),
|
|
||||||
endpoint_url: discord_webhook,
|
|
||||||
auth_token: None,
|
|
||||||
events: events.clone(),
|
|
||||||
enabled: self.notifications.enabled,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
self.notifications.daily_summary_time_local = self
|
||||||
if let Some(webhook_url) = self.notifications.webhook_url.clone() {
|
.notifications
|
||||||
targets.push(NotificationTargetConfig {
|
.daily_summary_time_local
|
||||||
name: "Webhook".to_string(),
|
.trim()
|
||||||
target_type: "webhook".to_string(),
|
.to_string();
|
||||||
endpoint_url: webhook_url,
|
if self.notifications.daily_summary_time_local.is_empty() {
|
||||||
auth_token: None,
|
self.notifications.daily_summary_time_local = default_daily_summary_time_local();
|
||||||
events,
|
|
||||||
enabled: self.notifications.enabled,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.notifications.targets = targets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
self.notifications.notify_on_complete = false;
|
self.notifications.notify_on_complete = false;
|
||||||
self.notifications.notify_on_failure = false;
|
self.notifications.notify_on_failure = false;
|
||||||
}
|
}
|
||||||
|
self.notifications.daily_summary_time_local = self
|
||||||
|
.notifications
|
||||||
|
.daily_summary_time_local
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if self.notifications.daily_summary_time_local.is_empty() {
|
||||||
|
self.notifications.daily_summary_time_local = default_daily_summary_time_local();
|
||||||
|
}
|
||||||
|
for target in &mut self.notifications.targets {
|
||||||
|
target.canonicalize_for_save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<()> {
|
||||||
@@ -837,10 +1123,13 @@ mod tests {
|
|||||||
config.migrate_legacy_notifications();
|
config.migrate_legacy_notifications();
|
||||||
|
|
||||||
assert_eq!(config.notifications.targets.len(), 1);
|
assert_eq!(config.notifications.targets.len(), 1);
|
||||||
assert_eq!(config.notifications.targets[0].target_type, "discord");
|
assert_eq!(
|
||||||
|
config.notifications.targets[0].target_type,
|
||||||
|
"discord_webhook"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.notifications.targets[0].events,
|
config.notifications.targets[0].events,
|
||||||
vec!["completed".to_string(), "failed".to_string()]
|
vec!["encode.completed".to_string(), "encode.failed".to_string()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,9 +1139,10 @@ mod tests {
|
|||||||
config.notifications.targets = vec![NotificationTargetConfig {
|
config.notifications.targets = vec![NotificationTargetConfig {
|
||||||
name: "Webhook".to_string(),
|
name: "Webhook".to_string(),
|
||||||
target_type: "webhook".to_string(),
|
target_type: "webhook".to_string(),
|
||||||
endpoint_url: "https://example.com/webhook".to_string(),
|
config_json: serde_json::json!({ "url": "https://example.com/webhook" }),
|
||||||
|
endpoint_url: Some("https://example.com/webhook".to_string()),
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
events: vec!["completed".to_string()],
|
events: vec!["encode.completed".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}];
|
}];
|
||||||
config.notifications.webhook_url = Some("https://legacy.example.com".to_string());
|
config.notifications.webhook_url = Some("https://legacy.example.com".to_string());
|
||||||
@@ -868,4 +1158,65 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
510
src/conversion.rs
Normal file
510
src/conversion.rs
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
use crate::config::{OutputCodec, TonemapAlgorithm};
|
||||||
|
use crate::error::{AlchemistError, Result};
|
||||||
|
use crate::media::ffmpeg::{FFmpegCommandBuilder, encoder_caps_clone};
|
||||||
|
use crate::media::pipeline::{
|
||||||
|
AudioCodec, AudioStreamPlan, Encoder, EncoderBackend, FilterStep, MediaAnalysis, RateControl,
|
||||||
|
SubtitleStreamPlan, TranscodeDecision, TranscodePlan,
|
||||||
|
};
|
||||||
|
use crate::system::hardware::HardwareInfo;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversionSettings {
|
||||||
|
pub output_container: String,
|
||||||
|
pub remux_only: bool,
|
||||||
|
pub video: ConversionVideoSettings,
|
||||||
|
pub audio: ConversionAudioSettings,
|
||||||
|
pub subtitles: ConversionSubtitleSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversionVideoSettings {
|
||||||
|
pub codec: String,
|
||||||
|
pub mode: String,
|
||||||
|
pub value: Option<u32>,
|
||||||
|
pub preset: Option<String>,
|
||||||
|
pub resolution: ConversionResolutionSettings,
|
||||||
|
pub hdr_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversionResolutionSettings {
|
||||||
|
pub mode: String,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub scale_factor: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversionAudioSettings {
|
||||||
|
pub codec: String,
|
||||||
|
pub bitrate_kbps: Option<u16>,
|
||||||
|
pub channels: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversionSubtitleSettings {
|
||||||
|
pub mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversionPreview {
|
||||||
|
pub normalized_settings: ConversionSettings,
|
||||||
|
pub command_preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConversionSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
output_container: "mkv".to_string(),
|
||||||
|
remux_only: false,
|
||||||
|
video: ConversionVideoSettings {
|
||||||
|
codec: "hevc".to_string(),
|
||||||
|
mode: "crf".to_string(),
|
||||||
|
value: Some(24),
|
||||||
|
preset: Some("medium".to_string()),
|
||||||
|
resolution: ConversionResolutionSettings {
|
||||||
|
mode: "original".to_string(),
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
scale_factor: None,
|
||||||
|
},
|
||||||
|
hdr_mode: "preserve".to_string(),
|
||||||
|
},
|
||||||
|
audio: ConversionAudioSettings {
|
||||||
|
codec: "copy".to_string(),
|
||||||
|
bitrate_kbps: Some(160),
|
||||||
|
channels: Some("auto".to_string()),
|
||||||
|
},
|
||||||
|
subtitles: ConversionSubtitleSettings {
|
||||||
|
mode: "copy".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_plan(
|
||||||
|
analysis: &MediaAnalysis,
|
||||||
|
output_path: &Path,
|
||||||
|
settings: &ConversionSettings,
|
||||||
|
hw_info: Option<HardwareInfo>,
|
||||||
|
) -> Result<TranscodePlan> {
|
||||||
|
let normalized = normalize_settings(analysis, settings)?;
|
||||||
|
let container = normalized.output_container.clone();
|
||||||
|
|
||||||
|
if normalized.remux_only {
|
||||||
|
let requested_codec = infer_source_codec(&analysis.metadata.codec_name)?;
|
||||||
|
return Ok(TranscodePlan {
|
||||||
|
decision: TranscodeDecision::Remux {
|
||||||
|
reason: "conversion_remux_only".to_string(),
|
||||||
|
},
|
||||||
|
is_remux: true,
|
||||||
|
copy_video: true,
|
||||||
|
output_path: Some(output_path.to_path_buf()),
|
||||||
|
container,
|
||||||
|
requested_codec,
|
||||||
|
output_codec: Some(requested_codec),
|
||||||
|
encoder: None,
|
||||||
|
backend: None,
|
||||||
|
rate_control: None,
|
||||||
|
encoder_preset: None,
|
||||||
|
threads: 0,
|
||||||
|
audio: AudioStreamPlan::Copy,
|
||||||
|
audio_stream_indices: None,
|
||||||
|
subtitles: SubtitleStreamPlan::CopyAllCompatible,
|
||||||
|
filters: Vec::new(),
|
||||||
|
allow_fallback: true,
|
||||||
|
fallback: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_codec = match normalized.video.codec.as_str() {
|
||||||
|
"copy" => infer_source_codec(&analysis.metadata.codec_name)?,
|
||||||
|
"av1" => OutputCodec::Av1,
|
||||||
|
"hevc" => OutputCodec::Hevc,
|
||||||
|
"h264" => OutputCodec::H264,
|
||||||
|
other => {
|
||||||
|
return Err(AlchemistError::Config(format!(
|
||||||
|
"Unsupported conversion video codec '{}'",
|
||||||
|
other
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let copy_video = normalized.video.codec == "copy";
|
||||||
|
let encoder = if copy_video {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(select_encoder_for_codec(
|
||||||
|
requested_codec,
|
||||||
|
hw_info.as_ref(),
|
||||||
|
&encoder_caps_clone(),
|
||||||
|
)?)
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = encoder.map(|value| value.backend());
|
||||||
|
let rate_control = if copy_video {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let selected_encoder = encoder.ok_or_else(|| {
|
||||||
|
AlchemistError::Config("Conversion encoder selection missing".to_string())
|
||||||
|
})?;
|
||||||
|
Some(build_rate_control(
|
||||||
|
&normalized.video.mode,
|
||||||
|
normalized.video.value,
|
||||||
|
selected_encoder,
|
||||||
|
)?)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut filters = Vec::new();
|
||||||
|
if !copy_video {
|
||||||
|
match normalized.video.resolution.mode.as_str() {
|
||||||
|
"custom" => {
|
||||||
|
let width = normalized
|
||||||
|
.video
|
||||||
|
.resolution
|
||||||
|
.width
|
||||||
|
.unwrap_or(analysis.metadata.width)
|
||||||
|
.max(2);
|
||||||
|
let height = normalized
|
||||||
|
.video
|
||||||
|
.resolution
|
||||||
|
.height
|
||||||
|
.unwrap_or(analysis.metadata.height)
|
||||||
|
.max(2);
|
||||||
|
filters.push(FilterStep::Scale {
|
||||||
|
width: even(width),
|
||||||
|
height: even(height),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"scale_factor" => {
|
||||||
|
let factor = normalized.video.resolution.scale_factor.unwrap_or(1.0);
|
||||||
|
let width =
|
||||||
|
even(((analysis.metadata.width as f32) * factor).round().max(2.0) as u32);
|
||||||
|
let height = even(
|
||||||
|
((analysis.metadata.height as f32) * factor)
|
||||||
|
.round()
|
||||||
|
.max(2.0) as u32,
|
||||||
|
);
|
||||||
|
filters.push(FilterStep::Scale { width, height });
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match normalized.video.hdr_mode.as_str() {
|
||||||
|
"tonemap" => filters.push(FilterStep::Tonemap {
|
||||||
|
algorithm: TonemapAlgorithm::Hable,
|
||||||
|
peak: 100.0,
|
||||||
|
desat: 0.2,
|
||||||
|
}),
|
||||||
|
"strip_metadata" => filters.push(FilterStep::StripHdrMetadata),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtitles = build_subtitle_plan(analysis, &normalized, copy_video)?;
|
||||||
|
if let SubtitleStreamPlan::Burn { stream_index } = subtitles {
|
||||||
|
filters.push(FilterStep::SubtitleBurn { stream_index });
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio = build_audio_plan(&normalized.audio)?;
|
||||||
|
|
||||||
|
Ok(TranscodePlan {
|
||||||
|
decision: TranscodeDecision::Transcode {
|
||||||
|
reason: "conversion_requested".to_string(),
|
||||||
|
},
|
||||||
|
is_remux: false,
|
||||||
|
copy_video,
|
||||||
|
output_path: Some(output_path.to_path_buf()),
|
||||||
|
container,
|
||||||
|
requested_codec,
|
||||||
|
output_codec: Some(requested_codec),
|
||||||
|
encoder,
|
||||||
|
backend,
|
||||||
|
rate_control,
|
||||||
|
encoder_preset: normalized.video.preset.clone(),
|
||||||
|
threads: 0,
|
||||||
|
audio,
|
||||||
|
audio_stream_indices: None,
|
||||||
|
subtitles,
|
||||||
|
filters,
|
||||||
|
allow_fallback: true,
|
||||||
|
fallback: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preview_command(
|
||||||
|
input_path: &Path,
|
||||||
|
output_path: &Path,
|
||||||
|
analysis: &MediaAnalysis,
|
||||||
|
settings: &ConversionSettings,
|
||||||
|
hw_info: Option<HardwareInfo>,
|
||||||
|
) -> Result<ConversionPreview> {
|
||||||
|
let normalized = normalize_settings(analysis, settings)?;
|
||||||
|
let plan = build_plan(analysis, output_path, &normalized, hw_info.clone())?;
|
||||||
|
let args = FFmpegCommandBuilder::new(input_path, output_path, &analysis.metadata, &plan)
|
||||||
|
.with_hardware(hw_info.as_ref())
|
||||||
|
.build_args()?;
|
||||||
|
Ok(ConversionPreview {
|
||||||
|
normalized_settings: normalized,
|
||||||
|
command_preview: format!(
|
||||||
|
"ffmpeg {}",
|
||||||
|
args.iter()
|
||||||
|
.map(|arg| shell_escape(arg))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(value: &str) -> String {
|
||||||
|
if value
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || "-_./:=+".contains(ch))
|
||||||
|
{
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
format!("'{}'", value.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_settings(
|
||||||
|
analysis: &MediaAnalysis,
|
||||||
|
settings: &ConversionSettings,
|
||||||
|
) -> Result<ConversionSettings> {
|
||||||
|
let mut normalized = settings.clone();
|
||||||
|
if normalized.output_container.trim().is_empty() {
|
||||||
|
normalized.output_container = "mkv".to_string();
|
||||||
|
}
|
||||||
|
normalized.output_container = normalized.output_container.trim().to_ascii_lowercase();
|
||||||
|
normalized.video.codec = normalized.video.codec.trim().to_ascii_lowercase();
|
||||||
|
normalized.video.mode = normalized.video.mode.trim().to_ascii_lowercase();
|
||||||
|
normalized.video.hdr_mode = normalized.video.hdr_mode.trim().to_ascii_lowercase();
|
||||||
|
normalized.video.resolution.mode = normalized.video.resolution.mode.trim().to_ascii_lowercase();
|
||||||
|
normalized.audio.codec = normalized.audio.codec.trim().to_ascii_lowercase();
|
||||||
|
normalized.subtitles.mode = normalized.subtitles.mode.trim().to_ascii_lowercase();
|
||||||
|
normalized.audio.channels = Some(
|
||||||
|
normalized
|
||||||
|
.audio
|
||||||
|
.channels
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("auto")
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if normalized.remux_only {
|
||||||
|
normalized.video.codec = "copy".to_string();
|
||||||
|
normalized.audio.codec = "copy".to_string();
|
||||||
|
normalized.subtitles.mode = "copy".to_string();
|
||||||
|
normalized.video.mode = "crf".to_string();
|
||||||
|
normalized.video.value = None;
|
||||||
|
normalized.video.resolution.mode = "original".to_string();
|
||||||
|
normalized.video.hdr_mode = "preserve".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.video.codec == "copy" {
|
||||||
|
if normalized.video.resolution.mode != "original" {
|
||||||
|
return Err(AlchemistError::Config(
|
||||||
|
"Video copy cannot be combined with resize controls".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if normalized.video.hdr_mode != "preserve" {
|
||||||
|
return Err(AlchemistError::Config(
|
||||||
|
"Video copy cannot be combined with HDR transforms".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if normalized.subtitles.mode == "burn" {
|
||||||
|
return Err(AlchemistError::Config(
|
||||||
|
"Burn-in subtitles requires video re-encoding".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.subtitles.mode == "burn"
|
||||||
|
&& !analysis
|
||||||
|
.metadata
|
||||||
|
.subtitle_streams
|
||||||
|
.iter()
|
||||||
|
.any(|stream| stream.burnable)
|
||||||
|
{
|
||||||
|
return Err(AlchemistError::Config(
|
||||||
|
"No burnable subtitle stream is available for this file".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_audio_plan(settings: &ConversionAudioSettings) -> Result<AudioStreamPlan> {
|
||||||
|
match settings.codec.as_str() {
|
||||||
|
"copy" => Ok(AudioStreamPlan::Copy),
|
||||||
|
"aac" => Ok(AudioStreamPlan::Transcode {
|
||||||
|
codec: AudioCodec::Aac,
|
||||||
|
bitrate_kbps: settings.bitrate_kbps.unwrap_or(160),
|
||||||
|
channels: parse_audio_channels(settings.channels.as_deref()),
|
||||||
|
}),
|
||||||
|
"opus" => Ok(AudioStreamPlan::Transcode {
|
||||||
|
codec: AudioCodec::Opus,
|
||||||
|
bitrate_kbps: settings.bitrate_kbps.unwrap_or(160),
|
||||||
|
channels: parse_audio_channels(settings.channels.as_deref()),
|
||||||
|
}),
|
||||||
|
"mp3" => Ok(AudioStreamPlan::Transcode {
|
||||||
|
codec: AudioCodec::Mp3,
|
||||||
|
bitrate_kbps: settings.bitrate_kbps.unwrap_or(192),
|
||||||
|
channels: parse_audio_channels(settings.channels.as_deref()),
|
||||||
|
}),
|
||||||
|
"remove" | "drop" | "none" => Ok(AudioStreamPlan::Drop),
|
||||||
|
other => Err(AlchemistError::Config(format!(
|
||||||
|
"Unsupported conversion audio codec '{}'",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_subtitle_plan(
|
||||||
|
analysis: &MediaAnalysis,
|
||||||
|
settings: &ConversionSettings,
|
||||||
|
copy_video: bool,
|
||||||
|
) -> Result<SubtitleStreamPlan> {
|
||||||
|
match settings.subtitles.mode.as_str() {
|
||||||
|
"copy" => Ok(SubtitleStreamPlan::CopyAllCompatible),
|
||||||
|
"remove" | "drop" | "none" => Ok(SubtitleStreamPlan::Drop),
|
||||||
|
"burn" => {
|
||||||
|
if copy_video {
|
||||||
|
return Err(AlchemistError::Config(
|
||||||
|
"Burn-in subtitles requires video re-encoding".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let stream = analysis
|
||||||
|
.metadata
|
||||||
|
.subtitle_streams
|
||||||
|
.iter()
|
||||||
|
.find(|stream| stream.forced && stream.burnable)
|
||||||
|
.or_else(|| {
|
||||||
|
analysis
|
||||||
|
.metadata
|
||||||
|
.subtitle_streams
|
||||||
|
.iter()
|
||||||
|
.find(|stream| stream.default && stream.burnable)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
analysis
|
||||||
|
.metadata
|
||||||
|
.subtitle_streams
|
||||||
|
.iter()
|
||||||
|
.find(|stream| stream.burnable)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AlchemistError::Config(
|
||||||
|
"No burnable subtitle stream is available for this file".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(SubtitleStreamPlan::Burn {
|
||||||
|
stream_index: stream.stream_index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => Err(AlchemistError::Config(format!(
|
||||||
|
"Unsupported subtitle mode '{}'",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_audio_channels(value: Option<&str>) -> Option<u32> {
|
||||||
|
match value.unwrap_or("auto") {
|
||||||
|
"auto" => None,
|
||||||
|
"stereo" => Some(2),
|
||||||
|
"5.1" => Some(6),
|
||||||
|
other => other.parse::<u32>().ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_rate_control(mode: &str, value: Option<u32>, encoder: Encoder) -> Result<RateControl> {
|
||||||
|
match mode {
|
||||||
|
"bitrate" => Ok(RateControl::Bitrate {
|
||||||
|
kbps: value.unwrap_or(4000),
|
||||||
|
}),
|
||||||
|
_ => {
|
||||||
|
let quality = value.unwrap_or(24) as u8;
|
||||||
|
match encoder.backend() {
|
||||||
|
EncoderBackend::Qsv => Ok(RateControl::QsvQuality { value: quality }),
|
||||||
|
EncoderBackend::Cpu => Ok(RateControl::Crf { value: quality }),
|
||||||
|
_ => Ok(RateControl::Cq { value: quality }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_encoder_for_codec(
|
||||||
|
requested_codec: OutputCodec,
|
||||||
|
hw_info: Option<&HardwareInfo>,
|
||||||
|
encoder_caps: &crate::media::ffmpeg::EncoderCapabilities,
|
||||||
|
) -> Result<Encoder> {
|
||||||
|
if let Some(hw) = hw_info {
|
||||||
|
for backend in &hw.backends {
|
||||||
|
if backend.codec != requested_codec.as_str() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(encoder) = encoder_from_name(&backend.encoder) {
|
||||||
|
return Ok(encoder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match requested_codec {
|
||||||
|
OutputCodec::Av1 if encoder_caps.has_libsvtav1() => Ok(Encoder::Av1Svt),
|
||||||
|
OutputCodec::Hevc if encoder_caps.has_libx265() => Ok(Encoder::HevcX265),
|
||||||
|
OutputCodec::H264 if encoder_caps.has_libx264() => Ok(Encoder::H264X264),
|
||||||
|
_ => Err(AlchemistError::Config(format!(
|
||||||
|
"No encoder is available for requested codec '{}'",
|
||||||
|
requested_codec.as_str()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoder_from_name(name: &str) -> Option<Encoder> {
|
||||||
|
match name {
|
||||||
|
"av1_qsv" => Some(Encoder::Av1Qsv),
|
||||||
|
"av1_nvenc" => Some(Encoder::Av1Nvenc),
|
||||||
|
"av1_vaapi" => Some(Encoder::Av1Vaapi),
|
||||||
|
"av1_videotoolbox" => Some(Encoder::Av1Videotoolbox),
|
||||||
|
"av1_amf" => Some(Encoder::Av1Amf),
|
||||||
|
"libsvtav1" => Some(Encoder::Av1Svt),
|
||||||
|
"libaom-av1" => Some(Encoder::Av1Aom),
|
||||||
|
"hevc_qsv" => Some(Encoder::HevcQsv),
|
||||||
|
"hevc_nvenc" => Some(Encoder::HevcNvenc),
|
||||||
|
"hevc_vaapi" => Some(Encoder::HevcVaapi),
|
||||||
|
"hevc_videotoolbox" => Some(Encoder::HevcVideotoolbox),
|
||||||
|
"hevc_amf" => Some(Encoder::HevcAmf),
|
||||||
|
"libx265" => Some(Encoder::HevcX265),
|
||||||
|
"h264_qsv" => Some(Encoder::H264Qsv),
|
||||||
|
"h264_nvenc" => Some(Encoder::H264Nvenc),
|
||||||
|
"h264_vaapi" => Some(Encoder::H264Vaapi),
|
||||||
|
"h264_videotoolbox" => Some(Encoder::H264Videotoolbox),
|
||||||
|
"h264_amf" => Some(Encoder::H264Amf),
|
||||||
|
"libx264" => Some(Encoder::H264X264),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_source_codec(value: &str) -> Result<OutputCodec> {
|
||||||
|
match value {
|
||||||
|
"av1" => Ok(OutputCodec::Av1),
|
||||||
|
"hevc" | "h265" => Ok(OutputCodec::Hevc),
|
||||||
|
"h264" | "avc1" => Ok(OutputCodec::H264),
|
||||||
|
other => Err(AlchemistError::Config(format!(
|
||||||
|
"Source codec '{}' cannot be used with video copy mode",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn even(value: u32) -> u32 {
|
||||||
|
if value % 2 == 0 {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
value.saturating_sub(1).max(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/db.rs
383
src/db.rs
@@ -40,6 +40,17 @@ pub struct JobStats {
|
|||||||
pub failed: i64,
|
pub failed: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DailySummaryStats {
|
||||||
|
pub completed: i64,
|
||||||
|
pub failed: i64,
|
||||||
|
pub skipped: i64,
|
||||||
|
pub bytes_saved: i64,
|
||||||
|
pub top_failure_reasons: Vec<String>,
|
||||||
|
pub top_skip_reasons: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct LogEntry {
|
pub struct LogEntry {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -56,6 +67,8 @@ pub enum AlchemistEvent {
|
|||||||
job_id: i64,
|
job_id: i64,
|
||||||
status: JobState,
|
status: JobState,
|
||||||
},
|
},
|
||||||
|
ScanCompleted,
|
||||||
|
EngineIdle,
|
||||||
Progress {
|
Progress {
|
||||||
job_id: i64,
|
job_id: i64,
|
||||||
percentage: f64,
|
percentage: f64,
|
||||||
@@ -170,6 +183,11 @@ impl From<AlchemistEvent> for JobEvent {
|
|||||||
AlchemistEvent::JobStateChanged { job_id, status } => {
|
AlchemistEvent::JobStateChanged { job_id, status } => {
|
||||||
JobEvent::StateChanged { job_id, status }
|
JobEvent::StateChanged { job_id, status }
|
||||||
}
|
}
|
||||||
|
AlchemistEvent::ScanCompleted | AlchemistEvent::EngineIdle => JobEvent::Log {
|
||||||
|
level: "info".to_string(),
|
||||||
|
job_id: None,
|
||||||
|
message: "non-job event".to_string(),
|
||||||
|
},
|
||||||
AlchemistEvent::Progress {
|
AlchemistEvent::Progress {
|
||||||
job_id,
|
job_id,
|
||||||
percentage,
|
percentage,
|
||||||
@@ -331,13 +349,28 @@ pub struct NotificationTarget {
|
|||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub target_type: String,
|
pub target_type: String,
|
||||||
pub endpoint_url: String,
|
pub config_json: String,
|
||||||
pub auth_token: Option<String>,
|
|
||||||
pub events: String,
|
pub events: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||||
|
pub struct ConversionJob {
|
||||||
|
pub id: i64,
|
||||||
|
pub upload_path: String,
|
||||||
|
pub output_path: Option<String>,
|
||||||
|
pub mode: String,
|
||||||
|
pub settings_json: String,
|
||||||
|
pub probe_json: Option<String>,
|
||||||
|
pub linked_job_id: Option<i64>,
|
||||||
|
pub status: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
pub downloaded_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||||
pub struct ScheduleWindow {
|
pub struct ScheduleWindow {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -1813,7 +1846,9 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_notification_targets(&self) -> Result<Vec<NotificationTarget>> {
|
pub async fn get_notification_targets(&self) -> Result<Vec<NotificationTarget>> {
|
||||||
let targets = sqlx::query_as::<_, NotificationTarget>("SELECT id, name, target_type, endpoint_url, auth_token, events, enabled, created_at FROM notification_targets")
|
let targets = sqlx::query_as::<_, NotificationTarget>(
|
||||||
|
"SELECT id, name, target_type, config_json, events, enabled, created_at FROM notification_targets",
|
||||||
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(targets)
|
Ok(targets)
|
||||||
@@ -1823,19 +1858,17 @@ impl Db {
|
|||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
target_type: &str,
|
target_type: &str,
|
||||||
endpoint_url: &str,
|
config_json: &str,
|
||||||
auth_token: Option<&str>,
|
|
||||||
events: &str,
|
events: &str,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<NotificationTarget> {
|
) -> Result<NotificationTarget> {
|
||||||
let row = sqlx::query_as::<_, NotificationTarget>(
|
let row = sqlx::query_as::<_, NotificationTarget>(
|
||||||
"INSERT INTO notification_targets (name, target_type, endpoint_url, auth_token, events, enabled)
|
"INSERT INTO notification_targets (name, target_type, config_json, events, enabled)
|
||||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING *"
|
VALUES (?, ?, ?, ?, ?) RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(target_type)
|
.bind(target_type)
|
||||||
.bind(endpoint_url)
|
.bind(config_json)
|
||||||
.bind(auth_token)
|
|
||||||
.bind(events)
|
.bind(events)
|
||||||
.bind(enabled)
|
.bind(enabled)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
@@ -1866,12 +1899,11 @@ impl Db {
|
|||||||
.await?;
|
.await?;
|
||||||
for target in targets {
|
for target in targets {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO notification_targets (name, target_type, endpoint_url, auth_token, events, enabled) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO notification_targets (name, target_type, config_json, events, enabled) VALUES (?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&target.name)
|
.bind(&target.name)
|
||||||
.bind(&target.target_type)
|
.bind(&target.target_type)
|
||||||
.bind(&target.endpoint_url)
|
.bind(target.config_json.to_string())
|
||||||
.bind(target.auth_token.as_deref())
|
|
||||||
.bind(serde_json::to_string(&target.events).unwrap_or_else(|_| "[]".to_string()))
|
.bind(serde_json::to_string(&target.events).unwrap_or_else(|_| "[]".to_string()))
|
||||||
.bind(target.enabled)
|
.bind(target.enabled)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -1881,6 +1913,152 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_conversion_job(
|
||||||
|
&self,
|
||||||
|
upload_path: &str,
|
||||||
|
mode: &str,
|
||||||
|
settings_json: &str,
|
||||||
|
probe_json: Option<&str>,
|
||||||
|
expires_at: &str,
|
||||||
|
) -> Result<ConversionJob> {
|
||||||
|
let row = sqlx::query_as::<_, ConversionJob>(
|
||||||
|
"INSERT INTO conversion_jobs (upload_path, mode, settings_json, probe_json, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(upload_path)
|
||||||
|
.bind(mode)
|
||||||
|
.bind(settings_json)
|
||||||
|
.bind(probe_json)
|
||||||
|
.bind(expires_at)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_conversion_job(&self, id: i64) -> Result<Option<ConversionJob>> {
|
||||||
|
let row = sqlx::query_as::<_, ConversionJob>(
|
||||||
|
"SELECT id, upload_path, output_path, mode, settings_json, probe_json, linked_job_id, status, expires_at, downloaded_at, created_at, updated_at
|
||||||
|
FROM conversion_jobs
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_conversion_job_by_linked_job_id(
|
||||||
|
&self,
|
||||||
|
linked_job_id: i64,
|
||||||
|
) -> Result<Option<ConversionJob>> {
|
||||||
|
let row = sqlx::query_as::<_, ConversionJob>(
|
||||||
|
"SELECT id, upload_path, output_path, mode, settings_json, probe_json, linked_job_id, status, expires_at, downloaded_at, created_at, updated_at
|
||||||
|
FROM conversion_jobs
|
||||||
|
WHERE linked_job_id = ?",
|
||||||
|
)
|
||||||
|
.bind(linked_job_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_conversion_job_probe(&self, id: i64, probe_json: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE conversion_jobs
|
||||||
|
SET probe_json = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(probe_json)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_conversion_job_settings(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
settings_json: &str,
|
||||||
|
mode: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE conversion_jobs
|
||||||
|
SET settings_json = ?, mode = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(settings_json)
|
||||||
|
.bind(mode)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_conversion_job_start(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
output_path: &str,
|
||||||
|
linked_job_id: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE conversion_jobs
|
||||||
|
SET output_path = ?, linked_job_id = ?, status = 'queued', updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(output_path)
|
||||||
|
.bind(linked_job_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_conversion_job_status(&self, id: i64, status: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE conversion_jobs
|
||||||
|
SET status = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(status)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_conversion_job_downloaded(&self, id: i64) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE conversion_jobs
|
||||||
|
SET downloaded_at = datetime('now'), status = 'downloaded', updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_conversion_job(&self, id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM conversion_jobs WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_expired_conversion_jobs(&self, now: &str) -> Result<Vec<ConversionJob>> {
|
||||||
|
let rows = sqlx::query_as::<_, ConversionJob>(
|
||||||
|
"SELECT id, upload_path, output_path, mode, settings_json, probe_json, linked_job_id, status, expires_at, downloaded_at, created_at, updated_at
|
||||||
|
FROM conversion_jobs
|
||||||
|
WHERE expires_at <= ?",
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_schedule_windows(&self) -> Result<Vec<ScheduleWindow>> {
|
pub async fn get_schedule_windows(&self) -> Result<Vec<ScheduleWindow>> {
|
||||||
let windows = sqlx::query_as::<_, ScheduleWindow>("SELECT * FROM schedule_windows")
|
let windows = sqlx::query_as::<_, ScheduleWindow>("SELECT * FROM schedule_windows")
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -2040,7 +2218,7 @@ impl Db {
|
|||||||
let days_str = format!("-{}", days);
|
let days_str = format!("-{}", days);
|
||||||
timed_query("get_daily_stats", || async {
|
timed_query("get_daily_stats", || async {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT
|
"SELECT
|
||||||
DATE(e.created_at) as date,
|
DATE(e.created_at) as date,
|
||||||
COUNT(*) as jobs_completed,
|
COUNT(*) as jobs_completed,
|
||||||
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved,
|
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved,
|
||||||
@@ -2284,6 +2462,75 @@ impl Db {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_daily_summary_stats(&self) -> Result<DailySummaryStats> {
|
||||||
|
let pool = &self.pool;
|
||||||
|
timed_query("get_daily_summary_stats", || async {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' AND DATE(updated_at, 'localtime') = DATE('now', 'localtime') THEN 1 ELSE 0 END), 0) AS completed,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'failed' AND DATE(updated_at, 'localtime') = DATE('now', 'localtime') THEN 1 ELSE 0 END), 0) AS failed,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'skipped' AND DATE(updated_at, 'localtime') = DATE('now', 'localtime') THEN 1 ELSE 0 END), 0) AS skipped
|
||||||
|
FROM jobs",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let completed: i64 = row.get("completed");
|
||||||
|
let failed: i64 = row.get("failed");
|
||||||
|
let skipped: i64 = row.get("skipped");
|
||||||
|
|
||||||
|
let bytes_row = sqlx::query(
|
||||||
|
"SELECT COALESCE(SUM(input_size_bytes - output_size_bytes), 0) AS bytes_saved
|
||||||
|
FROM encode_stats
|
||||||
|
WHERE DATE(created_at, 'localtime') = DATE('now', 'localtime')",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let bytes_saved: i64 = bytes_row.get("bytes_saved");
|
||||||
|
|
||||||
|
let failure_rows = sqlx::query(
|
||||||
|
"SELECT code, COUNT(*) AS count
|
||||||
|
FROM job_failure_explanations
|
||||||
|
WHERE DATE(updated_at, 'localtime') = DATE('now', 'localtime')
|
||||||
|
GROUP BY code
|
||||||
|
ORDER BY count DESC, code ASC
|
||||||
|
LIMIT 3",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let top_failure_reasons = failure_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.get::<String, _>("code"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let skip_rows = sqlx::query(
|
||||||
|
"SELECT COALESCE(reason_code, action) AS code, COUNT(*) AS count
|
||||||
|
FROM decisions
|
||||||
|
WHERE action = 'skip'
|
||||||
|
AND DATE(created_at, 'localtime') = DATE('now', 'localtime')
|
||||||
|
GROUP BY COALESCE(reason_code, action)
|
||||||
|
ORDER BY count DESC, code ASC
|
||||||
|
LIMIT 3",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let top_skip_reasons = skip_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.get::<String, _>("code"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(DailySummaryStats {
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
bytes_saved,
|
||||||
|
top_failure_reasons,
|
||||||
|
top_skip_reasons,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn add_log(&self, level: &str, job_id: Option<i64>, message: &str) -> Result<()> {
|
pub async fn add_log(&self, level: &str, job_id: Option<i64>, message: &str) -> Result<()> {
|
||||||
sqlx::query("INSERT INTO logs (level, job_id, message) VALUES (?, ?, ?)")
|
sqlx::query("INSERT INTO logs (level, job_id, message) VALUES (?, ?, ?)")
|
||||||
.bind(level)
|
.bind(level)
|
||||||
@@ -2432,6 +2679,75 @@ impl Db {
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_api_tokens(&self) -> Result<Vec<ApiToken>> {
|
||||||
|
let tokens = sqlx::query_as::<_, ApiToken>(
|
||||||
|
"SELECT id, name, access_level, created_at, last_used_at, revoked_at
|
||||||
|
FROM api_tokens
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_token(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
token: &str,
|
||||||
|
access_level: ApiTokenAccessLevel,
|
||||||
|
) -> Result<ApiToken> {
|
||||||
|
let token_hash = hash_api_token(token);
|
||||||
|
let row = sqlx::query_as::<_, ApiToken>(
|
||||||
|
"INSERT INTO api_tokens (name, token_hash, access_level)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
RETURNING id, name, access_level, created_at, last_used_at, revoked_at",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(access_level)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_active_api_token(&self, token: &str) -> Result<Option<ApiTokenRecord>> {
|
||||||
|
let token_hash = hash_api_token(token);
|
||||||
|
let row = sqlx::query_as::<_, ApiTokenRecord>(
|
||||||
|
"SELECT id, name, token_hash, access_level, created_at, last_used_at, revoked_at
|
||||||
|
FROM api_tokens
|
||||||
|
WHERE token_hash = ? AND revoked_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_api_token_last_used(&self, id: i64) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE api_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_api_token(&self, id: i64) -> Result<()> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE api_tokens
|
||||||
|
SET revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(crate::error::AlchemistError::Database(
|
||||||
|
sqlx::Error::RowNotFound,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_health_check(
|
pub async fn record_health_check(
|
||||||
&self,
|
&self,
|
||||||
job_id: i64,
|
job_id: i64,
|
||||||
@@ -2599,6 +2915,35 @@ pub struct Session {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||||
|
#[sqlx(rename_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ApiTokenAccessLevel {
|
||||||
|
ReadOnly,
|
||||||
|
FullAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||||
|
pub struct ApiToken {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub access_level: ApiTokenAccessLevel,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct ApiTokenRecord {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub access_level: ApiTokenAccessLevel,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Hash a session token using SHA256 for secure storage.
|
/// Hash a session token using SHA256 for secure storage.
|
||||||
///
|
///
|
||||||
/// # Security: Timing Attack Resistance
|
/// # Security: Timing Attack Resistance
|
||||||
@@ -2625,6 +2970,18 @@ fn hash_session_token(token: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn hash_api_token(token: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let mut out = String::with_capacity(64);
|
||||||
|
for byte in digest {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(&mut out, "{:02x}", byte);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#![deny(clippy::expect_used, clippy::unwrap_used)]
|
#![deny(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod conversion;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod explanations;
|
pub mod explanations;
|
||||||
|
|||||||
@@ -181,10 +181,6 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let encoder = self
|
|
||||||
.plan
|
|
||||||
.encoder
|
|
||||||
.ok_or_else(|| AlchemistError::Config("Transcode plan missing encoder".into()))?;
|
|
||||||
let rate_control = self.plan.rate_control.clone();
|
let rate_control = self.plan.rate_control.clone();
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"-hide_banner".to_string(),
|
"-hide_banner".to_string(),
|
||||||
@@ -219,48 +215,62 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
|||||||
args.push("0:s?".to_string());
|
args.push("0:s?".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
match encoder {
|
if self.plan.copy_video {
|
||||||
Encoder::Av1Qsv | Encoder::HevcQsv | Encoder::H264Qsv => {
|
args.extend(["-c:v".to_string(), "copy".to_string()]);
|
||||||
qsv::append_args(
|
} else {
|
||||||
&mut args,
|
let encoder = self
|
||||||
encoder,
|
.plan
|
||||||
self.hw_info,
|
.encoder
|
||||||
rate_control,
|
.ok_or_else(|| AlchemistError::Config("Transcode plan missing encoder".into()))?;
|
||||||
default_quality(&self.plan.rate_control, 23),
|
match encoder {
|
||||||
);
|
Encoder::Av1Qsv | Encoder::HevcQsv | Encoder::H264Qsv => {
|
||||||
}
|
qsv::append_args(
|
||||||
Encoder::Av1Nvenc | Encoder::HevcNvenc | Encoder::H264Nvenc => {
|
&mut args,
|
||||||
nvenc::append_args(
|
encoder,
|
||||||
&mut args,
|
self.hw_info,
|
||||||
encoder,
|
rate_control.clone(),
|
||||||
rate_control,
|
default_quality(&self.plan.rate_control, 23),
|
||||||
self.plan.encoder_preset.as_deref(),
|
);
|
||||||
);
|
}
|
||||||
}
|
Encoder::Av1Nvenc | Encoder::HevcNvenc | Encoder::H264Nvenc => {
|
||||||
Encoder::Av1Vaapi | Encoder::HevcVaapi | Encoder::H264Vaapi => {
|
nvenc::append_args(
|
||||||
vaapi::append_args(&mut args, encoder, self.hw_info);
|
&mut args,
|
||||||
}
|
encoder,
|
||||||
Encoder::Av1Amf | Encoder::HevcAmf | Encoder::H264Amf => {
|
rate_control.clone(),
|
||||||
amf::append_args(&mut args, encoder);
|
self.plan.encoder_preset.as_deref(),
|
||||||
}
|
);
|
||||||
Encoder::Av1Videotoolbox | Encoder::HevcVideotoolbox | Encoder::H264Videotoolbox => {
|
}
|
||||||
videotoolbox::append_args(
|
Encoder::Av1Vaapi | Encoder::HevcVaapi | Encoder::H264Vaapi => {
|
||||||
&mut args,
|
vaapi::append_args(&mut args, encoder, self.hw_info);
|
||||||
encoder,
|
}
|
||||||
rate_control,
|
Encoder::Av1Amf | Encoder::HevcAmf | Encoder::H264Amf => {
|
||||||
default_quality(&self.plan.rate_control, 65),
|
amf::append_args(&mut args, encoder);
|
||||||
);
|
}
|
||||||
}
|
Encoder::Av1Videotoolbox
|
||||||
Encoder::Av1Svt | Encoder::Av1Aom | Encoder::HevcX265 | Encoder::H264X264 => {
|
| Encoder::HevcVideotoolbox
|
||||||
cpu::append_args(
|
| Encoder::H264Videotoolbox => {
|
||||||
&mut args,
|
videotoolbox::append_args(
|
||||||
encoder,
|
&mut args,
|
||||||
rate_control,
|
encoder,
|
||||||
self.plan.encoder_preset.as_deref(),
|
rate_control.clone(),
|
||||||
);
|
default_quality(&self.plan.rate_control, 65),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Encoder::Av1Svt | Encoder::Av1Aom | Encoder::HevcX265 | Encoder::H264X264 => {
|
||||||
|
cpu::append_args(
|
||||||
|
&mut args,
|
||||||
|
encoder,
|
||||||
|
rate_control.clone(),
|
||||||
|
self.plan.encoder_preset.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(RateControl::Bitrate { kbps }) = rate_control {
|
||||||
|
args.extend(["-b:v".to_string(), format!("{kbps}k")]);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(filtergraph) = render_filtergraph(self.input, &self.plan.filters) {
|
if let Some(filtergraph) = render_filtergraph(self.input, &self.plan.filters) {
|
||||||
args.push("-vf".to_string());
|
args.push("-vf".to_string());
|
||||||
args.push(filtergraph);
|
args.push(filtergraph);
|
||||||
@@ -321,6 +331,7 @@ fn default_quality(rate_control: &Option<RateControl>, fallback: u8) -> u8 {
|
|||||||
Some(RateControl::Cq { value }) => *value,
|
Some(RateControl::Cq { value }) => *value,
|
||||||
Some(RateControl::QsvQuality { value }) => *value,
|
Some(RateControl::QsvQuality { value }) => *value,
|
||||||
Some(RateControl::Crf { value }) => *value,
|
Some(RateControl::Crf { value }) => *value,
|
||||||
|
Some(RateControl::Bitrate { .. }) => fallback,
|
||||||
None => fallback,
|
None => fallback,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +386,9 @@ fn apply_color_metadata(
|
|||||||
let tonemapped = filters
|
let tonemapped = filters
|
||||||
.iter()
|
.iter()
|
||||||
.any(|step| matches!(step, FilterStep::Tonemap { .. }));
|
.any(|step| matches!(step, FilterStep::Tonemap { .. }));
|
||||||
|
let strip_hdr_metadata = filters
|
||||||
|
.iter()
|
||||||
|
.any(|step| matches!(step, FilterStep::StripHdrMetadata));
|
||||||
|
|
||||||
if tonemapped {
|
if tonemapped {
|
||||||
args.extend([
|
args.extend([
|
||||||
@@ -390,6 +404,20 @@ fn apply_color_metadata(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strip_hdr_metadata {
|
||||||
|
args.extend([
|
||||||
|
"-color_primaries".to_string(),
|
||||||
|
"bt709".to_string(),
|
||||||
|
"-color_trc".to_string(),
|
||||||
|
"bt709".to_string(),
|
||||||
|
"-colorspace".to_string(),
|
||||||
|
"bt709".to_string(),
|
||||||
|
"-color_range".to_string(),
|
||||||
|
"tv".to_string(),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref primaries) = metadata.color_primaries {
|
if let Some(ref primaries) = metadata.color_primaries {
|
||||||
args.extend(["-color_primaries".to_string(), primaries.clone()]);
|
args.extend(["-color_primaries".to_string(), primaries.clone()]);
|
||||||
}
|
}
|
||||||
@@ -426,6 +454,13 @@ fn render_filtergraph(input: &Path, filters: &[FilterStep]) -> Option<String> {
|
|||||||
escape_filter_path(input)
|
escape_filter_path(input)
|
||||||
),
|
),
|
||||||
FilterStep::HwUpload => "hwupload".to_string(),
|
FilterStep::HwUpload => "hwupload".to_string(),
|
||||||
|
FilterStep::Scale { width, height } => {
|
||||||
|
format!("scale=w={width}:h={height}:force_original_aspect_ratio=decrease")
|
||||||
|
}
|
||||||
|
FilterStep::StripHdrMetadata => {
|
||||||
|
"setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
@@ -811,6 +846,7 @@ mod tests {
|
|||||||
reason: "test".to_string(),
|
reason: "test".to_string(),
|
||||||
},
|
},
|
||||||
is_remux: false,
|
is_remux: false,
|
||||||
|
copy_video: false,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
container: "mkv".to_string(),
|
container: "mkv".to_string(),
|
||||||
requested_codec: encoder.output_codec(),
|
requested_codec: encoder.output_codec(),
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ pub enum RateControl {
|
|||||||
Crf { value: u8 },
|
Crf { value: u8 },
|
||||||
Cq { value: u8 },
|
Cq { value: u8 },
|
||||||
QsvQuality { value: u8 },
|
QsvQuality { value: u8 },
|
||||||
|
Bitrate { kbps: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -281,6 +282,7 @@ pub struct PlannedFallback {
|
|||||||
pub enum AudioCodec {
|
pub enum AudioCodec {
|
||||||
Aac,
|
Aac,
|
||||||
Opus,
|
Opus,
|
||||||
|
Mp3,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioCodec {
|
impl AudioCodec {
|
||||||
@@ -288,6 +290,7 @@ impl AudioCodec {
|
|||||||
match self {
|
match self {
|
||||||
Self::Aac => "aac",
|
Self::Aac => "aac",
|
||||||
Self::Opus => "libopus",
|
Self::Opus => "libopus",
|
||||||
|
Self::Mp3 => "libmp3lame",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,12 +348,18 @@ pub enum FilterStep {
|
|||||||
stream_index: usize,
|
stream_index: usize,
|
||||||
},
|
},
|
||||||
HwUpload,
|
HwUpload,
|
||||||
|
Scale {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
},
|
||||||
|
StripHdrMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TranscodePlan {
|
pub struct TranscodePlan {
|
||||||
pub decision: TranscodeDecision,
|
pub decision: TranscodeDecision,
|
||||||
pub is_remux: bool,
|
pub is_remux: bool,
|
||||||
|
pub copy_video: bool,
|
||||||
pub output_path: Option<PathBuf>,
|
pub output_path: Option<PathBuf>,
|
||||||
pub container: String,
|
pub container: String,
|
||||||
pub requested_codec: crate::config::OutputCodec,
|
pub requested_codec: crate::config::OutputCodec,
|
||||||
@@ -432,6 +441,7 @@ struct FinalizeJobContext<'a> {
|
|||||||
output_path: &'a Path,
|
output_path: &'a Path,
|
||||||
temp_output_path: &'a Path,
|
temp_output_path: &'a Path,
|
||||||
plan: &'a TranscodePlan,
|
plan: &'a TranscodePlan,
|
||||||
|
bypass_quality_gates: bool,
|
||||||
start_time: std::time::Instant,
|
start_time: std::time::Instant,
|
||||||
metadata: &'a MediaMetadata,
|
metadata: &'a MediaMetadata,
|
||||||
execution_result: &'a ExecutionResult,
|
execution_result: &'a ExecutionResult,
|
||||||
@@ -790,42 +800,88 @@ impl Pipeline {
|
|||||||
|
|
||||||
let config_snapshot = self.config.read().await.clone();
|
let config_snapshot = self.config.read().await.clone();
|
||||||
let hw_info = self.hardware_state.snapshot().await;
|
let hw_info = self.hardware_state.snapshot().await;
|
||||||
let planner = BasicPlanner::new(Arc::new(config_snapshot.clone()), hw_info.clone());
|
let conversion_job = self
|
||||||
let profile = match self.db.get_profile_for_path(&job.input_path).await {
|
.db
|
||||||
Ok(profile) => profile,
|
.get_conversion_job_by_linked_job_id(job.id)
|
||||||
Err(err) => {
|
|
||||||
let msg = format!("Failed to resolve library profile: {err}");
|
|
||||||
tracing::error!("Job {}: {}", job.id, msg);
|
|
||||||
let _ = self.db.add_log("error", Some(job.id), &msg).await;
|
|
||||||
let explanation = crate::explanations::failure_from_summary(&msg);
|
|
||||||
let _ = self
|
|
||||||
.db
|
|
||||||
.upsert_job_failure_explanation(job.id, &explanation)
|
|
||||||
.await;
|
|
||||||
let _ = self
|
|
||||||
.update_job_state(job.id, crate::db::JobState::Failed)
|
|
||||||
.await;
|
|
||||||
return Err(JobFailure::Transient);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut plan = match planner
|
|
||||||
.plan(&analysis, &output_path, profile.as_ref())
|
|
||||||
.await
|
.await
|
||||||
{
|
.ok()
|
||||||
Ok(plan) => plan,
|
.flatten();
|
||||||
Err(e) => {
|
let bypass_quality_gates = conversion_job.is_some();
|
||||||
let msg = format!("Planner failed: {e}");
|
let mut plan = if let Some(conversion_job) = conversion_job.as_ref() {
|
||||||
tracing::error!("Job {}: {}", job.id, msg);
|
let settings: crate::conversion::ConversionSettings =
|
||||||
let _ = self.db.add_log("error", Some(job.id), &msg).await;
|
match serde_json::from_str(&conversion_job.settings_json) {
|
||||||
let explanation = crate::explanations::failure_from_summary(&msg);
|
Ok(settings) => settings,
|
||||||
let _ = self
|
Err(err) => {
|
||||||
.db
|
let msg = format!("Invalid conversion job settings: {err}");
|
||||||
.upsert_job_failure_explanation(job.id, &explanation)
|
tracing::error!("Job {}: {}", job.id, msg);
|
||||||
.await;
|
let _ = self.db.add_log("error", Some(job.id), &msg).await;
|
||||||
let _ = self
|
let explanation = crate::explanations::failure_from_summary(&msg);
|
||||||
.update_job_state(job.id, crate::db::JobState::Failed)
|
let _ = self
|
||||||
.await;
|
.db
|
||||||
return Err(JobFailure::PlannerBug);
|
.upsert_job_failure_explanation(job.id, &explanation)
|
||||||
|
.await;
|
||||||
|
let _ = self
|
||||||
|
.update_job_state(job.id, crate::db::JobState::Failed)
|
||||||
|
.await;
|
||||||
|
return Err(JobFailure::PlannerBug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crate::conversion::build_plan(&analysis, &output_path, &settings, hw_info.clone())
|
||||||
|
{
|
||||||
|
Ok(plan) => plan,
|
||||||
|
Err(err) => {
|
||||||
|
let msg = format!("Conversion planning failed: {err}");
|
||||||
|
tracing::error!("Job {}: {}", job.id, msg);
|
||||||
|
let _ = self.db.add_log("error", Some(job.id), &msg).await;
|
||||||
|
let explanation = crate::explanations::failure_from_summary(&msg);
|
||||||
|
let _ = self
|
||||||
|
.db
|
||||||
|
.upsert_job_failure_explanation(job.id, &explanation)
|
||||||
|
.await;
|
||||||
|
let _ = self
|
||||||
|
.update_job_state(job.id, crate::db::JobState::Failed)
|
||||||
|
.await;
|
||||||
|
return Err(JobFailure::PlannerBug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let planner = BasicPlanner::new(Arc::new(config_snapshot.clone()), hw_info.clone());
|
||||||
|
let profile = match self.db.get_profile_for_path(&job.input_path).await {
|
||||||
|
Ok(profile) => profile,
|
||||||
|
Err(err) => {
|
||||||
|
let msg = format!("Failed to resolve library profile: {err}");
|
||||||
|
tracing::error!("Job {}: {}", job.id, msg);
|
||||||
|
let _ = self.db.add_log("error", Some(job.id), &msg).await;
|
||||||
|
let explanation = crate::explanations::failure_from_summary(&msg);
|
||||||
|
let _ = self
|
||||||
|
.db
|
||||||
|
.upsert_job_failure_explanation(job.id, &explanation)
|
||||||
|
.await;
|
||||||
|
let _ = self
|
||||||
|
.update_job_state(job.id, crate::db::JobState::Failed)
|
||||||
|
.await;
|
||||||
|
return Err(JobFailure::Transient);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match planner
|
||||||
|
.plan(&analysis, &output_path, profile.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(plan) => plan,
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("Planner failed: {e}");
|
||||||
|
tracing::error!("Job {}: {}", job.id, msg);
|
||||||
|
let _ = self.db.add_log("error", Some(job.id), &msg).await;
|
||||||
|
let explanation = crate::explanations::failure_from_summary(&msg);
|
||||||
|
let _ = self
|
||||||
|
.db
|
||||||
|
.upsert_job_failure_explanation(job.id, &explanation)
|
||||||
|
.await;
|
||||||
|
let _ = self
|
||||||
|
.update_job_state(job.id, crate::db::JobState::Failed)
|
||||||
|
.await;
|
||||||
|
return Err(JobFailure::PlannerBug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -965,6 +1021,7 @@ impl Pipeline {
|
|||||||
output_path: &output_path,
|
output_path: &output_path,
|
||||||
temp_output_path: &temp_output_path,
|
temp_output_path: &temp_output_path,
|
||||||
plan: &plan,
|
plan: &plan,
|
||||||
|
bypass_quality_gates,
|
||||||
start_time,
|
start_time,
|
||||||
metadata,
|
metadata,
|
||||||
execution_result: &result,
|
execution_result: &result,
|
||||||
@@ -1124,8 +1181,10 @@ impl Pipeline {
|
|||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
let telemetry_enabled = config.system.enable_telemetry;
|
let telemetry_enabled = config.system.enable_telemetry;
|
||||||
|
|
||||||
if output_size == 0
|
if !context.bypass_quality_gates
|
||||||
|| (!context.plan.is_remux && reduction < config.transcode.size_reduction_threshold)
|
&& (output_size == 0
|
||||||
|
|| (!context.plan.is_remux
|
||||||
|
&& reduction < config.transcode.size_reduction_threshold))
|
||||||
{
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Job {}: Size reduction gate failed ({:.2}%). Reverting.",
|
"Job {}: Size reduction gate failed ({:.2}%). Reverting.",
|
||||||
@@ -1152,7 +1211,7 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut vmaf_score = None;
|
let mut vmaf_score = None;
|
||||||
if !context.plan.is_remux && config.quality.enable_vmaf {
|
if !context.bypass_quality_gates && !context.plan.is_remux && config.quality.enable_vmaf {
|
||||||
tracing::info!("[Job {}] Phase 2: Computing VMAF quality score...", job_id);
|
tracing::info!("[Job {}] Phase 2: Computing VMAF quality score...", job_id);
|
||||||
let input_clone = input_path.to_path_buf();
|
let input_clone = input_path.to_path_buf();
|
||||||
let output_clone = context.temp_output_path.to_path_buf();
|
let output_clone = context.temp_output_path.to_path_buf();
|
||||||
@@ -1552,6 +1611,7 @@ mod tests {
|
|||||||
reason: "test".to_string(),
|
reason: "test".to_string(),
|
||||||
},
|
},
|
||||||
is_remux: false,
|
is_remux: false,
|
||||||
|
copy_video: false,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
container: "mkv".to_string(),
|
container: "mkv".to_string(),
|
||||||
requested_codec: crate::config::OutputCodec::H264,
|
requested_codec: crate::config::OutputCodec::H264,
|
||||||
@@ -1647,6 +1707,7 @@ mod tests {
|
|||||||
reason: "test".to_string(),
|
reason: "test".to_string(),
|
||||||
},
|
},
|
||||||
is_remux: false,
|
is_remux: false,
|
||||||
|
copy_video: false,
|
||||||
output_path: Some(temp_output.clone()),
|
output_path: Some(temp_output.clone()),
|
||||||
container: "mkv".to_string(),
|
container: "mkv".to_string(),
|
||||||
requested_codec: crate::config::OutputCodec::H264,
|
requested_codec: crate::config::OutputCodec::H264,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ impl Planner for BasicPlanner {
|
|||||||
reason: reason.clone(),
|
reason: reason.clone(),
|
||||||
},
|
},
|
||||||
is_remux: true,
|
is_remux: true,
|
||||||
|
copy_video: true,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
container,
|
container,
|
||||||
requested_codec,
|
requested_codec,
|
||||||
@@ -188,6 +189,7 @@ impl Planner for BasicPlanner {
|
|||||||
Ok(TranscodePlan {
|
Ok(TranscodePlan {
|
||||||
decision,
|
decision,
|
||||||
is_remux: false,
|
is_remux: false,
|
||||||
|
copy_video: false,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
container,
|
container,
|
||||||
requested_codec,
|
requested_codec,
|
||||||
@@ -217,6 +219,7 @@ fn skip_plan(
|
|||||||
TranscodePlan {
|
TranscodePlan {
|
||||||
decision: TranscodeDecision::Skip { reason },
|
decision: TranscodeDecision::Skip { reason },
|
||||||
is_remux: false,
|
is_remux: false,
|
||||||
|
copy_video: false,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
container,
|
container,
|
||||||
requested_codec,
|
requested_codec,
|
||||||
@@ -845,6 +848,15 @@ fn audio_bitrate_kbps(codec: AudioCodec, channels: Option<u32>) -> u16 {
|
|||||||
320
|
320
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AudioCodec::Mp3 => {
|
||||||
|
if channels <= 2 {
|
||||||
|
192
|
||||||
|
} else if channels <= 6 {
|
||||||
|
320
|
||||||
|
} else {
|
||||||
|
384
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,6 +1095,7 @@ fn apply_crf_override(rate_control: RateControl, crf_override: Option<i32>) -> R
|
|||||||
RateControl::Crf { .. } => RateControl::Crf { value },
|
RateControl::Crf { .. } => RateControl::Crf { value },
|
||||||
RateControl::Cq { .. } => RateControl::Cq { value },
|
RateControl::Cq { .. } => RateControl::Cq { value },
|
||||||
RateControl::QsvQuality { .. } => RateControl::QsvQuality { value },
|
RateControl::QsvQuality { .. } => RateControl::QsvQuality { value },
|
||||||
|
RateControl::Bitrate { kbps } => RateControl::Bitrate { kbps },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub struct Agent {
|
|||||||
pub(crate) engine_mode: Arc<tokio::sync::RwLock<crate::config::EngineMode>>,
|
pub(crate) engine_mode: Arc<tokio::sync::RwLock<crate::config::EngineMode>>,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
in_flight_jobs: Arc<AtomicUsize>,
|
in_flight_jobs: Arc<AtomicUsize>,
|
||||||
|
idle_notified: Arc<AtomicBool>,
|
||||||
analyzing_boot: Arc<AtomicBool>,
|
analyzing_boot: Arc<AtomicBool>,
|
||||||
analysis_semaphore: Arc<tokio::sync::Semaphore>,
|
analysis_semaphore: Arc<tokio::sync::Semaphore>,
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ impl Agent {
|
|||||||
engine_mode: Arc::new(tokio::sync::RwLock::new(engine_mode)),
|
engine_mode: Arc::new(tokio::sync::RwLock::new(engine_mode)),
|
||||||
dry_run,
|
dry_run,
|
||||||
in_flight_jobs: Arc::new(AtomicUsize::new(0)),
|
in_flight_jobs: Arc::new(AtomicUsize::new(0)),
|
||||||
|
idle_notified: Arc::new(AtomicBool::new(false)),
|
||||||
analyzing_boot: Arc::new(AtomicBool::new(false)),
|
analyzing_boot: Arc::new(AtomicBool::new(false)),
|
||||||
analysis_semaphore: Arc::new(tokio::sync::Semaphore::new(1)),
|
analysis_semaphore: Arc::new(tokio::sync::Semaphore::new(1)),
|
||||||
}
|
}
|
||||||
@@ -105,6 +107,7 @@ impl Agent {
|
|||||||
|
|
||||||
// Notify scan completed
|
// Notify scan completed
|
||||||
let _ = self.event_channels.system.send(SystemEvent::ScanCompleted);
|
let _ = self.event_channels.system.send(SystemEvent::ScanCompleted);
|
||||||
|
let _ = self.tx.send(AlchemistEvent::ScanCompleted);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -144,6 +147,7 @@ impl Agent {
|
|||||||
|
|
||||||
pub fn resume(&self) {
|
pub fn resume(&self) {
|
||||||
self.paused.store(false, Ordering::SeqCst);
|
self.paused.store(false, Ordering::SeqCst);
|
||||||
|
self.idle_notified.store(false, Ordering::SeqCst);
|
||||||
info!("Engine resumed.");
|
info!("Engine resumed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +155,7 @@ impl Agent {
|
|||||||
// Stop accepting new jobs but finish active ones.
|
// Stop accepting new jobs but finish active ones.
|
||||||
// Sets draining=true. Does NOT set paused=true.
|
// Sets draining=true. Does NOT set paused=true.
|
||||||
self.draining.store(true, Ordering::SeqCst);
|
self.draining.store(true, Ordering::SeqCst);
|
||||||
|
self.idle_notified.store(false, Ordering::SeqCst);
|
||||||
info!("Engine draining — finishing active jobs, no new jobs will start.");
|
info!("Engine draining — finishing active jobs, no new jobs will start.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +402,7 @@ impl Agent {
|
|||||||
|
|
||||||
match self.db.claim_next_job().await {
|
match self.db.claim_next_job().await {
|
||||||
Ok(Some(job)) => {
|
Ok(Some(job)) => {
|
||||||
|
self.idle_notified.store(false, Ordering::SeqCst);
|
||||||
self.in_flight_jobs.fetch_add(1, Ordering::SeqCst);
|
self.in_flight_jobs.fetch_add(1, Ordering::SeqCst);
|
||||||
let agent = self.clone();
|
let agent = self.clone();
|
||||||
let counter = self.in_flight_jobs.clone();
|
let counter = self.in_flight_jobs.clone();
|
||||||
@@ -417,6 +423,11 @@ impl Agent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
if self.in_flight_jobs.load(Ordering::SeqCst) == 0
|
||||||
|
&& !self.idle_notified.swap(true, Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
let _ = self.tx.send(crate::db::AlchemistEvent::EngineIdle);
|
||||||
|
}
|
||||||
drop(permit);
|
drop(permit);
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,122 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::{AlchemistEvent, Db, NotificationTarget};
|
use crate::db::{AlchemistEvent, Db, NotificationTarget};
|
||||||
use crate::explanations::Explanation;
|
use crate::explanations::Explanation;
|
||||||
|
use chrono::Timelike;
|
||||||
|
use lettre::message::{Mailbox, Message, SinglePart, header::ContentType};
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||||
use reqwest::{Client, Url, redirect::Policy};
|
use reqwest::{Client, Url, redirect::Policy};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::net::lookup_host;
|
use tokio::net::lookup_host;
|
||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{Mutex, RwLock, broadcast};
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
|
type NotificationResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct NotificationManager {
|
pub struct NotificationManager {
|
||||||
db: Db,
|
db: Db,
|
||||||
config: Arc<RwLock<Config>>,
|
config: Arc<RwLock<Config>>,
|
||||||
|
daily_summary_last_sent: Arc<Mutex<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DiscordWebhookConfig {
|
||||||
|
webhook_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DiscordBotConfig {
|
||||||
|
bot_token: String,
|
||||||
|
channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GotifyConfig {
|
||||||
|
server_url: String,
|
||||||
|
app_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WebhookConfig {
|
||||||
|
url: String,
|
||||||
|
auth_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramConfig {
|
||||||
|
bot_token: String,
|
||||||
|
chat_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct EmailConfig {
|
||||||
|
smtp_host: String,
|
||||||
|
smtp_port: u16,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
from_address: String,
|
||||||
|
to_addresses: Vec<String>,
|
||||||
|
security: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_target_config<T: for<'de> Deserialize<'de>>(
|
||||||
|
target: &NotificationTarget,
|
||||||
|
) -> NotificationResult<T> {
|
||||||
|
Ok(serde_json::from_str(&target.config_json)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint_url_for_target(target: &NotificationTarget) -> NotificationResult<Option<String>> {
|
||||||
|
match target.target_type.as_str() {
|
||||||
|
"discord_webhook" => Ok(Some(
|
||||||
|
parse_target_config::<DiscordWebhookConfig>(target)?.webhook_url,
|
||||||
|
)),
|
||||||
|
"gotify" => Ok(Some(
|
||||||
|
parse_target_config::<GotifyConfig>(target)?.server_url,
|
||||||
|
)),
|
||||||
|
"webhook" => Ok(Some(parse_target_config::<WebhookConfig>(target)?.url)),
|
||||||
|
"discord_bot" => Ok(Some("https://discord.com".to_string())),
|
||||||
|
"telegram" => Ok(Some("https://api.telegram.org".to_string())),
|
||||||
|
"email" => Ok(None),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_key_from_event(event: &AlchemistEvent) -> Option<&'static str> {
|
||||||
|
match event {
|
||||||
|
AlchemistEvent::JobStateChanged { status, .. } => match status {
|
||||||
|
crate::db::JobState::Queued => Some(crate::config::NOTIFICATION_EVENT_ENCODE_QUEUED),
|
||||||
|
crate::db::JobState::Encoding | crate::db::JobState::Remuxing => {
|
||||||
|
Some(crate::config::NOTIFICATION_EVENT_ENCODE_STARTED)
|
||||||
|
}
|
||||||
|
crate::db::JobState::Completed => {
|
||||||
|
Some(crate::config::NOTIFICATION_EVENT_ENCODE_COMPLETED)
|
||||||
|
}
|
||||||
|
crate::db::JobState::Failed => Some(crate::config::NOTIFICATION_EVENT_ENCODE_FAILED),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
AlchemistEvent::ScanCompleted => Some(crate::config::NOTIFICATION_EVENT_SCAN_COMPLETED),
|
||||||
|
AlchemistEvent::EngineIdle => Some(crate::config::NOTIFICATION_EVENT_ENGINE_IDLE),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotificationManager {
|
impl NotificationManager {
|
||||||
pub fn new(db: Db, config: Arc<RwLock<Config>>) -> Self {
|
pub fn new(db: Db, config: Arc<RwLock<Config>>) -> Self {
|
||||||
Self { db, config }
|
Self {
|
||||||
|
db,
|
||||||
|
config,
|
||||||
|
daily_summary_last_sent: Arc::new(Mutex::new(None)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_listener(&self, mut rx: broadcast::Receiver<AlchemistEvent>) {
|
pub fn start_listener(&self, mut rx: broadcast::Receiver<AlchemistEvent>) {
|
||||||
let manager_clone = self.clone();
|
let manager_clone = self.clone();
|
||||||
|
let summary_manager = self.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -39,20 +133,26 @@ impl NotificationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
|
if let Err(err) = summary_manager.maybe_send_daily_summary().await {
|
||||||
|
error!("Daily summary notification error: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_test(
|
pub async fn send_test(&self, target: &NotificationTarget) -> NotificationResult<()> {
|
||||||
&self,
|
|
||||||
target: &NotificationTarget,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let event = AlchemistEvent::JobStateChanged {
|
let event = AlchemistEvent::JobStateChanged {
|
||||||
job_id: 0,
|
job_id: 0,
|
||||||
status: crate::db::JobState::Completed,
|
status: crate::db::JobState::Completed,
|
||||||
};
|
};
|
||||||
self.send(target, &event, "completed").await
|
self.send(target, &event).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_event(&self, event: AlchemistEvent) -> Result<(), Box<dyn std::error::Error>> {
|
async fn handle_event(&self, event: AlchemistEvent) -> NotificationResult<()> {
|
||||||
let targets = match self.db.get_notification_targets().await {
|
let targets = match self.db.get_notification_targets().await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -65,10 +165,9 @@ impl NotificationManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter events
|
let event_key = match event_key_from_event(&event) {
|
||||||
let status = match &event {
|
Some(event_key) => event_key,
|
||||||
AlchemistEvent::JobStateChanged { status, .. } => status.to_string(),
|
None => return Ok(()),
|
||||||
_ => return Ok(()), // Only handle job state changes for now
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for target in targets {
|
for target in targets {
|
||||||
@@ -86,12 +185,15 @@ impl NotificationManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if allowed.contains(&status) {
|
let normalized_allowed = crate::config::normalize_notification_events(&allowed);
|
||||||
|
if normalized_allowed
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate == event_key)
|
||||||
|
{
|
||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
let event_clone = event.clone();
|
let event_clone = event.clone();
|
||||||
let status_clone = status.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = manager.send(&target, &event_clone, &status_clone).await {
|
if let Err(e) = manager.send(&target, &event_clone).await {
|
||||||
error!(
|
error!(
|
||||||
"Failed to send notification to target '{}': {}",
|
"Failed to send notification to target '{}': {}",
|
||||||
target.name, e
|
target.name, e
|
||||||
@@ -103,53 +205,108 @@ impl NotificationManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn maybe_send_daily_summary(&self) -> NotificationResult<()> {
|
||||||
|
let config = self.config.read().await.clone();
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let parts = config
|
||||||
|
.notifications
|
||||||
|
.daily_summary_time_local
|
||||||
|
.split(':')
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let hour = parts[0].parse::<u32>().unwrap_or(9);
|
||||||
|
let minute = parts[1].parse::<u32>().unwrap_or(0);
|
||||||
|
if now.hour() != hour || now.minute() != minute {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary_key = now.format("%Y-%m-%d").to_string();
|
||||||
|
{
|
||||||
|
let last_sent = self.daily_summary_last_sent.lock().await;
|
||||||
|
if last_sent.as_deref() == Some(summary_key.as_str()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = self.db.get_daily_summary_stats().await?;
|
||||||
|
let targets = self.db.get_notification_targets().await?;
|
||||||
|
for target in targets {
|
||||||
|
if !target.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let allowed: Vec<String> = serde_json::from_str(&target.events).unwrap_or_default();
|
||||||
|
let normalized_allowed = crate::config::normalize_notification_events(&allowed);
|
||||||
|
if !normalized_allowed
|
||||||
|
.iter()
|
||||||
|
.any(|event| event == crate::config::NOTIFICATION_EVENT_DAILY_SUMMARY)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(err) = self.send_daily_summary_target(&target, &summary).await {
|
||||||
|
error!(
|
||||||
|
"Failed to send daily summary to target '{}': {}",
|
||||||
|
target.name, err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.daily_summary_last_sent.lock().await = Some(summary_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn send(
|
async fn send(
|
||||||
&self,
|
&self,
|
||||||
target: &NotificationTarget,
|
target: &NotificationTarget,
|
||||||
event: &AlchemistEvent,
|
event: &AlchemistEvent,
|
||||||
status: &str,
|
) -> NotificationResult<()> {
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
let event_key = event_key_from_event(event).unwrap_or("unknown");
|
||||||
let url = Url::parse(&target.endpoint_url)?;
|
let client = if let Some(endpoint_url) = endpoint_url_for_target(target)? {
|
||||||
let host = url
|
let url = Url::parse(&endpoint_url)?;
|
||||||
.host_str()
|
let host = url
|
||||||
.ok_or("notification endpoint host is missing")?;
|
.host_str()
|
||||||
let port = url.port_or_known_default().ok_or("invalid port")?;
|
.ok_or("notification endpoint host is missing")?;
|
||||||
|
let port = url.port_or_known_default().ok_or("invalid port")?;
|
||||||
|
|
||||||
let allow_local = self
|
let allow_local = self
|
||||||
.config
|
.config
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.notifications
|
.notifications
|
||||||
.allow_local_notifications;
|
.allow_local_notifications;
|
||||||
|
|
||||||
if !allow_local && host.eq_ignore_ascii_case("localhost") {
|
if !allow_local && host.eq_ignore_ascii_case("localhost") {
|
||||||
return Err("localhost is not allowed as a notification endpoint".into());
|
return Err("localhost is not allowed as a notification endpoint".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let addr = format!("{}:{}", host, port);
|
let addr = format!("{}:{}", host, port);
|
||||||
let ips = tokio::time::timeout(Duration::from_secs(3), lookup_host(&addr)).await??;
|
let ips = tokio::time::timeout(Duration::from_secs(3), lookup_host(&addr)).await??;
|
||||||
|
|
||||||
let target_ip = if allow_local {
|
let target_ip = if allow_local {
|
||||||
// When local notifications are allowed, accept any resolved IP
|
ips.into_iter()
|
||||||
ips.into_iter()
|
.map(|a| a.ip())
|
||||||
.map(|a| a.ip())
|
.next()
|
||||||
.next()
|
.ok_or("no IP address found for notification endpoint")?
|
||||||
.ok_or("no IP address found for notification endpoint")?
|
} else {
|
||||||
|
ips.into_iter()
|
||||||
|
.map(|a| a.ip())
|
||||||
|
.find(|ip| !is_private_ip(*ip))
|
||||||
|
.ok_or("no public IP address found for notification endpoint")?
|
||||||
|
};
|
||||||
|
|
||||||
|
Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.redirect(Policy::none())
|
||||||
|
.resolve(host, std::net::SocketAddr::new(target_ip, port))
|
||||||
|
.build()?
|
||||||
} else {
|
} else {
|
||||||
// When local notifications are blocked, only use public IPs
|
Client::builder()
|
||||||
ips.into_iter()
|
.timeout(Duration::from_secs(10))
|
||||||
.map(|a| a.ip())
|
.redirect(Policy::none())
|
||||||
.find(|ip| !is_private_ip(*ip))
|
.build()?
|
||||||
.ok_or("no public IP address found for notification endpoint")?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pin the request to the validated IP to prevent DNS rebinding
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.redirect(Policy::none())
|
|
||||||
.resolve(host, std::net::SocketAddr::new(target_ip, port))
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let (decision_explanation, failure_explanation) = match event {
|
let (decision_explanation, failure_explanation) = match event {
|
||||||
AlchemistEvent::JobStateChanged { job_id, status } => {
|
AlchemistEvent::JobStateChanged { job_id, status } => {
|
||||||
let decision_explanation = self
|
let decision_explanation = self
|
||||||
@@ -173,12 +330,23 @@ impl NotificationManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match target.target_type.as_str() {
|
match target.target_type.as_str() {
|
||||||
"discord" => {
|
"discord_webhook" => {
|
||||||
self.send_discord_with_client(
|
self.send_discord_with_client(
|
||||||
&client,
|
&client,
|
||||||
target,
|
target,
|
||||||
event,
|
event,
|
||||||
status,
|
event_key,
|
||||||
|
decision_explanation.as_ref(),
|
||||||
|
failure_explanation.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"discord_bot" => {
|
||||||
|
self.send_discord_bot_with_client(
|
||||||
|
&client,
|
||||||
|
target,
|
||||||
|
event,
|
||||||
|
event_key,
|
||||||
decision_explanation.as_ref(),
|
decision_explanation.as_ref(),
|
||||||
failure_explanation.as_ref(),
|
failure_explanation.as_ref(),
|
||||||
)
|
)
|
||||||
@@ -189,7 +357,7 @@ impl NotificationManager {
|
|||||||
&client,
|
&client,
|
||||||
target,
|
target,
|
||||||
event,
|
event,
|
||||||
status,
|
event_key,
|
||||||
decision_explanation.as_ref(),
|
decision_explanation.as_ref(),
|
||||||
failure_explanation.as_ref(),
|
failure_explanation.as_ref(),
|
||||||
)
|
)
|
||||||
@@ -200,7 +368,28 @@ impl NotificationManager {
|
|||||||
&client,
|
&client,
|
||||||
target,
|
target,
|
||||||
event,
|
event,
|
||||||
status,
|
event_key,
|
||||||
|
decision_explanation.as_ref(),
|
||||||
|
failure_explanation.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"telegram" => {
|
||||||
|
self.send_telegram_with_client(
|
||||||
|
&client,
|
||||||
|
target,
|
||||||
|
event,
|
||||||
|
event_key,
|
||||||
|
decision_explanation.as_ref(),
|
||||||
|
failure_explanation.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"email" => {
|
||||||
|
self.send_email(
|
||||||
|
target,
|
||||||
|
event,
|
||||||
|
event_key,
|
||||||
decision_explanation.as_ref(),
|
decision_explanation.as_ref(),
|
||||||
failure_explanation.as_ref(),
|
failure_explanation.as_ref(),
|
||||||
)
|
)
|
||||||
@@ -232,33 +421,74 @@ impl NotificationManager {
|
|||||||
format!("Job #{} is now {}", job_id, status)
|
format!("Job #{} is now {}", job_id, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_discord_with_client(
|
fn message_for_event(
|
||||||
&self,
|
&self,
|
||||||
client: &Client,
|
|
||||||
target: &NotificationTarget,
|
|
||||||
event: &AlchemistEvent,
|
event: &AlchemistEvent,
|
||||||
status: &str,
|
|
||||||
decision_explanation: Option<&Explanation>,
|
decision_explanation: Option<&Explanation>,
|
||||||
failure_explanation: Option<&Explanation>,
|
failure_explanation: Option<&Explanation>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> String {
|
||||||
let color = match status {
|
match event {
|
||||||
"completed" => 0x00FF00, // Green
|
|
||||||
"failed" => 0xFF0000, // Red
|
|
||||||
"queued" => 0xF1C40F, // Yellow
|
|
||||||
"encoding" | "remuxing" => 0x3498DB, // Blue
|
|
||||||
_ => 0x95A5A6, // Gray
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = match event {
|
|
||||||
AlchemistEvent::JobStateChanged { job_id, status } => self.notification_message(
|
AlchemistEvent::JobStateChanged { job_id, status } => self.notification_message(
|
||||||
*job_id,
|
*job_id,
|
||||||
&status.to_string(),
|
&status.to_string(),
|
||||||
decision_explanation,
|
decision_explanation,
|
||||||
failure_explanation,
|
failure_explanation,
|
||||||
),
|
),
|
||||||
|
AlchemistEvent::ScanCompleted => {
|
||||||
|
"Library scan completed. Review the queue for newly discovered work.".to_string()
|
||||||
|
}
|
||||||
|
AlchemistEvent::EngineIdle => {
|
||||||
|
"The engine is idle. There are no active jobs and no queued work ready to run."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
_ => "Event occurred".to_string(),
|
_ => "Event occurred".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn daily_summary_message(&self, summary: &crate::db::DailySummaryStats) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"Daily summary".to_string(),
|
||||||
|
format!("Completed: {}", summary.completed),
|
||||||
|
format!("Failed: {}", summary.failed),
|
||||||
|
format!("Skipped: {}", summary.skipped),
|
||||||
|
format!("Bytes saved: {}", summary.bytes_saved),
|
||||||
|
];
|
||||||
|
if !summary.top_failure_reasons.is_empty() {
|
||||||
|
lines.push(format!(
|
||||||
|
"Top failure reasons: {}",
|
||||||
|
summary.top_failure_reasons.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !summary.top_skip_reasons.is_empty() {
|
||||||
|
lines.push(format!(
|
||||||
|
"Top skip reasons: {}",
|
||||||
|
summary.top_skip_reasons.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_discord_with_client(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
target: &NotificationTarget,
|
||||||
|
event: &AlchemistEvent,
|
||||||
|
event_key: &str,
|
||||||
|
decision_explanation: Option<&Explanation>,
|
||||||
|
failure_explanation: Option<&Explanation>,
|
||||||
|
) -> NotificationResult<()> {
|
||||||
|
let config = parse_target_config::<DiscordWebhookConfig>(target)?;
|
||||||
|
let color = match event_key {
|
||||||
|
"encode.completed" => 0x00FF00,
|
||||||
|
"encode.failed" => 0xFF0000,
|
||||||
|
"encode.queued" => 0xF1C40F,
|
||||||
|
"encode.started" => 0x3498DB,
|
||||||
|
"daily.summary" => 0x9B59B6,
|
||||||
|
_ => 0x95A5A6,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let message = self.message_for_event(event, decision_explanation, failure_explanation);
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"embeds": [{
|
"embeds": [{
|
||||||
"title": "Alchemist Notification",
|
"title": "Alchemist Notification",
|
||||||
@@ -269,7 +499,7 @@ impl NotificationManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client
|
client
|
||||||
.post(&target.endpoint_url)
|
.post(&config.webhook_url)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
@@ -277,42 +507,63 @@ impl NotificationManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_discord_bot_with_client(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
target: &NotificationTarget,
|
||||||
|
event: &AlchemistEvent,
|
||||||
|
_event_key: &str,
|
||||||
|
decision_explanation: Option<&Explanation>,
|
||||||
|
failure_explanation: Option<&Explanation>,
|
||||||
|
) -> NotificationResult<()> {
|
||||||
|
let config = parse_target_config::<DiscordBotConfig>(target)?;
|
||||||
|
let message = self.message_for_event(event, decision_explanation, failure_explanation);
|
||||||
|
|
||||||
|
client
|
||||||
|
.post(format!(
|
||||||
|
"https://discord.com/api/v10/channels/{}/messages",
|
||||||
|
config.channel_id
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bot {}", config.bot_token))
|
||||||
|
.json(&json!({ "content": message }))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_gotify_with_client(
|
async fn send_gotify_with_client(
|
||||||
&self,
|
&self,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
target: &NotificationTarget,
|
target: &NotificationTarget,
|
||||||
event: &AlchemistEvent,
|
event: &AlchemistEvent,
|
||||||
status: &str,
|
event_key: &str,
|
||||||
decision_explanation: Option<&Explanation>,
|
decision_explanation: Option<&Explanation>,
|
||||||
failure_explanation: Option<&Explanation>,
|
failure_explanation: Option<&Explanation>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> NotificationResult<()> {
|
||||||
let message = match event {
|
let config = parse_target_config::<GotifyConfig>(target)?;
|
||||||
AlchemistEvent::JobStateChanged { job_id, status } => self.notification_message(
|
let message = self.message_for_event(event, decision_explanation, failure_explanation);
|
||||||
*job_id,
|
|
||||||
&status.to_string(),
|
|
||||||
decision_explanation,
|
|
||||||
failure_explanation,
|
|
||||||
),
|
|
||||||
_ => "Event occurred".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let priority = match status {
|
let priority = match event_key {
|
||||||
"failed" => 8,
|
"encode.failed" => 8,
|
||||||
"completed" => 5,
|
"encode.completed" => 5,
|
||||||
_ => 2,
|
_ => 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut req = client.post(&target.endpoint_url).json(&json!({
|
let req = client.post(&config.server_url).json(&json!({
|
||||||
"title": "Alchemist",
|
"title": "Alchemist",
|
||||||
"message": message,
|
"message": message,
|
||||||
"priority": priority
|
"priority": priority,
|
||||||
|
"extras": {
|
||||||
|
"client::display": {
|
||||||
|
"contentType": "text/plain"
|
||||||
|
}
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
req.header("X-Gotify-Key", config.app_token)
|
||||||
if let Some(token) = &target.auth_token {
|
.send()
|
||||||
req = req.header("X-Gotify-Key", token);
|
.await?
|
||||||
}
|
.error_for_status()?;
|
||||||
|
|
||||||
req.send().await?.error_for_status()?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,23 +572,15 @@ impl NotificationManager {
|
|||||||
client: &Client,
|
client: &Client,
|
||||||
target: &NotificationTarget,
|
target: &NotificationTarget,
|
||||||
event: &AlchemistEvent,
|
event: &AlchemistEvent,
|
||||||
status: &str,
|
event_key: &str,
|
||||||
decision_explanation: Option<&Explanation>,
|
decision_explanation: Option<&Explanation>,
|
||||||
failure_explanation: Option<&Explanation>,
|
failure_explanation: Option<&Explanation>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> NotificationResult<()> {
|
||||||
let message = match event {
|
let config = parse_target_config::<WebhookConfig>(target)?;
|
||||||
AlchemistEvent::JobStateChanged { job_id, status } => self.notification_message(
|
let message = self.message_for_event(event, decision_explanation, failure_explanation);
|
||||||
*job_id,
|
|
||||||
&status.to_string(),
|
|
||||||
decision_explanation,
|
|
||||||
failure_explanation,
|
|
||||||
),
|
|
||||||
_ => "Event occurred".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"event": "job_update",
|
"event": event_key,
|
||||||
"status": status,
|
|
||||||
"message": message,
|
"message": message,
|
||||||
"data": event,
|
"data": event,
|
||||||
"decision_explanation": decision_explanation,
|
"decision_explanation": decision_explanation,
|
||||||
@@ -345,14 +588,207 @@ impl NotificationManager {
|
|||||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut req = client.post(&target.endpoint_url).json(&body);
|
let mut req = client.post(&config.url).json(&body);
|
||||||
if let Some(token) = &target.auth_token {
|
if let Some(token) = &config.auth_token {
|
||||||
req = req.bearer_auth(token);
|
req = req.bearer_auth(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
req.send().await?.error_for_status()?;
|
req.send().await?.error_for_status()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_telegram_with_client(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
target: &NotificationTarget,
|
||||||
|
event: &AlchemistEvent,
|
||||||
|
_event_key: &str,
|
||||||
|
decision_explanation: Option<&Explanation>,
|
||||||
|
failure_explanation: Option<&Explanation>,
|
||||||
|
) -> NotificationResult<()> {
|
||||||
|
let config = parse_target_config::<TelegramConfig>(target)?;
|
||||||
|
let message = self.message_for_event(event, decision_explanation, failure_explanation);
|
||||||
|
|
||||||
|
client
|
||||||
|
.post(format!(
|
||||||
|
"https://api.telegram.org/bot{}/sendMessage",
|
||||||
|
config.bot_token
|
||||||
|
))
|
||||||
|
.json(&json!({
|
||||||
|
"chat_id": config.chat_id,
|
||||||
|
"text": message
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_email(
|
||||||
|
&self,
|
||||||
|
target: &NotificationTarget,
|
||||||
|
event: &AlchemistEvent,
|
||||||
|
_event_key: &str,
|
||||||
|
decision_explanation: Option<&Explanation>,
|
||||||
|
failure_explanation: Option<&Explanation>,
|
||||||
|
) -> NotificationResult<()> {
|
||||||
|
let config = parse_target_config::<EmailConfig>(target)?;
|
||||||
|
let message_text = self.message_for_event(event, decision_explanation, failure_explanation);
|
||||||
|
|
||||||
|
let from: Mailbox = config.from_address.parse()?;
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(from)
|
||||||
|
.subject("Alchemist Notification");
|
||||||
|
for address in &config.to_addresses {
|
||||||
|
builder = builder.to(address.parse::<Mailbox>()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = builder.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(message_text),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let security = config
|
||||||
|
.security
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("starttls")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
let mut transport = match security.as_str() {
|
||||||
|
"tls" | "smtps" => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host)?,
|
||||||
|
"none" => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp_host),
|
||||||
|
_ => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)?,
|
||||||
|
}
|
||||||
|
.port(config.smtp_port);
|
||||||
|
|
||||||
|
if let (Some(username), Some(password)) = (config.username.clone(), config.password.clone())
|
||||||
|
{
|
||||||
|
transport = transport.credentials(Credentials::new(username, password));
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.build().send(email).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_daily_summary_target(
|
||||||
|
&self,
|
||||||
|
target: &NotificationTarget,
|
||||||
|
summary: &crate::db::DailySummaryStats,
|
||||||
|
) -> NotificationResult<()> {
|
||||||
|
let message = self.daily_summary_message(summary);
|
||||||
|
match target.target_type.as_str() {
|
||||||
|
"discord_webhook" => {
|
||||||
|
let config = parse_target_config::<DiscordWebhookConfig>(target)?;
|
||||||
|
Client::new()
|
||||||
|
.post(config.webhook_url)
|
||||||
|
.json(&json!({
|
||||||
|
"embeds": [{
|
||||||
|
"title": "Alchemist Daily Summary",
|
||||||
|
"description": message,
|
||||||
|
"color": 0x9B59B6,
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
}
|
||||||
|
"discord_bot" => {
|
||||||
|
let config = parse_target_config::<DiscordBotConfig>(target)?;
|
||||||
|
Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"https://discord.com/api/v10/channels/{}/messages",
|
||||||
|
config.channel_id
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bot {}", config.bot_token))
|
||||||
|
.json(&json!({ "content": message }))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
}
|
||||||
|
"gotify" => {
|
||||||
|
let config = parse_target_config::<GotifyConfig>(target)?;
|
||||||
|
Client::new()
|
||||||
|
.post(config.server_url)
|
||||||
|
.header("X-Gotify-Key", config.app_token)
|
||||||
|
.json(&json!({
|
||||||
|
"title": "Alchemist Daily Summary",
|
||||||
|
"message": message,
|
||||||
|
"priority": 4
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
}
|
||||||
|
"webhook" => {
|
||||||
|
let config = parse_target_config::<WebhookConfig>(target)?;
|
||||||
|
let mut req = Client::new().post(config.url).json(&json!({
|
||||||
|
"event": crate::config::NOTIFICATION_EVENT_DAILY_SUMMARY,
|
||||||
|
"summary": summary,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||||
|
}));
|
||||||
|
if let Some(token) = config.auth_token {
|
||||||
|
req = req.bearer_auth(token);
|
||||||
|
}
|
||||||
|
req.send().await?.error_for_status()?;
|
||||||
|
}
|
||||||
|
"telegram" => {
|
||||||
|
let config = parse_target_config::<TelegramConfig>(target)?;
|
||||||
|
Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"https://api.telegram.org/bot{}/sendMessage",
|
||||||
|
config.bot_token
|
||||||
|
))
|
||||||
|
.json(&json!({
|
||||||
|
"chat_id": config.chat_id,
|
||||||
|
"text": message
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
}
|
||||||
|
"email" => {
|
||||||
|
let config = parse_target_config::<EmailConfig>(target)?;
|
||||||
|
let from: Mailbox = config.from_address.parse()?;
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(from)
|
||||||
|
.subject("Alchemist Daily Summary");
|
||||||
|
for address in &config.to_addresses {
|
||||||
|
builder = builder.to(address.parse::<Mailbox>()?);
|
||||||
|
}
|
||||||
|
let email = builder.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(message),
|
||||||
|
)?;
|
||||||
|
let security = config
|
||||||
|
.security
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("starttls")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
let mut transport = match security.as_str() {
|
||||||
|
"tls" | "smtps" => {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host)?
|
||||||
|
}
|
||||||
|
"none" => {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp_host)
|
||||||
|
}
|
||||||
|
_ => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)?,
|
||||||
|
}
|
||||||
|
.port(config.smtp_port);
|
||||||
|
if let (Some(username), Some(password)) =
|
||||||
|
(config.username.clone(), config.password.clone())
|
||||||
|
{
|
||||||
|
transport = transport.credentials(Credentials::new(username, password));
|
||||||
|
}
|
||||||
|
transport.build().send(email).await?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _unused_ensure_public_endpoint(raw: &str) -> Result<(), Box<dyn std::error::Error>> {
|
async fn _unused_ensure_public_endpoint(raw: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -421,7 +857,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_webhook_errors_on_non_success()
|
async fn test_webhook_errors_on_non_success()
|
||||||
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
-> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut db_path = std::env::temp_dir();
|
let mut db_path = std::env::temp_dir();
|
||||||
let token: u64 = rand::random();
|
let token: u64 = rand::random();
|
||||||
db_path.push(format!("alchemist_notifications_test_{}.db", token));
|
db_path.push(format!("alchemist_notifications_test_{}.db", token));
|
||||||
@@ -455,8 +891,7 @@ mod tests {
|
|||||||
id: 0,
|
id: 0,
|
||||||
name: "test".to_string(),
|
name: "test".to_string(),
|
||||||
target_type: "webhook".to_string(),
|
target_type: "webhook".to_string(),
|
||||||
endpoint_url: format!("http://{}", addr),
|
config_json: serde_json::json!({ "url": format!("http://{}", addr) }).to_string(),
|
||||||
auth_token: None,
|
|
||||||
events: "[]".to_string(),
|
events: "[]".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
@@ -466,7 +901,7 @@ mod tests {
|
|||||||
status: crate::db::JobState::Failed,
|
status: crate::db::JobState::Failed,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = manager.send(&target, &event, "failed").await;
|
let result = manager.send(&target, &event).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|
||||||
drop(manager);
|
drop(manager);
|
||||||
@@ -476,7 +911,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn webhook_payload_includes_structured_explanations()
|
async fn webhook_payload_includes_structured_explanations()
|
||||||
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
-> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut db_path = std::env::temp_dir();
|
let mut db_path = std::env::temp_dir();
|
||||||
let token: u64 = rand::random();
|
let token: u64 = rand::random();
|
||||||
db_path.push(format!("alchemist_notifications_payload_test_{}.db", token));
|
db_path.push(format!("alchemist_notifications_payload_test_{}.db", token));
|
||||||
@@ -536,8 +971,7 @@ mod tests {
|
|||||||
id: 0,
|
id: 0,
|
||||||
name: "test".to_string(),
|
name: "test".to_string(),
|
||||||
target_type: "webhook".to_string(),
|
target_type: "webhook".to_string(),
|
||||||
endpoint_url: format!("http://{}", addr),
|
config_json: serde_json::json!({ "url": format!("http://{}", addr) }).to_string(),
|
||||||
auth_token: None,
|
|
||||||
events: "[\"failed\"]".to_string(),
|
events: "[\"failed\"]".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
@@ -547,7 +981,7 @@ mod tests {
|
|||||||
status: JobState::Failed,
|
status: JobState::Failed,
|
||||||
};
|
};
|
||||||
|
|
||||||
manager.send(&target, &event, "failed").await?;
|
manager.send(&target, &event).await?;
|
||||||
let request = body_task.await??;
|
let request = body_task.await??;
|
||||||
let body = request
|
let body = request
|
||||||
.split("\r\n\r\n")
|
.split("\r\n\r\n")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "config.toml";
|
const DEFAULT_CONFIG_PATH: &str = "config.toml";
|
||||||
const DEFAULT_DB_PATH: &str = "alchemist.db";
|
const DEFAULT_DB_PATH: &str = "alchemist.db";
|
||||||
|
const DEFAULT_TEMP_DIR: &str = "temp";
|
||||||
|
|
||||||
fn parse_bool_env(value: &str) -> Option<bool> {
|
fn parse_bool_env(value: &str) -> Option<bool> {
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
@@ -71,6 +72,13 @@ pub fn db_path() -> PathBuf {
|
|||||||
default_data_dir().join(DEFAULT_DB_PATH)
|
default_data_dir().join(DEFAULT_DB_PATH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn temp_dir() -> PathBuf {
|
||||||
|
if let Ok(temp_dir) = env::var("ALCHEMIST_TEMP_DIR") {
|
||||||
|
return PathBuf::from(temp_dir);
|
||||||
|
}
|
||||||
|
default_data_dir().join(DEFAULT_TEMP_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn config_mutable() -> bool {
|
pub fn config_mutable() -> bool {
|
||||||
match env::var("ALCHEMIST_CONFIG_MUTABLE") {
|
match env::var("ALCHEMIST_CONFIG_MUTABLE") {
|
||||||
Ok(value) => parse_bool_env(&value).unwrap_or(true),
|
Ok(value) => parse_bool_env(&value).unwrap_or(true),
|
||||||
|
|||||||
424
src/server/conversion.rs
Normal file
424
src/server/conversion.rs
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
use super::AppState;
|
||||||
|
use crate::conversion::ConversionSettings;
|
||||||
|
use crate::media::pipeline::Analyzer as _;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Multipart, Path, State},
|
||||||
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path as FsPath, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(crate) struct ConversionUploadResponse {
|
||||||
|
conversion_job_id: i64,
|
||||||
|
probe: crate::media::pipeline::MediaAnalysis,
|
||||||
|
normalized_settings: ConversionSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct ConversionPreviewPayload {
|
||||||
|
conversion_job_id: i64,
|
||||||
|
settings: ConversionSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(crate) struct ConversionJobStatusResponse {
|
||||||
|
id: i64,
|
||||||
|
status: String,
|
||||||
|
progress: f64,
|
||||||
|
linked_job_id: Option<i64>,
|
||||||
|
output_path: Option<String>,
|
||||||
|
download_ready: bool,
|
||||||
|
probe: Option<crate::media::pipeline::MediaAnalysis>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conversion_root() -> PathBuf {
|
||||||
|
crate::runtime::temp_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uploads_root() -> PathBuf {
|
||||||
|
conversion_root().join("uploads")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outputs_root() -> PathBuf {
|
||||||
|
conversion_root().join("outputs")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_expired_jobs(state: &AppState) {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let expired = match state.db.get_expired_conversion_jobs(&now).await {
|
||||||
|
Ok(expired) => expired,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for job in expired {
|
||||||
|
let _ = remove_conversion_artifacts(&job).await;
|
||||||
|
let _ = state.db.delete_conversion_job(job.id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_conversion_artifacts(job: &crate::db::ConversionJob) -> std::io::Result<()> {
|
||||||
|
let upload_path = FsPath::new(&job.upload_path);
|
||||||
|
if upload_path.exists() {
|
||||||
|
let _ = fs::remove_file(upload_path).await;
|
||||||
|
}
|
||||||
|
if let Some(output_path) = &job.output_path {
|
||||||
|
let output_path = FsPath::new(output_path);
|
||||||
|
if output_path.exists() {
|
||||||
|
let _ = fs::remove_file(output_path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn upload_conversion_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
cleanup_expired_jobs(state.as_ref()).await;
|
||||||
|
|
||||||
|
let upload_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let upload_dir = uploads_root().join(&upload_id);
|
||||||
|
if let Err(err) = fs::create_dir_all(&upload_dir).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let field = match multipart.next_field().await {
|
||||||
|
Ok(Some(field)) => field,
|
||||||
|
Ok(None) => return (StatusCode::BAD_REQUEST, "missing upload file").into_response(),
|
||||||
|
Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
let stored_path: PathBuf = {
|
||||||
|
let file_name = field
|
||||||
|
.file_name()
|
||||||
|
.map(sanitize_filename)
|
||||||
|
.unwrap_or_else(|| "input.bin".to_string());
|
||||||
|
let path = upload_dir.join(file_name);
|
||||||
|
match field.bytes().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if let Err(err) = fs::write(&path, bytes).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let analyzer = crate::media::analyzer::FfmpegAnalyzer;
|
||||||
|
let analysis = match analyzer.analyze(&stored_path).await {
|
||||||
|
Ok(analysis) => analysis,
|
||||||
|
Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = ConversionSettings::default();
|
||||||
|
let expires_at = (chrono::Utc::now() + chrono::Duration::hours(24)).to_rfc3339();
|
||||||
|
let conversion_job = match state
|
||||||
|
.db
|
||||||
|
.create_conversion_job(
|
||||||
|
&stored_path.to_string_lossy(),
|
||||||
|
if settings.remux_only {
|
||||||
|
"remux"
|
||||||
|
} else {
|
||||||
|
"transcode"
|
||||||
|
},
|
||||||
|
&serde_json::to_string(&settings).unwrap_or_else(|_| "{}".to_string()),
|
||||||
|
Some(&serde_json::to_string(&analysis).unwrap_or_else(|_| "{}".to_string())),
|
||||||
|
&expires_at,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
axum::Json(ConversionUploadResponse {
|
||||||
|
conversion_job_id: conversion_job.id,
|
||||||
|
probe: analysis,
|
||||||
|
normalized_settings: settings,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn preview_conversion_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::Json(payload): axum::Json<ConversionPreviewPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
cleanup_expired_jobs(state.as_ref()).await;
|
||||||
|
|
||||||
|
let Some(job) = (match state.db.get_conversion_job(payload.conversion_job_id).await {
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}) else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let analysis: crate::media::pipeline::MediaAnalysis = match job.probe_json.as_deref() {
|
||||||
|
Some(probe_json) => match serde_json::from_str(probe_json) {
|
||||||
|
Ok(analysis) => analysis,
|
||||||
|
Err(err) => {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => return (StatusCode::BAD_REQUEST, "missing conversion probe").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let preview_output = outputs_root().join(format!(
|
||||||
|
"preview-{}.{}",
|
||||||
|
job.id, payload.settings.output_container
|
||||||
|
));
|
||||||
|
let hw_info = state.hardware_state.snapshot().await;
|
||||||
|
match crate::conversion::preview_command(
|
||||||
|
FsPath::new(&job.upload_path),
|
||||||
|
&preview_output,
|
||||||
|
&analysis,
|
||||||
|
&payload.settings,
|
||||||
|
hw_info,
|
||||||
|
) {
|
||||||
|
Ok(preview) => {
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.update_conversion_job_probe(
|
||||||
|
job.id,
|
||||||
|
&serde_json::to_string(&analysis).unwrap_or_else(|_| "{}".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.update_conversion_job_status(
|
||||||
|
job.id,
|
||||||
|
if preview.normalized_settings.remux_only {
|
||||||
|
"draft_remux"
|
||||||
|
} else {
|
||||||
|
"draft_transcode"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = sqlx_update_conversion_settings(
|
||||||
|
state.as_ref(),
|
||||||
|
job.id,
|
||||||
|
&preview.normalized_settings,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
axum::Json(preview).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sqlx_update_conversion_settings(
|
||||||
|
state: &AppState,
|
||||||
|
id: i64,
|
||||||
|
settings: &ConversionSettings,
|
||||||
|
) -> crate::error::Result<()> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.update_conversion_job_settings(
|
||||||
|
id,
|
||||||
|
&serde_json::to_string(settings).unwrap_or_else(|_| "{}".to_string()),
|
||||||
|
if settings.remux_only {
|
||||||
|
"remux"
|
||||||
|
} else {
|
||||||
|
"transcode"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn start_conversion_job_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
cleanup_expired_jobs(state.as_ref()).await;
|
||||||
|
|
||||||
|
let Some(job) = (match state.db.get_conversion_job(id).await {
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}) else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
if job.linked_job_id.is_some() {
|
||||||
|
return (StatusCode::CONFLICT, "conversion job already started").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let input_path = PathBuf::from(&job.upload_path);
|
||||||
|
let file_stem = input_path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("output");
|
||||||
|
let settings: ConversionSettings = match serde_json::from_str(&job.settings_json) {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_dir = outputs_root().join(job.id.to_string());
|
||||||
|
if let Err(err) = fs::create_dir_all(&output_dir).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
let output_path = output_dir.join(format!("{file_stem}.{}", settings.output_container));
|
||||||
|
let mtime = std::fs::metadata(&input_path)
|
||||||
|
.and_then(|metadata| metadata.modified())
|
||||||
|
.unwrap_or(std::time::SystemTime::now());
|
||||||
|
|
||||||
|
if let Err(err) = state.db.enqueue_job(&input_path, &output_path, mtime).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
let linked_job = match state
|
||||||
|
.db
|
||||||
|
.get_job_by_input_path(&input_path.to_string_lossy())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(job)) => job,
|
||||||
|
Ok(None) => {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "linked job missing").into_response();
|
||||||
|
}
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
if let Err(err) = state
|
||||||
|
.db
|
||||||
|
.update_conversion_job_start(id, &output_path.to_string_lossy(), linked_job.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_conversion_job_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
cleanup_expired_jobs(state.as_ref()).await;
|
||||||
|
|
||||||
|
let Some(conversion_job) = (match state.db.get_conversion_job(id).await {
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}) else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let linked_job = match conversion_job.linked_job_id {
|
||||||
|
Some(job_id) => match state.db.get_job_by_id(job_id).await {
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(err) => {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let probe = conversion_job
|
||||||
|
.probe_json
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| serde_json::from_str(value).ok());
|
||||||
|
let download_ready = conversion_job
|
||||||
|
.output_path
|
||||||
|
.as_deref()
|
||||||
|
.map(FsPath::new)
|
||||||
|
.is_some_and(|path| path.exists());
|
||||||
|
|
||||||
|
axum::Json(ConversionJobStatusResponse {
|
||||||
|
id: conversion_job.id,
|
||||||
|
status: linked_job
|
||||||
|
.as_ref()
|
||||||
|
.map(|job| job.status.to_string())
|
||||||
|
.unwrap_or(conversion_job.status),
|
||||||
|
progress: linked_job.as_ref().map(|job| job.progress).unwrap_or(0.0),
|
||||||
|
linked_job_id: conversion_job.linked_job_id,
|
||||||
|
output_path: conversion_job.output_path,
|
||||||
|
download_ready,
|
||||||
|
probe,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn download_conversion_job_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
cleanup_expired_jobs(state.as_ref()).await;
|
||||||
|
|
||||||
|
let Some(job) = (match state.db.get_conversion_job(id).await {
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}) else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(output_path) = job.output_path.clone() else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
if !FsPath::new(&output_path).exists() {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = match fs::File::open(&output_path).await {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
let file_name = FsPath::new(&output_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("output.bin");
|
||||||
|
let _ = state.db.mark_conversion_job_downloaded(id).await;
|
||||||
|
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
let body = Body::from_stream(stream);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/octet-stream"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
HeaderValue::from_str(&format!("attachment; filename=\"{}\"", file_name))
|
||||||
|
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
|
||||||
|
);
|
||||||
|
(headers, body).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_conversion_job_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
cleanup_expired_jobs(state.as_ref()).await;
|
||||||
|
|
||||||
|
let Some(job) = (match state.db.get_conversion_job(id).await {
|
||||||
|
Ok(job) => job,
|
||||||
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}) else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(linked_job_id) = job.linked_job_id {
|
||||||
|
if let Ok(Some(linked_job)) = state.db.get_job_by_id(linked_job_id).await {
|
||||||
|
if linked_job.is_active() {
|
||||||
|
return (StatusCode::CONFLICT, "conversion job is still active").into_response();
|
||||||
|
}
|
||||||
|
let _ = state.db.delete_job(linked_job_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = remove_conversion_artifacts(&job).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
if let Err(err) = state.db.delete_conversion_job(id).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|ch| match ch {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Authentication, rate limiting, and security middleware.
|
//! Authentication, rate limiting, and security middleware.
|
||||||
|
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
|
use crate::db::ApiTokenAccessLevel;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{ConnectInfo, Request, State},
|
extract::{ConnectInfo, Request, State},
|
||||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
http::{HeaderName, HeaderValue, Method, StatusCode, header},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -73,6 +74,7 @@ pub(crate) async fn auth_middleware(
|
|||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
|
let method = req.method().clone();
|
||||||
|
|
||||||
// 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") {
|
||||||
@@ -132,6 +134,18 @@ pub(crate) async fn auth_middleware(
|
|||||||
if let Ok(Some(_session)) = state.db.get_session(&t).await {
|
if let Ok(Some(_session)) = state.db.get_session(&t).await {
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
|
if let Ok(Some(api_token)) = state.db.get_active_api_token(&t).await {
|
||||||
|
let _ = state.db.update_api_token_last_used(api_token.id).await;
|
||||||
|
match api_token.access_level {
|
||||||
|
ApiTokenAccessLevel::FullAccess => return next.run(req).await,
|
||||||
|
ApiTokenAccessLevel::ReadOnly => {
|
||||||
|
if read_only_api_token_allows(&method, path) {
|
||||||
|
return next.run(req).await;
|
||||||
|
}
|
||||||
|
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||||
@@ -143,6 +157,40 @@ pub(crate) async fn auth_middleware(
|
|||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_only_api_token_allows(method: &Method, path: &str) -> bool {
|
||||||
|
if *method != Method::GET && *method != Method::HEAD {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "/api/health"
|
||||||
|
|| path == "/api/ready"
|
||||||
|
|| path == "/api/events"
|
||||||
|
|| path == "/api/stats"
|
||||||
|
|| path == "/api/stats/aggregated"
|
||||||
|
|| path == "/api/stats/daily"
|
||||||
|
|| path == "/api/stats/detailed"
|
||||||
|
|| path == "/api/stats/savings"
|
||||||
|
|| path == "/api/jobs"
|
||||||
|
|| path == "/api/jobs/table"
|
||||||
|
|| path == "/api/logs/history"
|
||||||
|
|| path == "/api/engine/status"
|
||||||
|
|| path == "/api/engine/mode"
|
||||||
|
|| path == "/api/system/resources"
|
||||||
|
|| path == "/api/system/info"
|
||||||
|
|| path == "/api/system/update"
|
||||||
|
|| path == "/api/system/hardware"
|
||||||
|
|| path == "/api/system/hardware/probe-log"
|
||||||
|
|| path == "/api/library/intelligence"
|
||||||
|
|| path == "/api/library/health"
|
||||||
|
|| path == "/api/library/health/issues"
|
||||||
|
|| path.starts_with("/api/jobs/") && path.ends_with("/details")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn rate_limit_middleware(
|
pub(crate) async fn rate_limit_middleware(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! HTTP server module: routes, state, middleware, and API handlers.
|
//! HTTP server module: routes, state, middleware, and API handlers.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod conversion;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
@@ -21,9 +22,10 @@ use crate::error::{AlchemistError, Result};
|
|||||||
use crate::system::hardware::{HardwareInfo, HardwareProbeLog, HardwareState};
|
use crate::system::hardware::{HardwareInfo, HardwareProbeLog, HardwareState};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
|
extract::State,
|
||||||
http::{StatusCode, Uri, header},
|
http::{StatusCode, Uri, header},
|
||||||
middleware as axum_middleware,
|
middleware as axum_middleware,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
};
|
};
|
||||||
#[cfg(feature = "embed-web")]
|
#[cfg(feature = "embed-web")]
|
||||||
@@ -79,6 +81,7 @@ 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)>>>,
|
||||||
@@ -143,6 +146,11 @@ 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,
|
||||||
@@ -160,6 +168,7 @@ 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)),
|
||||||
@@ -171,7 +180,18 @@ 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 app = app_router(state);
|
let inner_app = app_router(state.clone());
|
||||||
|
let app = if base_url.is_empty() {
|
||||||
|
inner_app
|
||||||
|
} else {
|
||||||
|
let redirect_target = format!("{base_url}/");
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/",
|
||||||
|
get(move || async move { Redirect::permanent(&redirect_target) }),
|
||||||
|
)
|
||||||
|
.nest(&base_url, inner_app)
|
||||||
|
};
|
||||||
|
|
||||||
let port = std::env::var("ALCHEMIST_SERVER_PORT")
|
let port = std::env::var("ALCHEMIST_SERVER_PORT")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -284,6 +304,7 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
|
|||||||
|
|
||||||
fn app_router(state: Arc<AppState>) -> Router {
|
fn app_router(state: Arc<AppState>) -> Router {
|
||||||
use auth::*;
|
use auth::*;
|
||||||
|
use conversion::*;
|
||||||
use jobs::*;
|
use jobs::*;
|
||||||
use scan::*;
|
use scan::*;
|
||||||
use settings::*;
|
use settings::*;
|
||||||
@@ -315,6 +336,20 @@ fn app_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/api/jobs/:id/restart", post(restart_job_handler))
|
.route("/api/jobs/:id/restart", post(restart_job_handler))
|
||||||
.route("/api/jobs/:id/delete", post(delete_job_handler))
|
.route("/api/jobs/:id/delete", post(delete_job_handler))
|
||||||
.route("/api/jobs/:id/details", get(get_job_detail_handler))
|
.route("/api/jobs/:id/details", get(get_job_detail_handler))
|
||||||
|
.route("/api/conversion/uploads", post(upload_conversion_handler))
|
||||||
|
.route("/api/conversion/preview", post(preview_conversion_handler))
|
||||||
|
.route(
|
||||||
|
"/api/conversion/jobs/:id/start",
|
||||||
|
post(start_conversion_job_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/conversion/jobs/:id",
|
||||||
|
get(get_conversion_job_handler).delete(delete_conversion_job_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/conversion/jobs/:id/download",
|
||||||
|
get(download_conversion_job_handler),
|
||||||
|
)
|
||||||
.route("/api/events", get(sse_handler))
|
.route("/api/events", get(sse_handler))
|
||||||
.route("/api/engine/pause", post(pause_engine_handler))
|
.route("/api/engine/pause", post(pause_engine_handler))
|
||||||
.route("/api/engine/resume", post(resume_engine_handler))
|
.route("/api/engine/resume", post(resume_engine_handler))
|
||||||
@@ -373,7 +408,9 @@ fn app_router(state: Arc<AppState>) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/settings/notifications",
|
"/api/settings/notifications",
|
||||||
get(get_notifications_handler).post(add_notification_handler),
|
get(get_notifications_handler)
|
||||||
|
.put(update_notifications_settings_handler)
|
||||||
|
.post(add_notification_handler),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/settings/notifications/:id",
|
"/api/settings/notifications/:id",
|
||||||
@@ -383,6 +420,14 @@ fn app_router(state: Arc<AppState>) -> Router {
|
|||||||
"/api/settings/notifications/test",
|
"/api/settings/notifications/test",
|
||||||
post(test_notification_handler),
|
post(test_notification_handler),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/settings/api-tokens",
|
||||||
|
get(list_api_tokens_handler).post(create_api_token_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/settings/api-tokens/:id",
|
||||||
|
delete(revoke_api_token_handler),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/settings/files",
|
"/api/settings/files",
|
||||||
get(get_file_settings_handler).post(update_file_settings_handler),
|
get(get_file_settings_handler).post(update_file_settings_handler),
|
||||||
@@ -405,6 +450,7 @@ fn app_router(state: Arc<AppState>) -> Router {
|
|||||||
// System Routes
|
// System Routes
|
||||||
.route("/api/system/resources", get(system_resources_handler))
|
.route("/api/system/resources", get(system_resources_handler))
|
||||||
.route("/api/system/info", get(get_system_info_handler))
|
.route("/api/system/info", get(get_system_info_handler))
|
||||||
|
.route("/api/system/update", get(get_system_update_handler))
|
||||||
.route("/api/system/hardware", get(get_hardware_info_handler))
|
.route("/api/system/hardware", get(get_hardware_info_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/system/hardware/probe-log",
|
"/api/system/hardware/probe-log",
|
||||||
@@ -778,11 +824,11 @@ fn sanitize_asset_path(raw: &str) -> Option<String> {
|
|||||||
|
|
||||||
// Static asset handlers
|
// Static asset handlers
|
||||||
|
|
||||||
async fn index_handler() -> impl IntoResponse {
|
async fn index_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
static_handler(Uri::from_static("/index.html")).await
|
static_handler(State(state), Uri::from_static("/index.html")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn static_handler(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,
|
||||||
@@ -791,7 +837,11 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|||||||
|
|
||||||
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 ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
|
return (
|
||||||
|
[(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)
|
||||||
@@ -799,7 +849,11 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|||||||
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 ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response();
|
return (
|
||||||
|
[(header::CONTENT_TYPE, mime.as_ref())],
|
||||||
|
maybe_inject_base_url(content, mime.as_ref(), &state.base_url),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,3 +890,14 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|||||||
// 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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ use super::{
|
|||||||
validate_notification_url, validate_transcode_payload,
|
validate_notification_url, validate_transcode_payload,
|
||||||
};
|
};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::db::ApiTokenAccessLevel;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
use rand::Rng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
// Transcode settings
|
// Transcode settings
|
||||||
@@ -414,47 +417,217 @@ pub(crate) async fn update_settings_config_handler(
|
|||||||
pub(crate) struct AddNotificationTargetPayload {
|
pub(crate) struct AddNotificationTargetPayload {
|
||||||
name: String,
|
name: String,
|
||||||
target_type: String,
|
target_type: String,
|
||||||
endpoint_url: String,
|
#[serde(default)]
|
||||||
|
config_json: JsonValue,
|
||||||
|
#[serde(default)]
|
||||||
|
endpoint_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
auth_token: Option<String>,
|
auth_token: Option<String>,
|
||||||
events: Vec<String>,
|
events: Vec<String>,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_notifications_handler(
|
#[derive(Serialize)]
|
||||||
State(state): State<Arc<AppState>>,
|
pub(crate) struct NotificationTargetResponse {
|
||||||
) -> impl IntoResponse {
|
id: i64,
|
||||||
match state.db.get_notification_targets().await {
|
name: String,
|
||||||
Ok(t) => axum::Json(serde_json::json!(t)).into_response(),
|
target_type: String,
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
config_json: JsonValue,
|
||||||
|
events: Vec<String>,
|
||||||
|
enabled: bool,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(crate) struct NotificationsSettingsResponse {
|
||||||
|
daily_summary_time_local: String,
|
||||||
|
targets: Vec<NotificationTargetResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct UpdateNotificationsSettingsPayload {
|
||||||
|
daily_summary_time_local: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_payload(
|
||||||
|
payload: &AddNotificationTargetPayload,
|
||||||
|
) -> crate::config::NotificationTargetConfig {
|
||||||
|
let mut config_json = payload.config_json.clone();
|
||||||
|
if !config_json.is_object() {
|
||||||
|
config_json = JsonValue::Object(JsonMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(config_map) = config_json.as_object_mut() else {
|
||||||
|
unreachable!("notification config_json should always be an object here");
|
||||||
|
};
|
||||||
|
match payload.target_type.as_str() {
|
||||||
|
"discord_webhook" | "discord" => {
|
||||||
|
if !config_map.contains_key("webhook_url") {
|
||||||
|
if let Some(endpoint_url) = payload.endpoint_url.as_ref() {
|
||||||
|
config_map.insert(
|
||||||
|
"webhook_url".to_string(),
|
||||||
|
JsonValue::String(endpoint_url.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"gotify" => {
|
||||||
|
if !config_map.contains_key("server_url") {
|
||||||
|
if let Some(endpoint_url) = payload.endpoint_url.as_ref() {
|
||||||
|
config_map.insert(
|
||||||
|
"server_url".to_string(),
|
||||||
|
JsonValue::String(endpoint_url.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !config_map.contains_key("app_token") {
|
||||||
|
if let Some(auth_token) = payload.auth_token.as_ref() {
|
||||||
|
config_map.insert(
|
||||||
|
"app_token".to_string(),
|
||||||
|
JsonValue::String(auth_token.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"webhook" => {
|
||||||
|
if !config_map.contains_key("url") {
|
||||||
|
if let Some(endpoint_url) = payload.endpoint_url.as_ref() {
|
||||||
|
config_map.insert("url".to_string(), JsonValue::String(endpoint_url.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !config_map.contains_key("auth_token") {
|
||||||
|
if let Some(auth_token) = payload.auth_token.as_ref() {
|
||||||
|
config_map.insert(
|
||||||
|
"auth_token".to_string(),
|
||||||
|
JsonValue::String(auth_token.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut target = crate::config::NotificationTargetConfig {
|
||||||
|
name: payload.name.clone(),
|
||||||
|
target_type: payload.target_type.clone(),
|
||||||
|
config_json,
|
||||||
|
endpoint_url: payload.endpoint_url.clone(),
|
||||||
|
auth_token: payload.auth_token.clone(),
|
||||||
|
events: payload.events.clone(),
|
||||||
|
enabled: payload.enabled,
|
||||||
|
};
|
||||||
|
target.migrate_legacy_shape();
|
||||||
|
target
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_target_response(
|
||||||
|
target: crate::db::NotificationTarget,
|
||||||
|
) -> NotificationTargetResponse {
|
||||||
|
NotificationTargetResponse {
|
||||||
|
id: target.id,
|
||||||
|
name: target.name,
|
||||||
|
target_type: target.target_type,
|
||||||
|
config_json: serde_json::from_str(&target.config_json)
|
||||||
|
.unwrap_or_else(|_| JsonValue::Object(JsonMap::new())),
|
||||||
|
events: serde_json::from_str(&target.events).unwrap_or_default(),
|
||||||
|
enabled: target.enabled,
|
||||||
|
created_at: target.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn add_notification_handler(
|
async fn validate_notification_target(
|
||||||
State(state): State<Arc<AppState>>,
|
state: &AppState,
|
||||||
axum::Json(payload): axum::Json<AddNotificationTargetPayload>,
|
target: &crate::config::NotificationTargetConfig,
|
||||||
) -> impl IntoResponse {
|
) -> std::result::Result<(), String> {
|
||||||
|
target.validate().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let allow_local = state
|
let allow_local = state
|
||||||
.config
|
.config
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.notifications
|
.notifications
|
||||||
.allow_local_notifications;
|
.allow_local_notifications;
|
||||||
if let Err(msg) = validate_notification_url(&payload.endpoint_url, allow_local).await {
|
let url = match target.target_type.as_str() {
|
||||||
|
"discord_webhook" => target
|
||||||
|
.config_json
|
||||||
|
.get("webhook_url")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(str::to_string),
|
||||||
|
"gotify" => target
|
||||||
|
.config_json
|
||||||
|
.get("server_url")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(str::to_string),
|
||||||
|
"webhook" => target
|
||||||
|
.config_json
|
||||||
|
.get("url")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(str::to_string),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(url) = url {
|
||||||
|
validate_notification_url(&url, allow_local).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_notifications_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match state.db.get_notification_targets().await {
|
||||||
|
Ok(t) => {
|
||||||
|
let daily_summary_time_local = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.notifications
|
||||||
|
.daily_summary_time_local
|
||||||
|
.clone();
|
||||||
|
axum::Json(NotificationsSettingsResponse {
|
||||||
|
daily_summary_time_local,
|
||||||
|
targets: t
|
||||||
|
.into_iter()
|
||||||
|
.map(notification_target_response)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn update_notifications_settings_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::Json(payload): axum::Json<UpdateNotificationsSettingsPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let mut next_config = state.config.read().await.clone();
|
||||||
|
next_config.notifications.daily_summary_time_local = payload.daily_summary_time_local;
|
||||||
|
if let Err(err) = next_config.validate() {
|
||||||
|
return (StatusCode::BAD_REQUEST, err.to_string()).into_response();
|
||||||
|
}
|
||||||
|
if let Err(response) = save_config_or_response(&state, &next_config).await {
|
||||||
|
return *response;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
*config = next_config;
|
||||||
|
}
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn add_notification_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::Json(payload): axum::Json<AddNotificationTargetPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let target = normalize_notification_payload(&payload);
|
||||||
|
if let Err(msg) = validate_notification_target(&state, &target).await {
|
||||||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut next_config = state.config.read().await.clone();
|
let mut next_config = state.config.read().await.clone();
|
||||||
next_config
|
next_config.notifications.targets.push(target);
|
||||||
.notifications
|
|
||||||
.targets
|
|
||||||
.push(crate::config::NotificationTargetConfig {
|
|
||||||
name: payload.name.clone(),
|
|
||||||
target_type: payload.target_type.clone(),
|
|
||||||
endpoint_url: payload.endpoint_url.clone(),
|
|
||||||
auth_token: payload.auth_token.clone(),
|
|
||||||
events: payload.events.clone(),
|
|
||||||
enabled: payload.enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = next_config.validate() {
|
if let Err(e) = next_config.validate() {
|
||||||
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||||
@@ -470,12 +643,8 @@ pub(crate) async fn add_notification_handler(
|
|||||||
match state.db.get_notification_targets().await {
|
match state.db.get_notification_targets().await {
|
||||||
Ok(targets) => targets
|
Ok(targets) => targets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|target| {
|
.find(|target| target.name == payload.name)
|
||||||
target.name == payload.name
|
.map(|target| axum::Json(notification_target_response(target)).into_response())
|
||||||
&& target.target_type == payload.target_type
|
|
||||||
&& target.endpoint_url == payload.endpoint_url
|
|
||||||
})
|
|
||||||
.map(|target| axum::Json(serde_json::json!(target)).into_response())
|
|
||||||
.unwrap_or_else(|| StatusCode::OK.into_response()),
|
.unwrap_or_else(|| StatusCode::OK.into_response()),
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
@@ -494,10 +663,13 @@ pub(crate) async fn delete_notification_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut next_config = state.config.read().await.clone();
|
let mut next_config = state.config.read().await.clone();
|
||||||
|
let target_config_json = target.config_json.clone();
|
||||||
|
let parsed_target_config_json =
|
||||||
|
serde_json::from_str::<JsonValue>(&target_config_json).unwrap_or(JsonValue::Null);
|
||||||
next_config.notifications.targets.retain(|candidate| {
|
next_config.notifications.targets.retain(|candidate| {
|
||||||
!(candidate.name == target.name
|
!(candidate.name == target.name
|
||||||
&& candidate.target_type == target.target_type
|
&& candidate.target_type == target.target_type
|
||||||
&& candidate.endpoint_url == target.endpoint_url)
|
&& candidate.config_json == parsed_target_config_json)
|
||||||
});
|
});
|
||||||
if let Err(response) = save_config_or_response(&state, &next_config).await {
|
if let Err(response) = save_config_or_response(&state, &next_config).await {
|
||||||
return *response;
|
return *response;
|
||||||
@@ -513,26 +685,18 @@ pub(crate) async fn test_notification_handler(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
axum::Json(payload): axum::Json<AddNotificationTargetPayload>,
|
axum::Json(payload): axum::Json<AddNotificationTargetPayload>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let allow_local = state
|
let target_config = normalize_notification_payload(&payload);
|
||||||
.config
|
if let Err(msg) = validate_notification_target(&state, &target_config).await {
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.notifications
|
|
||||||
.allow_local_notifications;
|
|
||||||
if let Err(msg) = validate_notification_url(&payload.endpoint_url, allow_local).await {
|
|
||||||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a temporary target
|
|
||||||
let events_json = serde_json::to_string(&payload.events).unwrap_or_default();
|
|
||||||
let target = crate::db::NotificationTarget {
|
let target = crate::db::NotificationTarget {
|
||||||
id: 0,
|
id: 0,
|
||||||
name: payload.name,
|
name: target_config.name,
|
||||||
target_type: payload.target_type,
|
target_type: target_config.target_type,
|
||||||
endpoint_url: payload.endpoint_url,
|
config_json: target_config.config_json.to_string(),
|
||||||
auth_token: payload.auth_token,
|
events: serde_json::to_string(&target_config.events).unwrap_or_else(|_| "[]".to_string()),
|
||||||
events: events_json,
|
enabled: target_config.enabled,
|
||||||
enabled: payload.enabled,
|
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -542,6 +706,71 @@ pub(crate) async fn test_notification_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API token settings
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct CreateApiTokenPayload {
|
||||||
|
name: String,
|
||||||
|
access_level: ApiTokenAccessLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(crate) struct CreatedApiTokenResponse {
|
||||||
|
token: crate::db::ApiToken,
|
||||||
|
plaintext_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_api_tokens_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match state.db.list_api_tokens().await {
|
||||||
|
Ok(tokens) => axum::Json(tokens).into_response(),
|
||||||
|
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn create_api_token_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::Json(payload): axum::Json<CreateApiTokenPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if payload.name.trim().is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, "token name must not be empty").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let plaintext_token = format!(
|
||||||
|
"alc_tok_{}",
|
||||||
|
rand::rng()
|
||||||
|
.sample_iter(rand::distr::Alphanumeric)
|
||||||
|
.take(48)
|
||||||
|
.map(char::from)
|
||||||
|
.collect::<String>()
|
||||||
|
);
|
||||||
|
|
||||||
|
match state
|
||||||
|
.db
|
||||||
|
.create_api_token(payload.name.trim(), &plaintext_token, payload.access_level)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(token) => axum::Json(CreatedApiTokenResponse {
|
||||||
|
token,
|
||||||
|
plaintext_token,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn revoke_api_token_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match state.db.revoke_api_token(id).await {
|
||||||
|
Ok(_) => StatusCode::OK.into_response(),
|
||||||
|
Err(err) if super::is_row_not_found(&err) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule settings
|
// Schedule settings
|
||||||
|
|
||||||
pub(crate) async fn get_schedule_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
pub(crate) async fn get_schedule_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! System information, hardware info, resources, health handlers.
|
//! System information, hardware info, resources, health handlers.
|
||||||
|
|
||||||
use super::{AppState, config_read_error_response};
|
use super::{AppState, config_read_error_response};
|
||||||
|
use crate::media::pipeline::{Analyzer as _, Planner as _, TranscodeDecision};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
@@ -44,6 +45,26 @@ struct DuplicatePath {
|
|||||||
struct LibraryIntelligenceResponse {
|
struct LibraryIntelligenceResponse {
|
||||||
duplicate_groups: Vec<DuplicateGroup>,
|
duplicate_groups: Vec<DuplicateGroup>,
|
||||||
total_duplicates: usize,
|
total_duplicates: usize,
|
||||||
|
recommendation_counts: RecommendationCounts,
|
||||||
|
recommendations: Vec<IntelligenceRecommendation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Default)]
|
||||||
|
struct RecommendationCounts {
|
||||||
|
duplicates: usize,
|
||||||
|
remux_only_candidate: usize,
|
||||||
|
wasteful_audio_layout: usize,
|
||||||
|
commentary_cleanup_candidate: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct IntelligenceRecommendation {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
recommendation_type: String,
|
||||||
|
title: String,
|
||||||
|
summary: String,
|
||||||
|
path: String,
|
||||||
|
suggested_action: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn system_resources_handler(State(state): State<Arc<AppState>>) -> Response {
|
pub(crate) async fn system_resources_handler(State(state): State<Arc<AppState>>) -> Response {
|
||||||
@@ -118,55 +139,158 @@ pub(crate) async fn library_intelligence_handler(State(state): State<Arc<AppStat
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
match state.db.get_duplicate_candidates().await {
|
let duplicate_candidates = match state.db.get_duplicate_candidates().await {
|
||||||
Ok(candidates) => {
|
Ok(candidates) => candidates,
|
||||||
let mut groups: HashMap<String, Vec<_>> = HashMap::new();
|
|
||||||
for candidate in candidates {
|
|
||||||
let stem = Path::new(&candidate.input_path)
|
|
||||||
.file_stem()
|
|
||||||
.map(|s| s.to_string_lossy().to_lowercase())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if stem.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
groups.entry(stem).or_default().push(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut duplicate_groups: Vec<DuplicateGroup> = groups
|
|
||||||
.into_iter()
|
|
||||||
.filter(|(_, paths)| paths.len() > 1)
|
|
||||||
.map(|(stem, paths)| {
|
|
||||||
let count = paths.len();
|
|
||||||
DuplicateGroup {
|
|
||||||
stem,
|
|
||||||
count,
|
|
||||||
paths: paths
|
|
||||||
.into_iter()
|
|
||||||
.map(|candidate| DuplicatePath {
|
|
||||||
id: candidate.id,
|
|
||||||
path: candidate.input_path,
|
|
||||||
status: candidate.status,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
duplicate_groups.sort_by(|a, b| b.count.cmp(&a.count).then(a.stem.cmp(&b.stem)));
|
|
||||||
|
|
||||||
let total_duplicates = duplicate_groups.iter().map(|group| group.count - 1).sum();
|
|
||||||
|
|
||||||
axum::Json(LibraryIntelligenceResponse {
|
|
||||||
duplicate_groups,
|
|
||||||
total_duplicates,
|
|
||||||
})
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to fetch duplicate candidates: {err}");
|
error!("Failed to fetch duplicate candidates: {err}");
|
||||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut groups: HashMap<String, Vec<_>> = HashMap::new();
|
||||||
|
for candidate in duplicate_candidates {
|
||||||
|
let stem = Path::new(&candidate.input_path)
|
||||||
|
.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if stem.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
groups.entry(stem).or_default().push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut duplicate_groups: Vec<DuplicateGroup> = groups
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, paths)| paths.len() > 1)
|
||||||
|
.map(|(stem, paths)| {
|
||||||
|
let count = paths.len();
|
||||||
|
DuplicateGroup {
|
||||||
|
stem,
|
||||||
|
count,
|
||||||
|
paths: paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| DuplicatePath {
|
||||||
|
id: candidate.id,
|
||||||
|
path: candidate.input_path,
|
||||||
|
status: candidate.status,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
duplicate_groups.sort_by(|a, b| b.count.cmp(&a.count).then(a.stem.cmp(&b.stem)));
|
||||||
|
let total_duplicates = duplicate_groups.iter().map(|group| group.count - 1).sum();
|
||||||
|
|
||||||
|
let mut recommendations = Vec::new();
|
||||||
|
let mut recommendation_counts = RecommendationCounts {
|
||||||
|
duplicates: duplicate_groups.len(),
|
||||||
|
..RecommendationCounts::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let jobs = match state.db.get_all_jobs().await {
|
||||||
|
Ok(jobs) => jobs,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to fetch jobs for intelligence recommendations: {err}");
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let analyzer = crate::media::analyzer::FfmpegAnalyzer;
|
||||||
|
let config_snapshot = state.config.read().await.clone();
|
||||||
|
let hw_snapshot = state.hardware_state.snapshot().await;
|
||||||
|
let planner = crate::media::planner::BasicPlanner::new(
|
||||||
|
std::sync::Arc::new(config_snapshot.clone()),
|
||||||
|
hw_snapshot,
|
||||||
|
);
|
||||||
|
|
||||||
|
for job in jobs {
|
||||||
|
if job.status == crate::db::JobState::Cancelled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let input_path = std::path::Path::new(&job.input_path);
|
||||||
|
if !input_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let analysis = match analyzer.analyze(input_path).await {
|
||||||
|
Ok(analysis) => analysis,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let profile: Option<crate::db::LibraryProfile> = state
|
||||||
|
.db
|
||||||
|
.get_profile_for_path(&job.input_path)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Ok(plan) = planner
|
||||||
|
.plan(
|
||||||
|
&analysis,
|
||||||
|
std::path::Path::new(&job.output_path),
|
||||||
|
profile.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if matches!(plan.decision, TranscodeDecision::Remux { .. }) {
|
||||||
|
recommendation_counts.remux_only_candidate += 1;
|
||||||
|
recommendations.push(IntelligenceRecommendation {
|
||||||
|
recommendation_type: "remux_only_candidate".to_string(),
|
||||||
|
title: "Remux-only opportunity".to_string(),
|
||||||
|
summary: "This file already matches the target video codec and looks like a container-normalization candidate instead of a full re-encode.".to_string(),
|
||||||
|
path: job.input_path.clone(),
|
||||||
|
suggested_action: "Queue a remux to normalize the container without re-encoding the video stream.".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if analysis.metadata.audio_is_heavy {
|
||||||
|
recommendation_counts.wasteful_audio_layout += 1;
|
||||||
|
recommendations.push(IntelligenceRecommendation {
|
||||||
|
recommendation_type: "wasteful_audio_layout".to_string(),
|
||||||
|
title: "Wasteful audio layout".to_string(),
|
||||||
|
summary: "This file contains a lossless or oversized audio stream that is likely worth transcoding for storage recovery.".to_string(),
|
||||||
|
path: job.input_path.clone(),
|
||||||
|
suggested_action: "Use a profile that transcodes heavy audio instead of copying it through unchanged.".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if analysis.metadata.audio_streams.iter().any(|stream| {
|
||||||
|
stream
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.map(|title| {
|
||||||
|
let lower = title.to_ascii_lowercase();
|
||||||
|
lower.contains("commentary")
|
||||||
|
|| lower.contains("director")
|
||||||
|
|| lower.contains("description")
|
||||||
|
|| lower.contains("descriptive")
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}) {
|
||||||
|
recommendation_counts.commentary_cleanup_candidate += 1;
|
||||||
|
recommendations.push(IntelligenceRecommendation {
|
||||||
|
recommendation_type: "commentary_cleanup_candidate".to_string(),
|
||||||
|
title: "Commentary or descriptive track cleanup".to_string(),
|
||||||
|
summary: "This file appears to contain commentary or descriptive audio tracks that existing stream rules could strip automatically.".to_string(),
|
||||||
|
path: job.input_path.clone(),
|
||||||
|
suggested_action: "Enable stream rules to strip commentary or descriptive tracks for this library.".to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recommendations.sort_by(|a, b| {
|
||||||
|
a.recommendation_type
|
||||||
|
.cmp(&b.recommendation_type)
|
||||||
|
.then(a.path.cmp(&b.path))
|
||||||
|
});
|
||||||
|
|
||||||
|
axum::Json(LibraryIntelligenceResponse {
|
||||||
|
duplicate_groups,
|
||||||
|
total_duplicates,
|
||||||
|
recommendation_counts,
|
||||||
|
recommendations,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query GPU utilization using nvidia-smi (NVIDIA) or other platform-specific tools
|
/// Query GPU utilization using nvidia-smi (NVIDIA) or other platform-specific tools
|
||||||
@@ -236,6 +360,14 @@ struct SystemInfo {
|
|||||||
ffmpeg_version: String,
|
ffmpeg_version: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UpdateInfo {
|
||||||
|
current_version: String,
|
||||||
|
latest_version: Option<String>,
|
||||||
|
update_available: bool,
|
||||||
|
release_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_system_info_handler(
|
pub(crate) async fn get_system_info_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -258,6 +390,96 @@ pub(crate) async fn get_system_info_handler(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_system_update_handler() -> impl IntoResponse {
|
||||||
|
let current_version = crate::version::current().to_string();
|
||||||
|
match fetch_latest_stable_release().await {
|
||||||
|
Ok(Some((latest_version, release_url))) => {
|
||||||
|
let update_available = version_is_newer(&latest_version, ¤t_version);
|
||||||
|
axum::Json(UpdateInfo {
|
||||||
|
current_version,
|
||||||
|
latest_version: Some(latest_version),
|
||||||
|
update_available,
|
||||||
|
release_url: Some(release_url),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => axum::Json(UpdateInfo {
|
||||||
|
current_version,
|
||||||
|
latest_version: None,
|
||||||
|
update_available: false,
|
||||||
|
release_url: None,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Err(err) => (
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("Failed to check for updates: {err}"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GitHubReleaseResponse {
|
||||||
|
tag_name: String,
|
||||||
|
html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_latest_stable_release() -> Result<Option<(String, String)>, reqwest::Error> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.user_agent(format!("alchemist/{}", crate::version::current()))
|
||||||
|
.build()?;
|
||||||
|
let response = client
|
||||||
|
.get("https://api.github.com/repos/bybrooklyn/alchemist/releases/latest")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let release: GitHubReleaseResponse = response.error_for_status()?.json().await?;
|
||||||
|
Ok(Some((
|
||||||
|
release.tag_name.trim_start_matches('v').to_string(),
|
||||||
|
release.html_url,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_is_newer(latest: &str, current: &str) -> bool {
|
||||||
|
parse_version(latest) > parse_version(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version(value: &str) -> (u64, u64, u64) {
|
||||||
|
let sanitized = value.trim_start_matches('v');
|
||||||
|
let parts = sanitized
|
||||||
|
.split(['.', '-'])
|
||||||
|
.filter_map(|part| part.parse::<u64>().ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(
|
||||||
|
*parts.first().unwrap_or(&0),
|
||||||
|
*parts.get(1).unwrap_or(&0),
|
||||||
|
*parts.get(2).unwrap_or(&0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_compare_detects_newer_stable_release() {
|
||||||
|
assert!(version_is_newer("0.3.1", "0.3.0"));
|
||||||
|
assert!(!version_is_newer("0.3.0", "0.3.0"));
|
||||||
|
assert!(!version_is_newer("0.2.9", "0.3.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_version_ignores_prefix_and_suffix() {
|
||||||
|
assert_eq!(parse_version("v0.3.1"), (0, 3, 1));
|
||||||
|
assert_eq!(parse_version("0.3.1-rc.1"), (0, 3, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_hardware_info_handler(
|
pub(crate) async fn get_hardware_info_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ 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)),
|
||||||
@@ -135,6 +136,17 @@ async fn create_session(
|
|||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_api_token(
|
||||||
|
db: &crate::db::Db,
|
||||||
|
access_level: crate::db::ApiTokenAccessLevel,
|
||||||
|
) -> std::result::Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let token = format!("api-token-{}", rand::random::<u64>());
|
||||||
|
let _ = db
|
||||||
|
.create_api_token("test-token", &token, access_level)
|
||||||
|
.await?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
fn auth_request(method: Method, uri: &str, token: &str, body: Body) -> Request<Body> {
|
fn auth_request(method: Method, uri: &str, token: &str, body: Body) -> Request<Body> {
|
||||||
match Request::builder()
|
match Request::builder()
|
||||||
.method(method)
|
.method(method)
|
||||||
@@ -147,6 +159,18 @@ fn auth_request(method: Method, uri: &str, token: &str, body: Body) -> Request<B
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bearer_request(method: Method, uri: &str, token: &str, body: Body) -> Request<Body> {
|
||||||
|
match Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
|
.body(body)
|
||||||
|
{
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(err) => panic!("failed to build bearer request: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn auth_json_request(
|
fn auth_json_request(
|
||||||
method: Method,
|
method: Method,
|
||||||
uri: &str,
|
uri: &str,
|
||||||
@@ -514,6 +538,234 @@ async fn engine_status_endpoint_reports_draining_state()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_only_api_token_allows_observability_only_routes()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
|
||||||
|
let token =
|
||||||
|
create_api_token(state.db.as_ref(), crate::db::ApiTokenAccessLevel::ReadOnly).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(bearer_request(
|
||||||
|
Method::GET,
|
||||||
|
"/api/system/info",
|
||||||
|
&token,
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(bearer_request(
|
||||||
|
Method::POST,
|
||||||
|
"/api/engine/resume",
|
||||||
|
&token,
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
drop(state);
|
||||||
|
let _ = std::fs::remove_file(config_path);
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_access_api_token_allows_mutation_routes()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
|
||||||
|
let token = create_api_token(
|
||||||
|
state.db.as_ref(),
|
||||||
|
crate::db::ApiTokenAccessLevel::FullAccess,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(bearer_request(
|
||||||
|
Method::POST,
|
||||||
|
"/api/engine/resume",
|
||||||
|
&token,
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
drop(state);
|
||||||
|
let _ = std::fs::remove_file(config_path);
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_token_endpoints_create_list_and_revoke_tokens()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
|
||||||
|
let session = create_session(state.db.as_ref()).await?;
|
||||||
|
|
||||||
|
let create_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(auth_json_request(
|
||||||
|
Method::POST,
|
||||||
|
"/api/settings/api-tokens",
|
||||||
|
&session,
|
||||||
|
json!({
|
||||||
|
"name": "Prometheus",
|
||||||
|
"access_level": "read_only"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create_response.status(), StatusCode::OK);
|
||||||
|
let create_payload: serde_json::Value =
|
||||||
|
serde_json::from_slice(&to_bytes(create_response.into_body(), usize::MAX).await?)?;
|
||||||
|
assert_eq!(create_payload["token"]["name"], "Prometheus");
|
||||||
|
assert_eq!(create_payload["token"]["access_level"], "read_only");
|
||||||
|
assert!(create_payload["plaintext_token"].as_str().is_some());
|
||||||
|
|
||||||
|
let list_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(auth_request(
|
||||||
|
Method::GET,
|
||||||
|
"/api/settings/api-tokens",
|
||||||
|
&session,
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(list_response.status(), StatusCode::OK);
|
||||||
|
let list_payload: serde_json::Value =
|
||||||
|
serde_json::from_slice(&to_bytes(list_response.into_body(), usize::MAX).await?)?;
|
||||||
|
let token_id = list_payload[0]["id"].as_i64().ok_or("missing token id")?;
|
||||||
|
|
||||||
|
let revoke_response = app
|
||||||
|
.oneshot(auth_request(
|
||||||
|
Method::DELETE,
|
||||||
|
&format!("/api/settings/api-tokens/{token_id}"),
|
||||||
|
&session,
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(revoke_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let tokens = state.db.list_api_tokens().await?;
|
||||||
|
assert_eq!(tokens.len(), 1);
|
||||||
|
assert!(tokens[0].revoked_at.is_some());
|
||||||
|
|
||||||
|
drop(state);
|
||||||
|
let _ = std::fs::remove_file(config_path);
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_token_storage_hashes_plaintext_token_material()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (state, _app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
|
||||||
|
let plaintext = format!("api-token-{}", rand::random::<u64>());
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.create_api_token(
|
||||||
|
"hash-test",
|
||||||
|
&plaintext,
|
||||||
|
crate::db::ApiTokenAccessLevel::ReadOnly,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let record = state
|
||||||
|
.db
|
||||||
|
.get_active_api_token(&plaintext)
|
||||||
|
.await?
|
||||||
|
.ok_or("missing stored api token")?;
|
||||||
|
assert_ne!(record.token_hash, plaintext);
|
||||||
|
assert_eq!(record.token_hash, crate::db::hash_api_token(&plaintext));
|
||||||
|
|
||||||
|
drop(state);
|
||||||
|
let _ = std::fs::remove_file(config_path);
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn revoked_api_token_is_rejected_by_auth_middleware()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
|
||||||
|
let token = create_api_token(
|
||||||
|
state.db.as_ref(),
|
||||||
|
crate::db::ApiTokenAccessLevel::FullAccess,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let stored = state
|
||||||
|
.db
|
||||||
|
.get_active_api_token(&token)
|
||||||
|
.await?
|
||||||
|
.ok_or("missing api token")?;
|
||||||
|
state.db.revoke_api_token(stored.id).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(bearer_request(
|
||||||
|
Method::GET,
|
||||||
|
"/api/system/info",
|
||||||
|
&token,
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
drop(state);
|
||||||
|
let _ = std::fs::remove_file(config_path);
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_only_api_token_cannot_access_settings_config()
|
||||||
|
-> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (state, app, config_path, db_path) = build_test_app(false, 8, |_| {}).await?;
|
||||||
|
let token =
|
||||||
|
create_api_token(state.db.as_ref(), crate::db::ApiTokenAccessLevel::ReadOnly).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(bearer_request(
|
||||||
|
Method::GET,
|
||||||
|
"/api/settings/config",
|
||||||
|
&token,
|
||||||
|
Body::empty(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
drop(state);
|
||||||
|
let _ = std::fs::remove_file(config_path);
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
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>> {
|
||||||
@@ -664,10 +916,11 @@ async fn setup_complete_accepts_nested_settings_payload()
|
|||||||
settings.appearance.active_theme_id = Some("midnight".to_string());
|
settings.appearance.active_theme_id = Some("midnight".to_string());
|
||||||
settings.notifications.targets = vec![crate::config::NotificationTargetConfig {
|
settings.notifications.targets = vec![crate::config::NotificationTargetConfig {
|
||||||
name: "Discord".to_string(),
|
name: "Discord".to_string(),
|
||||||
target_type: "discord".to_string(),
|
target_type: "discord_webhook".to_string(),
|
||||||
endpoint_url: "https://discord.com/api/webhooks/test".to_string(),
|
config_json: serde_json::json!({ "webhook_url": "https://discord.com/api/webhooks/test" }),
|
||||||
|
endpoint_url: Some("https://discord.com/api/webhooks/test".to_string()),
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
events: vec!["completed".to_string()],
|
events: vec!["encode.completed".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}];
|
}];
|
||||||
settings.schedule.windows = vec![crate::config::ScheduleWindowConfig {
|
settings.schedule.windows = vec![crate::config::ScheduleWindowConfig {
|
||||||
@@ -1005,10 +1258,11 @@ async fn settings_bundle_put_projects_extended_settings_to_db()
|
|||||||
payload.notifications.enabled = true;
|
payload.notifications.enabled = true;
|
||||||
payload.notifications.targets = vec![crate::config::NotificationTargetConfig {
|
payload.notifications.targets = vec![crate::config::NotificationTargetConfig {
|
||||||
name: "Discord".to_string(),
|
name: "Discord".to_string(),
|
||||||
target_type: "discord".to_string(),
|
target_type: "discord_webhook".to_string(),
|
||||||
endpoint_url: "https://discord.com/api/webhooks/test".to_string(),
|
config_json: serde_json::json!({ "webhook_url": "https://discord.com/api/webhooks/test" }),
|
||||||
|
endpoint_url: Some("https://discord.com/api/webhooks/test".to_string()),
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
events: vec!["completed".to_string()],
|
events: vec!["encode.completed".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -1035,7 +1289,7 @@ async fn settings_bundle_put_projects_extended_settings_to_db()
|
|||||||
|
|
||||||
let notifications = state.db.get_notification_targets().await?;
|
let notifications = state.db.get_notification_targets().await?;
|
||||||
assert_eq!(notifications.len(), 1);
|
assert_eq!(notifications.len(), 1);
|
||||||
assert_eq!(notifications[0].target_type, "discord");
|
assert_eq!(notifications[0].target_type, "discord_webhook");
|
||||||
|
|
||||||
let theme = state.db.get_preference("active_theme_id").await?;
|
let theme = state.db.get_preference("active_theme_id").await?;
|
||||||
assert_eq!(theme.as_deref(), Some("midnight"));
|
assert_eq!(theme.as_deref(), Some("midnight"));
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ pub fn parse_raw_config(raw_toml: &str) -> Result<Config> {
|
|||||||
let mut config: Config =
|
let mut config: Config =
|
||||||
toml::from_str(raw_toml).map_err(|err| AlchemistError::Config(err.to_string()))?;
|
toml::from_str(raw_toml).map_err(|err| AlchemistError::Config(err.to_string()))?;
|
||||||
config.migrate_legacy_notifications();
|
config.migrate_legacy_notifications();
|
||||||
|
config.apply_env_overrides();
|
||||||
config
|
config
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(|err| AlchemistError::Config(err.to_string()))?;
|
.map_err(|err| AlchemistError::Config(err.to_string()))?;
|
||||||
|
|||||||
@@ -203,13 +203,13 @@ impl HardwareState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait CommandRunner {
|
pub trait CommandRunner {
|
||||||
fn output(&self, program: &str, args: &[String]) -> std::io::Result<Output>;
|
fn output(&self, program: &str, args: &[String]) -> io::Result<Output>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SystemCommandRunner;
|
pub struct SystemCommandRunner;
|
||||||
|
|
||||||
impl CommandRunner for SystemCommandRunner {
|
impl CommandRunner for SystemCommandRunner {
|
||||||
fn output(&self, program: &str, args: &[String]) -> std::io::Result<Output> {
|
fn output(&self, program: &str, args: &[String]) -> io::Result<Output> {
|
||||||
run_command_with_timeout(program, args, Duration::from_secs(8))
|
run_command_with_timeout(program, args, Duration::from_secs(8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1389,7 +1389,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommandRunner for FakeRunner {
|
impl CommandRunner for FakeRunner {
|
||||||
fn output(&self, program: &str, args: &[String]) -> std::io::Result<Output> {
|
fn output(&self, program: &str, args: &[String]) -> io::Result<Output> {
|
||||||
match program {
|
match program {
|
||||||
"nvidia-smi" if self.nvidia_smi_ok => Ok(Output {
|
"nvidia-smi" if self.nvidia_smi_ok => Ok(Output {
|
||||||
status: exit_status(true),
|
status: exit_status(true),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ impl LibraryScanner {
|
|||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
info!("🚀 Starting full library scan...");
|
info!("Starting full library scan...");
|
||||||
|
|
||||||
let watch_dirs = match db.get_watch_dirs().await {
|
let watch_dirs = match db.get_watch_dirs().await {
|
||||||
Ok(dirs) => dirs,
|
Ok(dirs) => dirs,
|
||||||
@@ -141,7 +141,7 @@ impl LibraryScanner {
|
|||||||
s.files_added = added;
|
s.files_added = added;
|
||||||
s.is_running = false;
|
s.is_running = false;
|
||||||
s.current_folder = None;
|
s.current_folder = None;
|
||||||
info!("✅ Library scan complete. Added {} new files.", added);
|
info!("Library scan complete. Added {} new files.", added);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async fn v0_2_5_fixture_upgrades_and_preserves_core_state() -> Result<()> {
|
|||||||
|
|
||||||
let notifications = db.get_notification_targets().await?;
|
let notifications = db.get_notification_targets().await?;
|
||||||
assert_eq!(notifications.len(), 1);
|
assert_eq!(notifications.len(), 1);
|
||||||
assert_eq!(notifications[0].target_type, "discord");
|
assert_eq!(notifications[0].target_type, "discord_webhook");
|
||||||
|
|
||||||
let schedule_windows = db.get_schedule_windows().await?;
|
let schedule_windows = db.get_schedule_windows().await?;
|
||||||
assert_eq!(schedule_windows.len(), 1);
|
assert_eq!(schedule_windows.len(), 1);
|
||||||
@@ -101,7 +101,7 @@ async fn v0_2_5_fixture_upgrades_and_preserves_core_state() -> Result<()> {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await?
|
.await?
|
||||||
.get("value");
|
.get("value");
|
||||||
assert_eq!(schema_version, "6");
|
assert_eq!(schema_version, "8");
|
||||||
|
|
||||||
let min_compatible_version: String =
|
let min_compatible_version: String =
|
||||||
sqlx::query("SELECT value FROM schema_info WHERE key = 'min_compatible_version'")
|
sqlx::query("SELECT value FROM schema_info WHERE key = 'min_compatible_version'")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "alchemist-web-e2e",
|
"name": "alchemist-web-e2e",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ test("About modal opens and does not contain Al badge", async ({ page }) => {
|
|||||||
ffmpeg_version: "N-12345",
|
ffmpeg_version: "N-12345",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/system/update", async (route) => {
|
||||||
|
await fulfillJson(route, 200, {
|
||||||
|
current_version: "0.3.0",
|
||||||
|
latest_version: "0.3.1",
|
||||||
|
update_available: true,
|
||||||
|
release_url: "https://github.com/bybrooklyn/alchemist/releases/tag/v0.3.1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("button", { name: "About" }).click();
|
await page.getByRole("button", { name: "About" }).click();
|
||||||
@@ -84,6 +92,8 @@ test("About modal opens and does not contain Al badge", async ({ page }) => {
|
|||||||
await expect(page.getByRole("dialog")).toBeVisible();
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "Alchemist" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Alchemist" })).toBeVisible();
|
||||||
await expect(page.getByText("v0.3.0")).toBeVisible();
|
await expect(page.getByText("v0.3.0")).toBeVisible();
|
||||||
|
await expect(page.getByText("v0.3.1")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Download Update" })).toBeVisible();
|
||||||
await expect(page.getByText(/^Al$/)).toHaveCount(0);
|
await expect(page.getByText(/^Al$/)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
27
web/bun.lock
generated
27
web/bun.lock
generated
@@ -35,6 +35,7 @@
|
|||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
"svgo": "^4.0.1",
|
"svgo": "^4.0.1",
|
||||||
"unstorage": "^1.17.5",
|
"unstorage": "^1.17.5",
|
||||||
|
"vite": "6.4.2",
|
||||||
"yaml": "^2.8.3",
|
"yaml": "^2.8.3",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -670,30 +671,6 @@
|
|||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
|
||||||
|
|
||||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
|
||||||
|
|
||||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
@@ -1066,7 +1043,7 @@
|
|||||||
|
|
||||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||||
|
|
||||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "alchemist-web",
|
"name": "alchemist-web",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"rollup": "^4.60.1",
|
"rollup": "^4.60.1",
|
||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
"svgo": "^4.0.1",
|
"svgo": "^4.0.1",
|
||||||
|
"vite": "6.4.2",
|
||||||
"unstorage": "^1.17.5",
|
"unstorage": "^1.17.5",
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ interface SystemInfo {
|
|||||||
ffmpeg_version: string;
|
ffmpeg_version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpdateInfo {
|
||||||
|
current_version: string;
|
||||||
|
latest_version: string | null;
|
||||||
|
update_available: boolean;
|
||||||
|
release_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface AboutDialogProps {
|
interface AboutDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -34,6 +41,7 @@ function focusableElements(root: HTMLElement): HTMLElement[] {
|
|||||||
|
|
||||||
export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
||||||
const [info, setInfo] = useState<SystemInfo | null>(null);
|
const [info, setInfo] = useState<SystemInfo | null>(null);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
const lastFocusedRef = useRef<HTMLElement | null>(null);
|
const lastFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -48,6 +56,16 @@ export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
|||||||
}
|
}
|
||||||
}, [isOpen, info]);
|
}, [isOpen, info]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !updateInfo) {
|
||||||
|
apiJson<UpdateInfo>("/api/system/update")
|
||||||
|
.then(setUpdateInfo)
|
||||||
|
.catch(() => {
|
||||||
|
// Non-critical; keep update checks soft-fail.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, updateInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return;
|
return;
|
||||||
@@ -161,6 +179,31 @@ export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
|||||||
<InfoRow icon={Server} label="System" value={info.os_version} />
|
<InfoRow icon={Server} label="System" value={info.os_version} />
|
||||||
<InfoRow icon={Cpu} label="Environment" value={info.is_docker ? "Docker Container" : "Native"} />
|
<InfoRow icon={Cpu} label="Environment" value={info.is_docker ? "Docker Container" : "Native"} />
|
||||||
<InfoRow icon={ShieldCheck} label="Telemetry" value={info.telemetry_enabled ? "Enabled" : "Disabled"} />
|
<InfoRow icon={ShieldCheck} label="Telemetry" value={info.telemetry_enabled ? "Enabled" : "Disabled"} />
|
||||||
|
{updateInfo?.latest_version && (
|
||||||
|
<div className="rounded-xl bg-helios-surface-soft border border-helios-line/10 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-helios-slate">Latest Stable</p>
|
||||||
|
<p className="text-sm font-bold text-helios-ink">v{updateInfo.latest_version}</p>
|
||||||
|
</div>
|
||||||
|
{updateInfo.update_available && updateInfo.release_url && (
|
||||||
|
<a
|
||||||
|
href={updateInfo.release_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-lg bg-helios-solar px-3 py-2 text-xs font-bold text-helios-main hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Download Update
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-helios-slate">
|
||||||
|
{updateInfo.update_available
|
||||||
|
? "A newer stable release is available."
|
||||||
|
: "You are on the latest stable release."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center p-8">
|
<div className="flex justify-center p-8">
|
||||||
|
|||||||
216
web/src/components/ApiTokenSettings.tsx
Normal file
216
web/src/components/ApiTokenSettings.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { KeyRound, Plus, ShieldCheck, Trash2 } from "lucide-react";
|
||||||
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||||
|
|
||||||
|
type ApiTokenAccessLevel = "read_only" | "full_access";
|
||||||
|
|
||||||
|
interface ApiToken {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
access_level: ApiTokenAccessLevel;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedApiTokenResponse {
|
||||||
|
token: ApiToken;
|
||||||
|
plaintext_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiTokenSettings() {
|
||||||
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [accessLevel, setAccessLevel] = useState<ApiTokenAccessLevel>("read_only");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
|
||||||
|
const [createdTokenValue, setCreatedTokenValue] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchTokens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTokens = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiJson<ApiToken[]>("/api/settings/api-tokens");
|
||||||
|
setTokens(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(isApiError(err) ? err.message : "Failed to load API tokens.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload = await apiJson<CreatedApiTokenResponse>("/api/settings/api-tokens", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
access_level: accessLevel,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setTokens((current) => [payload.token, ...current]);
|
||||||
|
setCreatedTokenValue(payload.plaintext_token);
|
||||||
|
setName("");
|
||||||
|
setAccessLevel("read_only");
|
||||||
|
showToast({
|
||||||
|
kind: "success",
|
||||||
|
title: "API Tokens",
|
||||||
|
message: "Token created. Copy it now — it will not be shown again.",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : "Failed to create API token.";
|
||||||
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "API Tokens", message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await apiAction(`/api/settings/api-tokens/${id}`, { method: "DELETE" });
|
||||||
|
setTokens((current) =>
|
||||||
|
current.map((token) =>
|
||||||
|
token.id === id
|
||||||
|
? { ...token, revoked_at: new Date().toISOString() }
|
||||||
|
: token,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
showToast({
|
||||||
|
kind: "success",
|
||||||
|
title: "API Tokens",
|
||||||
|
message: "Token revoked.",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : "Failed to revoke token.";
|
||||||
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "API Tokens", message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" aria-live="polite">
|
||||||
|
<div className="rounded-xl border border-helios-line/20 bg-helios-surface-soft p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink">
|
||||||
|
<ShieldCheck size={16} className="text-helios-solar" />
|
||||||
|
Static API Tokens
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-helios-slate">
|
||||||
|
Read-only tokens are observability-only. Full-access tokens can do everything an authenticated session can do.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-status-error/20 bg-status-error/10 px-4 py-3 text-sm text-status-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createdTokenValue && (
|
||||||
|
<div className="rounded-lg border border-helios-solar/30 bg-helios-solar/10 px-4 py-3">
|
||||||
|
<p className="text-xs font-semibold text-helios-main">Copy this token now</p>
|
||||||
|
<p className="mt-2 break-all font-mono text-sm text-helios-ink">{createdTokenValue}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="grid gap-4 rounded-xl border border-helios-line/20 bg-helios-surface p-4 md:grid-cols-[1fr_220px_auto]">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-helios-slate mb-1">Token Name</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className="w-full bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||||
|
placeholder="Home Assistant"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-helios-slate mb-1">Access Level</label>
|
||||||
|
<select
|
||||||
|
value={accessLevel}
|
||||||
|
onChange={(event) => setAccessLevel(event.target.value as ApiTokenAccessLevel)}
|
||||||
|
className="w-full bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||||
|
>
|
||||||
|
<option value="read_only">Read Only</option>
|
||||||
|
<option value="full_access">Full Access</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="self-end flex items-center justify-center gap-2 rounded-lg bg-helios-solar px-4 py-2 text-sm font-bold text-helios-main"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Create Token
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-helios-slate animate-pulse">Loading API tokens…</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<div key={token.id} className="flex items-center justify-between gap-4 rounded-xl border border-helios-line/10 bg-helios-surface p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-lg bg-helios-surface-soft p-2 text-helios-slate">
|
||||||
|
<KeyRound size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-helios-ink">{token.name}</h3>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-helios-slate">
|
||||||
|
<span className="rounded bg-helios-surface-soft px-1.5 py-0.5">
|
||||||
|
{token.access_level}
|
||||||
|
</span>
|
||||||
|
<span>Created {new Date(token.created_at).toLocaleString()}</span>
|
||||||
|
<span>
|
||||||
|
{token.last_used_at
|
||||||
|
? `Last used ${new Date(token.last_used_at).toLocaleString()}`
|
||||||
|
: "Never used"}
|
||||||
|
</span>
|
||||||
|
{token.revoked_at && (
|
||||||
|
<span className="text-status-error">
|
||||||
|
Revoked {new Date(token.revoked_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDeleteId(token.id)}
|
||||||
|
disabled={Boolean(token.revoked_at)}
|
||||||
|
className="rounded-lg border border-red-500/20 p-2 text-red-500 hover:bg-red-500/10 disabled:opacity-40"
|
||||||
|
aria-label={`Revoke API token ${token.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tokens.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-helios-line/10 bg-helios-surface p-6 text-sm text-helios-slate">
|
||||||
|
No API tokens created yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDeleteId !== null}
|
||||||
|
title="Revoke API token"
|
||||||
|
description="Revoke this token? Existing automations or scripts using it will stop working immediately."
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
tone="danger"
|
||||||
|
onClose={() => setPendingDeleteId(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (pendingDeleteId === null) return;
|
||||||
|
await handleRevoke(pendingDeleteId);
|
||||||
|
setPendingDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -10,7 +11,7 @@ export default function AuthGuard() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
const path = window.location.pathname;
|
const path = stripBasePath(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;
|
||||||
@@ -27,7 +28,9 @@ export default function AuthGuard() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = setupStatus.setup_required ? "/setup" : "/login";
|
window.location.href = setupStatus.setup_required
|
||||||
|
? 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.
|
||||||
}
|
}
|
||||||
|
|||||||
525
web/src/components/ConversionTool.tsx
Normal file
525
web/src/components/ConversionTool.tsx
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Upload, Wand2, Play, Download, Trash2 } from "lucide-react";
|
||||||
|
import { apiAction, apiFetch, apiJson, isApiError } from "../lib/api";
|
||||||
|
import { withBasePath } from "../lib/basePath";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
|
interface SubtitleStreamMetadata {
|
||||||
|
stream_index: number;
|
||||||
|
codec_name: string;
|
||||||
|
language?: string;
|
||||||
|
title?: string;
|
||||||
|
burnable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioStreamMetadata {
|
||||||
|
stream_index: number;
|
||||||
|
codec_name: string;
|
||||||
|
language?: string;
|
||||||
|
title?: string;
|
||||||
|
channels?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaAnalysis {
|
||||||
|
metadata: {
|
||||||
|
container: string;
|
||||||
|
codec_name: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dynamic_range: string;
|
||||||
|
audio_streams: AudioStreamMetadata[];
|
||||||
|
subtitle_streams: SubtitleStreamMetadata[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversionSettings {
|
||||||
|
output_container: string;
|
||||||
|
remux_only: boolean;
|
||||||
|
video: {
|
||||||
|
codec: string;
|
||||||
|
mode: string;
|
||||||
|
value: number | null;
|
||||||
|
preset: string | null;
|
||||||
|
resolution: {
|
||||||
|
mode: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
scale_factor: number | null;
|
||||||
|
};
|
||||||
|
hdr_mode: string;
|
||||||
|
};
|
||||||
|
audio: {
|
||||||
|
codec: string;
|
||||||
|
bitrate_kbps: number | null;
|
||||||
|
channels: string | null;
|
||||||
|
};
|
||||||
|
subtitles: {
|
||||||
|
mode: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
conversion_job_id: number;
|
||||||
|
probe: MediaAnalysis;
|
||||||
|
normalized_settings: ConversionSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewResponse {
|
||||||
|
normalized_settings: ConversionSettings;
|
||||||
|
command_preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobStatusResponse {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
linked_job_id: number | null;
|
||||||
|
output_path: string | null;
|
||||||
|
download_ready: boolean;
|
||||||
|
probe: MediaAnalysis | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: ConversionSettings = {
|
||||||
|
output_container: "mkv",
|
||||||
|
remux_only: false,
|
||||||
|
video: {
|
||||||
|
codec: "hevc",
|
||||||
|
mode: "crf",
|
||||||
|
value: 24,
|
||||||
|
preset: "medium",
|
||||||
|
resolution: {
|
||||||
|
mode: "original",
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
scale_factor: null,
|
||||||
|
},
|
||||||
|
hdr_mode: "preserve",
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
codec: "copy",
|
||||||
|
bitrate_kbps: 160,
|
||||||
|
channels: "auto",
|
||||||
|
},
|
||||||
|
subtitles: {
|
||||||
|
mode: "copy",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConversionTool() {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [status, setStatus] = useState<JobStatusResponse | null>(null);
|
||||||
|
const [conversionJobId, setConversionJobId] = useState<number | null>(null);
|
||||||
|
const [probe, setProbe] = useState<MediaAnalysis | null>(null);
|
||||||
|
const [settings, setSettings] = useState<ConversionSettings>(DEFAULT_SETTINGS);
|
||||||
|
const [commandPreview, setCommandPreview] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversionJobId) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`)
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(() => {});
|
||||||
|
}, 2000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [conversionJobId]);
|
||||||
|
|
||||||
|
const updateSettings = (patch: Partial<ConversionSettings>) => {
|
||||||
|
setSettings((current) => ({ ...current, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const response = await apiFetch("/api/conversion/uploads", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as UploadResponse;
|
||||||
|
setConversionJobId(payload.conversion_job_id);
|
||||||
|
setProbe(payload.probe);
|
||||||
|
setSettings(payload.normalized_settings);
|
||||||
|
setStatus(null);
|
||||||
|
setCommandPreview("");
|
||||||
|
showToast({
|
||||||
|
kind: "success",
|
||||||
|
title: "Conversion",
|
||||||
|
message: "File uploaded and probed.",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Upload failed";
|
||||||
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "Conversion", message });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = async () => {
|
||||||
|
if (!conversionJobId) return;
|
||||||
|
setPreviewing(true);
|
||||||
|
try {
|
||||||
|
const payload = await apiJson<PreviewResponse>("/api/conversion/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversion_job_id: conversionJobId,
|
||||||
|
settings,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSettings(payload.normalized_settings);
|
||||||
|
setCommandPreview(payload.command_preview);
|
||||||
|
showToast({ kind: "success", title: "Conversion", message: "Preview updated." });
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : "Preview failed";
|
||||||
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "Conversion", message });
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
if (!conversionJobId) return;
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
await apiAction(`/api/conversion/jobs/${conversionJobId}/start`, { method: "POST" });
|
||||||
|
const payload = await apiJson<JobStatusResponse>(`/api/conversion/jobs/${conversionJobId}`);
|
||||||
|
setStatus(payload);
|
||||||
|
showToast({ kind: "success", title: "Conversion", message: "Conversion job queued." });
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : "Failed to start conversion";
|
||||||
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "Conversion", message });
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!conversionJobId) return;
|
||||||
|
try {
|
||||||
|
await apiAction(`/api/conversion/jobs/${conversionJobId}`, { method: "DELETE" });
|
||||||
|
setConversionJobId(null);
|
||||||
|
setProbe(null);
|
||||||
|
setStatus(null);
|
||||||
|
setSettings(DEFAULT_SETTINGS);
|
||||||
|
setCommandPreview("");
|
||||||
|
showToast({ kind: "success", title: "Conversion", message: "Conversion job removed." });
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : "Failed to remove conversion job";
|
||||||
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "Conversion", message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
if (!conversionJobId) return;
|
||||||
|
window.location.href = withBasePath(`/api/conversion/jobs/${conversionJobId}/download`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-helios-ink">Conversion / Remux</h1>
|
||||||
|
<p className="mt-1 text-sm text-helios-slate">
|
||||||
|
Upload a single file, inspect the streams, preview the generated FFmpeg command, and run it through Alchemist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-status-error/20 bg-status-error/10 px-4 py-3 text-sm text-status-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!probe && (
|
||||||
|
<label className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-helios-line/30 bg-helios-surface p-10 text-center cursor-pointer hover:bg-helios-surface-soft transition-colors">
|
||||||
|
<Upload size={28} className="text-helios-solar" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-helios-ink">Upload a source file</p>
|
||||||
|
<p className="text-xs text-helios-slate mt-1">The uploaded file is stored temporarily under Alchemist-managed temp storage.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
void uploadFile(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
<span className="rounded-lg bg-helios-solar px-4 py-2 text-sm font-bold text-helios-main">
|
||||||
|
{uploading ? "Uploading..." : "Choose File"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{probe && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||||
|
<Stat label="Container" value={probe.metadata.container} />
|
||||||
|
<Stat label="Video" value={probe.metadata.codec_name} />
|
||||||
|
<Stat label="Resolution" value={`${probe.metadata.width}x${probe.metadata.height}`} />
|
||||||
|
<Stat label="Dynamic Range" value={probe.metadata.dynamic_range} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-helios-ink">Output Container</h2>
|
||||||
|
<select value={settings.output_container} onChange={(event) => updateSettings({ output_container: event.target.value })} className="w-full md:w-60 bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink">
|
||||||
|
{["mkv", "mp4", "webm", "mov"].map((option) => (
|
||||||
|
<option key={option} value={option}>{option.toUpperCase()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-helios-ink">Remux Mode</h2>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-helios-ink">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.remux_only}
|
||||||
|
onChange={(event) => updateSettings({ remux_only: event.target.checked })}
|
||||||
|
/>
|
||||||
|
Remux only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-helios-slate">
|
||||||
|
Remux mode forces stream copy and disables re-encoding controls.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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">Video</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<SelectField
|
||||||
|
label="Codec"
|
||||||
|
value={settings.video.codec}
|
||||||
|
disabled={settings.remux_only}
|
||||||
|
options={["copy", "h264", "hevc", "av1"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, codec: value } }))}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Mode"
|
||||||
|
value={settings.video.mode}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
options={["crf", "bitrate"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, mode: value } }))}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label={settings.video.mode === "bitrate" ? "Bitrate (kbps)" : "Quality Value"}
|
||||||
|
value={settings.video.value ?? 0}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, value } }))}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Preset"
|
||||||
|
value={settings.video.preset ?? "medium"}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
options={["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, preset: value } }))}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Resolution Mode"
|
||||||
|
value={settings.video.resolution.mode}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
options={["original", "custom", "scale_factor"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, mode: value } } }))}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="HDR"
|
||||||
|
value={settings.video.hdr_mode}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
options={["preserve", "tonemap", "strip_metadata"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, hdr_mode: value } }))}
|
||||||
|
/>
|
||||||
|
{settings.video.resolution.mode === "custom" && (
|
||||||
|
<>
|
||||||
|
<NumberField
|
||||||
|
label="Width"
|
||||||
|
value={settings.video.resolution.width ?? probe.metadata.width}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, width: value } } }))}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Height"
|
||||||
|
value={settings.video.resolution.height ?? probe.metadata.height}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, height: value } } }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{settings.video.resolution.mode === "scale_factor" && (
|
||||||
|
<NumberField
|
||||||
|
label="Scale Factor"
|
||||||
|
value={settings.video.resolution.scale_factor ?? 1}
|
||||||
|
disabled={settings.remux_only || settings.video.codec === "copy"}
|
||||||
|
step="0.1"
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, video: { ...current.video, resolution: { ...current.video.resolution, scale_factor: value } } }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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">Audio</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<SelectField
|
||||||
|
label="Codec"
|
||||||
|
value={settings.audio.codec}
|
||||||
|
disabled={settings.remux_only}
|
||||||
|
options={["copy", "aac", "opus", "mp3"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, codec: value } }))}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Bitrate (kbps)"
|
||||||
|
value={settings.audio.bitrate_kbps ?? 160}
|
||||||
|
disabled={settings.remux_only || settings.audio.codec === "copy"}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, bitrate_kbps: value } }))}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Channels"
|
||||||
|
value={settings.audio.channels ?? "auto"}
|
||||||
|
disabled={settings.remux_only || settings.audio.codec === "copy"}
|
||||||
|
options={["auto", "stereo", "5.1"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, audio: { ...current.audio, channels: value } }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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">Subtitles</h2>
|
||||||
|
<SelectField
|
||||||
|
label="Mode"
|
||||||
|
value={settings.subtitles.mode}
|
||||||
|
disabled={settings.remux_only}
|
||||||
|
options={["copy", "burn", "remove"]}
|
||||||
|
onChange={(value) => setSettings((current) => ({ ...current, subtitles: { mode: value } }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button onClick={() => void preview()} disabled={previewing} className="flex items-center gap-2 rounded-lg bg-helios-solar px-4 py-2 text-sm font-bold text-helios-main">
|
||||||
|
<Wand2 size={16} />
|
||||||
|
{previewing ? "Previewing..." : "Preview Command"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => void start()} disabled={starting || !commandPreview} className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink">
|
||||||
|
<Play size={16} />
|
||||||
|
{starting ? "Starting..." : "Start Job"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => void download()} disabled={!status?.download_ready} className="flex items-center gap-2 rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink disabled:opacity-50">
|
||||||
|
<Download size={16} />
|
||||||
|
Download Result
|
||||||
|
</button>
|
||||||
|
<button onClick={() => void remove()} className="flex items-center gap-2 rounded-lg border border-red-500/20 px-4 py-2 text-sm font-semibold text-red-500">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{commandPreview && (
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft p-4 text-xs text-helios-ink whitespace-pre-wrap">
|
||||||
|
{commandPreview}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<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>
|
||||||
|
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||||
|
<Stat label="State" value={status.status} />
|
||||||
|
<Stat label="Progress" value={`${status.progress.toFixed(1)}%`} />
|
||||||
|
<Stat label="Linked Job" value={status.linked_job_id ? `#${status.linked_job_id}` : "None"} />
|
||||||
|
<Stat label="Download" value={status.download_ready ? "Ready" : "Pending"} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3">
|
||||||
|
<div className="text-xs text-helios-slate">{label}</div>
|
||||||
|
<div className="mt-1 font-mono text-sm font-semibold text-helios-ink">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: string[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-helios-slate mb-1">{label}</label>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
step = "1",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
step?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-helios-slate mb-1">{label}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
step={step}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
className="w-full bg-helios-surface-soft border border-helios-line/20 rounded p-2 text-sm text-helios-ink disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ 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";
|
||||||
@@ -144,7 +145,7 @@ function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (setupComplete !== "true") {
|
if (setupComplete !== "true") {
|
||||||
window.location.href = "/setup";
|
window.location.href = withBasePath("/setup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -232,7 +233,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="/jobs" className="text-xs font-medium text-helios-solar hover:underline">
|
<a href={withBasePath("/jobs")} className="text-xs font-medium text-helios-solar hover:underline">
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +249,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="/settings" className="text-xs text-helios-solar hover:underline">
|
<a href={withBasePath("/settings")} className="text-xs text-helios-solar hover:underline">
|
||||||
Add a library folder
|
Add a library folder
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ export default function HeaderActions() {
|
|||||||
message: "Logout request failed. Redirecting to login.",
|
message: "Logout request failed. Redirecting to login.",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
window.location.href = '/login';
|
window.location.href = withBasePath("/login");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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";
|
||||||
@@ -664,7 +665,7 @@ function JobManager() {
|
|||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
eventSource = new EventSource("/api/events");
|
eventSource = new EventSource(withBasePath("/api/events"));
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
// Reset reconnect attempts on successful connection
|
// Reset reconnect attempts on successful connection
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Copy, AlertTriangle } from "lucide-react";
|
import { AlertTriangle, Copy, Sparkles } from "lucide-react";
|
||||||
import { apiJson, isApiError } from "../lib/api";
|
import { apiJson, isApiError } from "../lib/api";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
@@ -15,9 +15,26 @@ interface DuplicateGroup {
|
|||||||
paths: DuplicatePath[];
|
paths: DuplicatePath[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecommendationCounts {
|
||||||
|
duplicates: number;
|
||||||
|
remux_only_candidate: number;
|
||||||
|
wasteful_audio_layout: number;
|
||||||
|
commentary_cleanup_candidate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntelligenceRecommendation {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
path: string;
|
||||||
|
suggested_action: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface IntelligenceResponse {
|
interface IntelligenceResponse {
|
||||||
duplicate_groups: DuplicateGroup[];
|
duplicate_groups: DuplicateGroup[];
|
||||||
total_duplicates: number;
|
total_duplicates: number;
|
||||||
|
recommendation_counts: RecommendationCounts;
|
||||||
|
recommendations: IntelligenceRecommendation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_DOT: Record<string, string> = {
|
const STATUS_DOT: Record<string, string> = {
|
||||||
@@ -31,6 +48,12 @@ const STATUS_DOT: Record<string, string> = {
|
|||||||
queued: "bg-helios-slate/30",
|
queued: "bg-helios-slate/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
remux_only_candidate: "Remux Opportunities",
|
||||||
|
wasteful_audio_layout: "Wasteful Audio Layouts",
|
||||||
|
commentary_cleanup_candidate: "Commentary Cleanup",
|
||||||
|
};
|
||||||
|
|
||||||
export default function LibraryIntelligence() {
|
export default function LibraryIntelligence() {
|
||||||
const [data, setData] = useState<IntelligenceResponse | null>(null);
|
const [data, setData] = useState<IntelligenceResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -57,12 +80,21 @@ export default function LibraryIntelligence() {
|
|||||||
void fetch();
|
void fetch();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const groupedRecommendations = data?.recommendations.reduce<Record<string, IntelligenceRecommendation[]>>(
|
||||||
|
(groups, recommendation) => {
|
||||||
|
groups[recommendation.type] ??= [];
|
||||||
|
groups[recommendation.type].push(recommendation);
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
) ?? {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-helios-ink">Library Intelligence</h1>
|
<h1 className="text-xl font-bold text-helios-ink">Library Intelligence</h1>
|
||||||
<p className="mt-1 text-sm text-helios-slate">
|
<p className="mt-1 text-sm text-helios-slate">
|
||||||
Files that appear more than once across your library, grouped by filename.
|
Deterministic storage-focused recommendations based on duplicate detection, planner output, and stream metadata.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,31 +112,52 @@ export default function LibraryIntelligence() {
|
|||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<div className="rounded-lg border border-helios-line/30 bg-helios-surface px-5 py-4">
|
<StatCard label="Duplicate groups" value={String(data.duplicate_groups.length)} accent="text-helios-ink" />
|
||||||
<p className="text-xs font-medium text-helios-slate">
|
<StatCard label="Extra copies" value={String(data.total_duplicates)} accent="text-helios-solar" />
|
||||||
Duplicate groups
|
<StatCard label="Remux opportunities" value={String(data.recommendation_counts.remux_only_candidate)} accent="text-helios-cyan" />
|
||||||
</p>
|
<StatCard label="Audio / commentary" value={String(data.recommendation_counts.wasteful_audio_layout + data.recommendation_counts.commentary_cleanup_candidate)} accent="text-helios-ink" />
|
||||||
<p className="mt-1 font-mono text-2xl font-bold text-helios-ink">
|
|
||||||
{data.duplicate_groups.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-helios-line/30 bg-helios-surface px-5 py-4">
|
|
||||||
<p className="text-xs font-medium text-helios-slate">Extra copies</p>
|
|
||||||
<p className="mt-1 font-mono text-2xl font-bold text-helios-solar">
|
|
||||||
{data.total_duplicates}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(groupedRecommendations).length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(groupedRecommendations).map(([type, recommendations]) => (
|
||||||
|
<section key={type} className="rounded-lg border border-helios-line/30 bg-helios-surface overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-helios-line/20 bg-helios-surface-soft/40 px-5 py-3">
|
||||||
|
<Sparkles size={14} className="text-helios-solar" />
|
||||||
|
<h2 className="text-sm font-semibold text-helios-ink">
|
||||||
|
{TYPE_LABELS[type] ?? type}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-helios-line/10">
|
||||||
|
{recommendations.map((recommendation, index) => (
|
||||||
|
<div key={`${recommendation.path}-${index}`} className="px-5 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-helios-ink">{recommendation.title}</h3>
|
||||||
|
<p className="mt-1 text-sm text-helios-slate">{recommendation.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 break-all font-mono text-xs text-helios-slate">{recommendation.path}</p>
|
||||||
|
<div className="mt-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-3 py-2 text-xs text-helios-ink">
|
||||||
|
<span className="font-semibold text-helios-solar">Suggested action:</span> {recommendation.suggested_action}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.duplicate_groups.length === 0 ? (
|
{data.duplicate_groups.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-helios-line/30 bg-helios-surface p-10 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-helios-line/30 bg-helios-surface p-10 text-center">
|
||||||
<AlertTriangle size={28} className="text-helios-slate/40" />
|
<AlertTriangle size={28} className="text-helios-slate/40" />
|
||||||
<p className="text-sm font-medium text-helios-ink">
|
<p className="text-sm font-medium text-helios-ink">
|
||||||
No duplicates found
|
No duplicate groups found
|
||||||
</p>
|
</p>
|
||||||
<p className="max-w-xs text-xs text-helios-slate">
|
<p className="max-w-xs text-xs text-helios-slate">
|
||||||
Every filename in your library appears to be unique.
|
Every tracked basename in your library appears to be unique.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -159,3 +212,20 @@ export default function LibraryIntelligence() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-helios-line/30 bg-helios-surface px-5 py-4">
|
||||||
|
<p className="text-xs font-medium text-helios-slate">{label}</p>
|
||||||
|
<p className={`mt-1 font-mono text-2xl font-bold ${accent}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ export default function LogViewer() {
|
|||||||
|
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
eventSource = new EventSource("/api/events");
|
eventSource = new EventSource(withBasePath("/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,33 +1,144 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Plus, Trash2, Zap } from "lucide-react";
|
import { Bell, Plus, Trash2, Zap } from "lucide-react";
|
||||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
import ConfirmDialog from "./ui/ConfirmDialog";
|
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||||
|
|
||||||
|
type NotificationTargetType =
|
||||||
|
| "discord_webhook"
|
||||||
|
| "discord_bot"
|
||||||
|
| "gotify"
|
||||||
|
| "webhook"
|
||||||
|
| "telegram"
|
||||||
|
| "email";
|
||||||
|
|
||||||
interface NotificationTarget {
|
interface NotificationTarget {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
target_type: "gotify" | "discord" | "webhook";
|
target_type: NotificationTargetType;
|
||||||
endpoint_url: string;
|
config_json: Record<string, unknown>;
|
||||||
auth_token?: string;
|
events: string[];
|
||||||
events: string;
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TARGET_TYPES: NotificationTarget["target_type"][] = ["discord", "gotify", "webhook"];
|
interface NotificationsSettingsResponse {
|
||||||
|
daily_summary_time_local: string;
|
||||||
|
targets: NotificationTarget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyNotificationTarget {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
target_type: "discord" | "gotify" | "webhook";
|
||||||
|
endpoint_url: string;
|
||||||
|
auth_token: string | null;
|
||||||
|
events: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGET_TYPES: Array<{ value: NotificationTargetType; label: string }> = [
|
||||||
|
{ value: "discord_webhook", label: "Discord Webhook" },
|
||||||
|
{ value: "discord_bot", label: "Discord Bot" },
|
||||||
|
{ value: "gotify", label: "Gotify" },
|
||||||
|
{ value: "webhook", label: "Generic Webhook" },
|
||||||
|
{ value: "telegram", label: "Telegram" },
|
||||||
|
{ value: "email", label: "Email" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_OPTIONS = [
|
||||||
|
"encode.queued",
|
||||||
|
"encode.started",
|
||||||
|
"encode.completed",
|
||||||
|
"encode.failed",
|
||||||
|
"scan.completed",
|
||||||
|
"engine.idle",
|
||||||
|
"daily.summary",
|
||||||
|
];
|
||||||
|
|
||||||
|
function targetSummary(target: NotificationTarget): string {
|
||||||
|
const config = target.config_json;
|
||||||
|
switch (target.target_type) {
|
||||||
|
case "discord_webhook":
|
||||||
|
return String(config.webhook_url ?? "");
|
||||||
|
case "discord_bot":
|
||||||
|
return `channel ${String(config.channel_id ?? "")}`;
|
||||||
|
case "gotify":
|
||||||
|
return String(config.server_url ?? "");
|
||||||
|
case "webhook":
|
||||||
|
return String(config.url ?? "");
|
||||||
|
case "telegram":
|
||||||
|
return `chat ${String(config.chat_id ?? "")}`;
|
||||||
|
case "email":
|
||||||
|
return String((config.to_addresses as string[] | undefined)?.join(", ") ?? "");
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTarget(target: NotificationTarget | LegacyNotificationTarget): NotificationTarget {
|
||||||
|
if ("config_json" in target) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedType: NotificationTargetType =
|
||||||
|
target.target_type === "discord" ? "discord_webhook" : target.target_type;
|
||||||
|
const config_json =
|
||||||
|
normalizedType === "discord_webhook"
|
||||||
|
? { webhook_url: target.endpoint_url }
|
||||||
|
: normalizedType === "gotify"
|
||||||
|
? { server_url: target.endpoint_url, app_token: target.auth_token ?? "" }
|
||||||
|
: { url: target.endpoint_url, auth_token: target.auth_token ?? "" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: target.id,
|
||||||
|
name: target.name,
|
||||||
|
target_type: normalizedType,
|
||||||
|
config_json,
|
||||||
|
events: JSON.parse(target.events),
|
||||||
|
enabled: target.enabled,
|
||||||
|
created_at: target.created_at ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfigForType(type: NotificationTargetType): Record<string, unknown> {
|
||||||
|
switch (type) {
|
||||||
|
case "discord_webhook":
|
||||||
|
return { webhook_url: "" };
|
||||||
|
case "discord_bot":
|
||||||
|
return { bot_token: "", channel_id: "" };
|
||||||
|
case "gotify":
|
||||||
|
return { server_url: "", app_token: "" };
|
||||||
|
case "webhook":
|
||||||
|
return { url: "", auth_token: "" };
|
||||||
|
case "telegram":
|
||||||
|
return { bot_token: "", chat_id: "" };
|
||||||
|
case "email":
|
||||||
|
return {
|
||||||
|
smtp_host: "",
|
||||||
|
smtp_port: 587,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
from_address: "",
|
||||||
|
to_addresses: [""],
|
||||||
|
security: "starttls",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function NotificationSettings() {
|
export default function NotificationSettings() {
|
||||||
const [targets, setTargets] = useState<NotificationTarget[]>([]);
|
const [targets, setTargets] = useState<NotificationTarget[]>([]);
|
||||||
|
const [dailySummaryTime, setDailySummaryTime] = useState("09:00");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [testingId, setTestingId] = useState<number | null>(null);
|
const [testingId, setTestingId] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [draftName, setDraftName] = useState("");
|
||||||
const [newType, setNewType] = useState<NotificationTarget["target_type"]>("discord");
|
const [draftType, setDraftType] = useState<NotificationTargetType>("discord_webhook");
|
||||||
const [newUrl, setNewUrl] = useState("");
|
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>(defaultConfigForType("discord_webhook"));
|
||||||
const [newToken, setNewToken] = useState("");
|
const [draftEvents, setDraftEvents] = useState<string[]>(["encode.completed", "encode.failed"]);
|
||||||
const [newEvents, setNewEvents] = useState<string[]>(["completed", "failed"]);
|
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
|
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,8 +147,16 @@ export default function NotificationSettings() {
|
|||||||
|
|
||||||
const fetchTargets = async () => {
|
const fetchTargets = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiJson<NotificationTarget[]>("/api/settings/notifications");
|
const data = await apiJson<NotificationsSettingsResponse | LegacyNotificationTarget[]>(
|
||||||
setTargets(data);
|
"/api/settings/notifications",
|
||||||
|
);
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setTargets(data.map(normalizeTarget));
|
||||||
|
setDailySummaryTime("09:00");
|
||||||
|
} else {
|
||||||
|
setTargets(data.targets.map(normalizeTarget));
|
||||||
|
setDailySummaryTime(data.daily_summary_time_local);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = isApiError(e) ? e.message : "Failed to load notification targets";
|
const message = isApiError(e) ? e.message : "Failed to load notification targets";
|
||||||
@@ -47,6 +166,32 @@ export default function NotificationSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveDailySummaryTime = async () => {
|
||||||
|
try {
|
||||||
|
await apiAction("/api/settings/notifications", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ daily_summary_time_local: dailySummaryTime }),
|
||||||
|
});
|
||||||
|
showToast({
|
||||||
|
kind: "success",
|
||||||
|
title: "Notifications",
|
||||||
|
message: "Daily summary time saved.",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const message = isApiError(e) ? e.message : "Failed to save daily summary time";
|
||||||
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "Notifications", message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDraft = (type: NotificationTargetType = "discord_webhook") => {
|
||||||
|
setDraftName("");
|
||||||
|
setDraftType(type);
|
||||||
|
setDraftConfig(defaultConfigForType(type));
|
||||||
|
setDraftEvents(["encode.completed", "encode.failed"]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd = async (e: React.FormEvent) => {
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -54,18 +199,15 @@ export default function NotificationSettings() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: newName,
|
name: draftName,
|
||||||
target_type: newType,
|
target_type: draftType,
|
||||||
endpoint_url: newUrl,
|
config_json: draftConfig,
|
||||||
auth_token: newToken || null,
|
events: draftEvents,
|
||||||
events: newEvents,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setNewName("");
|
resetDraft();
|
||||||
setNewUrl("");
|
|
||||||
setNewToken("");
|
|
||||||
setError(null);
|
setError(null);
|
||||||
await fetchTargets();
|
await fetchTargets();
|
||||||
showToast({ kind: "success", title: "Notifications", message: "Target added." });
|
showToast({ kind: "success", title: "Notifications", message: "Target added." });
|
||||||
@@ -92,29 +234,17 @@ export default function NotificationSettings() {
|
|||||||
const handleTest = async (target: NotificationTarget) => {
|
const handleTest = async (target: NotificationTarget) => {
|
||||||
setTestingId(target.id);
|
setTestingId(target.id);
|
||||||
try {
|
try {
|
||||||
let events: string[] = [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(target.events);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
events = parsed;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
events = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
await apiAction("/api/settings/notifications/test", {
|
await apiAction("/api/settings/notifications/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: target.name,
|
name: target.name,
|
||||||
target_type: target.target_type,
|
target_type: target.target_type,
|
||||||
endpoint_url: target.endpoint_url,
|
config_json: target.config_json,
|
||||||
auth_token: target.auth_token,
|
events: target.events,
|
||||||
events,
|
|
||||||
enabled: target.enabled,
|
enabled: target.enabled,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
showToast({ kind: "success", title: "Notifications", message: "Test notification sent." });
|
showToast({ kind: "success", title: "Notifications", message: "Test notification sent." });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = isApiError(e) ? e.message : "Test notification failed";
|
const message = isApiError(e) ? e.message : "Test notification failed";
|
||||||
@@ -126,18 +256,46 @@ export default function NotificationSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleEvent = (evt: string) => {
|
const toggleEvent = (evt: string) => {
|
||||||
if (newEvents.includes(evt)) {
|
setDraftEvents((current) =>
|
||||||
setNewEvents(newEvents.filter(e => e !== evt));
|
current.includes(evt)
|
||||||
} else {
|
? current.filter((candidate) => candidate !== evt)
|
||||||
setNewEvents([...newEvents, evt]);
|
: [...current, evt],
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setConfigField = (key: string, value: unknown) => {
|
||||||
|
setDraftConfig((current) => ({ ...current, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" aria-live="polite">
|
<div className="space-y-6" aria-live="polite">
|
||||||
<div className="flex justify-end mb-6">
|
<div className="grid gap-4 md:grid-cols-[1fr_auto] items-end">
|
||||||
|
<div className="rounded-xl border border-helios-line/20 bg-helios-surface-soft p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink">
|
||||||
|
<Bell size={16} className="text-helios-solar" />
|
||||||
|
Daily Summary Time
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-helios-slate">
|
||||||
|
Daily summaries are opt-in per target, but they all use one global local-time send window.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={dailySummaryTime}
|
||||||
|
onChange={(event) => setDailySummaryTime(event.target.value)}
|
||||||
|
className="mt-3 w-full max-w-xs bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
onClick={() => void saveDailySummaryTime()}
|
||||||
|
className="rounded-lg border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink hover:bg-helios-surface-soft transition-colors"
|
||||||
|
>
|
||||||
|
Save Summary Time
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm((current) => !current)}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-helios-surface border border-helios-line/30 hover:bg-helios-surface-soft text-helios-ink rounded-lg text-xs font-medium transition-colors"
|
className="flex items-center gap-2 px-3 py-1.5 bg-helios-surface border border-helios-line/30 hover:bg-helios-surface-soft text-helios-ink rounded-lg text-xs font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
@@ -157,8 +315,8 @@ export default function NotificationSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-helios-slate mb-1">Name</label>
|
<label className="block text-xs font-medium text-helios-slate mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
value={newName}
|
value={draftName}
|
||||||
onChange={e => setNewName(e.target.value)}
|
onChange={(event) => setDraftName(event.target.value)}
|
||||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||||
placeholder="My Discord"
|
placeholder="My Discord"
|
||||||
required
|
required
|
||||||
@@ -167,53 +325,177 @@ export default function NotificationSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-helios-slate mb-1">Type</label>
|
<label className="block text-xs font-medium text-helios-slate mb-1">Type</label>
|
||||||
<select
|
<select
|
||||||
value={newType}
|
value={draftType}
|
||||||
onChange={e => setNewType(e.target.value as NotificationTarget["target_type"])}
|
onChange={(event) => {
|
||||||
|
const nextType = event.target.value as NotificationTargetType;
|
||||||
|
setDraftType(nextType);
|
||||||
|
setDraftConfig(defaultConfigForType(nextType));
|
||||||
|
}}
|
||||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||||
>
|
>
|
||||||
{TARGET_TYPES.map((type) => (
|
{TARGET_TYPES.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type.value} value={type.value}>
|
||||||
{type === "discord" ? "Discord Webhook" : type === "gotify" ? "Gotify" : "Generic Webhook"}
|
{type.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{draftType === "discord_webhook" && (
|
||||||
<label className="block text-xs font-medium text-helios-slate mb-1">Endpoint URL</label>
|
<TextField
|
||||||
<input
|
label="Webhook URL"
|
||||||
value={newUrl}
|
value={String(draftConfig.webhook_url ?? "")}
|
||||||
onChange={e => setNewUrl(e.target.value)}
|
onChange={(value) => setConfigField("webhook_url", value)}
|
||||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div>
|
{draftType === "discord_bot" && (
|
||||||
<label className="block text-xs font-medium text-helios-slate mb-1">Auth Token (Optional)</label>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<input
|
<TextField
|
||||||
value={newToken}
|
label="Bot Token"
|
||||||
onChange={e => setNewToken(e.target.value)}
|
value={String(draftConfig.bot_token ?? "")}
|
||||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
|
onChange={(value) => setConfigField("bot_token", value)}
|
||||||
placeholder="Bearer token or API Key"
|
placeholder="Discord bot token"
|
||||||
/>
|
/>
|
||||||
</div>
|
<TextField
|
||||||
|
label="Channel ID"
|
||||||
|
value={String(draftConfig.channel_id ?? "")}
|
||||||
|
onChange={(value) => setConfigField("channel_id", value)}
|
||||||
|
placeholder="123456789012345678"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draftType === "gotify" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField
|
||||||
|
label="Server URL"
|
||||||
|
value={String(draftConfig.server_url ?? "")}
|
||||||
|
onChange={(value) => setConfigField("server_url", value)}
|
||||||
|
placeholder="https://gotify.example.com/message"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="App Token"
|
||||||
|
value={String(draftConfig.app_token ?? "")}
|
||||||
|
onChange={(value) => setConfigField("app_token", value)}
|
||||||
|
placeholder="Gotify app token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draftType === "webhook" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField
|
||||||
|
label="Endpoint URL"
|
||||||
|
value={String(draftConfig.url ?? "")}
|
||||||
|
onChange={(value) => setConfigField("url", value)}
|
||||||
|
placeholder="https://example.com/webhook"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Bearer Token (Optional)"
|
||||||
|
value={String(draftConfig.auth_token ?? "")}
|
||||||
|
onChange={(value) => setConfigField("auth_token", value)}
|
||||||
|
placeholder="Bearer token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draftType === "telegram" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField
|
||||||
|
label="Bot Token"
|
||||||
|
value={String(draftConfig.bot_token ?? "")}
|
||||||
|
onChange={(value) => setConfigField("bot_token", value)}
|
||||||
|
placeholder="Telegram bot token"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Chat ID"
|
||||||
|
value={String(draftConfig.chat_id ?? "")}
|
||||||
|
onChange={(value) => setConfigField("chat_id", value)}
|
||||||
|
placeholder="Telegram chat ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draftType === "email" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField
|
||||||
|
label="SMTP Host"
|
||||||
|
value={String(draftConfig.smtp_host ?? "")}
|
||||||
|
onChange={(value) => setConfigField("smtp_host", value)}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="SMTP Port"
|
||||||
|
value={String(draftConfig.smtp_port ?? 587)}
|
||||||
|
onChange={(value) => setConfigField("smtp_port", Number(value))}
|
||||||
|
placeholder="587"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
value={String(draftConfig.username ?? "")}
|
||||||
|
onChange={(value) => setConfigField("username", value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
value={String(draftConfig.password ?? "")}
|
||||||
|
onChange={(value) => setConfigField("password", value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="From Address"
|
||||||
|
value={String(draftConfig.from_address ?? "")}
|
||||||
|
onChange={(value) => setConfigField("from_address", value)}
|
||||||
|
placeholder="alchemist@example.com"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="To Addresses"
|
||||||
|
value={Array.isArray(draftConfig.to_addresses) ? String((draftConfig.to_addresses as string[]).join(", ")) : ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setConfigField(
|
||||||
|
"to_addresses",
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((candidate) => candidate.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="ops@example.com, alerts@example.com"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-helios-slate mb-1">Security</label>
|
||||||
|
<select
|
||||||
|
value={String(draftConfig.security ?? "starttls")}
|
||||||
|
onChange={(event) => setConfigField("security", event.target.value)}
|
||||||
|
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||||
|
>
|
||||||
|
<option value="starttls">STARTTLS</option>
|
||||||
|
<option value="tls">TLS / SMTPS</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-helios-slate mb-2">Events</label>
|
<label className="block text-xs font-medium text-helios-slate mb-2">Events</label>
|
||||||
<div className="flex gap-4 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{["completed", "failed", "queued"].map(evt => (
|
{EVENT_OPTIONS.map((evt) => (
|
||||||
<label key={evt} className="flex items-center gap-2 text-sm text-helios-ink cursor-pointer">
|
<button
|
||||||
<input
|
key={evt}
|
||||||
type="checkbox"
|
type="button"
|
||||||
checked={newEvents.includes(evt)}
|
onClick={() => toggleEvent(evt)}
|
||||||
onChange={() => toggleEvent(evt)}
|
className={`rounded-full border px-3 py-2 text-xs font-semibold transition-all ${
|
||||||
className="rounded border-helios-line/30 bg-helios-surface accent-helios-solar"
|
draftEvents.includes(evt)
|
||||||
/>
|
? "border-helios-solar bg-helios-solar/10 text-helios-ink"
|
||||||
<span className="capitalize">{evt}</span>
|
: "border-helios-line/20 text-helios-slate"
|
||||||
</label>
|
}`}
|
||||||
|
>
|
||||||
|
{evt}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,19 +510,28 @@ export default function NotificationSettings() {
|
|||||||
<div className="text-sm text-helios-slate animate-pulse">Loading targets…</div>
|
<div className="text-sm text-helios-slate animate-pulse">Loading targets…</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{targets.map(target => (
|
{targets.map((target) => (
|
||||||
<div key={target.id} className="flex items-center justify-between p-4 bg-helios-surface border border-helios-line/10 rounded-xl group/item">
|
<div key={target.id} className="flex items-center justify-between p-4 bg-helios-surface border border-helios-line/10 rounded-xl group/item">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
|
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
|
||||||
<Zap size={18} />
|
<Bell size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
|
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
<span className="text-xs font-medium text-helios-slate bg-helios-surface-soft px-1.5 rounded">
|
<span className="text-xs font-medium text-helios-slate bg-helios-surface-soft px-1.5 rounded">
|
||||||
{target.target_type}
|
{target.target_type}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-helios-slate truncate max-w-[200px]">{target.endpoint_url}</span>
|
<span className="text-xs text-helios-slate break-all">
|
||||||
|
{targetSummary(target)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{target.events.map((eventName) => (
|
||||||
|
<span key={eventName} className="rounded-full border border-helios-line/20 px-2 py-0.5 text-[11px] text-helios-slate">
|
||||||
|
{eventName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,3 +572,27 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TextField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-helios-slate mb-1">{label}</label>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FolderOpen, Bell, Calendar, FileCog, Cog, Server, LayoutGrid, Palette, Activity, FileCode2 } from "lucide-react";
|
import { FolderOpen, Bell, Calendar, FileCog, Cog, Server, LayoutGrid, Palette, Activity, FileCode2, KeyRound } from "lucide-react";
|
||||||
import WatchFolders from "./WatchFolders";
|
import WatchFolders from "./WatchFolders";
|
||||||
import NotificationSettings from "./NotificationSettings";
|
import NotificationSettings from "./NotificationSettings";
|
||||||
import ScheduleSettings from "./ScheduleSettings";
|
import ScheduleSettings from "./ScheduleSettings";
|
||||||
@@ -10,6 +10,7 @@ import HardwareSettings from "./HardwareSettings";
|
|||||||
import AppearanceSettings from "./AppearanceSettings";
|
import AppearanceSettings from "./AppearanceSettings";
|
||||||
import QualitySettings from "./QualitySettings";
|
import QualitySettings from "./QualitySettings";
|
||||||
import ConfigEditorSettings from "./ConfigEditorSettings";
|
import ConfigEditorSettings from "./ConfigEditorSettings";
|
||||||
|
import ApiTokenSettings from "./ApiTokenSettings";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette, component: AppearanceSettings },
|
{ id: "appearance", label: "Appearance", icon: Palette, component: AppearanceSettings },
|
||||||
@@ -19,6 +20,7 @@ const TABS = [
|
|||||||
{ id: "files", label: "Output & Files", icon: FileCog, component: FileSettings },
|
{ id: "files", label: "Output & Files", icon: FileCog, component: FileSettings },
|
||||||
{ id: "schedule", label: "Automation", icon: Calendar, component: ScheduleSettings },
|
{ id: "schedule", label: "Automation", icon: Calendar, component: ScheduleSettings },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
{ id: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||||
|
{ id: "api-tokens", label: "API Tokens", icon: KeyRound, component: ApiTokenSettings },
|
||||||
{ id: "hardware", label: "Hardware", icon: LayoutGrid, component: HardwareSettings },
|
{ id: "hardware", label: "Hardware", icon: LayoutGrid, component: HardwareSettings },
|
||||||
{ id: "system", label: "Runtime", icon: Server, component: SystemSettings },
|
{ id: "system", label: "Runtime", icon: Server, component: SystemSettings },
|
||||||
{ id: "config", label: "Config", icon: FileCode2, component: ConfigEditorSettings },
|
{ id: "config", label: "Config", icon: FileCode2, component: ConfigEditorSettings },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
@@ -102,7 +103,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 = "/";
|
window.location.href = withBasePath("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message = "Failed to save setup configuration.";
|
let message = "Failed to save setup configuration.";
|
||||||
if (isApiError(err)) {
|
if (isApiError(err)) {
|
||||||
@@ -112,7 +113,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 = "/"; }, 1500);
|
setTimeout(() => { window.location.href = withBasePath("/"); }, 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 {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Settings,
|
Settings,
|
||||||
|
Wand2,
|
||||||
Video,
|
Video,
|
||||||
Terminal,
|
Terminal,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -12,6 +13,12 @@ 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 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 },
|
||||||
@@ -19,13 +26,14 @@ const navItems = [
|
|||||||
{ href: "/logs", label: "Logs", Icon: Terminal },
|
{ href: "/logs", label: "Logs", Icon: Terminal },
|
||||||
{ href: "/stats", label: "Statistics", Icon: BarChart3 },
|
{ href: "/stats", label: "Statistics", Icon: BarChart3 },
|
||||||
{ href: "/intelligence", label: "Intelligence", Icon: Sparkles },
|
{ href: "/intelligence", label: "Intelligence", Icon: Sparkles },
|
||||||
|
{ href: "/convert", label: "Convert", Icon: Wand2 },
|
||||||
{ href: "/settings", label: "Settings", Icon: Settings },
|
{ href: "/settings", label: "Settings", Icon: Settings },
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
{/* 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="/" class="font-bold text-lg tracking-tight text-helios-ink">Alchemist</a>
|
<a href={withBase("/")} 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"
|
||||||
@@ -50,7 +58,7 @@ const navItems = [
|
|||||||
transition-transform duration-200 lg:transition-none"
|
transition-transform duration-200 lg:transition-none"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={withBase("/")}
|
||||||
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">
|
||||||
@@ -69,11 +77,11 @@ const navItems = [
|
|||||||
{
|
{
|
||||||
navItems.map(({ href, label, Icon }) => {
|
navItems.map(({ href, label, Icon }) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
currentPath === href ||
|
strippedPath === href ||
|
||||||
(href !== "/" && currentPath.startsWith(href));
|
(href !== "/" && strippedPath.startsWith(href));
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={withBase(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,6 +9,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { title } = Astro.props;
|
const { title } = Astro.props;
|
||||||
|
const basePath = "__ALCHEMIST_BASE_URL__";
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -17,9 +18,12 @@ const { title } = Astro.props;
|
|||||||
<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="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href={`${basePath}/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,3 +1,5 @@
|
|||||||
|
import { stripBasePath, withBasePath } from "./basePath";
|
||||||
|
|
||||||
export interface ApiErrorShape {
|
export interface ApiErrorShape {
|
||||||
status: number;
|
status: number;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -83,6 +85,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 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") {
|
||||||
@@ -105,7 +108,7 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(resolvedUrl, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
credentials: options.credentials ?? "same-origin",
|
credentials: options.credentials ?? "same-origin",
|
||||||
@@ -113,10 +116,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 = window.location.pathname;
|
const path = stripBasePath(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 = "/login";
|
window.location.href = withBasePath("/login");
|
||||||
return new Promise(() => {});
|
return new Promise(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +136,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(url, response);
|
throw await toApiError(withBasePath(url), response);
|
||||||
}
|
}
|
||||||
return (await parseResponseBody(response)) as T;
|
return (await parseResponseBody(response)) as T;
|
||||||
}
|
}
|
||||||
@@ -141,7 +144,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(url, response);
|
throw await toApiError(withBasePath(url), response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
web/src/lib/basePath.ts
Normal file
53
web/src/lib/basePath.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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,6 +3,7 @@ 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">
|
||||||
@@ -23,7 +24,7 @@ import HeaderActions from "../components/HeaderActions.tsx";
|
|||||||
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="/"
|
href={`${basePath}/`}
|
||||||
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,6 +7,7 @@ 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">
|
||||||
@@ -28,7 +29,7 @@ const { error } = Astro.props;
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={`${basePath}/`}
|
||||||
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
|
||||||
|
|||||||
20
web/src/pages/convert.astro
Normal file
20
web/src/pages/convert.astro
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import Sidebar from "../components/Sidebar.astro";
|
||||||
|
import HeaderActions from "../components/HeaderActions.tsx";
|
||||||
|
import ConversionTool from "../components/ConversionTool.tsx";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Alchemist | Convert">
|
||||||
|
<div class="app-shell">
|
||||||
|
<Sidebar />
|
||||||
|
<main class="app-main overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-end px-6 py-3 border-b border-helios-line/20">
|
||||||
|
<HeaderActions client:load />
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<ConversionTool client:load />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
@@ -68,11 +68,12 @@ 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 = "/setup";
|
window.location.href = `${basePath}/setup`;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -95,7 +96,7 @@ import { ArrowRight } from "lucide-react";
|
|||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
window.location.href = '/';
|
window.location.href = `${basePath}/`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Login failed", err);
|
console.error("Login failed", err);
|
||||||
errorMsg?.classList.remove('hidden');
|
errorMsg?.classList.remove('hidden');
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ 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 = "/";
|
window.location.href = `${basePath}/`;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user