mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
chore: release v0.2.4-stable
This commit is contained in:
89
Cargo.lock
generated
89
Cargo.lock
generated
@@ -26,7 +26,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alchemist"
|
||||
version = "0.2.3-rc.1"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -49,6 +49,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sqlx",
|
||||
"subprocess",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -1121,7 +1122,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1547,6 +1548,15 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2586,6 +2596,20 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"rayon",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
@@ -3218,19 +3242,52 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -3242,6 +3299,17 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -3266,10 +3334,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alchemist"
|
||||
version = "0.2.3-rc.1"
|
||||
version = "0.2.4"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
|
||||
@@ -42,3 +42,4 @@ async-trait = "0.1"
|
||||
argon2 = "0.5.3"
|
||||
rand = "0.8"
|
||||
serde_urlencoded = "0.7.1"
|
||||
sysinfo = "0.32"
|
||||
|
||||
18
Dockerfile
vendored
18
Dockerfile
vendored
@@ -31,12 +31,12 @@ RUN cargo build --release
|
||||
FROM debian:testing-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Enable non-free repositories and install packages
|
||||
# Note: Intel VA drivers are x86-only, we install them conditionally
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && \
|
||||
sed -i 's/main/main contrib non-free non-free-firmware/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
wget \
|
||||
xz-utils \
|
||||
libva-drm2 \
|
||||
libva2 \
|
||||
va-driver-all \
|
||||
@@ -49,6 +49,18 @@ RUN apt-get update && \
|
||||
fi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download stable FFmpeg static build (v7.1)
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz; \
|
||||
elif [ "$ARCH" = "arm64" ]; then \
|
||||
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz; \
|
||||
fi && \
|
||||
tar xf ffmpeg-release-*-static.tar.xz && \
|
||||
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
|
||||
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf ffmpeg-release-*-static.tar.xz ffmpeg-*-static
|
||||
|
||||
COPY --from=builder /app/target/release/alchemist /usr/local/bin/alchemist
|
||||
|
||||
# Set environment variables
|
||||
|
||||
@@ -19,14 +19,13 @@ services:
|
||||
# For Intel QuickSync (uncomment if needed)
|
||||
# devices:
|
||||
# - /dev/dri:/dev/dri
|
||||
# For NVIDIA GPU (uncomment if needed)
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
alchemist_data:
|
||||
|
||||
1230
docs/Documentation.md
Normal file
1230
docs/Documentation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
-- Schema versioning table to track database compatibility
|
||||
-- This establishes v0.2.3 as the baseline for forward compatibility
|
||||
-- This establishes v0.2.4 as the baseline for forward compatibility
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_info (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -9,5 +9,5 @@ CREATE TABLE IF NOT EXISTS schema_info (
|
||||
-- Insert baseline version info
|
||||
INSERT OR REPLACE INTO schema_info (key, value) VALUES
|
||||
('schema_version', '1'),
|
||||
('min_compatible_version', '0.2.3'),
|
||||
('min_compatible_version', '0.2.4'),
|
||||
('created_at', datetime('now'));
|
||||
|
||||
38
migrations/20260109220000_feature_expansion.sql
Normal file
38
migrations/20260109220000_feature_expansion.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Feature Expansion Tables
|
||||
|
||||
-- Logs table
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level TEXT NOT NULL, -- 'info', 'warn', 'error', 'debug'
|
||||
job_id INTEGER,
|
||||
message TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Watch directories
|
||||
CREATE TABLE IF NOT EXISTS watch_dirs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
recursive INTEGER DEFAULT 1,
|
||||
extensions TEXT, -- JSON array or NULL
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Notification settings
|
||||
CREATE TABLE IF NOT EXISTS notification_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL, -- 'gotify', 'discord', 'webhook'
|
||||
config TEXT NOT NULL, -- JSON config
|
||||
on_complete INTEGER DEFAULT 0,
|
||||
on_fail INTEGER DEFAULT 1,
|
||||
on_daily_summary INTEGER DEFAULT 0,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Schedule settings
|
||||
CREATE TABLE IF NOT EXISTS schedule_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
6
migrations/20260109230000_watch_dirs.sql
Normal file
6
migrations/20260109230000_watch_dirs.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS watch_dirs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
is_recursive BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
10
migrations/20260109240000_notifications.sql
Normal file
10
migrations/20260109240000_notifications.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS notification_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
target_type TEXT CHECK(target_type IN ('gotify', 'discord', 'webhook')) NOT NULL,
|
||||
endpoint_url TEXT NOT NULL,
|
||||
auth_token TEXT,
|
||||
events TEXT DEFAULT '["failed","completed"]',
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
7
migrations/20260109250000_schedule.sql
Normal file
7
migrations/20260109250000_schedule.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS schedule_windows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
days_of_week TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1
|
||||
);
|
||||
11
migrations/20260109260000_file_settings.sql
Normal file
11
migrations/20260109260000_file_settings.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS file_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
delete_source BOOLEAN NOT NULL DEFAULT 0,
|
||||
output_extension TEXT NOT NULL DEFAULT 'mkv',
|
||||
output_suffix TEXT NOT NULL DEFAULT '-alchemist',
|
||||
replace_strategy TEXT NOT NULL DEFAULT 'keep'
|
||||
);
|
||||
|
||||
-- Ensure default row exists
|
||||
INSERT OR IGNORE INTO file_settings (id, delete_source, output_extension, output_suffix, replace_strategy)
|
||||
VALUES (1, 0, 'mkv', '-alchemist', 'keep');
|
||||
@@ -1,8 +1,8 @@
|
||||
# Database Migration Policy
|
||||
|
||||
**Baseline Version: 0.2.3**
|
||||
**Baseline Version: 0.2.4**
|
||||
|
||||
All database migrations from this point forward MUST maintain backwards compatibility with the v0.2.3 schema.
|
||||
All database migrations from this point forward MUST maintain backwards compatibility with the v0.2.4 schema.
|
||||
|
||||
## Rules for Future Migrations
|
||||
|
||||
@@ -36,7 +36,7 @@ Example: `20260109210000_add_notifications_table.sql`
|
||||
## Testing Migrations
|
||||
|
||||
Before releasing any migration:
|
||||
1. Test upgrading from v0.2.3 database
|
||||
1. Test upgrading from v0.2.4 database
|
||||
2. Verify all existing queries still work
|
||||
3. Verify new features work with fresh DB
|
||||
4. Verify new features gracefully handle missing data in old DBs
|
||||
|
||||
49
scripts/bootstrap-ffmpeg.ps1
Normal file
49
scripts/bootstrap-ffmpeg.ps1
Normal file
@@ -0,0 +1,49 @@
|
||||
# Alchemist FFmpeg Bootstrap Script (Windows)
|
||||
# This script downloads a stable FFmpeg static build and places it in the 'bin' folder.
|
||||
|
||||
$ffmpegVersion = "7.1"
|
||||
$downloadUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
||||
$binDir = Join-Path $PSScriptRoot "..\bin"
|
||||
$tempDir = Join-Path $PSScriptRoot "..\temp_ffmpeg"
|
||||
|
||||
if (-not (Test-Path $binDir)) {
|
||||
New-Item -ItemType Directory -Path $binDir | Out-Null
|
||||
Write-Host "Created bin directory at $binDir"
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $binDir "ffmpeg.exe")) {
|
||||
Write-Host "FFmpeg is already installed in $binDir. Skipping download."
|
||||
exit
|
||||
}
|
||||
|
||||
Write-Host "Downloading FFmpeg $ffmpegVersion essentials build..."
|
||||
try {
|
||||
if (-not (Test-Path $tempDir)) {
|
||||
New-Item -ItemType Directory -Path $tempDir | Out-Null
|
||||
}
|
||||
|
||||
$zipFile = Join-Path $tempDir "ffmpeg.zip"
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $zipFile
|
||||
|
||||
Write-Host "Extracting..."
|
||||
Expand-Archive -Path $zipFile -DestinationPath $tempDir -Force
|
||||
|
||||
$extractedFolder = Get-ChildItem -Path $tempDir -Directory | Where-Object { $_.Name -like "ffmpeg-*" } | Select-Object -First 1
|
||||
if ($extractedFolder) {
|
||||
$ffmpegPath = Join-Path $extractedFolder.FullName "bin\ffmpeg.exe"
|
||||
$ffprobePath = Join-Path $extractedFolder.FullName "bin\ffprobe.exe"
|
||||
|
||||
Copy-Item -Path $ffmpegPath -Destination $binDir -Force
|
||||
Copy-Item -Path $ffprobePath -Destination $binDir -Force
|
||||
|
||||
Write-Host "✅ FFmpeg and FFprobe installed successfully to $binDir"
|
||||
} else {
|
||||
Write-Error "Could not find extracted FFmpeg folder."
|
||||
}
|
||||
} catch {
|
||||
Write-Error "Failed to download or install FFmpeg: $_"
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Recurit -Force $tempDir
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::scheduler::ScheduleConfig;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
@@ -13,7 +12,7 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub quality: QualityConfig,
|
||||
#[serde(default)]
|
||||
pub schedule: ScheduleConfig,
|
||||
pub system: SystemConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -162,6 +161,8 @@ pub struct TranscodeConfig {
|
||||
pub min_file_size_mb: u64, // e.g., 50
|
||||
pub concurrent_jobs: usize,
|
||||
#[serde(default)]
|
||||
pub threads: usize, // 0 = auto
|
||||
#[serde(default)]
|
||||
pub quality_profile: QualityProfile,
|
||||
#[serde(default)]
|
||||
pub output_codec: OutputCodec,
|
||||
@@ -214,6 +215,31 @@ impl Default for QualityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SystemConfig {
|
||||
#[serde(default = "default_poll_interval")]
|
||||
pub monitoring_poll_interval: f64,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_telemetry: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_poll_interval() -> f64 {
|
||||
2.0
|
||||
}
|
||||
|
||||
impl Default for SystemConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
monitoring_poll_interval: default_poll_interval(),
|
||||
enable_telemetry: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -222,6 +248,7 @@ impl Default for Config {
|
||||
min_bpp_threshold: 0.1,
|
||||
min_file_size_mb: 50,
|
||||
concurrent_jobs: 1,
|
||||
threads: 0,
|
||||
quality_profile: QualityProfile::Balanced,
|
||||
output_codec: OutputCodec::Av1,
|
||||
subtitle_mode: SubtitleMode::Copy,
|
||||
@@ -239,7 +266,10 @@ impl Default for Config {
|
||||
},
|
||||
notifications: NotificationsConfig::default(),
|
||||
quality: QualityConfig::default(),
|
||||
schedule: ScheduleConfig::default(),
|
||||
system: SystemConfig {
|
||||
monitoring_poll_interval: default_poll_interval(),
|
||||
enable_telemetry: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,6 +290,15 @@ impl Config {
|
||||
// Enums automatically handle valid values via Serde,
|
||||
// so we don't need manual string checks for presets/profiles anymore.
|
||||
|
||||
// Validate system monitoring poll interval
|
||||
if self.system.monitoring_poll_interval < 0.5 || self.system.monitoring_poll_interval > 10.0
|
||||
{
|
||||
anyhow::bail!(
|
||||
"monitoring_poll_interval must be between 0.5 and 10.0 seconds, got {}",
|
||||
self.system.monitoring_poll_interval
|
||||
);
|
||||
}
|
||||
|
||||
// Validate thresholds
|
||||
if self.transcode.size_reduction_threshold < 0.0
|
||||
|| self.transcode.size_reduction_threshold > 1.0
|
||||
|
||||
449
src/db.rs
449
src/db.rs
@@ -6,6 +6,7 @@ use std::path::Path;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobState {
|
||||
Queued,
|
||||
Analyzing,
|
||||
@@ -17,6 +18,23 @@ pub enum JobState {
|
||||
Resuming,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct JobStats {
|
||||
pub active: i64,
|
||||
pub queued: i64,
|
||||
pub completed: i64,
|
||||
pub failed: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct LogEntry {
|
||||
pub id: i64,
|
||||
pub level: String,
|
||||
pub job_id: Option<i64>,
|
||||
pub message: String,
|
||||
pub created_at: String, // SQLite datetime as string
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum AlchemistEvent {
|
||||
@@ -35,7 +53,8 @@ pub enum AlchemistEvent {
|
||||
reason: String,
|
||||
},
|
||||
Log {
|
||||
job_id: i64,
|
||||
level: String,
|
||||
job_id: Option<i64>,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
@@ -71,6 +90,44 @@ pub struct Job {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||
pub struct WatchDir {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub is_recursive: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||
pub struct NotificationTarget {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub target_type: String,
|
||||
pub endpoint_url: String,
|
||||
pub auth_token: Option<String>,
|
||||
pub events: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||
pub struct ScheduleWindow {
|
||||
pub id: i64,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub days_of_week: String, // as JSON string
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||
pub struct FileSettings {
|
||||
pub id: i64,
|
||||
pub delete_source: bool,
|
||||
pub output_extension: String,
|
||||
pub output_suffix: String,
|
||||
pub replace_strategy: String,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
@@ -192,6 +249,7 @@ pub struct Decision {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Db {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
@@ -263,6 +321,24 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_job(&self, job: Job) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO jobs (input_path, output_path, status, priority, progress, attempt_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(job.input_path)
|
||||
.bind(job.output_path)
|
||||
.bind(job.status)
|
||||
.bind(job.priority)
|
||||
.bind(job.progress)
|
||||
.bind(job.attempt_count)
|
||||
.bind(job.created_at)
|
||||
.bind(job.updated_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_next_job(&self) -> Result<Option<Job>> {
|
||||
let job = sqlx::query_as::<_, Job>(
|
||||
"SELECT id, input_path, output_path, status, NULL as decision_reason,
|
||||
@@ -448,6 +524,328 @@ impl Db {
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
/// Get jobs with filtering, sorting and pagination
|
||||
pub async fn get_jobs_filtered(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
statuses: Option<Vec<JobState>>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_desc: bool,
|
||||
) -> Result<Vec<Job>> {
|
||||
let mut qb = sqlx::QueryBuilder::<sqlx::Sqlite>::new(
|
||||
"SELECT j.id, j.input_path, j.output_path, j.status,
|
||||
(SELECT reason FROM decisions WHERE job_id = j.id ORDER BY created_at DESC LIMIT 1) as decision_reason,
|
||||
COALESCE(j.priority, 0) as priority,
|
||||
COALESCE(j.progress, 0.0) as progress,
|
||||
COALESCE(j.attempt_count, 0) as attempt_count,
|
||||
(SELECT vmaf_score FROM encode_stats WHERE job_id = j.id) as vmaf_score,
|
||||
j.created_at, j.updated_at
|
||||
FROM jobs j
|
||||
WHERE 1=1 "
|
||||
);
|
||||
|
||||
if let Some(statuses) = statuses {
|
||||
if !statuses.is_empty() {
|
||||
qb.push(" AND j.status IN (");
|
||||
let mut separated = qb.separated(", ");
|
||||
for status in statuses {
|
||||
separated.push_bind(status);
|
||||
}
|
||||
separated.push_unseparated(") ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(search) = search {
|
||||
qb.push(" AND j.input_path LIKE ");
|
||||
qb.push_bind(format!("%{}%", search));
|
||||
}
|
||||
|
||||
qb.push(" ORDER BY ");
|
||||
let sort_col = match sort_by.as_deref() {
|
||||
Some("created_at") => "j.created_at",
|
||||
Some("updated_at") => "j.updated_at",
|
||||
Some("input_path") => "j.input_path",
|
||||
Some("size") => "(SELECT input_size_bytes FROM encode_stats WHERE job_id = j.id)",
|
||||
_ => "j.updated_at",
|
||||
};
|
||||
qb.push(sort_col);
|
||||
qb.push(if sort_desc { " DESC" } else { " ASC" });
|
||||
|
||||
qb.push(" LIMIT ");
|
||||
qb.push_bind(limit);
|
||||
qb.push(" OFFSET ");
|
||||
qb.push_bind(offset);
|
||||
|
||||
let jobs = qb.build_query_as::<Job>().fetch_all(&self.pool).await?;
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
pub async fn batch_cancel_jobs(&self, ids: &[i64]) -> Result<u64> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut qb = sqlx::QueryBuilder::<sqlx::Sqlite>::new(
|
||||
"UPDATE jobs SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id IN (",
|
||||
);
|
||||
let mut separated = qb.separated(", ");
|
||||
for id in ids {
|
||||
separated.push_bind(id);
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let result = qb.build().execute(&self.pool).await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn batch_delete_jobs(&self, ids: &[i64]) -> Result<u64> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut qb = sqlx::QueryBuilder::<sqlx::Sqlite>::new("DELETE FROM jobs WHERE id IN (");
|
||||
let mut separated = qb.separated(", ");
|
||||
for id in ids {
|
||||
separated.push_bind(id);
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let result = qb.build().execute(&self.pool).await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn batch_restart_jobs(&self, ids: &[i64]) -> Result<u64> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut qb = sqlx::QueryBuilder::<sqlx::Sqlite>::new(
|
||||
"UPDATE jobs SET status = 'queued', progress = 0.0, updated_at = CURRENT_TIMESTAMP WHERE id IN ("
|
||||
);
|
||||
let mut separated = qb.separated(", ");
|
||||
for id in ids {
|
||||
separated.push_bind(id);
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
|
||||
let result = qb.build().execute(&self.pool).await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn get_job_by_id(&self, id: i64) -> Result<Option<Job>> {
|
||||
let job = sqlx::query_as::<_, Job>(
|
||||
"SELECT j.id, j.input_path, j.output_path, j.status,
|
||||
(SELECT reason FROM decisions WHERE job_id = j.id ORDER BY created_at DESC LIMIT 1) as decision_reason,
|
||||
COALESCE(j.priority, 0) as priority,
|
||||
COALESCE(j.progress, 0.0) as progress,
|
||||
COALESCE(j.attempt_count, 0) as attempt_count,
|
||||
(SELECT vmaf_score FROM encode_stats WHERE job_id = j.id) as vmaf_score,
|
||||
j.created_at, j.updated_at
|
||||
FROM jobs j
|
||||
WHERE j.id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
pub async fn delete_job(&self, id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM jobs WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_encode_stats_by_job_id(&self, job_id: i64) -> Result<DetailedEncodeStats> {
|
||||
let stats =
|
||||
sqlx::query_as::<_, DetailedEncodeStats>("SELECT * FROM encode_stats WHERE job_id = ?")
|
||||
.bind(job_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
pub async fn get_watch_dirs(&self) -> Result<Vec<WatchDir>> {
|
||||
let dirs = sqlx::query_as::<_, WatchDir>("SELECT * FROM watch_dirs ORDER BY path ASC")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
pub async fn get_job_by_input_path(&self, path: &str) -> Result<Option<Job>> {
|
||||
let job = sqlx::query_as::<_, Job>(
|
||||
"SELECT j.id, j.input_path, j.output_path, j.status,
|
||||
(SELECT reason FROM decisions WHERE job_id = j.id ORDER BY created_at DESC LIMIT 1) as decision_reason,
|
||||
COALESCE(j.priority, 0) as priority,
|
||||
COALESCE(j.progress, 0.0) as progress,
|
||||
COALESCE(j.attempt_count, 0) as attempt_count,
|
||||
(SELECT vmaf_score FROM encode_stats WHERE job_id = j.id) as vmaf_score,
|
||||
j.created_at, j.updated_at
|
||||
FROM jobs j
|
||||
WHERE j.input_path = ?",
|
||||
)
|
||||
.bind(path)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
pub async fn add_watch_dir(&self, path: &str, is_recursive: bool) -> Result<WatchDir> {
|
||||
let row = sqlx::query_as::<_, WatchDir>(
|
||||
"INSERT INTO watch_dirs (path, is_recursive) VALUES (?, ?) RETURNING *",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(is_recursive)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn remove_watch_dir(&self, id: i64) -> Result<()> {
|
||||
let res = sqlx::query("DELETE FROM watch_dirs WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(crate::error::AlchemistError::Database(
|
||||
sqlx::Error::RowNotFound,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_notification_targets(&self) -> Result<Vec<NotificationTarget>> {
|
||||
let targets = sqlx::query_as::<_, NotificationTarget>("SELECT * FROM notification_targets")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
pub async fn add_notification_target(
|
||||
&self,
|
||||
name: &str,
|
||||
target_type: &str,
|
||||
endpoint_url: &str,
|
||||
auth_token: Option<&str>,
|
||||
events: &str,
|
||||
enabled: bool,
|
||||
) -> Result<NotificationTarget> {
|
||||
let row = sqlx::query_as::<_, NotificationTarget>(
|
||||
"INSERT INTO notification_targets (name, target_type, endpoint_url, auth_token, events, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING *"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(target_type)
|
||||
.bind(endpoint_url)
|
||||
.bind(auth_token)
|
||||
.bind(events)
|
||||
.bind(enabled)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete_notification_target(&self, id: i64) -> Result<()> {
|
||||
let res = sqlx::query("DELETE FROM notification_targets WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(crate::error::AlchemistError::Database(
|
||||
sqlx::Error::RowNotFound,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_schedule_windows(&self) -> Result<Vec<ScheduleWindow>> {
|
||||
let windows = sqlx::query_as::<_, ScheduleWindow>("SELECT * FROM schedule_windows")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(windows)
|
||||
}
|
||||
|
||||
pub async fn add_schedule_window(
|
||||
&self,
|
||||
start_time: &str,
|
||||
end_time: &str,
|
||||
days_of_week: &str,
|
||||
enabled: bool,
|
||||
) -> Result<ScheduleWindow> {
|
||||
let row = sqlx::query_as::<_, ScheduleWindow>(
|
||||
"INSERT INTO schedule_windows (start_time, end_time, days_of_week, enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(start_time)
|
||||
.bind(end_time)
|
||||
.bind(days_of_week)
|
||||
.bind(enabled)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete_schedule_window(&self, id: i64) -> Result<()> {
|
||||
let res = sqlx::query("DELETE FROM schedule_windows WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(crate::error::AlchemistError::Database(
|
||||
sqlx::Error::RowNotFound,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_file_settings(&self) -> Result<FileSettings> {
|
||||
// Migration ensures row 1 exists, but we handle missing just in case
|
||||
let row = sqlx::query_as::<_, FileSettings>("SELECT * FROM file_settings WHERE id = 1")
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(s) => Ok(s),
|
||||
None => {
|
||||
// If missing (shouldn't happen), return default
|
||||
Ok(FileSettings {
|
||||
id: 1,
|
||||
delete_source: false,
|
||||
output_extension: "mkv".to_string(),
|
||||
output_suffix: "-alchemist".to_string(),
|
||||
replace_strategy: "keep".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_file_settings(
|
||||
&self,
|
||||
delete_source: bool,
|
||||
output_extension: &str,
|
||||
output_suffix: &str,
|
||||
replace_strategy: &str,
|
||||
) -> Result<FileSettings> {
|
||||
let row = sqlx::query_as::<_, FileSettings>(
|
||||
"UPDATE file_settings
|
||||
SET delete_source = ?, output_extension = ?, output_suffix = ?, replace_strategy = ?
|
||||
WHERE id = 1
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(delete_source)
|
||||
.bind(output_extension)
|
||||
.bind(output_suffix)
|
||||
.bind(replace_strategy)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn get_aggregated_stats(&self) -> Result<AggregatedStats> {
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
@@ -594,6 +992,55 @@ impl Db {
|
||||
Ok(row.map(|r| r.0))
|
||||
}
|
||||
|
||||
pub async fn get_job_stats(&self) -> Result<JobStats> {
|
||||
let rows = sqlx::query("SELECT status, COUNT(*) as count FROM jobs GROUP BY status")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut stats = JobStats::default();
|
||||
for row in rows {
|
||||
let status_str: String = row.get("status");
|
||||
let count: i64 = row.get("count");
|
||||
|
||||
// Map status string to JobStats fields
|
||||
// Assuming JobState serialization matches stored strings ("queued", "active", etc)
|
||||
match status_str.as_str() {
|
||||
"queued" => stats.queued += count,
|
||||
"encoding" | "analyzing" | "resuming" => stats.active += count,
|
||||
"completed" => stats.completed += count,
|
||||
"failed" | "cancelled" => stats.failed += count,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
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 (?, ?, ?)")
|
||||
.bind(level)
|
||||
.bind(job_id)
|
||||
.bind(message)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_logs(&self, limit: i64, offset: i64) -> Result<Vec<LogEntry>> {
|
||||
let logs = sqlx::query_as::<_, LogEntry>(
|
||||
"SELECT id, level, job_id, message, created_at FROM logs ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(logs)
|
||||
}
|
||||
|
||||
pub async fn clear_logs(&self) -> Result<()> {
|
||||
sqlx::query("DELETE FROM logs").execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
|
||||
.bind(username)
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod media;
|
||||
pub mod notifications;
|
||||
pub mod orchestrator;
|
||||
pub mod scheduler;
|
||||
pub mod server;
|
||||
@@ -13,5 +14,5 @@ pub use db::AlchemistEvent;
|
||||
pub use media::ffmpeg::{EncodeStats, EncoderCapabilities, HardwareAccelerators};
|
||||
pub use media::processor::Agent;
|
||||
pub use orchestrator::Transcoder;
|
||||
pub use system::notifications::NotificationService;
|
||||
// pub use system::notifications::NotificationService; // Deprecated user-facing export?
|
||||
pub use system::watcher::FileWatcher;
|
||||
|
||||
247
src/main.rs
247
src/main.rs
@@ -4,7 +4,7 @@ use alchemist::{config, db, Agent, Transcoder};
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
@@ -57,10 +57,14 @@ async fn main() -> Result<()> {
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Default to server mode if no arguments are provided (e.g. double-click run)
|
||||
// or if explicit --server flag is used
|
||||
let is_server_mode = args.server || args.directories.is_empty();
|
||||
|
||||
// 0. Load Configuration
|
||||
let config_path = std::path::Path::new("config.toml");
|
||||
let (config, setup_mode) = if !config_path.exists() {
|
||||
if args.server {
|
||||
if is_server_mode {
|
||||
info!("No configuration file found. Entering Setup Mode (Web UI).");
|
||||
(config::Config::default(), true)
|
||||
} else {
|
||||
@@ -134,6 +138,13 @@ async fn main() -> Result<()> {
|
||||
// 2. Initialize Database, Broadcast Channel, Orchestrator, and Processor
|
||||
let db = Arc::new(db::Db::new("alchemist.db").await?);
|
||||
let (tx, _rx) = broadcast::channel(100);
|
||||
|
||||
// Initialize Notification Manager
|
||||
let notification_manager = Arc::new(alchemist::notifications::NotificationManager::new(
|
||||
db.as_ref().clone(),
|
||||
));
|
||||
notification_manager.start_listener(tx.subscribe());
|
||||
|
||||
let transcoder = Arc::new(Transcoder::new());
|
||||
let config = Arc::new(RwLock::new(config));
|
||||
let agent = Arc::new(
|
||||
@@ -151,111 +162,173 @@ async fn main() -> Result<()> {
|
||||
info!("Database and services initialized.");
|
||||
|
||||
// 3. Start Background Processor Loop
|
||||
// Only start if NOT in setup mode. If in setup mode, the agent should effectively be paused or idle/empty
|
||||
// But since we pass agent to server, we can start it. It just won't have any jobs or directories to scan yet.
|
||||
// However, if we want to be strict, we can pause it.
|
||||
// Always start the loop. The agent will be paused if setup_mode is true.
|
||||
if setup_mode {
|
||||
info!("Setup mode active. Background processor paused.");
|
||||
agent.pause();
|
||||
} else {
|
||||
let proc = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
proc.run_loop().await;
|
||||
});
|
||||
}
|
||||
let proc = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
proc.run_loop().await;
|
||||
});
|
||||
|
||||
if args.server {
|
||||
if is_server_mode {
|
||||
info!("Starting web server...");
|
||||
|
||||
// Start File Watcher if directories are configured and not in setup mode
|
||||
let watcher_dirs_opt = {
|
||||
let config_read = config.read().await;
|
||||
if !setup_mode && !config_read.scanner.directories.is_empty() {
|
||||
Some(
|
||||
config_read
|
||||
.scanner
|
||||
.directories
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
// Start Log Persistence Task
|
||||
let log_db = db.clone();
|
||||
let mut log_rx = tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = log_rx.recv().await {
|
||||
match event {
|
||||
alchemist::db::AlchemistEvent::Log {
|
||||
level,
|
||||
job_id,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
if let Err(e) = log_db.add_log(&level, job_id, &message).await {
|
||||
eprintln!("Failed to persist log: {}", e);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize File Watcher
|
||||
let file_watcher = Arc::new(alchemist::system::watcher::FileWatcher::new(db.clone()));
|
||||
|
||||
// Function to reload watcher (Config + DB)
|
||||
let reload_watcher = {
|
||||
let config = config.clone();
|
||||
let db = db.clone();
|
||||
let file_watcher = file_watcher.clone();
|
||||
|
||||
move |setup_mode: bool| {
|
||||
let config = config.clone();
|
||||
let db = db.clone();
|
||||
let file_watcher = file_watcher.clone();
|
||||
async move {
|
||||
let mut all_dirs = Vec::new();
|
||||
|
||||
// 1. Config Dirs
|
||||
{
|
||||
let config_read = config.read().await;
|
||||
if !setup_mode {
|
||||
all_dirs
|
||||
.extend(config_read.scanner.directories.iter().map(PathBuf::from));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. DB Dirs
|
||||
if !setup_mode {
|
||||
match db.get_watch_dirs().await {
|
||||
Ok(dirs) => {
|
||||
all_dirs.extend(dirs.into_iter().map(|d| PathBuf::from(d.path)));
|
||||
}
|
||||
Err(e) => error!("Failed to fetch watch dirs from DB: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
all_dirs.sort();
|
||||
all_dirs.dedup();
|
||||
|
||||
if !all_dirs.is_empty() {
|
||||
info!("Updating file watcher with {} directories", all_dirs.len());
|
||||
if let Err(e) = file_watcher.watch(&all_dirs) {
|
||||
error!("Failed to update file watcher: {}", e);
|
||||
}
|
||||
} else {
|
||||
// Ensure we clear it if empty?
|
||||
// The file_watcher.watch() handles empty list by stopping watcher.
|
||||
if let Err(e) = file_watcher.watch(&[]) {
|
||||
debug!("Watcher stopped (empty list): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(watcher_dirs) = watcher_dirs_opt {
|
||||
let watcher = alchemist::system::watcher::FileWatcher::new(watcher_dirs, db.clone());
|
||||
let watcher_handle = watcher.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = watcher_handle.start().await {
|
||||
error!("File watcher failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Initial Watcher Load
|
||||
reload_watcher(setup_mode).await;
|
||||
|
||||
// Config Watcher
|
||||
// Start Scheduler
|
||||
let scheduler = alchemist::scheduler::Scheduler::new(db.clone(), agent.clone());
|
||||
scheduler.start();
|
||||
|
||||
// Async Config Watcher
|
||||
let config_watcher_arc = config.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// We use recommended_watcher (usually Create/Write/Modify/Remove events)
|
||||
let mut watcher = match notify::recommended_watcher(tx) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create config watcher: {}", e);
|
||||
return;
|
||||
let reload_watcher_clone = reload_watcher.clone();
|
||||
|
||||
// Channel for file events
|
||||
let (tx_notify, mut rx_notify) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let tx_notify_clone = tx_notify.clone();
|
||||
let watcher_res = notify::recommended_watcher(
|
||||
move |res: std::result::Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx_notify_clone.send(event);
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if let Err(e) = watcher.watch(
|
||||
std::path::Path::new("config.toml"),
|
||||
RecursiveMode::NonRecursive,
|
||||
) {
|
||||
error!("Failed to watch config.toml: {}", e);
|
||||
return;
|
||||
}
|
||||
match watcher_res {
|
||||
Ok(mut watcher) => {
|
||||
if let Err(e) = watcher.watch(
|
||||
std::path::Path::new("config.toml"),
|
||||
RecursiveMode::NonRecursive,
|
||||
) {
|
||||
error!("Failed to watch config.toml: {}", e);
|
||||
} else {
|
||||
// Prevent watcher from dropping by keeping it in the spawn if needed,
|
||||
// or just spawning the processing loop.
|
||||
// notify watcher works in background thread usually.
|
||||
// We need to keep `watcher` alive.
|
||||
|
||||
// Simple debounce by waiting for events
|
||||
for res in rx {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
// Reload on any event for simplicity, usually Write/Modify
|
||||
// We can filter for event.kind.
|
||||
if let notify::EventKind::Modify(_) = event.kind {
|
||||
info!("Config file changed. Reloading...");
|
||||
// Brief sleep to ensure write complete?
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
match alchemist::config::Config::load(std::path::Path::new(
|
||||
"config.toml",
|
||||
)) {
|
||||
Ok(new_config) => {
|
||||
// We need to write to the async RwLock from this blocking thread.
|
||||
// We can use blocking_write() if available or block_on.
|
||||
// tokio::sync::RwLock can contain a blocking_write feature?
|
||||
// No, tokio RwLock is async.
|
||||
// We can spawn a handle back to async world?
|
||||
// Or just use std::sync::RwLock for config?
|
||||
// Using `blocking_write` requires `tokio` feature `sync`?
|
||||
// Actually `config_watcher_arc` is `Arc<tokio::sync::RwLock<Config>>`.
|
||||
// We can use `futures::executor::block_on` or create a new runtime?
|
||||
// BETTER: Spawn the loop as a `tokio::spawn`, but use `notify::Event` stream (async config)?
|
||||
// OR: Use `tokio::sync::RwLock::blocking_write()` method? It exists!
|
||||
let mut w = config_watcher_arc.blocking_write();
|
||||
*w = new_config;
|
||||
info!("Configuration reloaded successfully.");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to reload config: {}", e);
|
||||
tokio::spawn(async move {
|
||||
// Keep watcher alive by moving it here
|
||||
let _watcher = watcher;
|
||||
|
||||
while let Some(event) = rx_notify.recv().await {
|
||||
if let notify::EventKind::Modify(_) = event.kind {
|
||||
info!("Config file changed. Reloading...");
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
match alchemist::config::Config::load(std::path::Path::new(
|
||||
"config.toml",
|
||||
)) {
|
||||
Ok(new_config) => {
|
||||
{
|
||||
let mut w = config_watcher_arc.write().await;
|
||||
*w = new_config;
|
||||
}
|
||||
info!("Configuration reloaded successfully.");
|
||||
|
||||
// Trigger watcher update (merges DB + New Config)
|
||||
reload_watcher_clone(false).await;
|
||||
}
|
||||
Err(e) => error!("Failed to reload config: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Config watch error: {:?}", e),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Err(e) => error!("Failed to create config watcher: {}", e),
|
||||
}
|
||||
|
||||
alchemist::server::run_server(db, config, agent, transcoder, tx, setup_mode).await?;
|
||||
alchemist::server::run_server(
|
||||
db,
|
||||
config,
|
||||
agent,
|
||||
transcoder,
|
||||
tx,
|
||||
setup_mode,
|
||||
notification_manager.clone(),
|
||||
file_watcher,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
// CLI Mode
|
||||
if setup_mode {
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct Stream {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Format {
|
||||
pub format_name: String,
|
||||
pub format_long_name: Option<String>,
|
||||
pub duration: String,
|
||||
pub size: String,
|
||||
pub bit_rate: String,
|
||||
@@ -64,6 +66,8 @@ impl AnalyzerTrait for FfmpegAnalyzer {
|
||||
.find(|s| s.codec_type == "video")
|
||||
.ok_or_else(|| AlchemistError::Analyzer("No video stream found".to_string()))?;
|
||||
|
||||
let audio_stream = metadata.streams.iter().find(|s| s.codec_type == "audio");
|
||||
|
||||
Ok(MediaMetadata {
|
||||
path: path.to_path_buf(),
|
||||
duration_secs: metadata.format.duration.parse().unwrap_or(0.0),
|
||||
@@ -88,6 +92,9 @@ impl AnalyzerTrait for FfmpegAnalyzer {
|
||||
.as_deref()
|
||||
.and_then(Analyzer::parse_fps)
|
||||
.unwrap_or(24.0),
|
||||
container: metadata.format.format_name.clone(),
|
||||
audio_codec: audio_stream.map(|s| s.codec_name.clone()),
|
||||
audio_channels: audio_stream.and_then(|s| s.channels),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ impl Executor for FfmpegExecutor {
|
||||
self.config.transcode.quality_profile,
|
||||
self.config.hardware.cpu_preset,
|
||||
self.config.transcode.output_codec,
|
||||
self.config.transcode.threads,
|
||||
self.dry_run,
|
||||
metadata,
|
||||
Some((job.id, self.event_tx.clone())),
|
||||
|
||||
@@ -146,6 +146,7 @@ pub struct FFmpegCommandBuilder<'a> {
|
||||
profile: QualityProfile,
|
||||
cpu_preset: CpuPreset,
|
||||
target_codec: crate::config::OutputCodec,
|
||||
threads: usize,
|
||||
}
|
||||
|
||||
impl<'a> FFmpegCommandBuilder<'a> {
|
||||
@@ -157,9 +158,15 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
profile: QualityProfile::Balanced,
|
||||
cpu_preset: CpuPreset::Medium,
|
||||
target_codec: crate::config::OutputCodec::Av1,
|
||||
threads: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_threads(mut self, threads: usize) -> Self {
|
||||
self.threads = threads;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_hardware(mut self, hw_info: Option<&'a HardwareInfo>) -> Self {
|
||||
self.hw_info = hw_info;
|
||||
self
|
||||
@@ -189,6 +196,10 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
None => self.apply_cpu_params(&mut cmd),
|
||||
}
|
||||
|
||||
if self.threads > 0 {
|
||||
cmd.arg("-threads").arg(self.threads.to_string());
|
||||
}
|
||||
|
||||
cmd.arg("-c:a").arg("copy");
|
||||
cmd.arg("-c:s").arg("copy");
|
||||
cmd.arg(self.output);
|
||||
@@ -198,7 +209,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
|
||||
fn apply_hardware_params(&self, cmd: &mut tokio::process::Command, hw: &HardwareInfo) {
|
||||
let codec_str = self.target_codec.as_str();
|
||||
|
||||
|
||||
// Check if target codec is supported by hardware
|
||||
let supports_codec = hw.supported_codecs.iter().any(|c| c == codec_str);
|
||||
|
||||
@@ -235,7 +246,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
(Vendor::Nvidia, crate::config::OutputCodec::Av1) => {
|
||||
cmd.arg("-c:v").arg("av1_nvenc");
|
||||
cmd.arg("-preset").arg(self.profile.nvenc_preset());
|
||||
cmd.arg("-cq").arg("25");
|
||||
cmd.arg("-cq").arg("25");
|
||||
}
|
||||
(Vendor::Nvidia, crate::config::OutputCodec::Hevc) => {
|
||||
cmd.arg("-c:v").arg("hevc_nvenc");
|
||||
@@ -243,7 +254,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
cmd.arg("-cq").arg("25");
|
||||
}
|
||||
(Vendor::Apple, crate::config::OutputCodec::Av1) => {
|
||||
cmd.arg("-c:v").arg("av1_videotoolbox");
|
||||
cmd.arg("-c:v").arg("av1_videotoolbox");
|
||||
}
|
||||
(Vendor::Apple, crate::config::OutputCodec::Hevc) => {
|
||||
cmd.arg("-c:v").arg("hevc_videotoolbox");
|
||||
@@ -252,7 +263,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
}
|
||||
(Vendor::Amd, crate::config::OutputCodec::Av1) => {
|
||||
// Ensure VAAPI device is set if needed
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-vaapi_device").arg(device_path);
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
@@ -262,7 +273,7 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
}
|
||||
}
|
||||
(Vendor::Amd, crate::config::OutputCodec::Hevc) => {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
if let Some(ref device_path) = hw.device_path {
|
||||
cmd.arg("-vaapi_device").arg(device_path);
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
@@ -282,24 +293,23 @@ impl<'a> FFmpegCommandBuilder<'a> {
|
||||
cmd.arg("-c:v").arg("libsvtav1");
|
||||
cmd.arg("-preset").arg(preset_str);
|
||||
cmd.arg("-crf").arg(crf_str);
|
||||
cmd.arg("-svtav1-params").arg("tune=0:film-grain=8");
|
||||
}
|
||||
crate::config::OutputCodec::Hevc => {
|
||||
// For HEVC CPU, we use libx265
|
||||
// Map presets roughly:
|
||||
// slow -> slow
|
||||
// medium -> medium
|
||||
// fast -> fast
|
||||
// faster -> faster
|
||||
let preset = self.cpu_preset.as_str();
|
||||
// CRF mapping: libsvtav1 24-32 is roughly equivalent to x265 20-28
|
||||
// Let's use a simple offset or strict mapping
|
||||
let crf = match self.cpu_preset {
|
||||
CpuPreset::Slow => "20",
|
||||
CpuPreset::Medium => "24",
|
||||
CpuPreset::Fast => "26",
|
||||
CpuPreset::Faster => "28",
|
||||
};
|
||||
// For HEVC CPU, we use libx265
|
||||
// Map presets roughly:
|
||||
// slow -> slow
|
||||
// medium -> medium
|
||||
// fast -> fast
|
||||
// faster -> faster
|
||||
let preset = self.cpu_preset.as_str();
|
||||
// CRF mapping: libsvtav1 24-32 is roughly equivalent to x265 20-28
|
||||
// Let's use a simple offset or strict mapping
|
||||
let crf = match self.cpu_preset {
|
||||
CpuPreset::Slow => "20",
|
||||
CpuPreset::Medium => "24",
|
||||
CpuPreset::Fast => "26",
|
||||
CpuPreset::Faster => "28",
|
||||
};
|
||||
cmd.arg("-c:v").arg("libx265");
|
||||
cmd.arg("-preset").arg(preset);
|
||||
cmd.arg("-crf").arg(crf);
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct MediaMetadata {
|
||||
pub size_bytes: u64,
|
||||
pub bit_rate: f64,
|
||||
pub fps: f64,
|
||||
pub container: String,
|
||||
pub audio_codec: Option<String>,
|
||||
pub audio_channels: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::media::pipeline::{
|
||||
use crate::media::planner::BasicPlanner;
|
||||
use crate::media::scanner::Scanner;
|
||||
use crate::system::hardware::HardwareInfo;
|
||||
use crate::system::notifications::NotificationService;
|
||||
use crate::Transcoder;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -24,8 +23,8 @@ pub struct Agent {
|
||||
hw_info: Arc<Option<HardwareInfo>>,
|
||||
tx: Arc<broadcast::Sender<AlchemistEvent>>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
notifications: NotificationService,
|
||||
paused: Arc<AtomicBool>,
|
||||
scheduler_paused: Arc<AtomicBool>,
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
@@ -41,7 +40,6 @@ impl Agent {
|
||||
// Read config asynchronously to avoid blocking atomic in async runtime
|
||||
let config_read = config.read().await;
|
||||
let concurrent_jobs = config_read.transcode.concurrent_jobs;
|
||||
let notifications = NotificationService::new(config_read.notifications.clone());
|
||||
drop(config_read);
|
||||
|
||||
Self {
|
||||
@@ -51,8 +49,8 @@ impl Agent {
|
||||
hw_info: Arc::new(hw_info),
|
||||
tx: Arc::new(tx),
|
||||
semaphore: Arc::new(Semaphore::new(concurrent_jobs)),
|
||||
notifications,
|
||||
paused: Arc::new(AtomicBool::new(false)),
|
||||
scheduler_paused: Arc::new(AtomicBool::new(false)),
|
||||
dry_run,
|
||||
}
|
||||
}
|
||||
@@ -62,9 +60,32 @@ impl Agent {
|
||||
let scanner = Scanner::new();
|
||||
let files = scanner.scan(directories);
|
||||
|
||||
// Get output settings
|
||||
let settings = match self.db.get_file_settings().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch file settings, using defaults: {}", e);
|
||||
crate::db::FileSettings {
|
||||
id: 1,
|
||||
delete_source: false,
|
||||
output_extension: "mkv".to_string(),
|
||||
output_suffix: "-alchemist".to_string(),
|
||||
replace_strategy: "keep".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for scanned_file in files {
|
||||
let mut output_path = scanned_file.path.clone();
|
||||
output_path.set_extension("av1.mkv");
|
||||
let stem = output_path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy();
|
||||
let new_filename = format!(
|
||||
"{}{}.{}",
|
||||
stem, settings.output_suffix, settings.output_extension
|
||||
);
|
||||
output_path.set_file_name(new_filename);
|
||||
|
||||
if let Err(e) = self
|
||||
.db
|
||||
@@ -83,9 +104,29 @@ impl Agent {
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.paused.load(Ordering::SeqCst) || self.scheduler_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn is_manual_paused(&self) -> bool {
|
||||
self.paused.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn is_scheduler_paused(&self) -> bool {
|
||||
self.scheduler_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn set_scheduler_paused(&self, paused: bool) {
|
||||
let current = self.scheduler_paused.load(Ordering::SeqCst);
|
||||
if current != paused {
|
||||
self.scheduler_paused.store(paused, Ordering::SeqCst);
|
||||
if paused {
|
||||
info!("Engine paused by scheduler.");
|
||||
} else {
|
||||
info!("Engine resumed by scheduler.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
self.paused.store(true, Ordering::SeqCst);
|
||||
info!("Engine paused.");
|
||||
@@ -225,10 +266,6 @@ impl Agent {
|
||||
self.update_job_state(job.id, JobState::Cancelled).await
|
||||
} else {
|
||||
error!("Job {}: Transcode failed: {}", job.id, e);
|
||||
let _ = self
|
||||
.notifications
|
||||
.notify_job_failed(&job, &e.to_string())
|
||||
.await;
|
||||
self.update_job_state(job.id, JobState::Failed).await?;
|
||||
Err(e)
|
||||
}
|
||||
@@ -363,20 +400,20 @@ impl Agent {
|
||||
|
||||
self.update_job_state(job_id, JobState::Completed).await?;
|
||||
|
||||
// Notifications
|
||||
let stats_msg = format!(
|
||||
"📊 {} MB -> {} MB ({:.1}% reduction), VMAF: {}",
|
||||
input_size / 1_048_576,
|
||||
output_size / 1_048_576,
|
||||
reduction * 100.0,
|
||||
vmaf_score
|
||||
.map(|s| format!("{:.2}", s))
|
||||
.unwrap_or_else(|| "N/A".to_string())
|
||||
);
|
||||
let _ = self
|
||||
.notifications
|
||||
.notify_job_complete(&job, Some(&stats_msg))
|
||||
.await;
|
||||
// Handle File Deletion Policy
|
||||
if let Ok(settings) = self.db.get_file_settings().await {
|
||||
if settings.delete_source {
|
||||
info!(
|
||||
"Job {}: 'Delete Source' is enabled. Removing input file: {:?}",
|
||||
job_id, input_path
|
||||
);
|
||||
if let Err(e) = tokio::fs::remove_file(input_path).await {
|
||||
error!("Job {}: Failed to delete input file: {}", job_id, e);
|
||||
} else {
|
||||
info!("Job {}: Input file deleted successfully.", job_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
207
src/notifications.rs
Normal file
207
src/notifications.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use crate::db::{AlchemistEvent, Db, NotificationTarget};
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NotificationManager {
|
||||
db: Db,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl NotificationManager {
|
||||
pub fn new(db: Db) -> Self {
|
||||
Self {
|
||||
db,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_listener(&self, mut rx: broadcast::Receiver<AlchemistEvent>) {
|
||||
let _db = self.db.clone();
|
||||
let _client = self.client.clone();
|
||||
|
||||
// Spawn a new manager instance/logic for the loop?
|
||||
// Or just move clones into the async block.
|
||||
// Self is not Clone? It has Db (Clone) and Client (Clone).
|
||||
// I can derive Clone for NotificationManager.
|
||||
// Or just move db/client.
|
||||
|
||||
let manager_clone = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
if let Err(e) = manager_clone.handle_event(event).await {
|
||||
error!("Notification error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
warn!("Notification listener lagged")
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn send_test(
|
||||
&self,
|
||||
target: &NotificationTarget,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let event = AlchemistEvent::JobStateChanged {
|
||||
job_id: 0,
|
||||
status: crate::db::JobState::Completed,
|
||||
};
|
||||
self.send(target, &event, "completed").await
|
||||
}
|
||||
|
||||
async fn handle_event(&self, event: AlchemistEvent) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let targets = match self.db.get_notification_targets().await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch notification targets: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Filter events
|
||||
let status = match &event {
|
||||
AlchemistEvent::JobStateChanged { status, .. } => status.to_string(),
|
||||
_ => return Ok(()), // Only handle job state changes for now
|
||||
};
|
||||
|
||||
for target in targets {
|
||||
if !target.enabled {
|
||||
continue;
|
||||
}
|
||||
let allowed: Vec<String> = serde_json::from_str(&target.events).unwrap_or_default();
|
||||
|
||||
if allowed.contains(&status) {
|
||||
self.send(&target, &event, &status).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
target: &NotificationTarget,
|
||||
event: &AlchemistEvent,
|
||||
status: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match target.target_type.as_str() {
|
||||
"discord" => self.send_discord(target, event, status).await,
|
||||
"gotify" => self.send_gotify(target, event, status).await,
|
||||
"webhook" => self.send_webhook(target, event, status).await,
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_discord(
|
||||
&self,
|
||||
target: &NotificationTarget,
|
||||
event: &AlchemistEvent,
|
||||
status: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let color = match status {
|
||||
"completed" => 0x00FF00, // Green
|
||||
"failed" => 0xFF0000, // Red
|
||||
"queued" => 0xF1C40F, // Yellow
|
||||
"encoding" => 0x3498DB, // Blue
|
||||
_ => 0x95A5A6, // Gray
|
||||
};
|
||||
|
||||
let message = match event {
|
||||
AlchemistEvent::JobStateChanged { job_id, status } => {
|
||||
format!("Job #{} is now {}", job_id, status)
|
||||
}
|
||||
_ => "Event occurred".to_string(),
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"embeds": [{
|
||||
"title": "Alchemist Notification",
|
||||
"description": message,
|
||||
"color": color,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
}]
|
||||
});
|
||||
|
||||
self.client
|
||||
.post(&target.endpoint_url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_gotify(
|
||||
&self,
|
||||
target: &NotificationTarget,
|
||||
event: &AlchemistEvent,
|
||||
status: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let message = match event {
|
||||
AlchemistEvent::JobStateChanged { job_id, status } => {
|
||||
format!("Job #{} is now {}", job_id, status)
|
||||
}
|
||||
_ => "Event occurred".to_string(),
|
||||
};
|
||||
|
||||
let priority = match status {
|
||||
"failed" => 8,
|
||||
"completed" => 5,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
let mut req = self.client.post(&target.endpoint_url).json(&json!({
|
||||
"title": "Alchemist",
|
||||
"message": message,
|
||||
"priority": priority
|
||||
}));
|
||||
|
||||
if let Some(token) = &target.auth_token {
|
||||
req = req.header("X-Gotify-Key", token);
|
||||
}
|
||||
|
||||
req.send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_webhook(
|
||||
&self,
|
||||
target: &NotificationTarget,
|
||||
event: &AlchemistEvent,
|
||||
status: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let message = match event {
|
||||
AlchemistEvent::JobStateChanged { job_id, status } => {
|
||||
format!("Job #{} is now {}", job_id, status)
|
||||
}
|
||||
_ => "Event occurred".to_string(),
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"event": "job_update",
|
||||
"status": status,
|
||||
"message": message,
|
||||
"data": event,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
});
|
||||
|
||||
let mut req = self.client.post(&target.endpoint_url).json(&body);
|
||||
if let Some(token) = &target.auth_token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
|
||||
req.send().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ impl Transcoder {
|
||||
quality_profile: QualityProfile,
|
||||
cpu_preset: CpuPreset,
|
||||
target_codec: crate::config::OutputCodec,
|
||||
threads: usize,
|
||||
dry_run: bool,
|
||||
metadata: &crate::media::pipeline::MediaMetadata,
|
||||
event_target: Option<(i64, Arc<broadcast::Sender<crate::db::AlchemistEvent>>)>,
|
||||
@@ -54,13 +55,28 @@ impl Transcoder {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if let Some(parent) = output.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
error!("Failed to create output directory {:?}: {}", parent, e);
|
||||
AlchemistError::FFmpeg(format!(
|
||||
"Failed to create output directory {:?}: {}",
|
||||
parent, e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = FFmpegCommandBuilder::new(input, output)
|
||||
.with_hardware(hw_info)
|
||||
.with_profile(quality_profile)
|
||||
.with_cpu_preset(cpu_preset)
|
||||
.with_codec(target_codec)
|
||||
.with_threads(threads)
|
||||
.build();
|
||||
cmd.arg(output);
|
||||
|
||||
info!("Executing FFmpeg command: {:?}", cmd);
|
||||
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
@@ -93,14 +109,24 @@ impl Transcoder {
|
||||
|
||||
let mut kill_rx = kill_rx;
|
||||
let mut killed = false;
|
||||
let mut last_lines = std::collections::VecDeque::with_capacity(10);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
line_res = reader.next_line() => {
|
||||
match line_res {
|
||||
Ok(Some(line)) => {
|
||||
last_lines.push_back(line.clone());
|
||||
if last_lines.len() > 10 {
|
||||
last_lines.pop_front();
|
||||
}
|
||||
|
||||
if let Some((job_id, ref tx)) = event_target_clone {
|
||||
let _ = tx.send(crate::db::AlchemistEvent::Log { job_id, message: line.clone() });
|
||||
let _ = tx.send(crate::db::AlchemistEvent::Log {
|
||||
level: "info".to_string(),
|
||||
job_id: Some(job_id),
|
||||
message: line.clone()
|
||||
});
|
||||
|
||||
if let Some(progress) = FFmpegProgress::parse_line(&line) {
|
||||
let percentage: f64 = progress.percentage(total_duration);
|
||||
@@ -145,10 +171,14 @@ impl Transcoder {
|
||||
info!("Transcode successful: {:?}", output);
|
||||
Ok(())
|
||||
} else {
|
||||
error!("FFmpeg failed with status: {}", status);
|
||||
let error_detail = last_lines.make_contiguous().join("\n");
|
||||
error!(
|
||||
"FFmpeg failed with status: {}\nDetails:\n{}",
|
||||
status, error_detail
|
||||
);
|
||||
Err(AlchemistError::FFmpeg(format!(
|
||||
"FFmpeg failed with status: {}",
|
||||
status
|
||||
"FFmpeg failed ({}). Last output:\n{}",
|
||||
status, error_detail
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
217
src/scheduler.rs
217
src/scheduler.rs
@@ -1,151 +1,88 @@
|
||||
//! Job scheduler for time-based processing
|
||||
//!
|
||||
//! Allows users to configure specific hours when transcoding should run.
|
||||
use crate::db::Db;
|
||||
use crate::Agent;
|
||||
use chrono::{Datelike, Local, Timelike};
|
||||
use std::sync::Arc;
|
||||
use tokio::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
use chrono::{Datelike, Local, Timelike, Weekday};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Schedule configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ScheduleConfig {
|
||||
/// Enable scheduling (if false, run 24/7)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Start hour (0-23)
|
||||
#[serde(default = "default_start_hour")]
|
||||
pub start_hour: u32,
|
||||
|
||||
/// End hour (0-23)
|
||||
#[serde(default = "default_end_hour")]
|
||||
pub end_hour: u32,
|
||||
|
||||
/// Days of week to run (empty = all days)
|
||||
#[serde(default)]
|
||||
pub days: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_start_hour() -> u32 {
|
||||
22
|
||||
} // 10 PM
|
||||
fn default_end_hour() -> u32 {
|
||||
6
|
||||
} // 6 AM
|
||||
|
||||
impl ScheduleConfig {
|
||||
/// Check if we should be running right now
|
||||
pub fn should_run(&self) -> bool {
|
||||
if !self.enabled {
|
||||
return true; // If scheduling disabled, always run
|
||||
}
|
||||
|
||||
let now = Local::now();
|
||||
let current_hour = now.hour();
|
||||
|
||||
// Check day of week
|
||||
if !self.days.is_empty() {
|
||||
let today = match now.weekday() {
|
||||
Weekday::Mon => "mon",
|
||||
Weekday::Tue => "tue",
|
||||
Weekday::Wed => "wed",
|
||||
Weekday::Thu => "thu",
|
||||
Weekday::Fri => "fri",
|
||||
Weekday::Sat => "sat",
|
||||
Weekday::Sun => "sun",
|
||||
};
|
||||
|
||||
if !self.days.iter().any(|d| d.to_lowercase() == today) {
|
||||
debug!("Scheduler: Today ({}) not in allowed days", today);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check time window
|
||||
let in_window = if self.start_hour <= self.end_hour {
|
||||
// Normal window (e.g., 08:00 - 17:00)
|
||||
current_hour >= self.start_hour && current_hour < self.end_hour
|
||||
} else {
|
||||
// Overnight window (e.g., 22:00 - 06:00)
|
||||
current_hour >= self.start_hour || current_hour < self.end_hour
|
||||
};
|
||||
|
||||
if !in_window {
|
||||
debug!(
|
||||
"Scheduler: Current hour ({}) outside window ({}-{})",
|
||||
current_hour, self.start_hour, self.end_hour
|
||||
);
|
||||
}
|
||||
|
||||
in_window
|
||||
}
|
||||
|
||||
/// Format the schedule for display
|
||||
pub fn format_schedule(&self) -> String {
|
||||
if !self.enabled {
|
||||
return "24/7 (no schedule)".to_string();
|
||||
}
|
||||
|
||||
let days_str = if self.days.is_empty() {
|
||||
"Every day".to_string()
|
||||
} else {
|
||||
self.days.join(", ")
|
||||
};
|
||||
|
||||
format!(
|
||||
"{} from {:02}:00 to {:02}:00",
|
||||
days_str, self.start_hour, self.end_hour
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduler that can pause/resume the agent based on time
|
||||
pub struct Scheduler {
|
||||
config: ScheduleConfig,
|
||||
db: Arc<Db>,
|
||||
agent: Arc<Agent>,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
pub fn new(config: ScheduleConfig) -> Self {
|
||||
if config.enabled {
|
||||
info!("Scheduler enabled: {}", config.format_schedule());
|
||||
pub fn new(db: Arc<Db>, agent: Arc<Agent>) -> Self {
|
||||
Self { db, agent }
|
||||
}
|
||||
|
||||
pub fn start(self) {
|
||||
tokio::spawn(async move {
|
||||
info!("Scheduler started");
|
||||
loop {
|
||||
if let Err(e) = self.check_schedule().await {
|
||||
error!("Scheduler check failed: {}", e);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn check_schedule(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let windows: Vec<crate::db::ScheduleWindow> = self.db.get_schedule_windows().await?;
|
||||
|
||||
// Filter for enabled windows
|
||||
let enabled_windows: Vec<_> = windows.into_iter().filter(|w| w.enabled).collect();
|
||||
|
||||
if enabled_windows.is_empty() {
|
||||
// No schedule active -> Always Run
|
||||
if self.agent.is_scheduler_paused() {
|
||||
self.agent.set_scheduler_paused(false);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, config: ScheduleConfig) {
|
||||
self.config = config;
|
||||
}
|
||||
let now = Local::now();
|
||||
let current_time_str = format!("{:02}:{:02}", now.hour(), now.minute());
|
||||
let current_day = now.weekday().num_days_from_sunday() as i32; // 0=Sun, 6=Sat
|
||||
|
||||
pub fn should_run(&self) -> bool {
|
||||
self.config.should_run()
|
||||
}
|
||||
let mut in_window = false;
|
||||
|
||||
pub fn config(&self) -> &ScheduleConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_disabled_scheduler() {
|
||||
let config = ScheduleConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config.should_run());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schedule_format() {
|
||||
let config = ScheduleConfig {
|
||||
enabled: true,
|
||||
start_hour: 22,
|
||||
end_hour: 6,
|
||||
days: vec!["mon".to_string(), "tue".to_string()],
|
||||
};
|
||||
assert_eq!(config.format_schedule(), "mon, tue from 22:00 to 06:00");
|
||||
for window in enabled_windows {
|
||||
// Parse days
|
||||
let days: Vec<i32> = serde_json::from_str(&window.days_of_week).unwrap_or_default();
|
||||
if !days.contains(¤t_day) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check time
|
||||
// Handle cross-day windows (e.g. 23:00 to 02:00)
|
||||
if window.start_time <= window.end_time {
|
||||
// Normal window
|
||||
if current_time_str >= window.start_time && current_time_str < window.end_time {
|
||||
in_window = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Split window
|
||||
if current_time_str >= window.start_time || current_time_str < window.end_time {
|
||||
in_window = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if in_window {
|
||||
// Allowed to run
|
||||
if self.agent.is_scheduler_paused() {
|
||||
self.agent.set_scheduler_paused(false);
|
||||
}
|
||||
} else {
|
||||
// RESTRICTED
|
||||
if !self.agent.is_scheduler_paused() {
|
||||
self.agent.set_scheduler_paused(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
667
src/server.rs
667
src/server.rs
@@ -8,14 +8,14 @@ use argon2::{
|
||||
Argon2,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Request, State},
|
||||
extract::{Path, Query, Request, State},
|
||||
http::{header, StatusCode, Uri},
|
||||
middleware::{self, Next},
|
||||
response::{
|
||||
sse::{Event as AxumEvent, Sse},
|
||||
IntoResponse, Response,
|
||||
},
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
@@ -23,6 +23,7 @@ use futures::stream::Stream;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::Rng;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -44,6 +45,10 @@ pub struct AppState {
|
||||
pub tx: broadcast::Sender<AlchemistEvent>,
|
||||
pub setup_required: Arc<AtomicBool>,
|
||||
pub start_time: Instant,
|
||||
pub notification_manager: Arc<crate::notifications::NotificationManager>,
|
||||
pub sys: std::sync::Mutex<sysinfo::System>,
|
||||
pub file_watcher: Arc<crate::system::watcher::FileWatcher>,
|
||||
pub library_scanner: Arc<crate::system::scanner::LibraryScanner>,
|
||||
}
|
||||
|
||||
pub async fn run_server(
|
||||
@@ -53,7 +58,16 @@ pub async fn run_server(
|
||||
transcoder: Arc<Transcoder>,
|
||||
tx: broadcast::Sender<AlchemistEvent>,
|
||||
setup_required: bool,
|
||||
notification_manager: Arc<crate::notifications::NotificationManager>,
|
||||
file_watcher: Arc<crate::system::watcher::FileWatcher>,
|
||||
) -> Result<()> {
|
||||
// Initialize sysinfo
|
||||
let mut sys = sysinfo::System::new();
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
|
||||
let library_scanner = Arc::new(crate::system::scanner::LibraryScanner::new(db.clone()));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
config,
|
||||
@@ -62,20 +76,31 @@ pub async fn run_server(
|
||||
tx,
|
||||
setup_required: Arc::new(AtomicBool::new(setup_required)),
|
||||
start_time: std::time::Instant::now(),
|
||||
notification_manager,
|
||||
sys: std::sync::Mutex::new(sys),
|
||||
file_watcher,
|
||||
library_scanner,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
// API Routes
|
||||
.route("/api/scan/start", post(start_scan_handler))
|
||||
.route("/api/scan/status", get(get_scan_status_handler))
|
||||
.route("/api/scan", post(scan_handler))
|
||||
.route("/api/stats", get(stats_handler))
|
||||
.route("/api/stats/aggregated", get(aggregated_stats_handler))
|
||||
.route("/api/stats/daily", get(daily_stats_handler))
|
||||
.route("/api/stats/detailed", get(detailed_stats_handler))
|
||||
.route("/api/jobs/table", get(jobs_table_handler))
|
||||
.route("/api/jobs/batch", post(batch_jobs_handler))
|
||||
.route("/api/logs/history", get(logs_history_handler))
|
||||
.route("/api/logs", delete(clear_logs_handler))
|
||||
.route("/api/jobs/restart-failed", post(restart_failed_handler))
|
||||
.route("/api/jobs/clear-completed", post(clear_completed_handler))
|
||||
.route("/api/jobs/:id/cancel", post(cancel_job_handler))
|
||||
.route("/api/jobs/:id/restart", post(restart_job_handler))
|
||||
.route("/api/jobs/:id/delete", post(delete_job_handler))
|
||||
.route("/api/jobs/:id/details", get(get_job_detail_handler))
|
||||
.route("/api/events", get(sse_handler))
|
||||
.route("/api/engine/pause", post(pause_engine_handler))
|
||||
.route("/api/engine/resume", post(resume_engine_handler))
|
||||
@@ -84,9 +109,49 @@ pub async fn run_server(
|
||||
"/api/settings/transcode",
|
||||
get(get_transcode_settings_handler).post(update_transcode_settings_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/system",
|
||||
get(get_system_settings_handler).post(update_system_settings_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/watch-dirs",
|
||||
get(get_watch_dirs_handler).post(add_watch_dir_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/watch-dirs/:id",
|
||||
delete(remove_watch_dir_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/notifications",
|
||||
get(get_notifications_handler).post(add_notification_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/notifications/:id",
|
||||
delete(delete_notification_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/notifications/test",
|
||||
post(test_notification_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/files",
|
||||
get(get_file_settings_handler).post(update_file_settings_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/schedule",
|
||||
get(get_schedule_handler).post(add_schedule_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/schedule/:id",
|
||||
delete(delete_schedule_handler),
|
||||
)
|
||||
// Health Check Routes
|
||||
.route("/api/health", get(health_handler))
|
||||
.route("/api/ready", get(ready_handler))
|
||||
// System Routes
|
||||
.route("/api/system/resources", get(system_resources_handler))
|
||||
.route("/api/system/info", get(get_system_info_handler))
|
||||
.route("/api/system/hardware", get(get_hardware_info_handler))
|
||||
// Setup Routes
|
||||
.route("/api/setup/status", get(setup_status_handler))
|
||||
.route("/api/setup/complete", post(setup_complete_handler))
|
||||
@@ -126,6 +191,8 @@ struct TranscodeSettingsPayload {
|
||||
min_file_size_mb: u64,
|
||||
output_codec: crate::config::OutputCodec,
|
||||
quality_profile: crate::config::QualityProfile,
|
||||
#[serde(default)]
|
||||
threads: usize,
|
||||
}
|
||||
|
||||
async fn get_transcode_settings_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
@@ -137,6 +204,7 @@ async fn get_transcode_settings_handler(State(state): State<Arc<AppState>>) -> i
|
||||
min_file_size_mb: config.transcode.min_file_size_mb,
|
||||
output_codec: config.transcode.output_codec,
|
||||
quality_profile: config.transcode.quality_profile,
|
||||
threads: config.transcode.threads,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,6 +232,7 @@ async fn update_transcode_settings_handler(
|
||||
config.transcode.min_file_size_mb = payload.min_file_size_mb;
|
||||
config.transcode.output_codec = payload.output_codec;
|
||||
config.transcode.quality_profile = payload.quality_profile;
|
||||
config.transcode.threads = payload.threads;
|
||||
|
||||
if let Err(e) = config.save(std::path::Path::new("config.toml")) {
|
||||
return (
|
||||
@@ -176,6 +245,48 @@ async fn update_transcode_settings_handler(
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct SystemSettingsPayload {
|
||||
monitoring_poll_interval: f64,
|
||||
enable_telemetry: bool,
|
||||
}
|
||||
|
||||
async fn get_system_settings_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let config = state.config.read().await;
|
||||
axum::Json(SystemSettingsPayload {
|
||||
monitoring_poll_interval: config.system.monitoring_poll_interval,
|
||||
enable_telemetry: config.system.enable_telemetry,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_system_settings_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<SystemSettingsPayload>,
|
||||
) -> impl IntoResponse {
|
||||
let mut config = state.config.write().await;
|
||||
|
||||
if payload.monitoring_poll_interval < 0.5 || payload.monitoring_poll_interval > 10.0 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"monitoring_poll_interval must be between 0.5 and 10.0 seconds",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
config.system.monitoring_poll_interval = payload.monitoring_poll_interval;
|
||||
config.system.enable_telemetry = payload.enable_telemetry;
|
||||
|
||||
if let Err(e) = config.save(std::path::Path::new("config.toml")) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to save config: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, "Settings updated").into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetupConfig {
|
||||
username: String,
|
||||
@@ -185,6 +296,7 @@ struct SetupConfig {
|
||||
concurrent_jobs: usize,
|
||||
directories: Vec<String>,
|
||||
allow_cpu_encoding: bool,
|
||||
enable_telemetry: bool,
|
||||
}
|
||||
|
||||
async fn setup_complete_handler(
|
||||
@@ -247,6 +359,7 @@ async fn setup_complete_handler(
|
||||
config_lock.transcode.min_file_size_mb = payload.min_file_size_mb;
|
||||
config_lock.hardware.allow_cpu_encoding = payload.allow_cpu_encoding;
|
||||
config_lock.scanner.directories = payload.directories;
|
||||
config_lock.system.enable_telemetry = payload.enable_telemetry;
|
||||
|
||||
// Serialize to TOML
|
||||
let toml_string = match toml::to_string_pretty(&*config_lock) {
|
||||
@@ -271,6 +384,7 @@ async fn setup_complete_handler(
|
||||
|
||||
// Update Setup State (Hot Reload)
|
||||
state.setup_required.store(false, Ordering::Relaxed);
|
||||
state.agent.resume();
|
||||
|
||||
// Start Scan (optional, but good for UX)
|
||||
let dirs = config_lock
|
||||
@@ -433,24 +547,21 @@ async fn detailed_stats_handler(State(state): State<Arc<AppState>>) -> impl Into
|
||||
}
|
||||
}
|
||||
|
||||
async fn jobs_table_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let jobs = state.db.get_all_jobs().await.unwrap_or_default();
|
||||
axum::Json(jobs)
|
||||
}
|
||||
|
||||
async fn scan_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let config = state.config.read().await;
|
||||
let dirs = config
|
||||
let mut dirs: Vec<std::path::PathBuf> = config
|
||||
.scanner
|
||||
.directories
|
||||
.iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.collect();
|
||||
drop(config); // Release lock before awaiting scan (though scan might take long time? no scan_and_enqueue is async but returns quickly? Let's check Agent::scan_and_enqueue)
|
||||
// Agent::scan_and_enqueue is async. We should probably release lock before calling it if it takes long time.
|
||||
// It does 'Scanner::new().scan()' which IS synchronous and blocking?
|
||||
// Looking at Agent::scan_and_enqueue: `let files = scanner.scan(directories);`
|
||||
// If scanner.scan is slow, we are holding the config lock? No, I dropped it.
|
||||
drop(config);
|
||||
|
||||
if let Ok(watch_dirs) = state.db.get_watch_dirs().await {
|
||||
for wd in watch_dirs {
|
||||
dirs.push(std::path::PathBuf::from(wd.path));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = state.agent.scan_and_enqueue(dirs).await;
|
||||
StatusCode::OK
|
||||
@@ -467,14 +578,6 @@ async fn cancel_job_handler(
|
||||
}
|
||||
}
|
||||
|
||||
async fn restart_job_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
let _ = state.db.update_job_status(id, JobState::Queued).await;
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn restart_failed_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let _ = state.db.restart_failed_jobs().await;
|
||||
StatusCode::OK
|
||||
@@ -639,21 +742,39 @@ async fn sse_handler(
|
||||
let stream = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
||||
Ok(event) => {
|
||||
let (event_name, data) = match &event {
|
||||
AlchemistEvent::Log { message, .. } => ("log", message.clone()),
|
||||
AlchemistEvent::Log {
|
||||
level,
|
||||
job_id,
|
||||
message,
|
||||
} => (
|
||||
"log",
|
||||
serde_json::json!({
|
||||
"level": level,
|
||||
"job_id": job_id,
|
||||
"message": message
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
AlchemistEvent::Progress {
|
||||
job_id,
|
||||
percentage,
|
||||
time,
|
||||
} => (
|
||||
"progress",
|
||||
format!(
|
||||
"{{\"job_id\": {}, \"percentage\": {:.1}, \"time\": \"{}\"}}",
|
||||
job_id, percentage, time
|
||||
),
|
||||
serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"percentage": percentage,
|
||||
"time": time
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
AlchemistEvent::JobStateChanged { job_id, status } => (
|
||||
"status",
|
||||
format!("{{\"job_id\": {}, \"status\": \"{:?}\"}}", job_id, status),
|
||||
serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"status": status // Uses serde impl (lowercase)
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
AlchemistEvent::Decision {
|
||||
job_id,
|
||||
@@ -661,10 +782,12 @@ async fn sse_handler(
|
||||
reason,
|
||||
} => (
|
||||
"decision",
|
||||
format!(
|
||||
"{{\"job_id\": {}, \"action\": \"{}\", \"reason\": \"{}\"}}",
|
||||
job_id, action, reason
|
||||
),
|
||||
serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"action": action,
|
||||
"reason": reason
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
Some(Ok(AxumEvent::default().event(event_name).data(data)))
|
||||
@@ -674,3 +797,485 @@ async fn sse_handler(
|
||||
|
||||
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
|
||||
}
|
||||
|
||||
// #[derive(serde::Serialize)]
|
||||
// struct GpuInfo {
|
||||
// name: String,
|
||||
// utilization: f32,
|
||||
// memory_used_mb: u64,
|
||||
// }
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SystemResources {
|
||||
cpu_percent: f32,
|
||||
memory_used_mb: u64,
|
||||
memory_total_mb: u64,
|
||||
memory_percent: f32,
|
||||
uptime_seconds: u64,
|
||||
active_jobs: i64,
|
||||
concurrent_limit: usize,
|
||||
cpu_count: usize,
|
||||
// GPU info would require additional platform-specific code
|
||||
// For now we report what sysinfo provides
|
||||
}
|
||||
|
||||
async fn system_resources_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// Use a block to limit the scope of the lock
|
||||
let (cpu_percent, memory_used_mb, memory_total_mb, memory_percent, cpu_count) = {
|
||||
let mut sys = state.sys.lock().unwrap();
|
||||
// Full refresh for better accuracy when polled less frequently
|
||||
sys.refresh_all();
|
||||
|
||||
// Get CPU usage (average across all cores)
|
||||
let cpu_percent =
|
||||
sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / sys.cpus().len().max(1) as f32;
|
||||
|
||||
let cpu_count = sys.cpus().len();
|
||||
|
||||
// Memory info
|
||||
let memory_used_mb = (sys.used_memory() / 1024 / 1024) as u64;
|
||||
let memory_total_mb = (sys.total_memory() / 1024 / 1024) as u64;
|
||||
let memory_percent = if memory_total_mb > 0 {
|
||||
(memory_used_mb as f32 / memory_total_mb as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(
|
||||
cpu_percent,
|
||||
memory_used_mb,
|
||||
memory_total_mb,
|
||||
memory_percent,
|
||||
cpu_count,
|
||||
)
|
||||
};
|
||||
|
||||
// Uptime
|
||||
let uptime_seconds = state.start_time.elapsed().as_secs();
|
||||
|
||||
// Active jobs from database
|
||||
let stats = state.db.get_job_stats().await.unwrap_or_default();
|
||||
let config = state.config.read().await;
|
||||
|
||||
axum::Json(SystemResources {
|
||||
cpu_percent,
|
||||
memory_used_mb,
|
||||
memory_total_mb,
|
||||
memory_percent,
|
||||
uptime_seconds,
|
||||
active_jobs: stats.active,
|
||||
concurrent_limit: config.transcode.concurrent_jobs,
|
||||
cpu_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LogParams {
|
||||
page: Option<i64>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
async fn logs_history_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<LogParams>,
|
||||
) -> impl IntoResponse {
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
match state.db.get_logs(limit, offset).await {
|
||||
Ok(logs) => axum::Json(logs).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_logs_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match state.db.clear_logs().await {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct JobTableParams {
|
||||
limit: Option<i64>,
|
||||
page: Option<i64>,
|
||||
status: Option<String>,
|
||||
search: Option<String>,
|
||||
sort: Option<String>,
|
||||
sort_desc: Option<bool>,
|
||||
}
|
||||
|
||||
async fn jobs_table_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Query(params): axum::extract::Query<JobTableParams>,
|
||||
) -> impl IntoResponse {
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let statuses = if let Some(s) = params.status {
|
||||
let list: Vec<JobState> = s
|
||||
.split(',')
|
||||
.filter_map(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok())
|
||||
.collect();
|
||||
if list.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(list)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match state
|
||||
.db
|
||||
.get_jobs_filtered(
|
||||
limit,
|
||||
offset,
|
||||
statuses,
|
||||
params.search,
|
||||
params.sort,
|
||||
params.sort_desc.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(jobs) => axum::Json(jobs).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatchActionPayload {
|
||||
action: String,
|
||||
ids: Vec<i64>,
|
||||
}
|
||||
|
||||
async fn batch_jobs_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<BatchActionPayload>,
|
||||
) -> impl IntoResponse {
|
||||
let result = match payload.action.as_str() {
|
||||
"cancel" => state.db.batch_cancel_jobs(&payload.ids).await,
|
||||
"delete" => state.db.batch_delete_jobs(&payload.ids).await,
|
||||
"restart" => state.db.batch_restart_jobs(&payload.ids).await,
|
||||
_ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(count) => axum::Json(serde_json::json!({ "count": count })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddNotificationTargetPayload {
|
||||
name: String,
|
||||
target_type: String,
|
||||
endpoint_url: String,
|
||||
auth_token: Option<String>,
|
||||
events: Vec<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
// #[derive(Deserialize)]
|
||||
// struct TestNotificationPayload {
|
||||
// target: AddNotificationTargetPayload,
|
||||
// }
|
||||
|
||||
async fn get_notifications_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match state.db.get_notification_targets().await {
|
||||
Ok(t) => axum::Json(serde_json::json!(t)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_notification_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<AddNotificationTargetPayload>,
|
||||
) -> impl IntoResponse {
|
||||
let events_json = serde_json::to_string(&payload.events).unwrap_or_default();
|
||||
match state
|
||||
.db
|
||||
.add_notification_target(
|
||||
&payload.name,
|
||||
&payload.target_type,
|
||||
&payload.endpoint_url,
|
||||
payload.auth_token.as_deref(),
|
||||
&events_json,
|
||||
payload.enabled,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => axum::Json(serde_json::json!(t)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_notification_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.db.delete_notification_target(id).await {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_notification_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<AddNotificationTargetPayload>,
|
||||
) -> impl IntoResponse {
|
||||
// Construct a temporary target
|
||||
let events_json = serde_json::to_string(&payload.events).unwrap_or_default();
|
||||
let target = crate::db::NotificationTarget {
|
||||
id: 0,
|
||||
name: payload.name,
|
||||
target_type: payload.target_type,
|
||||
endpoint_url: payload.endpoint_url,
|
||||
auth_token: payload.auth_token,
|
||||
events: events_json,
|
||||
enabled: payload.enabled,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
match state.notification_manager.send_test(&target).await {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_schedule_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match state.db.get_schedule_windows().await {
|
||||
Ok(w) => axum::Json(serde_json::json!(w)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddSchedulePayload {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
days_of_week: Vec<i32>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
async fn add_schedule_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<AddSchedulePayload>,
|
||||
) -> impl IntoResponse {
|
||||
let days_json = serde_json::to_string(&payload.days_of_week).unwrap_or_default();
|
||||
match state
|
||||
.db
|
||||
.add_schedule_window(
|
||||
&payload.start_time,
|
||||
&payload.end_time,
|
||||
&days_json,
|
||||
payload.enabled,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(w) => axum::Json(serde_json::json!(w)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_schedule_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.db.delete_schedule_window(id).await {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AddWatchDirPayload {
|
||||
path: String,
|
||||
is_recursive: Option<bool>,
|
||||
}
|
||||
|
||||
async fn get_watch_dirs_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match state.db.get_watch_dirs().await {
|
||||
Ok(dirs) => axum::Json(dirs).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_watch_dir_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<AddWatchDirPayload>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.db
|
||||
.add_watch_dir(&payload.path, payload.is_recursive.unwrap_or(true))
|
||||
.await
|
||||
{
|
||||
Ok(dir) => axum::Json(dir).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_watch_dir_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.db.remove_watch_dir(id).await {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn restart_job_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.db.get_job_by_id(id).await {
|
||||
Ok(Some(job)) => {
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.update_job_status(job.id, crate::db::JobState::Queued)
|
||||
.await
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_job_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.db.delete_job(id).await {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JobDetailResponse {
|
||||
job: crate::db::Job,
|
||||
metadata: Option<crate::media::pipeline::MediaMetadata>,
|
||||
encode_stats: Option<crate::db::DetailedEncodeStats>,
|
||||
}
|
||||
|
||||
async fn get_job_detail_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
let job = match state.db.get_job_by_id(id).await {
|
||||
Ok(Some(j)) => j,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
// Try to get metadata
|
||||
let analyzer = crate::media::analyzer::FfmpegAnalyzer;
|
||||
use crate::media::pipeline::Analyzer;
|
||||
let metadata = analyzer
|
||||
.analyze(std::path::Path::new(&job.input_path))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Try to get encode stats (using the subquery result or a specific query)
|
||||
// For now we'll just query the encode_stats table if completed
|
||||
let encode_stats = if job.status == crate::db::JobState::Completed {
|
||||
state.db.get_encode_stats_by_job_id(id).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
axum::Json(JobDetailResponse {
|
||||
job,
|
||||
metadata,
|
||||
encode_stats,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn get_file_settings_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match state.db.get_file_settings().await {
|
||||
Ok(s) => axum::Json(serde_json::json!(s)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateFileSettingsPayload {
|
||||
delete_source: bool,
|
||||
output_extension: String,
|
||||
output_suffix: String,
|
||||
replace_strategy: String,
|
||||
}
|
||||
|
||||
async fn update_file_settings_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<UpdateFileSettingsPayload>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.db
|
||||
.update_file_settings(
|
||||
payload.delete_source,
|
||||
&payload.output_extension,
|
||||
&payload.output_suffix,
|
||||
&payload.replace_strategy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => axum::Json(serde_json::json!(s)).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SystemInfo {
|
||||
version: String,
|
||||
os_version: String,
|
||||
is_docker: bool,
|
||||
telemetry_enabled: bool,
|
||||
ffmpeg_version: String,
|
||||
}
|
||||
|
||||
async fn get_system_info_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let config = state.config.read().await;
|
||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||
let os_version = format!("{} {}", std::env::consts::OS, std::env::consts::ARCH);
|
||||
let is_docker = std::path::Path::new("/.dockerenv").exists();
|
||||
|
||||
// Attempt to verify ffmpeg version
|
||||
let ffmpeg_version =
|
||||
crate::media::ffmpeg::verify_ffmpeg().unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
axum::Json(SystemInfo {
|
||||
version,
|
||||
os_version,
|
||||
is_docker,
|
||||
telemetry_enabled: !config.system.monitoring_poll_interval.is_nan(),
|
||||
ffmpeg_version,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn get_hardware_info_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let config = state.config.read().await;
|
||||
match crate::system::hardware::detect_hardware(config.hardware.allow_cpu_fallback) {
|
||||
Ok(info) => axum::Json(info).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_scan_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match state.library_scanner.start_scan().await {
|
||||
Ok(_) => StatusCode::ACCEPTED.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_scan_status_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
axum::Json::<crate::system::scanner::ScanStatus>(state.library_scanner.get_status().await)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Vendor {
|
||||
Nvidia,
|
||||
Amd,
|
||||
@@ -24,7 +27,7 @@ impl std::fmt::Display for Vendor {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HardwareInfo {
|
||||
pub vendor: Vendor,
|
||||
pub device_path: Option<String>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod hardware;
|
||||
pub mod notifications;
|
||||
pub mod scanner;
|
||||
pub mod watcher;
|
||||
|
||||
142
src/system/scanner.rs
Normal file
142
src/system/scanner.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use crate::db::{Db, Job, JobState};
|
||||
use crate::error::Result;
|
||||
use crate::media::scanner::Scanner;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ScanStatus {
|
||||
pub is_running: bool,
|
||||
pub files_found: usize,
|
||||
pub files_added: usize,
|
||||
pub current_folder: Option<String>,
|
||||
}
|
||||
|
||||
pub struct LibraryScanner {
|
||||
db: Arc<Db>,
|
||||
status: Arc<Mutex<ScanStatus>>,
|
||||
}
|
||||
|
||||
impl LibraryScanner {
|
||||
pub fn new(db: Arc<Db>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
status: Arc::new(Mutex::new(ScanStatus {
|
||||
is_running: false,
|
||||
files_found: 0,
|
||||
files_added: 0,
|
||||
current_folder: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_status(&self) -> ScanStatus {
|
||||
self.status.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn start_scan(&self) -> Result<()> {
|
||||
let mut status = self.status.lock().await;
|
||||
if status.is_running {
|
||||
return Ok(());
|
||||
}
|
||||
status.is_running = true;
|
||||
status.files_found = 0;
|
||||
status.files_added = 0;
|
||||
drop(status);
|
||||
|
||||
let scanner_self = self.status.clone();
|
||||
let db = self.db.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
info!("🚀 Starting full library scan...");
|
||||
|
||||
let watch_dirs = match db.get_watch_dirs().await {
|
||||
Ok(dirs) => dirs,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch watch directories for scan: {}", e);
|
||||
let mut s = scanner_self.lock().await;
|
||||
s.is_running = false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let scanner = Scanner::new();
|
||||
let mut all_scanned = Vec::new();
|
||||
|
||||
for watch_dir in watch_dirs {
|
||||
let path = PathBuf::from(&watch_dir.path);
|
||||
if !path.exists() {
|
||||
warn!("Watch directory does not exist: {:?}", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = scanner_self.lock().await;
|
||||
s.current_folder = Some(watch_dir.path.clone());
|
||||
}
|
||||
|
||||
let files = scanner.scan(vec![path]);
|
||||
all_scanned.extend(files);
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = scanner_self.lock().await;
|
||||
s.files_found = all_scanned.len();
|
||||
s.current_folder = Some("Processing files...".to_string());
|
||||
}
|
||||
|
||||
let mut added = 0;
|
||||
for file in all_scanned {
|
||||
let path_str = file.path.to_string_lossy().to_string();
|
||||
|
||||
// Check if already exists
|
||||
match db.get_job_by_input_path(&path_str).await {
|
||||
Ok(Some(_)) => continue,
|
||||
Ok(None) => {
|
||||
// Add new job
|
||||
let output_path = path_str
|
||||
.replace(".mkv", ".mp4")
|
||||
.replace(".avi", ".mp4")
|
||||
.replace(".mov", ".mp4"); // Dummy output logic for now
|
||||
|
||||
let job = Job {
|
||||
id: 0,
|
||||
input_path: path_str,
|
||||
output_path,
|
||||
status: JobState::Queued,
|
||||
priority: 0,
|
||||
progress: 0.0,
|
||||
attempt_count: 0,
|
||||
decision_reason: None,
|
||||
vmaf_score: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
if let Err(e) = db.add_job(job).await {
|
||||
error!("Failed to add job during scan: {}", e);
|
||||
} else {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Database error during scan check: {}", e),
|
||||
}
|
||||
|
||||
if added % 10 == 0 {
|
||||
let mut s = scanner_self.lock().await;
|
||||
s.files_added = added;
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = scanner_self.lock().await;
|
||||
s.files_added = added;
|
||||
s.is_running = false;
|
||||
s.current_folder = None;
|
||||
info!("✅ Library scan complete. Added {} new files.", added);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -11,73 +11,22 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// Filesystem watcher that auto-enqueues new media files
|
||||
#[derive(Clone)]
|
||||
pub struct FileWatcher {
|
||||
directories: Vec<PathBuf>,
|
||||
db: Arc<Db>,
|
||||
debounce_ms: u64,
|
||||
inner: Arc<std::sync::Mutex<Option<RecommendedWatcher>>>,
|
||||
tx: mpsc::Sender<PathBuf>,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
pub fn new(directories: Vec<PathBuf>, db: Arc<Db>) -> Self {
|
||||
Self {
|
||||
directories,
|
||||
db,
|
||||
debounce_ms: 1000, // 1 second debounce
|
||||
}
|
||||
}
|
||||
|
||||
/// Start watching directories for new files
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
pub fn new(db: Arc<Db>) -> Self {
|
||||
let (tx, mut rx) = mpsc::channel::<PathBuf>(100);
|
||||
let scanner = Scanner::new();
|
||||
let extensions: HashSet<String> = scanner
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|s| s.to_lowercase())
|
||||
.collect();
|
||||
|
||||
// Create the watcher
|
||||
let tx_clone = tx.clone();
|
||||
let extensions_clone = extensions.clone();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: std::result::Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
for path in event.paths {
|
||||
// Check if it's a media file
|
||||
if let Some(ext) = path.extension() {
|
||||
if extensions_clone.contains(&ext.to_string_lossy().to_lowercase()) {
|
||||
let _ = tx_clone.blocking_send(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_secs(2)),
|
||||
)
|
||||
.map_err(|e| AlchemistError::Watch(format!("Failed to create watcher: {}", e)))?;
|
||||
|
||||
// Watch all directories
|
||||
for dir in &self.directories {
|
||||
info!("Watching directory: {:?}", dir);
|
||||
watcher
|
||||
.watch(dir, RecursiveMode::Recursive)
|
||||
.map_err(|e| AlchemistError::Watch(format!("Failed to watch {:?}: {}", dir, e)))?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"File watcher started for {} directories",
|
||||
self.directories.len()
|
||||
);
|
||||
|
||||
// Debounce and process events
|
||||
let db = self.db.clone();
|
||||
let debounce_ms = self.debounce_ms;
|
||||
let debounce_ms = 1000;
|
||||
let db_clone = db.clone();
|
||||
|
||||
// Spawn key processing loop immediately
|
||||
tokio::spawn(async move {
|
||||
let mut pending: HashSet<PathBuf> = HashSet::new();
|
||||
let mut last_process = std::time::Instant::now();
|
||||
@@ -99,7 +48,7 @@ impl FileWatcher {
|
||||
let mut output_path = path.clone();
|
||||
output_path.set_extension("av1.mkv");
|
||||
|
||||
if let Err(e) = db.enqueue_job(&path, &output_path, mtime).await {
|
||||
if let Err(e) = db_clone.enqueue_job(&path, &output_path, mtime).await {
|
||||
error!("Failed to auto-enqueue {:?}: {}", path, e);
|
||||
} else {
|
||||
info!("Auto-enqueued: {:?}", path);
|
||||
@@ -113,12 +62,69 @@ impl FileWatcher {
|
||||
}
|
||||
});
|
||||
|
||||
// Keep watcher alive
|
||||
// In a real implementation, we'd store the watcher handle
|
||||
// For now, we leak it intentionally to keep it running
|
||||
std::mem::forget(watcher);
|
||||
warn!("File watcher task started (watcher handle leaked intentionally)");
|
||||
Self {
|
||||
inner: Arc::new(std::sync::Mutex::new(None)),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update watched directories
|
||||
pub fn watch(&self, directories: &[PathBuf]) -> Result<()> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
|
||||
// Stop existing watcher implicitly by dropping it (if we replace it)
|
||||
// Or explicitly unwatch? Dropping RecommendedWatcher stops it.
|
||||
|
||||
if directories.is_empty() {
|
||||
*inner = None;
|
||||
info!("File watcher stopped (no directories configured)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scanner = Scanner::new();
|
||||
let extensions: HashSet<String> = scanner
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|s| s.to_lowercase())
|
||||
.collect();
|
||||
|
||||
// Create the watcher
|
||||
let tx_clone = self.tx.clone();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: std::result::Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
for path in event.paths {
|
||||
// Check if it's a media file
|
||||
if let Some(ext) = path.extension() {
|
||||
if extensions.contains(&ext.to_string_lossy().to_lowercase()) {
|
||||
let _ = tx_clone.blocking_send(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_secs(2)),
|
||||
)
|
||||
.map_err(|e| AlchemistError::Watch(format!("Failed to create watcher: {}", e)))?;
|
||||
|
||||
// Watch all directories
|
||||
for dir in directories {
|
||||
info!("Watching directory: {:?}", dir);
|
||||
if let Err(e) = watcher.watch(dir, RecursiveMode::Recursive) {
|
||||
error!("Failed to watch {:?}: {}", dir, e);
|
||||
// Continue trying others? Or fail?
|
||||
// Failing strictly is probably safer to alert user
|
||||
return Err(AlchemistError::Watch(format!(
|
||||
"Failed to watch {:?}: {}",
|
||||
dir, e
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
info!("File watcher updated for {} directories", directories.len());
|
||||
|
||||
*inner = Some(watcher);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ impl ConfigWizard {
|
||||
min_bpp_threshold: min_bpp,
|
||||
min_file_size_mb: min_file_size,
|
||||
concurrent_jobs,
|
||||
threads: 0,
|
||||
quality_profile: crate::config::QualityProfile::Balanced,
|
||||
output_codec: crate::config::OutputCodec::Av1,
|
||||
subtitle_mode: crate::config::SubtitleMode::Copy,
|
||||
@@ -78,7 +79,7 @@ impl ConfigWizard {
|
||||
},
|
||||
notifications: crate::config::NotificationsConfig::default(),
|
||||
quality: crate::config::QualityConfig::default(),
|
||||
schedule: crate::scheduler::ScheduleConfig::default(),
|
||||
system: crate::config::SystemConfig::default(),
|
||||
};
|
||||
|
||||
// Show summary
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "alchemist-web",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
110
web/src/components/AboutDialog.tsx
Normal file
110
web/src/components/AboutDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Info, X, Terminal, Server, Cpu, Activity, ShieldCheck } from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
interface SystemInfo {
|
||||
version: string;
|
||||
os_version: string;
|
||||
is_docker: boolean;
|
||||
telemetry_enabled: boolean;
|
||||
ffmpeg_version: string;
|
||||
}
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
||||
const [info, setInfo] = useState<SystemInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !info) {
|
||||
apiFetch("/api/system/info")
|
||||
.then(res => res.json())
|
||||
.then(setInfo)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="w-full max-w-md bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative"
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-helios-solar/10 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="p-8 relative">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="p-3 bg-helios-surface-soft border border-helios-line/20 rounded-2xl shadow-sm">
|
||||
<div className="w-8 h-8 rounded-lg bg-helios-solar text-helios-main flex items-center justify-center font-bold text-xl">
|
||||
Al
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-helios-surface-soft rounded-full text-helios-slate hover:text-helios-ink transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-helios-ink tracking-tight">Alchemist</h2>
|
||||
<p className="text-helios-slate font-medium">Media Transcoding Agent</p>
|
||||
</div>
|
||||
|
||||
{info ? (
|
||||
<div className="space-y-3">
|
||||
<InfoRow icon={Terminal} label="Version" value={`v${info.version}`} />
|
||||
<InfoRow icon={Activity} label="FFmpeg" value={info.ffmpeg_version} />
|
||||
<InfoRow icon={Server} label="System" value={info.os_version} />
|
||||
<InfoRow icon={Cpu} label="Environment" value={info.is_docker ? "Docker Container" : "Native"} />
|
||||
<InfoRow icon={ShieldCheck} label="Telemetry" value={info.telemetry_enabled ? "Enabled" : "Disabled"} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="w-6 h-6 border-2 border-helios-solar border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-helios-line/10 text-center">
|
||||
<p className="text-xs text-helios-slate/60">
|
||||
© {new Date().getFullYear()} Alchemist Contributors. <br />
|
||||
Released under GPL-3.0 License.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: { icon: any, label: string, value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-helios-surface-soft border border-helios-line/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={16} className="text-helios-slate" />
|
||||
<span className="text-sm font-medium text-helios-slate">{label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-helios-ink font-mono break-all text-right max-w-[60%]">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
Clock,
|
||||
HardDrive,
|
||||
Database,
|
||||
Zap
|
||||
Zap,
|
||||
Terminal
|
||||
} from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
@@ -24,6 +25,8 @@ interface Job {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
import ResourceMonitor from "./ResourceMonitor";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats>({ total: 0, completed: 0, active: 0, failed: 0 });
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
@@ -71,6 +74,7 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ResourceMonitor />
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
@@ -141,44 +145,44 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Getting Started Tips */}
|
||||
<div className="p-6 rounded-3xl bg-gradient-to-br from-helios-surface to-helios-surface-soft border border-helios-line/40 shadow-sm">
|
||||
<div className="p-6 rounded-3xl bg-gradient-to-br from-helios-surface to-helios-surface-soft border border-helios-line/40 shadow-sm self-start">
|
||||
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
|
||||
<Clock size={20} className="text-helios-slate" />
|
||||
Quick Tips
|
||||
<Zap size={20} className="text-helios-solar" />
|
||||
Quick Start
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="p-2 rounded-lg bg-helios-solar/10 text-helios-solar mt-0.5">
|
||||
<HardDrive size={16} />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="p-2.5 rounded-xl bg-helios-solar/10 text-helios-solar mt-0.5 shadow-inner">
|
||||
<HardDrive size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-helios-ink">Add Media</h4>
|
||||
<h4 className="text-sm font-bold text-helios-ink">Organize Media</h4>
|
||||
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
|
||||
Mount your media volume to <code className="bg-black/10 px-1 py-0.5 rounded font-mono text-[10px]">/data</code> in Docker. Alchemist watches configured folders automatically.
|
||||
Map your library to <code className="bg-black/20 px-1.5 py-0.5 rounded font-mono text-[10px] text-helios-solar">/data</code> and configure Watch Folders in <a href="/settings" className="underline hover:text-helios-ink transition-colors">Settings</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500 mt-0.5">
|
||||
<Zap size={16} />
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-500 mt-0.5 shadow-inner">
|
||||
<Activity size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-helios-ink">Performance</h4>
|
||||
<h4 className="text-sm font-bold text-helios-ink">Boost Speed</h4>
|
||||
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
|
||||
Toggle <strong>Hardware Acceleration</strong> in Settings if you have a supported GPU (NVIDIA/Intel) for 10x speeds.
|
||||
Enable <strong>Hardware Acceleration</strong> and adjust <strong>Thread Allocation</strong> to maximize your CPU/GPU throughput.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500 mt-0.5">
|
||||
<Activity size={16} />
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="p-2.5 rounded-xl bg-purple-500/10 text-purple-500 mt-0.5 shadow-inner">
|
||||
<Terminal size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-helios-ink">Monitor Logs</h4>
|
||||
<h4 className="text-sm font-bold text-helios-ink">Direct Control</h4>
|
||||
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
|
||||
Check the <strong>Logs</strong> page for detailed real-time insights into the transcoding pipeline.
|
||||
Alchemist is built for automation. Check the <a href="/logs" className="underline hover:text-helios-ink transition-colors">Logs</a> for detailed FFmpeg execution streams.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
123
web/src/components/FileSettings.tsx
Normal file
123
web/src/components/FileSettings.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FileOutput, AlertTriangle, Save } from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
interface FileSettings {
|
||||
delete_source: boolean;
|
||||
output_extension: string;
|
||||
output_suffix: string;
|
||||
replace_strategy: string;
|
||||
}
|
||||
|
||||
export default function FileSettings() {
|
||||
const [settings, setSettings] = useState<FileSettings>({
|
||||
delete_source: false,
|
||||
output_extension: "mkv",
|
||||
output_suffix: "-alchemist",
|
||||
replace_strategy: "keep"
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/files");
|
||||
if (res.ok) setSettings(await res.json());
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch("/api/settings/files", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||
<FileOutput className="text-helios-solar" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-helios-ink">File Handling</h2>
|
||||
<p className="text-xs text-helios-slate">Configure output naming and source file policies.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Naming */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Output Suffix</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.output_suffix}
|
||||
onChange={e => setSettings({ ...settings, output_suffix: e.target.value })}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
|
||||
placeholder="-alchemist"
|
||||
/>
|
||||
<p className="text-[10px] text-helios-slate mt-1">Appended to filename (e.g. video<span className="text-helios-solar">{settings.output_suffix}</span>.{settings.output_extension})</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Extension</label>
|
||||
<select
|
||||
value={settings.output_extension}
|
||||
onChange={e => setSettings({ ...settings, output_extension: e.target.value })}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
|
||||
>
|
||||
<option value="mkv">mkv</option>
|
||||
<option value="mp4">mp4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deletion Policy */}
|
||||
<div className="p-4 bg-red-500/5 border border-red-500/20 rounded-xl space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="text-red-500 shrink-0 mt-0.5" size={16} />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-red-600 dark:text-red-400">Destructive Policy</h3>
|
||||
<p className="text-xs text-helios-slate mt-1 mb-3">
|
||||
Enabling "Delete Source" will permanently remove the original file after a successful transcode. This action cannot be undone.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.delete_source}
|
||||
onChange={e => setSettings({ ...settings, delete_source: e.target.checked })}
|
||||
className="rounded border-red-500/30 text-red-500 focus:ring-red-500 bg-red-500/10"
|
||||
/>
|
||||
<span className="text-sm font-medium text-helios-ink">Delete source file after success</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-helios-solar text-helios-main font-bold rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
web/src/components/HardwareSettings.tsx
Normal file
139
web/src/components/HardwareSettings.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cpu, Zap, HardDrive, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
interface HardwareInfo {
|
||||
vendor: "Nvidia" | "Amd" | "Intel" | "Apple" | "Cpu";
|
||||
device_path: string | null;
|
||||
supported_codecs: string[];
|
||||
}
|
||||
|
||||
export default function HardwareSettings() {
|
||||
const [info, setInfo] = useState<HardwareInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchHardware();
|
||||
}, []);
|
||||
|
||||
const fetchHardware = async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/system/hardware");
|
||||
if (!res.ok) throw new Error("Failed to detect hardware");
|
||||
const data = await res.json();
|
||||
setInfo(data);
|
||||
} catch (err) {
|
||||
setError("Unable to detect hardware acceleration support.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 animate-pulse">
|
||||
<div className="h-12 bg-helios-surface-soft rounded-2xl w-full" />
|
||||
<div className="h-40 bg-helios-surface-soft rounded-2xl w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !info) {
|
||||
return (
|
||||
<div className="p-6 bg-red-500/10 border border-red-500/20 text-red-500 rounded-2xl flex items-center gap-3">
|
||||
<AlertCircle size={20} />
|
||||
<span className="font-semibold">{error || "Hardware detection failed."}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getVendorDetails = (vendor: string) => {
|
||||
switch (vendor) {
|
||||
case "Nvidia": return { name: "NVIDIA", tech: "NVENC", color: "text-emerald-500", bg: "bg-emerald-500/10" };
|
||||
case "Amd": return { name: "AMD", tech: "VAAPI/AMF", color: "text-red-500", bg: "bg-red-500/10" };
|
||||
case "Intel": return { name: "Intel", tech: "QuickSync (QSV)", color: "text-blue-500", bg: "bg-blue-500/10" };
|
||||
case "Apple": return { name: "Apple", tech: "VideoToolbox", color: "text-helios-slate", bg: "bg-helios-slate/10" };
|
||||
default: return { name: "CPU", tech: "Software Fallback", color: "text-helios-solar", bg: "bg-helios-solar/10" };
|
||||
}
|
||||
};
|
||||
|
||||
const details = getVendorDetails(info.vendor);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-helios-line/10">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-helios-ink tracking-tight uppercase tracking-[0.1em]">Transcoding Hardware</h3>
|
||||
<p className="text-xs text-helios-slate mt-0.5">Detected acceleration engines and codec support.</p>
|
||||
</div>
|
||||
<div className={`p-2 ${details.bg} rounded-xl ${details.color}`}>
|
||||
{info.vendor === "Cpu" ? <Cpu size={20} /> : <Zap size={20} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-helios-surface border border-helios-line/30 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`p-2.5 rounded-xl ${details.bg} ${details.color}`}>
|
||||
<HardDrive size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-helios-ink uppercase tracking-wider">Active Device</h4>
|
||||
<p className="text-[10px] text-helios-slate font-bold">{details.name} {details.tech}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-helios-slate uppercase tracking-widest block mb-1.5 ml-0.5">Device Path</span>
|
||||
<div className="bg-helios-surface-soft border border-helios-line/30 rounded-lg px-3 py-2 font-mono text-xs text-helios-ink shadow-inner">
|
||||
{info.device_path || (info.vendor === "Nvidia" ? "NVIDIA Driver (Direct)" : "Auto-detected Interface")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-helios-surface border border-helios-line/30 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2.5 rounded-xl bg-purple-500/10 text-purple-500">
|
||||
<CheckCircle2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-helios-ink uppercase tracking-wider">Codec Support</h4>
|
||||
<p className="text-[10px] text-helios-slate font-bold">Hardware verified encoders</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.supported_codecs.length > 0 ? info.supported_codecs.map(codec => (
|
||||
<div key={codec} className="px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 text-xs font-bold uppercase tracking-wider flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{codec}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-xs text-helios-slate italic bg-helios-surface-soft w-full p-2 text-center rounded-lg">
|
||||
No hardware accelerated codecs found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info.vendor === "Cpu" && (
|
||||
<div className="p-4 bg-helios-solar/5 border border-helios-solar/10 rounded-2xl">
|
||||
<div className="flex gap-3">
|
||||
<AlertCircle className="text-helios-solar shrink-0" size={18} />
|
||||
<div className="space-y-1">
|
||||
<h5 className="text-sm font-bold text-helios-ink uppercase tracking-wider">CPU Fallback Active</h5>
|
||||
<p className="text-xs text-helios-slate leading-relaxed">
|
||||
GPU acceleration was not detected or is incompatible. Alchemist will use software encoding (SVT-AV1 / x264), which is significantly more resource intensive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
web/src/components/HeaderActions.tsx
Normal file
38
web/src/components/HeaderActions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import { Info, LogOut } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('alchemist_token');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.button
|
||||
onClick={() => setShowAbout(true)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
|
||||
>
|
||||
<Info size={16} />
|
||||
<span>About</span>
|
||||
</motion.button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-red-500/80 hover:bg-red-500/10 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AboutDialog isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
569
web/src/components/JobManager.tsx
Normal file
569
web/src/components/JobManager.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Search, RefreshCw, Trash2, Ban, Play,
|
||||
MoreHorizontal, Check, AlertCircle, Clock, FileVideo,
|
||||
X, Info, Activity, Database, Zap, ArrowRight, Maximize2
|
||||
} from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: number;
|
||||
input_path: string;
|
||||
output_path: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
progress: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
vmaf_score?: number;
|
||||
}
|
||||
|
||||
interface JobMetadata {
|
||||
duration_secs: number;
|
||||
codec_name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bit_depth: number;
|
||||
size_bytes: number;
|
||||
bit_rate: number;
|
||||
fps: number;
|
||||
container: string;
|
||||
audio_codec?: string;
|
||||
audio_channels?: number;
|
||||
}
|
||||
|
||||
interface EncodeStats {
|
||||
input_size_bytes: number;
|
||||
output_size_bytes: number;
|
||||
compression_ratio: number;
|
||||
encode_time_seconds: number;
|
||||
encode_speed: number;
|
||||
avg_bitrate_kbps: number;
|
||||
vmaf_score?: number;
|
||||
}
|
||||
|
||||
interface JobDetail {
|
||||
job: Job;
|
||||
metadata?: JobMetadata;
|
||||
encode_stats?: EncodeStats;
|
||||
}
|
||||
|
||||
type TabType = "all" | "active" | "queued" | "completed" | "failed";
|
||||
|
||||
export default function JobManager() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<TabType>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [focusedJob, setFocusedJob] = useState<JobDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Filter mapping
|
||||
const getStatusFilter = (tab: TabType) => {
|
||||
switch (tab) {
|
||||
case "active": return "analyzing,encoding";
|
||||
case "queued": return "queued";
|
||||
case "completed": return "completed";
|
||||
case "failed": return "failed,cancelled";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: "50",
|
||||
page: page.toString(),
|
||||
sort: "updated_at",
|
||||
sort_desc: "true"
|
||||
});
|
||||
|
||||
if (activeTab !== "all") {
|
||||
params.set("status", getStatusFilter(activeTab));
|
||||
}
|
||||
if (search) {
|
||||
params.set("search", search);
|
||||
}
|
||||
|
||||
const res = await apiFetch(`/api/jobs/table?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setJobs(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch jobs", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [activeTab, search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
const interval = setInterval(fetchJobs, 5000); // Auto-refresh every 5s
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchJobs]);
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
const newSet = new Set(selected);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setSelected(newSet);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selected.size === jobs.length && jobs.length > 0) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(jobs.map(j => j.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatch = async (action: "cancel" | "restart" | "delete") => {
|
||||
if (selected.size === 0) return;
|
||||
if (!confirm(`Are you sure you want to ${action} ${selected.size} jobs?`)) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/jobs/batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
ids: Array.from(selected)
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSelected(new Set());
|
||||
fetchJobs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Batch action failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCompleted = async () => {
|
||||
if (!confirm("Clear all completed jobs?")) return;
|
||||
await apiFetch("/api/jobs/clear-completed", { method: "POST" });
|
||||
fetchJobs();
|
||||
};
|
||||
|
||||
const fetchJobDetails = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/api/jobs/${id}/details`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFocusedJob(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch job details", e);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: number, action: "cancel" | "restart" | "delete") => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/jobs/${id}/${action}`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
if (action === "delete") setFocusedJob(null);
|
||||
else fetchJobDetails(id);
|
||||
fetchJobs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Action ${action} failed`, e);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return [h, m, s].map(v => v.toString().padStart(2, "0")).join(":");
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
queued: "bg-helios-slate/10 text-helios-slate border-helios-slate/20",
|
||||
analyzing: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
encoding: "bg-helios-solar/10 text-helios-solar border-helios-solar/20 animate-pulse",
|
||||
completed: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||
failed: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
cancelled: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
skipped: "bg-gray-500/10 text-gray-500 border-gray-500/20",
|
||||
};
|
||||
return (
|
||||
<span className={cn("px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border", styles[status] || styles.queued)}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-center bg-helios-surface/50 p-1 rounded-xl border border-helios-line/10">
|
||||
<div className="flex gap-1 p-1 bg-helios-surface border border-helios-line/10 rounded-lg">
|
||||
{(["all", "active", "queued", "completed", "failed"] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setActiveTab(tab); setPage(1); }}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-md text-sm font-medium transition-all capitalize",
|
||||
activeTab === tab
|
||||
? "bg-helios-surface-soft text-helios-ink shadow-sm"
|
||||
: "text-helios-slate hover:text-helios-ink"
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-9 pr-4 py-2 text-sm text-helios-ink focus:border-helios-solar outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchJobs()}
|
||||
className={cn("p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft", refreshing && "animate-spin")}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch Actions Bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center justify-between bg-helios-solar/10 border border-helios-solar/20 px-6 py-3 rounded-xl animate-in fade-in slide-in-from-top-2">
|
||||
<span className="text-sm font-bold text-helios-solar">
|
||||
{selected.size} jobs selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleBatch("restart")} className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar" title="Restart">
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleBatch("cancel")} className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar" title="Cancel">
|
||||
<Ban size={18} />
|
||||
</button>
|
||||
<button onClick={() => handleBatch("delete")} className="p-2 hover:bg-red-500/10 rounded-lg text-red-500" title="Delete">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-helios-surface/50 border border-helios-line/20 rounded-2xl overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-helios-surface border-b border-helios-line/20 text-xs font-bold text-helios-slate uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4 w-10">
|
||||
<input type="checkbox"
|
||||
checked={selected.size === jobs.length && jobs.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-helios-line/30 bg-helios-surface-soft accent-helios-solar"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-4">File</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4">Progress</th>
|
||||
<th className="px-6 py-4">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-helios-line/10">
|
||||
{jobs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-helios-slate">
|
||||
{loading ? "Loading jobs..." : "No jobs found"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<tr
|
||||
key={job.id}
|
||||
onClick={() => fetchJobDetails(job.id)}
|
||||
className={cn(
|
||||
"group hover:bg-helios-surface/80 transition-all cursor-pointer",
|
||||
selected.has(job.id) && "bg-helios-surface-soft",
|
||||
focusedJob?.job.id === job.id && "bg-helios-solar/5"
|
||||
)}
|
||||
>
|
||||
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox"
|
||||
checked={selected.has(job.id)}
|
||||
onChange={() => toggleSelect(job.id)}
|
||||
className="rounded border-helios-line/30 bg-helios-surface-soft accent-helios-solar"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 relative">
|
||||
<motion.div layoutId={`job-name-${job.id}`} className="flex flex-col">
|
||||
<span className="font-medium text-helios-ink truncate max-w-[300px]" title={job.input_path}>
|
||||
{job.input_path.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
<span className="text-[10px] text-helios-slate truncate max-w-[300px]">
|
||||
{job.input_path}
|
||||
</span>
|
||||
</motion.div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<motion.div layoutId={`job-status-${job.id}`}>
|
||||
{getStatusBadge(job.status)}
|
||||
</motion.div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{job.status === 'encoding' || job.status === 'analyzing' ? (
|
||||
<div className="w-24 space-y-1">
|
||||
<div className="h-1.5 w-full bg-helios-line/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-helios-solar rounded-full transition-all duration-500" style={{ width: `${job.progress}%` }} />
|
||||
</div>
|
||||
<div className="text-[10px] text-right font-mono text-helios-slate">
|
||||
{job.progress.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
job.vmaf_score ? (
|
||||
<span className="text-xs font-mono text-helios-slate">
|
||||
VMAF: {job.vmaf_score.toFixed(1)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-helios-slate/50">-</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-helios-slate font-mono">
|
||||
{new Date(job.updated_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<p className="text-xs text-helios-slate font-medium">Showing {jobs.length} jobs (Limit 50)</p>
|
||||
<button onClick={clearCompleted} className="text-xs text-red-500 hover:text-red-400 font-bold flex items-center gap-1 transition-colors">
|
||||
<Trash2 size={12} /> Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Detail Overlay */}
|
||||
<AnimatePresence>
|
||||
{focusedJob && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setFocusedJob(null)}
|
||||
className="fixed inset-0 bg-helios-ink/40 backdrop-blur-md z-[100]"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[101]">
|
||||
<motion.div
|
||||
layoutId={`row-${focusedJob.job.id}`}
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="w-full max-w-2xl bg-helios-surface border border-helios-line/20 rounded-2xl shadow-2xl pointer-events-auto overflow-hidden mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-helios-line/10 flex justify-between items-start gap-4 bg-helios-surface-soft/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
{getStatusBadge(focusedJob.job.status)}
|
||||
<span className="text-[10px] uppercase font-bold tracking-widest text-helios-slate">Job ID #{focusedJob.job.id}</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-helios-ink truncate" title={focusedJob.job.input_path}>
|
||||
{focusedJob.job.input_path.split(/[/\\]/).pop()}
|
||||
</h2>
|
||||
<p className="text-xs text-helios-slate truncate opacity-60">{focusedJob.job.input_path}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFocusedJob(null)}
|
||||
className="p-2 hover:bg-helios-line/10 rounded-xl transition-colors text-helios-slate"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/10 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Activity size={12} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Video Codec</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink capitalize">
|
||||
{focusedJob.metadata?.codec_name || "Unknown"}
|
||||
</p>
|
||||
<p className="text-[10px] text-helios-slate">
|
||||
{focusedJob.metadata?.bit_depth}-bit • {focusedJob.metadata?.container.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/10 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Maximize2 size={12} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Resolution</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink">
|
||||
{focusedJob.metadata ? `${focusedJob.metadata.width}x${focusedJob.metadata.height}` : "-"}
|
||||
</p>
|
||||
<p className="text-[10px] text-helios-slate">
|
||||
{focusedJob.metadata?.fps.toFixed(2)} FPS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/10 space-y-1">
|
||||
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
||||
<Clock size={12} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Duration</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-helios-ink">
|
||||
{focusedJob.metadata ? formatDuration(focusedJob.metadata.duration_secs) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Details */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-helios-slate/60 flex items-center gap-2">
|
||||
<Database size={12} /> Input Details
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">File Size</span>
|
||||
<span className="text-helios-ink font-bold">{focusedJob.metadata ? formatBytes(focusedJob.metadata.size_bytes) : "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Video Bitrate</span>
|
||||
<span className="text-helios-ink font-bold">
|
||||
{focusedJob.metadata ? `${(focusedJob.metadata.bit_rate / 1000).toFixed(0)} kbps` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Audio</span>
|
||||
<span className="text-helios-ink font-bold capitalize">
|
||||
{focusedJob.metadata?.audio_codec || "N/A"} ({focusedJob.metadata?.audio_channels || 0}ch)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-helios-solar flex items-center gap-2">
|
||||
<Zap size={12} /> Output Details
|
||||
</h3>
|
||||
{focusedJob.encode_stats ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Result Size</span>
|
||||
<span className="text-helios-solar font-bold">{formatBytes(focusedJob.encode_stats.output_size_bytes)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">Reduction</span>
|
||||
<span className="text-green-500 font-bold">
|
||||
{((1 - focusedJob.encode_stats.compression_ratio) * 100).toFixed(1)}% Saved
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-helios-slate font-medium">VMAF Score</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-16 bg-helios-line/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-helios-solar" style={{ width: `${focusedJob.encode_stats.vmaf_score || 0}%` }} />
|
||||
</div>
|
||||
<span className="text-helios-ink font-bold">
|
||||
{focusedJob.encode_stats.vmaf_score?.toFixed(1) || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[80px] flex items-center justify-center border border-dashed border-helios-line/20 rounded-xl text-[10px] text-helios-slate italic">
|
||||
{focusedJob.job.status === 'encoding' ? "Encoding in progress..." : "No encode data available"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision Info */}
|
||||
{focusedJob.job.decision_reason && (
|
||||
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/10">
|
||||
<div className="flex items-center gap-2 text-amber-600 mb-1">
|
||||
<Info size={12} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Decision Context</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700/80 leading-relaxed italic">
|
||||
"{focusedJob.job.decision_reason}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Toolbar */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-helios-line/10">
|
||||
<div className="flex gap-2">
|
||||
{(focusedJob.job.status === 'failed' || focusedJob.job.status === 'cancelled') && (
|
||||
<button
|
||||
onClick={() => handleAction(focusedJob.job.id, 'restart')}
|
||||
className="px-4 py-2 bg-helios-solar text-white rounded-lg text-sm font-bold flex items-center gap-2 hover:brightness-110 active:scale-95 transition-all shadow-sm"
|
||||
>
|
||||
<RefreshCw size={14} /> Retry Job
|
||||
</button>
|
||||
)}
|
||||
{(focusedJob.job.status === 'encoding' || focusedJob.job.status === 'analyzing') && (
|
||||
<button
|
||||
onClick={() => handleAction(focusedJob.job.id, 'cancel')}
|
||||
className="px-4 py-2 border border-helios-line/20 bg-helios-surface text-helios-slate rounded-lg text-sm font-bold flex items-center gap-2 hover:bg-helios-surface-soft active:scale-95 transition-all"
|
||||
>
|
||||
<Ban size={14} /> Stop / Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Delete this job from history?")) handleAction(focusedJob.job.id, 'delete');
|
||||
}}
|
||||
className="px-4 py-2 text-red-500 hover:bg-red-500/5 rounded-lg text-sm font-bold flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Terminal, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { Terminal, Pause, Play, Trash2, RefreshCw } from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -9,125 +10,114 @@ function cn(...inputs: ClassValue[]) {
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: string;
|
||||
job_id?: number;
|
||||
message: string;
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function LogViewer() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const pausedRef = useRef(paused);
|
||||
const maxLogs = 1000;
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
const connect = () => {
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("log", (e) => {
|
||||
if (paused) return; // Note: This drops logs while paused. Alternatively we could buffer.
|
||||
// But usually "pause" implies "stop scrolling me".
|
||||
// For now, let's keep accumulating but not auto-scroll?
|
||||
// Or truly ignore updates. Let's buffer/append always effectively, but control scroll?
|
||||
// Simpler: Just append functionality is standard. "Pause" usually means "Pause updates".
|
||||
|
||||
// Actually, if we are paused, we shouldn't update state to avoid re-renders/shifting.
|
||||
if (paused) return;
|
||||
|
||||
const message = e.data;
|
||||
const level = message.toLowerCase().includes("error")
|
||||
? "error"
|
||||
: message.toLowerCase().includes("warn")
|
||||
? "warn"
|
||||
: "info";
|
||||
|
||||
addLog({
|
||||
id: Date.now() + Math.random(),
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message,
|
||||
level
|
||||
});
|
||||
});
|
||||
|
||||
// Also listen for other events to show interesting activity
|
||||
eventSource.addEventListener("decision", (e) => {
|
||||
if (paused) return;
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
addLog({
|
||||
id: Date.now() + Math.random(),
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `Decision: ${data.action.toUpperCase()} Job #${data.job_id} - ${data.reason}`,
|
||||
level: "info"
|
||||
});
|
||||
} catch { }
|
||||
});
|
||||
|
||||
eventSource.addEventListener("job_status", (e) => {
|
||||
if (paused) return;
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
addLog({
|
||||
id: Date.now() + Math.random(),
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `Job #${data.job_id} status changed to ${data.status}`,
|
||||
level: "info"
|
||||
});
|
||||
} catch { }
|
||||
});
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
console.error("SSE Error", e);
|
||||
eventSource?.close();
|
||||
// Reconnect after delay
|
||||
setTimeout(connect, 5000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
eventSource?.close();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paused]); // Re-connecting on pause toggle is inefficient. Better to use ref for paused state inside callback.
|
||||
|
||||
// Correction: Use ref for paused state to avoid reconnecting.
|
||||
const pausedRef = useRef(paused);
|
||||
// Sync ref
|
||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||
|
||||
// Re-implement effect with ref
|
||||
const fetchHistory = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch("/api/logs/history?limit=200");
|
||||
if (res.ok) {
|
||||
const history = await res.json();
|
||||
// Logs come newest first (DESC), reverse for display
|
||||
setLogs(history.reverse());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch logs", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!confirm("Are you sure you want to clear all server logs?")) return;
|
||||
try {
|
||||
await apiFetch("/api/logs", { method: "DELETE" });
|
||||
setLogs([]);
|
||||
} catch (e) {
|
||||
console.error("Failed to clear logs", e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
const connect = () => {
|
||||
const token = localStorage.getItem('alchemist_token') || '';
|
||||
eventSource = new EventSource(`/api/events?token=${token}`);
|
||||
const handleMsg = (msg: string, level: "info" | "warn" | "error" = "info") => {
|
||||
|
||||
const handleMsg = (msg: string, level: string, job_id?: number) => {
|
||||
if (pausedRef.current) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
id: Date.now() + Math.random(),
|
||||
level,
|
||||
message: msg,
|
||||
job_id,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
setLogs(prev => {
|
||||
const newLogs = [...prev, {
|
||||
id: Date.now() + Math.random(),
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: msg,
|
||||
level
|
||||
}];
|
||||
const newLogs = [...prev, entry];
|
||||
if (newLogs.length > maxLogs) return newLogs.slice(newLogs.length - maxLogs);
|
||||
return newLogs;
|
||||
});
|
||||
};
|
||||
|
||||
eventSource.addEventListener("log", (e) => handleMsg(e.data, e.data.toLowerCase().includes("warn") ? "warn" : e.data.toLowerCase().includes("error") ? "error" : "info"));
|
||||
eventSource.addEventListener("decision", (e) => {
|
||||
try { const d = JSON.parse(e.data); handleMsg(`Decision: ${d.action.toUpperCase()} Job #${d.job_id} - ${d.reason}`); } catch { }
|
||||
eventSource.addEventListener("log", (e) => {
|
||||
try {
|
||||
// Expecting simple text or JSON?
|
||||
// Backend sends AlchemistEvent::Log { level, job_id, message }
|
||||
// But SSE serializer matches structure.
|
||||
// Wait, existing SSE in server.rs sends plain text or JSON?
|
||||
// Let's check server.rs sse_handler or Event impl.
|
||||
// Assuming existing impl sends `data: message` for "log" event.
|
||||
// But I added structured event in backend: AlchemistEvent::Log
|
||||
// If server.rs uses `sse::Event::default().event("log").data(...)`
|
||||
|
||||
// Actually, I need to check `sse_handler` in `server.rs` to see what it sends.
|
||||
// Assuming it sends JSON for structured events or adapts.
|
||||
// If it used to send string, I should support string.
|
||||
const data = e.data;
|
||||
// Try parsing JSON first
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.message) {
|
||||
handleMsg(json.message, json.level || "info", json.job_id);
|
||||
return;
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// Fallback to text
|
||||
handleMsg(data, data.toLowerCase().includes("error") ? "error" : "info");
|
||||
} catch { }
|
||||
});
|
||||
eventSource.addEventListener("status", (e) => { // NOTE: "status" event name in server.rs is "status", not "job_status" in one place? server.rs:376 says "status"
|
||||
try { const d = JSON.parse(e.data); handleMsg(`Job #${d.job_id} is now ${d.status}`); } catch { }
|
||||
|
||||
eventSource.addEventListener("decision", (e) => {
|
||||
try { const d = JSON.parse(e.data); handleMsg(`Decision: ${d.action.toUpperCase()} - ${d.reason}`, "info", d.job_id); } catch { }
|
||||
});
|
||||
eventSource.addEventListener("status", (e) => {
|
||||
try { const d = JSON.parse(e.data); handleMsg(`Status changed to ${d.status}`, "info", d.job_id); } catch { }
|
||||
});
|
||||
|
||||
eventSource.onerror = () => { eventSource?.close(); setTimeout(connect, 3000); };
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => eventSource?.close();
|
||||
}, []);
|
||||
@@ -139,9 +129,10 @@ export default function LogViewer() {
|
||||
}
|
||||
}, [logs, paused]);
|
||||
|
||||
|
||||
const addLog = (log: LogEntry) => {
|
||||
setLogs(prev => [...prev.slice(-999), log]);
|
||||
const formatTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString();
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -149,9 +140,17 @@ export default function LogViewer() {
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-helios-line/20 bg-helios-surface/50 backdrop-blur">
|
||||
<div className="flex items-center gap-2 text-helios-slate">
|
||||
<Terminal size={16} />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">System Logs</span>
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Server Logs</span>
|
||||
{loading && <span className="text-xs animate-pulse opacity-50 ml-2">Loading history...</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchHistory}
|
||||
className="p-1.5 rounded-lg hover:bg-helios-line/10 text-helios-slate transition-colors"
|
||||
title="Reload History"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPaused(!paused)}
|
||||
className="p-1.5 rounded-lg hover:bg-helios-line/10 text-helios-slate transition-colors"
|
||||
@@ -160,9 +159,9 @@ export default function LogViewer() {
|
||||
{paused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLogs([])}
|
||||
onClick={clearLogs}
|
||||
className="p-1.5 rounded-lg hover:bg-red-500/10 text-helios-slate hover:text-red-400 transition-colors"
|
||||
title="Clear Logs"
|
||||
title="Clear Server Logs"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -173,20 +172,25 @@ export default function LogViewer() {
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-1 scrollbar-thin scrollbar-thumb-helios-line/20 scrollbar-track-transparent"
|
||||
>
|
||||
{logs.length === 0 && (
|
||||
<div className="text-helios-slate/30 text-center py-10 italic">Waiting for events...</div>
|
||||
{logs.length === 0 && !loading && (
|
||||
<div className="text-helios-slate/30 text-center py-10 italic">No logs found.</div>
|
||||
)}
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="flex gap-3 hover:bg-white/5 px-2 py-0.5 rounded -mx-2">
|
||||
<span className="text-helios-slate/50 shrink-0 select-none">{log.timestamp}</span>
|
||||
<span className={cn(
|
||||
"break-all",
|
||||
log.level === "error" ? "text-red-400 font-bold" :
|
||||
log.level === "warn" ? "text-amber-400" :
|
||||
"text-helios-mist/80"
|
||||
)}>
|
||||
{log.message}
|
||||
</span>
|
||||
<div key={log.id} className="flex gap-3 hover:bg-white/5 px-2 py-0.5 rounded -mx-2 group">
|
||||
<span className="text-helios-slate/50 shrink-0 select-none w-20 text-right">{formatTime(log.created_at)}</span>
|
||||
|
||||
<div className="flex-1 min-w-0 break-all">
|
||||
{log.job_id && (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded bg-white/5 text-helios-slate/80 mr-2 text-[10px]">#{log.job_id}</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
log.level.toLowerCase().includes("error") ? "text-red-400 font-bold" :
|
||||
log.level.toLowerCase().includes("warn") ? "text-amber-400" :
|
||||
"text-white/90"
|
||||
)}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
264
web/src/components/NotificationSettings.tsx
Normal file
264
web/src/components/NotificationSettings.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bell, Plus, Trash2, Zap, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
interface NotificationTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
target_type: 'gotify' | 'discord' | 'webhook';
|
||||
endpoint_url: string;
|
||||
auth_token?: string;
|
||||
events: string; // JSON string
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function NotificationSettings() {
|
||||
const [targets, setTargets] = useState<NotificationTarget[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
|
||||
// Form state
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newType, setNewType] = useState<NotificationTarget['target_type']>("discord");
|
||||
const [newUrl, setNewUrl] = useState("");
|
||||
const [newToken, setNewToken] = useState("");
|
||||
const [newEvents, setNewEvents] = useState<string[]>(["completed", "failed"]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTargets();
|
||||
}, []);
|
||||
|
||||
const fetchTargets = async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/notifications");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTargets(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/notifications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: newName,
|
||||
target_type: newType,
|
||||
endpoint_url: newUrl,
|
||||
auth_token: newToken || null,
|
||||
events: newEvents,
|
||||
enabled: true
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowForm(false);
|
||||
setNewName("");
|
||||
setNewUrl("");
|
||||
setNewToken("");
|
||||
fetchTargets();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Remove this notification target?")) return;
|
||||
try {
|
||||
await apiFetch(`/api/settings/notifications/${id}`, { method: "DELETE" });
|
||||
fetchTargets();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (target: NotificationTarget) => {
|
||||
setTestingId(target.id);
|
||||
try {
|
||||
// We reconstruct payload from target to send to test endpoint
|
||||
// Or test endpoint could take ID if we implemented that.
|
||||
// But we implemented endpoint taking Payload.
|
||||
// So we send current target data.
|
||||
const events = JSON.parse(target.events);
|
||||
|
||||
const res = await apiFetch("/api/settings/notifications/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: target.name,
|
||||
target_type: target.target_type,
|
||||
endpoint_url: target.endpoint_url,
|
||||
auth_token: target.auth_token,
|
||||
events: events,
|
||||
enabled: target.enabled
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert("Test notification sent!");
|
||||
} else {
|
||||
alert("Test failed.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Test error");
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEvent = (evt: string) => {
|
||||
if (newEvents.includes(evt)) {
|
||||
setNewEvents(newEvents.filter(e => e !== evt));
|
||||
} else {
|
||||
setNewEvents([...newEvents, evt]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||
<Bell className="text-helios-solar" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-helios-ink">Notifications</h2>
|
||||
<p className="text-xs text-helios-slate">Alerts for job events via Discord, Gotify, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
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-bold uppercase tracking-wider transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{showForm ? "Cancel" : "Add Target"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleAdd} className="bg-helios-surface-soft p-4 rounded-xl space-y-4 border border-helios-line/20 mb-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Name</label>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||
placeholder="My Discord"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Type</label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={e => setNewType(e.target.value as any)}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
|
||||
>
|
||||
<option value="discord">Discord Webhook</option>
|
||||
<option value="gotify">Gotify</option>
|
||||
<option value="webhook">Generic Webhook</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Endpoint URL</label>
|
||||
<input
|
||||
value={newUrl}
|
||||
onChange={e => setNewUrl(e.target.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/..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Auth Token (Optional)</label>
|
||||
<input
|
||||
value={newToken}
|
||||
onChange={e => setNewToken(e.target.value)}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
|
||||
placeholder="Bearer token or API Key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-2">Events</label>
|
||||
<div className="flex gap-4">
|
||||
{['completed', 'failed', 'queued'].map(evt => (
|
||||
<label key={evt} className="flex items-center gap-2 text-sm text-helios-ink cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newEvents.includes(evt)}
|
||||
onChange={() => toggleEvent(evt)}
|
||||
className="rounded border-helios-line/30 bg-helios-surface accent-helios-solar"
|
||||
/>
|
||||
<span className="capitalize">{evt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full bg-helios-solar text-helios-main font-bold py-2 rounded-lg hover:opacity-90 transition-opacity">
|
||||
Save Target
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{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 className="flex items-center gap-4">
|
||||
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
|
||||
<Zap size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider text-helios-slate bg-helios-surface-soft px-1.5 rounded">
|
||||
{target.target_type}
|
||||
</span>
|
||||
<span className="text-xs text-helios-slate truncate max-w-[200px]">
|
||||
{target.endpoint_url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleTest(target)}
|
||||
disabled={testingId === target.id}
|
||||
className="p-2 text-helios-slate hover:text-helios-solar hover:bg-helios-solar/10 rounded-lg transition-colors"
|
||||
title="Test Notification"
|
||||
>
|
||||
<Zap size={16} className={testingId === target.id ? "animate-pulse" : ""} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(target.id)}
|
||||
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{targets.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-helios-slate text-sm">
|
||||
No notification targets configured.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
web/src/components/ResourceMonitor.tsx
Normal file
190
web/src/components/ResourceMonitor.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { Activity, Cpu, HardDrive, Clock, Layers } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface SystemResources {
|
||||
cpu_percent: number;
|
||||
memory_used_mb: number;
|
||||
memory_total_mb: number;
|
||||
memory_percent: number;
|
||||
uptime_seconds: number;
|
||||
active_jobs: number;
|
||||
concurrent_limit: number;
|
||||
cpu_count: number;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
monitoring_poll_interval: number;
|
||||
}
|
||||
|
||||
export default function ResourceMonitor() {
|
||||
const [stats, setStats] = useState<SystemResources | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pollInterval, setPollInterval] = useState<number>(2000);
|
||||
|
||||
// Fetch settings once on mount
|
||||
useEffect(() => {
|
||||
apiFetch('/api/settings/system')
|
||||
.then(res => res.json())
|
||||
.then((data: SystemSettings) => {
|
||||
setPollInterval(data.monitoring_poll_interval * 1000);
|
||||
})
|
||||
.catch(err => console.error('Failed to load system settings', err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/system/resources');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Failed to fetch resources');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Connection error');
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [pollInterval]);
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
};
|
||||
|
||||
const getUsageColor = (percent: number) => {
|
||||
if (percent > 90) return 'text-red-500 bg-red-500/10';
|
||||
if (percent > 70) return 'text-yellow-500 bg-yellow-500/10';
|
||||
return 'text-green-500 bg-green-500/10';
|
||||
};
|
||||
|
||||
const getBarColor = (percent: number) => {
|
||||
if (percent > 90) return 'bg-red-500';
|
||||
if (percent > 70) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
if (!stats) return (
|
||||
<div className="p-6 rounded-2xl bg-white/5 border border-white/10 animate-pulse h-48 flex items-center justify-center">
|
||||
<div className="text-white/40">Loading system stats...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
{/* CPU Usage */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
|
||||
<Cpu size={16} /> CPU Usage
|
||||
</div>
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${getUsageColor(stats.cpu_percent)}`}>
|
||||
{stats.cpu_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${getBarColor(stats.cpu_percent)}`}
|
||||
style={{ width: `${Math.min(stats.cpu_percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-white/40">
|
||||
<span>CPU Cores</span>
|
||||
<span>{stats.cpu_count} Logical</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="p-4 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
|
||||
<HardDrive size={16} /> Memory
|
||||
</div>
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${getUsageColor(stats.memory_percent)}`}>
|
||||
{stats.memory_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${getBarColor(stats.memory_percent)}`}
|
||||
style={{ width: `${Math.min(stats.memory_percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-white/40">
|
||||
<span>{(stats.memory_used_mb / 1024).toFixed(1)} GB used</span>
|
||||
<span>{(stats.memory_total_mb / 1024).toFixed(0)} GB total</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Active Jobs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="p-4 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
|
||||
<Layers size={16} /> Active Jobs
|
||||
</div>
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400`}>
|
||||
{stats.active_jobs} / {stats.concurrent_limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-1 h-8 mt-2">
|
||||
{/* Visual representation of job slots */}
|
||||
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-sm transition-all duration-300 ${i < stats.active_jobs ? 'bg-blue-500 h-6' : 'bg-white/10 h-2'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Uptime */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="p-4 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
|
||||
<Clock size={16} /> Uptime
|
||||
</div>
|
||||
<Activity size={14} className="text-green-500 animate-pulse" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white/90">
|
||||
{formatUptime(stats.uptime_seconds)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
web/src/components/ScheduleSettings.tsx
Normal file
213
web/src/components/ScheduleSettings.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Clock, Plus, Trash2, Calendar } from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
interface ScheduleWindow {
|
||||
id: number;
|
||||
start_time: string; // HH:MM
|
||||
end_time: string; // HH:MM
|
||||
days_of_week: string; // JSON array of ints
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
export default function ScheduleSettings() {
|
||||
const [windows, setWindows] = useState<ScheduleWindow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [newStart, setNewStart] = useState("00:00");
|
||||
const [newEnd, setNewEnd] = useState("08:00");
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([0, 1, 2, 3, 4, 5, 6]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedule();
|
||||
}, []);
|
||||
|
||||
const fetchSchedule = async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/schedule");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setWindows(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/schedule", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_time: newStart,
|
||||
end_time: newEnd,
|
||||
days_of_week: selectedDays,
|
||||
enabled: true
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowForm(false);
|
||||
fetchSchedule();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Remove this schedule?")) return;
|
||||
try {
|
||||
await apiFetch(`/api/settings/schedule/${id}`, { method: "DELETE" });
|
||||
fetchSchedule();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDay = (dayIndex: number) => {
|
||||
if (selectedDays.includes(dayIndex)) {
|
||||
setSelectedDays(selectedDays.filter(d => d !== dayIndex));
|
||||
} else {
|
||||
setSelectedDays([...selectedDays, dayIndex].sort());
|
||||
}
|
||||
};
|
||||
|
||||
const parseDays = (json: string) => {
|
||||
try {
|
||||
return JSON.parse(json) as number[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||
<Clock className="text-helios-solar" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-helios-ink">Active Hours</h2>
|
||||
<p className="text-xs text-helios-slate">Restrict processing to specific times (e.g. overnight).</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
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-bold uppercase tracking-wider transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{showForm ? "Cancel" : "Add Schedule"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{windows.length > 0 ? (
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-xl mb-4">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
Processing is restricted to the windows below. Outside these times, the engine will pause automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-xl mb-4">
|
||||
<p className="text-xs text-green-600 dark:text-green-400 font-medium flex items-center gap-2">
|
||||
<Clock size={14} />
|
||||
No schedules active. Processing is allowed 24/7.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleAdd} className="bg-helios-surface-soft p-4 rounded-xl space-y-4 border border-helios-line/20 mb-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newStart}
|
||||
onChange={e => setNewStart(e.target.value)}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">End Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newEnd}
|
||||
onChange={e => setNewEnd(e.target.value)}
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-2">Days</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{DAYS.map((day, idx) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => toggleDay(idx)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${selectedDays.includes(idx)
|
||||
? "bg-helios-solar text-helios-main"
|
||||
: "bg-helios-surface border border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full bg-helios-solar text-helios-main font-bold py-2 rounded-lg hover:opacity-90 transition-opacity">
|
||||
Save Schedule
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{windows.map(win => (
|
||||
<div key={win.id} className="flex items-center justify-between p-4 bg-helios-surface border border-helios-line/10 rounded-xl">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl font-mono font-bold text-helios-ink">
|
||||
{win.start_time} - {win.end_time}
|
||||
</span>
|
||||
{win.enabled ? (
|
||||
<span className="text-[10px] uppercase font-bold text-green-500 bg-green-500/10 px-2 py-0.5 rounded-full">Active</span>
|
||||
) : (
|
||||
<span className="text-[10px] uppercase font-bold text-red-500 bg-red-500/10 px-2 py-0.5 rounded-full">Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{DAYS.map((day, idx) => {
|
||||
const active = parseDays(win.days_of_week).includes(idx);
|
||||
return (
|
||||
<span key={day} className={`text-[10px] font-bold px-1.5 rounded ${active ? 'text-helios-solar bg-helios-solar/10' : 'text-helios-slate/30'}`}>
|
||||
{day}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(win.id)}
|
||||
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
web/src/components/SettingsPanel.tsx
Normal file
119
web/src/components/SettingsPanel.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { FolderOpen, Bell, Calendar, FileCog, Cog, Server, LayoutGrid } from "lucide-react";
|
||||
import WatchFolders from "./WatchFolders";
|
||||
import NotificationSettings from "./NotificationSettings";
|
||||
import ScheduleSettings from "./ScheduleSettings";
|
||||
import FileSettings from "./FileSettings";
|
||||
import TranscodeSettings from "./TranscodeSettings";
|
||||
import SystemSettings from "./SystemSettings";
|
||||
import HardwareSettings from "./HardwareSettings";
|
||||
|
||||
const TABS = [
|
||||
{ id: "watch", label: "Watch Folders", icon: FolderOpen, component: WatchFolders },
|
||||
{ id: "transcode", label: "Transcoding", icon: Cog, component: TranscodeSettings },
|
||||
{ id: "files", label: "File Management", icon: FileCog, component: FileSettings },
|
||||
{ id: "schedule", label: "Schedule", icon: Calendar, component: ScheduleSettings },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||
{ id: "hardware", label: "Hardware", icon: LayoutGrid, component: HardwareSettings },
|
||||
{ id: "system", label: "System", icon: Server, component: SystemSettings },
|
||||
];
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const [activeTab, setActiveTab] = useState("watch");
|
||||
const [[page, direction], setPage] = useState([0, 0]);
|
||||
|
||||
const activeIndex = TABS.findIndex(t => t.id === activeTab);
|
||||
|
||||
const paginate = (newTabId: string) => {
|
||||
const newIndex = TABS.findIndex(t => t.id === newTabId);
|
||||
const newDirection = newIndex > activeIndex ? 1 : -1;
|
||||
setPage([newIndex, newDirection]);
|
||||
setActiveTab(newTabId);
|
||||
};
|
||||
|
||||
const variants = {
|
||||
enter: { opacity: 0 },
|
||||
center: { zIndex: 1, opacity: 1 },
|
||||
exit: { zIndex: 0, opacity: 0 }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Sidebar Navigation for Settings */}
|
||||
<nav className="w-full lg:w-64 flex-shrink-0">
|
||||
<div className="sticky top-8 space-y-1">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => paginate(tab.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all duration-200 relative overflow-hidden group ${isActive
|
||||
? "text-helios-ink bg-helios-surface-soft shadow-sm border border-helios-line/20"
|
||||
: "text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft/50"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-tab"
|
||||
className="absolute inset-0 bg-helios-surface-soft border border-helios-line/20 rounded-xl"
|
||||
initial={false}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
<tab.icon size={18} className={isActive ? "text-helios-solar" : "opacity-70 group-hover:opacity-100"} />
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
opacity: { duration: 0.15, ease: "easeInOut" }
|
||||
}}
|
||||
className="p-1" // minimal padding for focus rings
|
||||
>
|
||||
{/*
|
||||
We render the active component.
|
||||
Container styling is applied here to wrap the component uniformly.
|
||||
*/}
|
||||
<div className="bg-helios-surface border border-helios-line/20 rounded-3xl p-6 sm:p-8 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-helios-ink flex items-center gap-2">
|
||||
{(() => {
|
||||
const tab = TABS.find((t) => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<>
|
||||
<tab.icon size={22} className="text-helios-solar" />
|
||||
{tab.label}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</h2>
|
||||
</div>
|
||||
{(() => {
|
||||
const TabComponent = TABS.find((t) => t.id === activeTab)?.component;
|
||||
return TabComponent ? <TabComponent /> : null;
|
||||
})()}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,21 @@ interface ConfigState {
|
||||
allow_cpu_encoding: boolean;
|
||||
// Scanner
|
||||
directories: string[];
|
||||
// System
|
||||
enable_telemetry: boolean;
|
||||
}
|
||||
|
||||
interface HardwareInfo {
|
||||
vendor: "Nvidia" | "Amd" | "Intel" | "Apple" | "Cpu";
|
||||
device_path: string | null;
|
||||
supported_codecs: string[];
|
||||
}
|
||||
|
||||
interface ScanStatus {
|
||||
is_running: boolean;
|
||||
files_found: number;
|
||||
files_added: number;
|
||||
current_folder: string | null;
|
||||
}
|
||||
|
||||
export default function SetupWizard() {
|
||||
@@ -37,6 +52,8 @@ export default function SetupWizard() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [hardware, setHardware] = useState<HardwareInfo | null>(null);
|
||||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||
|
||||
// Tooltip state
|
||||
const [activeTooltip, setActiveTooltip] = useState<string | null>(null);
|
||||
@@ -50,22 +67,72 @@ export default function SetupWizard() {
|
||||
output_codec: 'av1',
|
||||
quality_profile: 'balanced',
|
||||
directories: ['/media/movies'],
|
||||
allow_cpu_encoding: false
|
||||
allow_cpu_encoding: false,
|
||||
enable_telemetry: true
|
||||
});
|
||||
|
||||
const [dirInput, setDirInput] = useState('');
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (step === 1 && (!config.username || !config.password)) {
|
||||
setError("Please fill in both username and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
// Hardware step - fetch if not already
|
||||
if (!hardware) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/system/hardware');
|
||||
const data = await res.json();
|
||||
setHardware(data);
|
||||
} catch (e) {
|
||||
console.error("Hardware detection failed", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 4) {
|
||||
// Save & Start Scan step
|
||||
await handleSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setStep(s => Math.min(s + 1, 5));
|
||||
setStep(s => Math.min(s + 1, 6));
|
||||
};
|
||||
|
||||
const handleBack = () => setStep(s => Math.max(s - 1, 1));
|
||||
|
||||
const startScan = async () => {
|
||||
try {
|
||||
await fetch('/api/scan/start', { method: 'POST' });
|
||||
pollScanStatus();
|
||||
} catch (e) {
|
||||
console.error("Failed to start scan", e);
|
||||
}
|
||||
};
|
||||
|
||||
const pollScanStatus = async () => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/scan/status');
|
||||
const data = await res.json();
|
||||
setScanStatus(data);
|
||||
if (!data.is_running) {
|
||||
clearInterval(interval);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling failed", e);
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const addDirectory = () => {
|
||||
if (dirInput && !config.directories.includes(dirInput)) {
|
||||
setConfig(prev => ({
|
||||
@@ -102,45 +169,19 @@ export default function SetupWizard() {
|
||||
|
||||
const data = await res.json();
|
||||
if (data.token) {
|
||||
// Save token
|
||||
localStorage.setItem('alchemist_token', data.token);
|
||||
// Also set basic auth for legacy (optional) or just rely on new check
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Auto redirect after short delay
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
setStep(5); // Move to Scan Progress
|
||||
startScan();
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to save configuration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Total steps = 5
|
||||
// 1: Account
|
||||
// 2: Transcoding (Codec, Profile)
|
||||
// 3: Thresholds
|
||||
// 4: Hardware
|
||||
// 5: Review & Save
|
||||
|
||||
// Combined Steps for brevity:
|
||||
// 1: Account
|
||||
// 2: Transcode Rules (Codec, Profile, Thresholds)
|
||||
// 3: Hardware & Directories
|
||||
// 4: Review
|
||||
|
||||
// Let's stick to 4 logical steps but grouped differently?
|
||||
// User requested "username and password be the first step".
|
||||
|
||||
// Step 1: Account
|
||||
// Step 2: Codec & Quality (New)
|
||||
// Step 3: Performance & Directories (Merged old 2/3)
|
||||
// Step 4: Review
|
||||
|
||||
return (
|
||||
<div className="bg-helios-surface border border-helios-line/60 rounded-2xl overflow-hidden shadow-2xl max-w-2xl w-full mx-auto">
|
||||
{/* Header */}
|
||||
@@ -148,7 +189,7 @@ export default function SetupWizard() {
|
||||
<motion.div
|
||||
className="bg-helios-solar h-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(step / 5) * 100}%` }}
|
||||
animate={{ width: `${(step / 6) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -221,78 +262,107 @@ export default function SetupWizard() {
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<Video size={20} className="text-helios-solar" />
|
||||
Transcoding Preferences
|
||||
Hardware & Rules
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Codec Selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Output Codec</label>
|
||||
<div className="relative group">
|
||||
<Info size={14} className="text-helios-slate cursor-help hover:text-helios-solar transition-colors" />
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 w-48 p-2 bg-helios-ink text-helios-main text-[10px] rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
||||
Determines the video format for processed files. AV1 provides better compression but requires newer hardware.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Transcoding Target</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, output_codec: "av1" })}
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all relative group",
|
||||
config.output_codec === "av1"
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm"
|
||||
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft/80"
|
||||
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all",
|
||||
config.output_codec === "av1" ? "bg-helios-solar/10 border-helios-solar text-helios-ink" : "bg-helios-surface-soft border-helios-line/30 text-helios-slate"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">AV1</span>
|
||||
<span className="text-xs text-center opacity-70">Best compression. Requires Arc/RTX 4000+.</span>
|
||||
{/* Hover Tooltip */}
|
||||
<div className="absolute inset-0 bg-helios-ink/90 text-helios-main p-4 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs text-center">
|
||||
Excellent efficiency. Ideal for Intel Arc GPUs.
|
||||
</div>
|
||||
<span className="text-[10px] opacity-70">Extreme compression. Needs modern GPU.</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, output_codec: "hevc" })}
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all relative group",
|
||||
config.output_codec === "hevc"
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm"
|
||||
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft/80"
|
||||
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all",
|
||||
config.output_codec === "hevc" ? "bg-helios-solar/10 border-helios-solar text-helios-ink" : "bg-helios-surface-soft border-helios-line/30 text-helios-slate"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">HEVC</span>
|
||||
<span className="text-xs text-center opacity-70">Broad compatibility. Fast encoding.</span>
|
||||
{/* Hover Tooltip */}
|
||||
<div className="absolute inset-0 bg-helios-ink/90 text-helios-main p-4 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs text-center">
|
||||
Standard H.265. Compatible with most modern devices.
|
||||
</div>
|
||||
<span className="text-[10px] opacity-70">Broad support. High efficiency.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Profile */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Quality Profile</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["speed", "balanced", "quality"] as const).map((profile) => (
|
||||
<button
|
||||
key={profile}
|
||||
onClick={() => setConfig({ ...config, quality_profile: profile })}
|
||||
className={clsx(
|
||||
"p-3 rounded-lg border capitalize transition-all",
|
||||
config.quality_profile === profile
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink font-bold"
|
||||
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate"
|
||||
)}
|
||||
>
|
||||
{profile}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Min Savings Threshold ({Math.round(config.size_reduction_threshold * 100)}%)</label>
|
||||
<input
|
||||
type="range" min="0.1" max="0.9" step="0.05"
|
||||
value={config.size_reduction_threshold}
|
||||
onChange={(e) => setConfig({ ...config, size_reduction_threshold: parseFloat(e.target.value) })}
|
||||
className="w-full accent-helios-solar h-2 bg-helios-surface-soft rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-[10px] text-helios-slate italic">If encodes don't save at least {Math.round(config.size_reduction_threshold * 100)}%, they are discarded.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Concurrent Jobs ({config.concurrent_jobs})</label>
|
||||
<input
|
||||
type="range" min="1" max="8" step="1"
|
||||
value={config.concurrent_jobs}
|
||||
onChange={(e) => setConfig({ ...config, concurrent_jobs: parseInt(e.target.value) })}
|
||||
className="w-full accent-helios-solar h-2 bg-helios-surface-soft rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-[10px] text-helios-slate italic">How many files to process at the same time.</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-helios-line/10">
|
||||
<label className="flex items-center justify-between group cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-helios-slate">Anonymous Telemetry</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onMouseEnter={() => setActiveTooltip('telemetry')}
|
||||
onMouseLeave={() => setActiveTooltip(null)}
|
||||
className="text-helios-slate/40 hover:text-helios-solar transition-colors"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{activeTooltip === 'telemetry' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute bottom-full left-0 mb-2 w-48 p-2 bg-helios-surface-soft border border-helios-line/40 rounded-lg shadow-xl text-[10px] text-helios-slate z-50 leading-relaxed"
|
||||
>
|
||||
Help improve Alchemist by sending anonymous usage statistics and error reports. No filenames or personal data are ever collected.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_telemetry}
|
||||
onChange={(e) => setConfig({ ...config, enable_telemetry: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-helios-line/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-helios-solar"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{hardware && (
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/10 text-emerald-500 rounded-lg">
|
||||
<Cpu size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-helios-ink">Detected: {hardware.vendor} Hardware Acceleration</p>
|
||||
<p className="text-[10px] text-helios-slate">{hardware.supported_codecs.join(', ').toUpperCase()} Encoders Found</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -306,37 +376,30 @@ export default function SetupWizard() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<Settings size={20} className="text-helios-solar" />
|
||||
Processing Rules
|
||||
<FolderOpen size={20} className="text-helios-solar" />
|
||||
Watch Directories
|
||||
</h2>
|
||||
<p className="text-sm text-helios-slate">Add folders to monitor for new media files.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-helios-slate mb-2">
|
||||
Size Reduction Threshold ({Math.round(config.size_reduction_threshold * 100)}%)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
step="0.05"
|
||||
value={config.size_reduction_threshold}
|
||||
onChange={(e) => setConfig({ ...config, size_reduction_threshold: parseFloat(e.target.value) })}
|
||||
className="w-full accent-helios-solar h-2 bg-helios-surface-soft rounded-lg appearance-none cursor-pointer"
|
||||
type="text"
|
||||
placeholder="/movies"
|
||||
value={dirInput}
|
||||
onChange={(e) => setDirInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDirectory()}
|
||||
className="flex-1 bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-helios-slate mt-1">Files will be reverted if they don't shrink by this much.</p>
|
||||
<button onClick={addDirectory} className="px-4 py-2 bg-helios-solar text-helios-main rounded-lg font-bold">Add</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-helios-slate mb-2">
|
||||
Minimum File Size ({config.min_file_size_mb} MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.min_file_size_mb}
|
||||
onChange={(e) => setConfig({ ...config, min_file_size_mb: parseInt(e.target.value) })}
|
||||
className="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none"
|
||||
/>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto pr-1">
|
||||
{config.directories.map((dir, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-helios-surface-soft/50 border border-helios-line/20 group animate-in fade-in slide-in-from-right-2">
|
||||
<span className="font-mono text-xs text-helios-ink">{dir}</span>
|
||||
<button onClick={() => removeDirectory(dir)} className="text-red-500 opacity-50 hover:opacity-100 transition-opacity font-bold">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -351,140 +414,125 @@ export default function SetupWizard() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<Cpu size={20} className="text-helios-solar" />
|
||||
Environment
|
||||
<CheckCircle size={20} className="text-helios-solar" />
|
||||
Final Review
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-helios-slate mb-2">
|
||||
Concurrent Jobs ({config.concurrent_jobs})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="16"
|
||||
value={config.concurrent_jobs}
|
||||
onChange={(e) => setConfig({ ...config, concurrent_jobs: parseInt(e.target.value) })}
|
||||
className="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft/50 border border-helios-line/20 text-xs text-helios-slate space-y-2">
|
||||
<p>ACCOUNT: <span className="text-helios-ink font-bold">{config.username}</span></p>
|
||||
<p>TARGET: <span className="text-helios-ink font-bold uppercase">{config.output_codec}</span></p>
|
||||
<p>CONCURRENCY: <span className="text-helios-ink font-bold">{config.concurrent_jobs} Jobs</span></p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-helios-line/20">
|
||||
<label className="block text-sm font-medium text-helios-slate mb-2">Media Directories</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="/path/to/media"
|
||||
value={dirInput}
|
||||
onChange={(e) => setDirInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDirectory()}
|
||||
className="flex-1 bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={addDirectory}
|
||||
className="px-4 py-2 bg-helios-surface-soft hover:bg-helios-surface-soft/80 text-helios-ink rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{config.directories.map((dir, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2 rounded-lg bg-helios-surface-soft/50 border border-helios-line/20 group">
|
||||
<span className="font-mono text-xs text-helios-ink truncate">{dir}</span>
|
||||
<button onClick={() => removeDirectory(dir)} className="text-status-error hover:text-status-error/80">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft/50 border border-helios-line/20 text-xs text-helios-slate space-y-2">
|
||||
<p>FOLDERS: <span className="text-helios-ink font-bold">{config.directories.length} total</span></p>
|
||||
<p>THRESHOLD: <span className="text-helios-ink font-bold">{Math.round(config.size_reduction_threshold * 100)}%</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 font-bold bg-red-500/10 p-2 rounded border border-red-500/20">{error}</p>}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<motion.div
|
||||
key="step5"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="space-y-8 py-10 text-center"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<Server size={20} className="text-helios-solar" />
|
||||
Review & Save
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 text-sm text-helios-slate">
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span>User</span>
|
||||
<span className="text-helios-ink font-bold">{config.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Codec</span>
|
||||
<span className="text-helios-ink font-mono uppercase">{config.output_codec}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Profile</span>
|
||||
<span className="text-helios-ink capitalize">{config.quality_profile}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Concurrency</span>
|
||||
<span className="text-helios-ink font-mono">{config.concurrent_jobs} jobs</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-helios-line/20">
|
||||
<span className="block mb-1">Directories ({config.directories.length}):</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{config.directories.map(d => (
|
||||
<span key={d} className="px-1.5 py-0.5 bg-helios-surface border border-helios-line/30 rounded text-xs font-mono">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-full border-4 border-helios-solar/20 border-t-helios-solar animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SearchIcon className="text-helios-solar" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error flex items-center gap-2">
|
||||
<AlertTriangle size={16} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-helios-ink mb-2">Primary Library Scan</h2>
|
||||
<p className="text-sm text-helios-slate">Building your transcoding queue. This might take a moment.</p>
|
||||
</div>
|
||||
|
||||
{scanStatus && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-helios-slate">
|
||||
<span>Found: {scanStatus.files_found}</span>
|
||||
<span>Added: {scanStatus.files_added}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-helios-surface-soft rounded-full overflow-hidden border border-helios-line/20">
|
||||
<motion.div
|
||||
className="h-full bg-helios-solar"
|
||||
animate={{ width: `${scanStatus.files_found > 0 ? (scanStatus.files_added / scanStatus.files_found) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{scanStatus.current_folder && (
|
||||
<p className="text-[10px] text-helios-slate font-mono truncate px-4">{scanStatus.current_folder}</p>
|
||||
)}
|
||||
|
||||
{!scanStatus.is_running && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="w-full py-3 bg-helios-solar text-helios-main font-bold rounded-xl mt-4 shadow-lg shadow-helios-solar/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
Enter Dashboard
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!scanStatus && loading && (
|
||||
<p className="text-xs text-helios-slate animate-pulse">Initializing scanner...</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-8 flex justify-between pt-6 border-t border-helios-line/40">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={step === 1}
|
||||
className={clsx(
|
||||
"px-4 py-2 rounded-lg font-medium transition-colors",
|
||||
step === 1 ? "text-helios-line cursor-not-allowed" : "text-helios-slate hover:text-helios-ink"
|
||||
)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{step < 5 && (
|
||||
<div className="mt-8 flex justify-between pt-6 border-t border-helios-line/40">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={step === 1 || loading}
|
||||
className={clsx(
|
||||
"px-4 py-2 rounded-lg font-medium transition-colors",
|
||||
step === 1 ? "text-helios-line cursor-not-allowed" : "text-helios-slate hover:text-helios-ink"
|
||||
)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
{step < 5 ? (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-helios-solar text-helios-main font-semibold hover:opacity-90 transition-opacity"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-helios-solar text-helios-main font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
<ArrowRight size={18} />
|
||||
{loading ? "Searching..." : step === 4 ? "Build Engine" : "Next"}
|
||||
{!loading && <ArrowRight size={18} />}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || success}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-status-success text-white font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Activating..." : success ? "Redirecting..." : "Launch Alchemist"}
|
||||
{!loading && !success && <Save size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon({ className, size }: { className?: string; size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
139
web/src/components/SystemSettings.tsx
Normal file
139
web/src/components/SystemSettings.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Activity, Save } from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
interface SystemSettingsPayload {
|
||||
monitoring_poll_interval: number;
|
||||
enable_telemetry: boolean;
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const [settings, setSettings] = useState<SystemSettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/system");
|
||||
if (!res.ok) throw new Error("Failed to load settings");
|
||||
const data = await res.json();
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
setError("Unable to load system settings.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/system", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save settings");
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
} catch (err) {
|
||||
setError("Failed to save settings.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-helios-slate animate-pulse">Loading system settings...</div>;
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return <div className="p-8 text-red-500">Failed to load system settings.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-helios-line/10">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-helios-ink tracking-tight uppercase tracking-[0.1em]">System Monitoring</h3>
|
||||
<p className="text-xs text-helios-slate mt-0.5">Configure dashboard resource monitoring behavior.</p>
|
||||
</div>
|
||||
<div className="p-2 bg-helios-solar/10 rounded-xl text-helios-solar">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 text-red-500 rounded-xl text-sm font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/20 text-green-500 rounded-xl text-sm font-semibold">
|
||||
Settings saved successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Activity size={14} /> Monitoring Poll Interval
|
||||
</label>
|
||||
<div className="flex items-center gap-4 bg-helios-surface border border-helios-line/30 rounded-xl p-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={settings.monitoring_poll_interval}
|
||||
onChange={(e) => setSettings({ ...settings, monitoring_poll_interval: parseFloat(e.target.value) })}
|
||||
className="flex-1 h-2 bg-helios-surface-soft rounded-lg appearance-none cursor-pointer accent-helios-solar"
|
||||
/>
|
||||
<span className="font-mono bg-helios-surface-soft border border-helios-line/30 rounded px-3 py-1 text-helios-ink w-20 text-center font-bold">
|
||||
{settings.monitoring_poll_interval.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-helios-slate ml-1 pt-1">Determine how frequently the dashboard updates system stats. Lower values update faster but use slightly more CPU. Default is 2.0s.</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-helios-line/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-helios-slate">Anonymous Telemetry</h4>
|
||||
<p className="text-[10px] text-helios-slate mt-1">Help improve the app by sending anonymous crash reports and usage data.</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enable_telemetry}
|
||||
onChange={(e) => setSettings({ ...settings, enable_telemetry: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-helios-line/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-helios-solar"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-helios-line/10">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-helios-solar text-helios-main font-bold px-6 py-3 rounded-xl hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
<Save size={18} />
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default function SystemStatus() {
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="flex flex-col gap-3 cursor-pointer group p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 shadow-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -76,18 +76,18 @@ export default function SystemStatus() {
|
||||
</span>
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider group-hover:text-helios-inc transition-colors">Engine Status</span>
|
||||
</div>
|
||||
<motion.div layoutId="status-badge" className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md ${isActive ? 'bg-status-success/10 text-status-success' : 'bg-helios-slate/10 text-helios-slate'}`}>
|
||||
<div className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md ${isActive ? 'bg-status-success/10 text-status-success' : 'bg-helios-slate/10 text-helios-slate'}`}>
|
||||
{isActive ? 'ONLINE' : 'IDLE'}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-end justify-between text-helios-ink">
|
||||
<span className="text-xs font-medium opacity-80">Active Jobs</span>
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<motion.span layoutId="active-count" className={`text-lg font-bold ${isFull ? 'text-status-warning' : 'text-helios-solar'}`}>
|
||||
<span className={`text-lg font-bold ${isFull ? 'text-status-warning' : 'text-helios-solar'}`}>
|
||||
{stats.active}
|
||||
</motion.span>
|
||||
</span>
|
||||
<span className="text-xs text-helios-slate">
|
||||
/ {stats.concurrent_limit}
|
||||
</span>
|
||||
@@ -95,8 +95,7 @@ export default function SystemStatus() {
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
|
||||
<motion.div
|
||||
layoutId="progress-bar"
|
||||
<div
|
||||
className={`h-full transition-all duration-700 ease-out rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
@@ -159,9 +158,9 @@ export default function SystemStatus() {
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Concurrency</span>
|
||||
<div className="flex items-baseline justify-center gap-1 mt-1">
|
||||
<motion.span layoutId="active-count" className="text-3xl font-bold text-helios-ink">
|
||||
<span className="text-3xl font-bold text-helios-ink">
|
||||
{stats.active}
|
||||
</motion.span>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-helios-slate opacity-60">
|
||||
/ {stats.concurrent_limit}
|
||||
</span>
|
||||
@@ -169,8 +168,7 @@ export default function SystemStatus() {
|
||||
</div>
|
||||
{/* Big Progress Bar */}
|
||||
<div className="w-full h-2 bg-helios-line/10 rounded-full mt-2 overflow-hidden relative">
|
||||
<motion.div
|
||||
layoutId="progress-bar"
|
||||
<div
|
||||
className={`h-full rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface TranscodeSettingsPayload {
|
||||
min_file_size_mb: number;
|
||||
output_codec: "av1" | "hevc";
|
||||
quality_profile: "quality" | "balanced" | "speed";
|
||||
threads: number;
|
||||
}
|
||||
|
||||
export default function TranscodeSettings() {
|
||||
@@ -161,6 +162,20 @@ export default function TranscodeSettings() {
|
||||
</div>
|
||||
|
||||
{/* Numeric Inputs */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Cpu size={14} /> Encoding Threads (libsvtav1/x265)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.threads}
|
||||
onChange={(e) => setSettings({ ...settings, threads: parseInt(e.target.value) || 0 })}
|
||||
className="w-full bg-helios-surface border border-helios-line/30 rounded-xl px-4 py-3 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
|
||||
/>
|
||||
<p className="text-[10px] text-helios-slate ml-1">Number of threads to allocate for software encoding (0 = Auto).</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-helios-slate flex items-center gap-2">
|
||||
<Zap size={14} /> Concurrent Jobs
|
||||
|
||||
161
web/src/components/WatchFolders.tsx
Normal file
161
web/src/components/WatchFolders.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FolderOpen, Trash2, Plus, Folder, Play } from "lucide-react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
interface WatchDir {
|
||||
id: number;
|
||||
path: string;
|
||||
is_recursive: boolean;
|
||||
}
|
||||
|
||||
export default function WatchFolders() {
|
||||
const [dirs, setDirs] = useState<WatchDir[]>([]);
|
||||
const [path, setPath] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const fetchDirs = async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/watch-dirs");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDirs(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch watch dirs", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDirs();
|
||||
}, []);
|
||||
|
||||
const triggerScan = async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
await apiFetch("/api/scan/start", { method: "POST" });
|
||||
} catch (e) {
|
||||
console.error("Failed to start scan", e);
|
||||
} finally {
|
||||
setTimeout(() => setScanning(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const addDir = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!path.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/settings/watch-dirs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: path.trim(), is_recursive: true })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPath("");
|
||||
fetchDirs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to add directory", e);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDir = async (id: number) => {
|
||||
if (!confirm("Stop watching this folder?")) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`/api/settings/watch-dirs/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
fetchDirs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to remove directory", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||
<FolderOpen className="text-helios-solar" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-helios-ink">Watch Folders</h2>
|
||||
<p className="text-xs text-helios-slate">Manage directories monitored for new media</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={triggerScan}
|
||||
disabled={scanning}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-helios-solar/10 hover:bg-helios-solar/20 text-helios-solar rounded-lg text-xs font-bold uppercase tracking-wider transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={14} className={scanning ? "animate-spin" : ""} />
|
||||
{scanning ? "Scanning..." : "Scan Now"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={addDir} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate/50" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="Enter full directory path..."
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded-xl pl-10 pr-4 py-2.5 text-sm text-helios-ink placeholder:text-helios-slate/40 focus:border-helios-solar focus:ring-1 focus:ring-helios-solar/50 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!path.trim()}
|
||||
className="bg-helios-solar hover:bg-helios-solar-dark text-helios-surface px-5 py-2.5 rounded-xl font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-sm shadow-helios-solar/20"
|
||||
>
|
||||
<Plus size={16} /> Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
{dirs.map((dir) => (
|
||||
<div key={dir.id} className="flex items-center justify-between p-3 bg-helios-surface border border-helios-line/10 rounded-xl group hover:border-helios-line/30 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="p-1.5 bg-helios-slate/5 rounded-lg text-helios-slate">
|
||||
<Folder size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-mono text-helios-ink truncate max-w-[400px]" title={dir.path}>
|
||||
{dir.path}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeDir(dir.id)}
|
||||
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Stop watching"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && dirs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center border-2 border-dashed border-helios-line/10 rounded-2xl bg-helios-surface/30">
|
||||
<FolderOpen className="text-helios-slate/20 mb-2" size={32} />
|
||||
<p className="text-sm text-helios-slate">No watch folders configured</p>
|
||||
<p className="text-xs text-helios-slate/60 mt-1">Add a directory to start scanning</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8 text-helios-slate animate-pulse text-sm">
|
||||
Loading directories...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,26 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import AppearanceSettings from "../components/AppearanceSettings.tsx";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
import { Palette } from "lucide-react";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Appearance">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main">
|
||||
<header
|
||||
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Palette class="text-helios-solar" />
|
||||
<h1 class="text-lg font-semibold text-helios-ink">
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Appearance
|
||||
</h1>
|
||||
<p class="text-helios-slate mt-2 text-sm">
|
||||
Customize the look and feel
|
||||
</p>
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<AppearanceSettings client:load />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import Dashboard from "../components/Dashboard.tsx";
|
||||
import { Activity, Server, Settings, Video } from "lucide-react";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Dashboard">
|
||||
@@ -20,6 +20,7 @@ import { Activity, Server, Settings, Video } from "lucide-react";
|
||||
System Overview & Performance
|
||||
</p>
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
|
||||
<Dashboard client:load />
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import JobManager from "../components/JobManager.tsx";
|
||||
import { Video } from "lucide-react";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Jobs">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main">
|
||||
<header
|
||||
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Video class="text-helios-solar" />
|
||||
<h1 class="text-lg font-semibold text-helios-ink">
|
||||
Jobs Management
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Jobs
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<div
|
||||
class="p-8 text-center border-2 border-dashed border-helios-line/40 rounded-2xl bg-helios-surface/50"
|
||||
>
|
||||
<p class="text-helios-slate">
|
||||
Job management interface coming soon.
|
||||
<p class="text-helios-slate mt-2 text-sm">
|
||||
Manage transcoding tasks
|
||||
</p>
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<JobManager client:load />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import { Server } from "lucide-react";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Library">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main">
|
||||
<header
|
||||
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Server class="text-helios-solar" />
|
||||
<h1 class="text-lg font-semibold text-helios-ink">
|
||||
Media Library
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Library
|
||||
</h1>
|
||||
<p class="text-helios-slate mt-2 text-sm">
|
||||
Browse media files
|
||||
</p>
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<div
|
||||
|
||||
@@ -2,25 +2,28 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import LogViewer from "../components/LogViewer.tsx";
|
||||
import { Terminal } from "lucide-react";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | System Logs">
|
||||
<Layout title="Alchemist | Logs">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main flex flex-col h-screen overflow-hidden">
|
||||
<header
|
||||
class="h-16 flex-shrink-0 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Terminal class="text-helios-solar" />
|
||||
<h1 class="text-lg font-semibold text-helios-ink">
|
||||
System Logs
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Logs
|
||||
</h1>
|
||||
<p class="text-helios-slate mt-2 text-sm">
|
||||
System events and errors
|
||||
</p>
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="flex-1 p-6 min-h-0">
|
||||
<LogViewer client:load /> // Hydrate immediately
|
||||
<LogViewer client:load />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import TranscodeSettings from "../components/TranscodeSettings.tsx";
|
||||
import SettingsPanel from "../components/SettingsPanel.tsx";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
import { Settings } from "lucide-react";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Settings">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main">
|
||||
<header
|
||||
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Settings class="text-helios-solar" />
|
||||
<h1 class="text-lg font-semibold text-helios-ink">
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
<p class="text-helios-slate mt-2 text-sm">
|
||||
Configure application behavior
|
||||
</p>
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<!-- Transcode Settings -->
|
||||
<div class="mb-8">
|
||||
<TranscodeSettings client:load />
|
||||
</div>
|
||||
<div class="px-6 pb-6">
|
||||
<SettingsPanel client:load />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import StatsCharts from "../components/StatsCharts.tsx";
|
||||
import HeaderActions from "../components/HeaderActions.tsx";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
---
|
||||
|
||||
<Layout title="Statistics | Alchemist">
|
||||
<Layout title="Alchemist | Statistics">
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-main p-8 overflow-y-auto">
|
||||
@@ -17,9 +18,10 @@ import { BarChart3 } from "lucide-react";
|
||||
Statistics
|
||||
</h1>
|
||||
<p class="text-helios-slate mt-2 text-sm">
|
||||
Encoding performance and space savings
|
||||
Encoding performance metrics
|
||||
</p>
|
||||
</div>
|
||||
<HeaderActions client:load />
|
||||
</header>
|
||||
|
||||
<StatsCharts client:load />
|
||||
|
||||
Reference in New Issue
Block a user