chore: release v0.2.4-stable

This commit is contained in:
Brooklyn
2026-01-09 20:18:25 -05:00
parent f464e0b726
commit ad58c355e2
56 changed files with 5956 additions and 819 deletions

89
Cargo.lock generated
View File

@@ -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"

View File

@@ -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
View File

@@ -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

View File

@@ -1 +1 @@
0.2.3-rc.1
0.2.4

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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'));

View 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
);

View 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
);

View 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
);

View 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
);

View 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');

View File

@@ -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

View 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
}
}

View File

@@ -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
View File

@@ -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)

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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),
})
}
}

View File

@@ -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())),

View File

@@ -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);

View File

@@ -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)]

View File

@@ -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
View 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(())
}
}

View File

@@ -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
)))
}
}

View File

@@ -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(&current_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(())
}
}

View File

@@ -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()
}

View File

@@ -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>,

View File

@@ -1,3 +1,4 @@
pub mod hardware;
pub mod notifications;
pub mod scanner;
pub mod watcher;

142
src/system/scanner.rs Normal file
View 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(())
}
}

View File

@@ -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(())
}
}

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "alchemist-web",
"version": "0.1.1",
"version": "0.2.4",
"private": true,
"type": "module",
"scripts": {

View 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">
&copy; {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>
);
}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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)} />
</>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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}%` }}
/>

View File

@@ -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

View 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>
);
}

View File

@@ -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 />

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 />