Add library health checks and expand dashboard management

This commit is contained in:
2026-03-21 20:31:35 -04:00
parent 8fb6cd44e0
commit 33c0e5d882
51 changed files with 2855 additions and 275 deletions

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS library_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
preset TEXT NOT NULL DEFAULT 'balanced',
codec TEXT NOT NULL DEFAULT 'av1',
quality_profile TEXT NOT NULL DEFAULT 'balanced',
hdr_mode TEXT NOT NULL DEFAULT 'preserve',
audio_mode TEXT NOT NULL DEFAULT 'copy',
crf_override INTEGER,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE watch_dirs ADD COLUMN profile_id INTEGER REFERENCES library_profiles(id);
ALTER TABLE encode_stats ADD COLUMN output_codec TEXT;
INSERT OR IGNORE INTO library_profiles
(id, name, preset, codec, quality_profile, hdr_mode, audio_mode, notes)
VALUES
(1, 'Space Saver', 'space_saver', 'av1', 'speed', 'tonemap', 'aac', 'Optimized for aggressive size reduction.'),
(2, 'Quality First', 'quality_first', 'hevc', 'quality', 'preserve', 'copy', 'Prioritizes fidelity over maximum compression.'),
(3, 'Balanced', 'balanced', 'av1', 'balanced', 'preserve', 'copy', 'Balanced compression and playback quality.'),
(4, 'Streaming', 'streaming', 'h264', 'balanced', 'tonemap', 'aac_stereo', 'Maximizes compatibility for streaming clients.');
INSERT OR REPLACE INTO schema_info (key, value) VALUES
('schema_version', '4'),
('min_compatible_version', '0.2.5'),
('last_updated', datetime('now'));

View File

@@ -0,0 +1,15 @@
ALTER TABLE jobs ADD COLUMN health_issues TEXT;
ALTER TABLE jobs ADD COLUMN last_health_check TEXT;
CREATE TABLE IF NOT EXISTS health_scan_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
files_checked INTEGER NOT NULL DEFAULT 0,
issues_found INTEGER NOT NULL DEFAULT 0
);
INSERT OR REPLACE INTO schema_info (key, value) VALUES
('schema_version', '5'),
('min_compatible_version', '0.2.5'),
('last_updated', datetime('now'));

View File

@@ -148,6 +148,25 @@ impl OutputCodec {
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AudioMode {
#[default]
Copy,
Aac,
AacStereo,
}
impl AudioMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Copy => "copy",
Self::Aac => "aac",
Self::AacStereo => "aac_stereo",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
@@ -250,6 +269,8 @@ pub struct TranscodeConfig {
pub tonemap_desat: f32,
#[serde(default)]
pub subtitle_mode: SubtitleMode,
#[serde(default)]
pub vmaf_min_score: Option<f64>,
}
// Removed default_quality_profile helper as Default trait on enum handles it now.
@@ -372,6 +393,8 @@ pub struct SystemConfig {
pub monitoring_poll_interval: f64,
#[serde(default = "default_true")]
pub enable_telemetry: bool,
#[serde(default = "default_log_retention_days")]
pub log_retention_days: Option<u32>,
}
fn default_true() -> bool {
@@ -382,15 +405,88 @@ fn default_poll_interval() -> f64 {
2.0
}
fn default_log_retention_days() -> Option<u32> {
Some(30)
}
impl Default for SystemConfig {
fn default() -> Self {
Self {
monitoring_poll_interval: default_poll_interval(),
enable_telemetry: true,
log_retention_days: default_log_retention_days(),
}
}
}
#[derive(Debug, Serialize, Clone, Copy)]
pub struct BuiltInLibraryProfile {
pub id: i64,
pub name: &'static str,
pub preset: &'static str,
pub codec: OutputCodec,
pub quality_profile: QualityProfile,
pub hdr_mode: HdrMode,
pub audio_mode: AudioMode,
pub crf_override: Option<i32>,
pub notes: Option<&'static str>,
}
pub const PRESET_SPACE_SAVER: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 1,
name: "Space Saver",
preset: "space_saver",
codec: OutputCodec::Av1,
quality_profile: QualityProfile::Speed,
hdr_mode: HdrMode::Tonemap,
audio_mode: AudioMode::Aac,
crf_override: None,
notes: Some("Optimized for aggressive size reduction."),
};
pub const PRESET_QUALITY_FIRST: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 2,
name: "Quality First",
preset: "quality_first",
codec: OutputCodec::Hevc,
quality_profile: QualityProfile::Quality,
hdr_mode: HdrMode::Preserve,
audio_mode: AudioMode::Copy,
crf_override: None,
notes: Some("Prioritizes fidelity over maximum compression."),
};
pub const PRESET_BALANCED: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 3,
name: "Balanced",
preset: "balanced",
codec: OutputCodec::Av1,
quality_profile: QualityProfile::Balanced,
hdr_mode: HdrMode::Preserve,
audio_mode: AudioMode::Copy,
crf_override: None,
notes: Some("Balanced compression and playback quality."),
};
pub const PRESET_STREAMING: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 4,
name: "Streaming",
preset: "streaming",
codec: OutputCodec::H264,
quality_profile: QualityProfile::Balanced,
hdr_mode: HdrMode::Tonemap,
audio_mode: AudioMode::AacStereo,
crf_override: None,
notes: Some("Maximizes compatibility for streaming clients."),
};
pub const BUILT_IN_LIBRARY_PROFILES: [BuiltInLibraryProfile; 4] = [
PRESET_SPACE_SAVER,
PRESET_QUALITY_FIRST,
PRESET_BALANCED,
PRESET_STREAMING,
];
impl Default for Config {
fn default() -> Self {
Self {
@@ -409,6 +505,7 @@ impl Default for Config {
tonemap_peak: default_tonemap_peak(),
tonemap_desat: default_tonemap_desat(),
subtitle_mode: SubtitleMode::Copy,
vmaf_min_score: None,
},
hardware: HardwareConfig {
preferred_vendor: None,
@@ -429,6 +526,7 @@ impl Default for Config {
system: SystemConfig {
monitoring_poll_interval: default_poll_interval(),
enable_telemetry: true,
log_retention_days: default_log_retention_days(),
},
}
}
@@ -531,6 +629,15 @@ impl Config {
);
}
if let Some(vmaf_min_score) = self.transcode.vmaf_min_score {
if !(0.0..=100.0).contains(&vmaf_min_score) {
anyhow::bail!(
"vmaf_min_score must be between 0.0 and 100.0, got {}",
vmaf_min_score
);
}
}
Ok(())
}

639
src/db.rs
View File

@@ -6,6 +6,7 @@ use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode},
Row, SqlitePool,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::info;
@@ -102,9 +103,37 @@ pub struct WatchDir {
pub id: i64,
pub path: String,
pub is_recursive: bool,
pub profile_id: Option<i64>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct LibraryProfile {
pub id: i64,
pub name: String,
pub preset: String,
pub codec: String,
pub quality_profile: String,
pub hdr_mode: String,
pub audio_mode: String,
pub crf_override: Option<i32>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NewLibraryProfile {
pub name: String,
pub preset: String,
pub codec: String,
pub quality_profile: String,
pub hdr_mode: String,
pub audio_mode: String,
pub crf_override: Option<i32>,
pub notes: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct NotificationTarget {
pub id: i64,
@@ -332,6 +361,37 @@ pub struct EncodeStatsInput {
pub encode_speed: f64,
pub avg_bitrate: f64,
pub vmaf_score: Option<f64>,
pub output_codec: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CodecSavings {
pub codec: String,
pub bytes_saved: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DailySavings {
pub date: String,
pub bytes_saved: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SavingsSummary {
pub total_input_bytes: i64,
pub total_output_bytes: i64,
pub total_bytes_saved: i64,
pub savings_percent: f64,
pub job_count: i64,
pub savings_by_codec: Vec<CodecSavings>,
pub savings_over_time: Vec<DailySavings>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct HealthSummary {
pub total_checked: i64,
pub issues_found: i64,
pub last_run: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
@@ -377,23 +437,23 @@ impl Db {
migrate_start.elapsed().as_millis()
);
let db = Self { pool };
db.reset_interrupted_jobs().await?;
Ok(db)
Ok(Self { pool })
}
// init method removed as it is replaced by migrations
pub async fn reset_interrupted_jobs(&self) -> Result<()> {
sqlx::query(
"UPDATE jobs SET status = 'queued', updated_at = CURRENT_TIMESTAMP
WHERE status IN ('analyzing', 'encoding', 'resuming')",
pub async fn reset_interrupted_jobs(&self) -> Result<u64> {
let result = sqlx::query(
"UPDATE jobs
SET status = 'queued',
progress = 0.0,
updated_at = CURRENT_TIMESTAMP
WHERE status IN ('encoding', 'analyzing') AND archived = 0",
)
.execute(&self.pool)
.await?;
Ok(())
Ok(result.rows_affected())
}
pub async fn enqueue_job(
@@ -466,7 +526,18 @@ impl Db {
COALESCE(attempt_count, 0) as attempt_count,
NULL as vmaf_score,
created_at, updated_at
FROM jobs WHERE status = 'queued' AND archived = 0
FROM jobs
WHERE status = 'queued'
AND archived = 0
AND (
COALESCE(attempt_count, 0) = 0
OR CASE
WHEN COALESCE(attempt_count, 0) = 1 THEN datetime(updated_at, '+5 minutes')
WHEN COALESCE(attempt_count, 0) = 2 THEN datetime(updated_at, '+15 minutes')
WHEN COALESCE(attempt_count, 0) = 3 THEN datetime(updated_at, '+60 minutes')
ELSE datetime(updated_at, '+360 minutes')
END <= datetime('now')
)
ORDER BY priority DESC, created_at ASC LIMIT 1",
)
.fetch_optional(&self.pool)
@@ -480,7 +551,19 @@ impl Db {
"UPDATE jobs
SET status = 'analyzing', updated_at = CURRENT_TIMESTAMP
WHERE id = (
SELECT id FROM jobs WHERE status = 'queued' AND archived = 0
SELECT id
FROM jobs
WHERE status = 'queued'
AND archived = 0
AND (
COALESCE(attempt_count, 0) = 0
OR CASE
WHEN COALESCE(attempt_count, 0) = 1 THEN datetime(updated_at, '+5 minutes')
WHEN COALESCE(attempt_count, 0) = 2 THEN datetime(updated_at, '+15 minutes')
WHEN COALESCE(attempt_count, 0) = 3 THEN datetime(updated_at, '+60 minutes')
ELSE datetime(updated_at, '+360 minutes')
END <= datetime('now')
)
ORDER BY priority DESC, created_at ASC LIMIT 1
)
RETURNING id, input_path, output_path, status, NULL as decision_reason,
@@ -612,8 +695,8 @@ impl Db {
let result = sqlx::query(
"INSERT INTO encode_stats
(job_id, input_size_bytes, output_size_bytes, compression_ratio,
encode_time_seconds, encode_speed, avg_bitrate_kbps, vmaf_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
encode_time_seconds, encode_speed, avg_bitrate_kbps, vmaf_score, output_codec)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(job_id) DO UPDATE SET
input_size_bytes = excluded.input_size_bytes,
output_size_bytes = excluded.output_size_bytes,
@@ -621,7 +704,8 @@ impl Db {
encode_time_seconds = excluded.encode_time_seconds,
encode_speed = excluded.encode_speed,
avg_bitrate_kbps = excluded.avg_bitrate_kbps,
vmaf_score = excluded.vmaf_score",
vmaf_score = excluded.vmaf_score,
output_codec = excluded.output_codec",
)
.bind(stats.job_id)
.bind(stats.input_size as i64)
@@ -631,6 +715,7 @@ impl Db {
.bind(stats.encode_speed)
.bind(stats.avg_bitrate)
.bind(stats.vmaf_score)
.bind(stats.output_codec)
.execute(&self.pool)
.await?;
@@ -774,7 +859,9 @@ impl Db {
if ids.is_empty() {
return Ok(0);
}
let mut qb = sqlx::QueryBuilder::<sqlx::Sqlite>::new("DELETE FROM jobs WHERE id IN (");
let mut qb = sqlx::QueryBuilder::<sqlx::Sqlite>::new(
"UPDATE jobs SET archived = 1, updated_at = CURRENT_TIMESTAMP WHERE id IN (",
);
let mut separated = qb.separated(", ");
for id in ids {
separated.push_bind(id);
@@ -849,10 +936,14 @@ impl Db {
}
pub async fn delete_job(&self, id: i64) -> Result<()> {
let result = sqlx::query("DELETE FROM jobs WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
let result = sqlx::query(
"UPDATE jobs
SET archived = 1, updated_at = CURRENT_TIMESTAMP
WHERE id = ?",
)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
@@ -888,6 +979,7 @@ impl Db {
let has_is_recursive = self.has_column("watch_dirs", "is_recursive").await?;
let has_recursive = self.has_column("watch_dirs", "recursive").await?;
let has_enabled = self.has_column("watch_dirs", "enabled").await?;
let has_profile_id = self.has_column("watch_dirs", "profile_id").await?;
let recursive_expr = if has_is_recursive {
"is_recursive"
@@ -902,9 +994,11 @@ impl Db {
} else {
""
};
let profile_expr = if has_profile_id { "profile_id" } else { "NULL" };
let query = format!(
"SELECT id, path, {} as is_recursive, created_at FROM watch_dirs {}ORDER BY path ASC",
recursive_expr, enabled_filter
"SELECT id, path, {} as is_recursive, {} as profile_id, created_at
FROM watch_dirs {}ORDER BY path ASC",
recursive_expr, profile_expr, enabled_filter
);
let dirs = sqlx::query_as::<_, WatchDir>(&query)
@@ -944,10 +1038,30 @@ impl Db {
pub async fn add_watch_dir(&self, path: &str, is_recursive: bool) -> Result<WatchDir> {
let has_is_recursive = self.has_column("watch_dirs", "is_recursive").await?;
let has_recursive = self.has_column("watch_dirs", "recursive").await?;
let has_profile_id = self.has_column("watch_dirs", "profile_id").await?;
let row = if has_is_recursive {
let row = if has_is_recursive && has_profile_id {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, is_recursive) VALUES (?, ?) RETURNING id, path, is_recursive, created_at",
"INSERT INTO watch_dirs (path, is_recursive) VALUES (?, ?)
RETURNING id, path, is_recursive, profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
.fetch_one(&self.pool)
.await?
} else if has_is_recursive {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, is_recursive) VALUES (?, ?)
RETURNING id, path, is_recursive, NULL as profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
.fetch_one(&self.pool)
.await?
} else if has_recursive && has_profile_id {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, recursive) VALUES (?, ?)
RETURNING id, path, recursive as is_recursive, profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
@@ -955,7 +1069,8 @@ impl Db {
.await?
} else if has_recursive {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path, recursive) VALUES (?, ?) RETURNING id, path, recursive as is_recursive, created_at",
"INSERT INTO watch_dirs (path, recursive) VALUES (?, ?)
RETURNING id, path, recursive as is_recursive, NULL as profile_id, created_at",
)
.bind(path)
.bind(is_recursive)
@@ -963,7 +1078,8 @@ impl Db {
.await?
} else {
sqlx::query_as::<_, WatchDir>(
"INSERT INTO watch_dirs (path) VALUES (?) RETURNING id, path, 1 as is_recursive, created_at",
"INSERT INTO watch_dirs (path) VALUES (?)
RETURNING id, path, 1 as is_recursive, NULL as profile_id, created_at",
)
.bind(path)
.fetch_one(&self.pool)
@@ -978,17 +1094,45 @@ impl Db {
) -> Result<()> {
let has_is_recursive = self.has_column("watch_dirs", "is_recursive").await?;
let has_recursive = self.has_column("watch_dirs", "recursive").await?;
let has_profile_id = self.has_column("watch_dirs", "profile_id").await?;
let preserved_profiles = if has_profile_id {
let rows = sqlx::query("SELECT path, profile_id FROM watch_dirs")
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
let path: String = row.get("path");
let profile_id: Option<i64> = row.get("profile_id");
(path, profile_id)
})
.collect::<HashMap<_, _>>()
} else {
HashMap::new()
};
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM watch_dirs")
.execute(&mut *tx)
.await?;
for watch_dir in watch_dirs {
if has_is_recursive {
sqlx::query("INSERT INTO watch_dirs (path, is_recursive) VALUES (?, ?)")
.bind(&watch_dir.path)
.bind(watch_dir.is_recursive)
.execute(&mut *tx)
.await?;
let preserved_profile_id = preserved_profiles.get(&watch_dir.path).copied().flatten();
if has_is_recursive && has_profile_id {
sqlx::query(
"INSERT INTO watch_dirs (path, is_recursive, profile_id) VALUES (?, ?, ?)",
)
.bind(&watch_dir.path)
.bind(watch_dir.is_recursive)
.bind(preserved_profile_id)
.execute(&mut *tx)
.await?;
} else if has_recursive && has_profile_id {
sqlx::query(
"INSERT INTO watch_dirs (path, recursive, profile_id) VALUES (?, ?, ?)",
)
.bind(&watch_dir.path)
.bind(watch_dir.is_recursive)
.bind(preserved_profile_id)
.execute(&mut *tx)
.await?;
} else if has_recursive {
sqlx::query("INSERT INTO watch_dirs (path, recursive) VALUES (?, ?)")
.bind(&watch_dir.path)
@@ -1019,6 +1163,197 @@ impl Db {
Ok(())
}
pub async fn get_all_profiles(&self) -> Result<Vec<LibraryProfile>> {
let profiles = sqlx::query_as::<_, LibraryProfile>(
"SELECT id, name, preset, codec, quality_profile, hdr_mode, audio_mode,
crf_override, notes, created_at, updated_at
FROM library_profiles
ORDER BY id ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(profiles)
}
pub async fn get_profile(&self, id: i64) -> Result<Option<LibraryProfile>> {
let profile = sqlx::query_as::<_, LibraryProfile>(
"SELECT id, name, preset, codec, quality_profile, hdr_mode, audio_mode,
crf_override, notes, created_at, updated_at
FROM library_profiles
WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(profile)
}
pub async fn create_profile(&self, profile: NewLibraryProfile) -> Result<i64> {
let id = sqlx::query(
"INSERT INTO library_profiles
(name, preset, codec, quality_profile, hdr_mode, audio_mode, crf_override, notes, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
)
.bind(profile.name)
.bind(profile.preset)
.bind(profile.codec)
.bind(profile.quality_profile)
.bind(profile.hdr_mode)
.bind(profile.audio_mode)
.bind(profile.crf_override)
.bind(profile.notes)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
pub async fn update_profile(&self, id: i64, profile: NewLibraryProfile) -> Result<()> {
let result = sqlx::query(
"UPDATE library_profiles
SET name = ?,
preset = ?,
codec = ?,
quality_profile = ?,
hdr_mode = ?,
audio_mode = ?,
crf_override = ?,
notes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?",
)
.bind(profile.name)
.bind(profile.preset)
.bind(profile.codec)
.bind(profile.quality_profile)
.bind(profile.hdr_mode)
.bind(profile.audio_mode)
.bind(profile.crf_override)
.bind(profile.notes)
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn delete_profile(&self, id: i64) -> Result<()> {
let result = sqlx::query("DELETE FROM library_profiles WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn assign_profile_to_watch_dir(
&self,
dir_id: i64,
profile_id: Option<i64>,
) -> Result<()> {
let result = sqlx::query(
"UPDATE watch_dirs
SET profile_id = ?
WHERE id = ?",
)
.bind(profile_id)
.bind(dir_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AlchemistError::Database(
sqlx::Error::RowNotFound,
));
}
Ok(())
}
pub async fn get_profile_for_path(&self, path: &str) -> Result<Option<LibraryProfile>> {
let normalized = Path::new(path);
let candidate = sqlx::query_as::<_, LibraryProfile>(
"SELECT lp.id, lp.name, lp.preset, lp.codec, lp.quality_profile, lp.hdr_mode,
lp.audio_mode, lp.crf_override, lp.notes, lp.created_at, lp.updated_at
FROM watch_dirs wd
JOIN library_profiles lp ON lp.id = wd.profile_id
WHERE wd.profile_id IS NOT NULL
AND (? = wd.path OR ? LIKE wd.path || '/%' OR ? LIKE wd.path || '\\%')
ORDER BY LENGTH(wd.path) DESC
LIMIT 1",
)
.bind(path)
.bind(path)
.bind(path)
.fetch_optional(&self.pool)
.await?;
if candidate.is_some() {
return Ok(candidate);
}
// SQLite prefix matching is a fast first pass; fall back to strict path ancestry
// if separators or normalization differ.
let rows = sqlx::query(
"SELECT wd.path,
lp.id, lp.name, lp.preset, lp.codec, lp.quality_profile, lp.hdr_mode,
lp.audio_mode, lp.crf_override, lp.notes, lp.created_at, lp.updated_at
FROM watch_dirs wd
JOIN library_profiles lp ON lp.id = wd.profile_id
WHERE wd.profile_id IS NOT NULL",
)
.fetch_all(&self.pool)
.await?;
let mut best: Option<(usize, LibraryProfile)> = None;
for row in rows {
let watch_path: String = row.get("path");
let profile = LibraryProfile {
id: row.get("id"),
name: row.get("name"),
preset: row.get("preset"),
codec: row.get("codec"),
quality_profile: row.get("quality_profile"),
hdr_mode: row.get("hdr_mode"),
audio_mode: row.get("audio_mode"),
crf_override: row.get("crf_override"),
notes: row.get("notes"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
};
let watch_path_buf = PathBuf::from(&watch_path);
if normalized == watch_path_buf || normalized.starts_with(&watch_path_buf) {
let score = watch_path.len();
if best
.as_ref()
.map_or(true, |(best_score, _)| score > *best_score)
{
best = Some((score, profile));
}
}
}
Ok(best.map(|(_, profile)| profile))
}
pub async fn count_watch_dirs_using_profile(&self, profile_id: i64) -> Result<i64> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM watch_dirs WHERE profile_id = ?")
.bind(profile_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn get_notification_targets(&self) -> Result<Vec<NotificationTarget>> {
let targets = sqlx::query_as::<_, NotificationTarget>("SELECT id, name, target_type, endpoint_url, auth_token, events, enabled, created_at FROM notification_targets")
.fetch_all(&self.pool)
@@ -1295,6 +1630,77 @@ impl Db {
Ok(stats)
}
pub async fn get_savings_summary(&self) -> Result<SavingsSummary> {
let totals = sqlx::query(
"SELECT
COALESCE(SUM(input_size_bytes), 0) as total_input_bytes,
COALESCE(SUM(output_size_bytes), 0) as total_output_bytes,
COUNT(*) as job_count
FROM encode_stats
WHERE output_size_bytes IS NOT NULL",
)
.fetch_one(&self.pool)
.await?;
let total_input_bytes: i64 = totals.get("total_input_bytes");
let total_output_bytes: i64 = totals.get("total_output_bytes");
let job_count: i64 = totals.get("job_count");
let total_bytes_saved = (total_input_bytes - total_output_bytes).max(0);
let savings_percent = if total_input_bytes > 0 {
(total_bytes_saved as f64 / total_input_bytes as f64) * 100.0
} else {
0.0
};
let savings_by_codec = sqlx::query(
"SELECT
COALESCE(NULLIF(TRIM(e.output_codec), ''), 'unknown') as codec,
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved
FROM encode_stats e
JOIN jobs j ON j.id = e.job_id
WHERE e.output_size_bytes IS NOT NULL
GROUP BY codec
ORDER BY bytes_saved DESC, codec ASC",
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|row| CodecSavings {
codec: row.get("codec"),
bytes_saved: row.get("bytes_saved"),
})
.collect::<Vec<_>>();
let savings_over_time = sqlx::query(
"SELECT
DATE(e.created_at) as date,
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved
FROM encode_stats e
WHERE e.output_size_bytes IS NOT NULL
AND e.created_at >= datetime('now', '-30 days')
GROUP BY DATE(e.created_at)
ORDER BY date ASC",
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|row| DailySavings {
date: row.get("date"),
bytes_saved: row.get("bytes_saved"),
})
.collect::<Vec<_>>();
Ok(SavingsSummary {
total_input_bytes,
total_output_bytes,
total_bytes_saved,
savings_percent,
job_count,
savings_by_codec,
savings_over_time,
})
}
/// Batch update job statuses (for batch operations)
pub async fn batch_update_status(
&self,
@@ -1423,6 +1829,17 @@ impl Db {
Ok(())
}
pub async fn prune_old_logs(&self, max_age_days: u32) -> Result<u64> {
let result = sqlx::query(
"DELETE FROM logs
WHERE created_at < datetime('now', '-' || ? || ' days')",
)
.bind(max_age_days as i64)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
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)
@@ -1512,6 +1929,117 @@ impl Db {
Ok(())
}
pub async fn cleanup_expired_sessions(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP")
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn record_health_check(&self, job_id: i64, issues: Option<String>) -> Result<()> {
sqlx::query(
"UPDATE jobs
SET health_issues = ?,
last_health_check = datetime('now')
WHERE id = ?",
)
.bind(issues)
.bind(job_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_health_summary(&self) -> Result<HealthSummary> {
let row = sqlx::query(
"SELECT
(SELECT COUNT(*) FROM jobs WHERE last_health_check IS NOT NULL) as total_checked,
(SELECT COUNT(*)
FROM jobs
WHERE health_issues IS NOT NULL AND TRIM(health_issues) != '') as issues_found,
(SELECT MAX(started_at) FROM health_scan_runs) as last_run",
)
.fetch_one(&self.pool)
.await?;
Ok(HealthSummary {
total_checked: row.get("total_checked"),
issues_found: row.get("issues_found"),
last_run: row.get("last_run"),
})
}
pub async fn create_health_scan_run(&self) -> Result<i64> {
let id = sqlx::query("INSERT INTO health_scan_runs DEFAULT VALUES")
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
pub async fn complete_health_scan_run(
&self,
id: i64,
files_checked: i64,
issues_found: i64,
) -> Result<()> {
sqlx::query(
"UPDATE health_scan_runs
SET completed_at = datetime('now'),
files_checked = ?,
issues_found = ?
WHERE id = ?",
)
.bind(files_checked)
.bind(issues_found)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_jobs_needing_health_check(&self) -> Result<Vec<Job>> {
let jobs = 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(CAST(j.progress AS REAL), 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.status = 'completed'
AND (
j.last_health_check IS NULL
OR j.last_health_check < datetime('now', '-7 days')
)
ORDER BY COALESCE(j.last_health_check, '1970-01-01') ASC, j.updated_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(jobs)
}
pub async fn get_jobs_with_health_issues(&self) -> Result<Vec<Job>> {
let jobs = 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(CAST(j.progress AS REAL), 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.archived = 0
AND j.health_issues IS NOT NULL
AND TRIM(j.health_issues) != ''
ORDER BY j.updated_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(jobs)
}
pub async fn reset_auth(&self) -> Result<()> {
sqlx::query("DELETE FROM sessions")
.execute(&self.pool)
@@ -1714,6 +2242,54 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn claim_next_job_respects_attempt_backoff(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut db_path = std::env::temp_dir();
let token: u64 = rand::random();
db_path.push(format!("alchemist_backoff_test_{}.db", token));
let db = Db::new(db_path.to_string_lossy().as_ref()).await?;
let input = Path::new("backoff-input.mkv");
let output = Path::new("backoff-output.mkv");
let _ = db
.enqueue_job(input, output, SystemTime::UNIX_EPOCH)
.await?;
let job = db
.get_job_by_input_path("backoff-input.mkv")
.await?
.ok_or_else(|| std::io::Error::other("missing backoff job"))?;
sqlx::query(
"UPDATE jobs
SET attempt_count = 1,
updated_at = datetime('now')
WHERE id = ?",
)
.bind(job.id)
.execute(&db.pool)
.await?;
assert!(db.claim_next_job().await?.is_none());
sqlx::query(
"UPDATE jobs
SET updated_at = datetime('now', '-6 minutes')
WHERE id = ?",
)
.bind(job.id)
.execute(&db.pool)
.await?;
let claimed = db.claim_next_job().await?;
assert!(claimed.is_some());
drop(db);
let _ = std::fs::remove_file(db_path);
Ok(())
}
#[tokio::test]
async fn clear_completed_archives_jobs_but_preserves_encode_stats(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
@@ -1742,6 +2318,7 @@ mod tests {
encode_speed: 1.2,
avg_bitrate: 800.0,
vmaf_score: Some(96.5),
output_codec: Some("av1".to_string()),
})
.await?;

View File

@@ -103,6 +103,10 @@ fn should_reload_config_for_event(event: &notify::Event, config_path: &Path) ->
)
}
fn orphaned_temp_output_path(output_path: &str) -> PathBuf {
PathBuf::from(format!("{output_path}.alchemist.tmp"))
}
async fn run() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
@@ -221,6 +225,53 @@ async fn run() -> Result<()> {
db_path,
db_start.elapsed().as_millis()
);
let interrupted_jobs = {
let mut jobs = Vec::new();
match db.get_jobs_by_status(db::JobState::Encoding).await {
Ok(mut encoding_jobs) => jobs.append(&mut encoding_jobs),
Err(err) => error!("Failed to load interrupted encoding jobs: {}", err),
}
match db.get_jobs_by_status(db::JobState::Analyzing).await {
Ok(mut analyzing_jobs) => jobs.append(&mut analyzing_jobs),
Err(err) => error!("Failed to load interrupted analyzing jobs: {}", err),
}
jobs
};
match db.reset_interrupted_jobs().await {
Ok(count) if count > 0 => {
warn!("{} interrupted jobs reset to queued", count);
for job in interrupted_jobs {
let temp_path = orphaned_temp_output_path(&job.output_path);
if std::fs::metadata(&temp_path).is_ok() {
match std::fs::remove_file(&temp_path) {
Ok(_) => warn!("Removed orphaned temp file: {}", temp_path.display()),
Err(err) => error!(
"Failed to remove orphaned temp file {}: {}",
temp_path.display(),
err
),
}
}
}
}
Ok(_) => {}
Err(err) => error!("Failed to reset interrupted jobs: {}", err),
}
let log_retention_days = config.system.log_retention_days.unwrap_or(30);
match db.prune_old_logs(log_retention_days).await {
Ok(count) if count > 0 => info!("Pruned {} old log rows", count),
Ok(_) => {}
Err(err) => error!("Failed to prune old logs: {}", err),
}
match db.cleanup_expired_sessions().await {
Ok(count) => debug!("Removed {} expired sessions at startup", count),
Err(err) => error!("Failed to cleanup expired sessions: {}", err),
}
if args.reset_auth {
db.reset_auth().await?;
warn!("Auth reset requested. All users and sessions cleared.");
@@ -326,6 +377,34 @@ async fn run() -> Result<()> {
let transcoder = Arc::new(Transcoder::new());
let hardware_state = hardware::HardwareState::new(Some(hw_info.clone()));
let config = Arc::new(RwLock::new(config));
let maintenance_db = db.clone();
let maintenance_config = config.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60 * 60 * 24));
interval.tick().await;
loop {
interval.tick().await;
let retention_days = maintenance_config
.read()
.await
.system
.log_retention_days
.unwrap_or(30);
match maintenance_db.prune_old_logs(retention_days).await {
Ok(count) if count > 0 => info!("Pruned {} old log rows", count),
Ok(_) => {}
Err(err) => error!("Failed to prune old logs: {}", err),
}
match maintenance_db.cleanup_expired_sessions().await {
Ok(count) => debug!("Removed {} expired sessions", count),
Err(err) => error!("Failed to cleanup expired sessions: {}", err),
}
}
});
let agent = Arc::new(
Agent::new(
db.clone(),

View File

@@ -313,6 +313,7 @@ fn apply_audio_plan(args: &mut Vec<String>, plan: &AudioStreamPlan) {
AudioStreamPlan::Transcode {
codec,
bitrate_kbps,
channels,
} => {
args.extend([
"-c:a".to_string(),
@@ -320,6 +321,9 @@ fn apply_audio_plan(args: &mut Vec<String>, plan: &AudioStreamPlan) {
"-b:a".to_string(),
format!("{bitrate_kbps}k"),
]);
if let Some(channels) = channels {
args.extend(["-ac".to_string(), channels.to_string()]);
}
if matches!(codec, AudioCodec::Aac) {
args.extend(["-profile:a".to_string(), "aac_low".to_string()]);
}
@@ -996,6 +1000,7 @@ mod tests {
plan.audio = AudioStreamPlan::Transcode {
codec: AudioCodec::Aac,
bitrate_kbps: 192,
channels: None,
};
plan.requested_codec = OutputCodec::H264;
let metadata = metadata();

57
src/media/health.rs Normal file
View File

@@ -0,0 +1,57 @@
use crate::error::{AlchemistError, Result};
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
pub struct HealthChecker;
impl HealthChecker {
pub async fn check_file(path: &Path) -> Result<Option<String>> {
let mut command = Command::new("ffmpeg");
command
.kill_on_drop(true)
.args(["-v", "error", "-i"])
.arg(path)
.args(["-f", "null", "-"])
.stdout(Stdio::null())
.stderr(Stdio::piped());
let child = command.spawn().map_err(|err| match err.kind() {
std::io::ErrorKind::NotFound => AlchemistError::FFmpegNotFound,
_ => AlchemistError::FFmpeg(format!(
"Failed to start library health check for {}: {}",
path.display(),
err
)),
})?;
let output = match timeout(Duration::from_secs(60), child.wait_with_output()).await {
Ok(result) => result.map_err(AlchemistError::Io)?,
Err(_) => {
return Err(AlchemistError::FFmpeg(format!(
"Library health check timed out after 60 seconds for {}",
path.display()
)));
}
};
let stderr_output = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() && stderr_output.is_empty() {
return Ok(None);
}
if !stderr_output.is_empty() {
return Ok(Some(stderr_output));
}
Ok(Some(format!(
"ffmpeg exited with status {}",
output
.status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "unknown".to_string())
)))
}
}

View File

@@ -1,6 +1,7 @@
pub mod analyzer;
pub mod executor;
pub mod ffmpeg;
pub mod health;
pub mod pipeline;
pub mod planner;
pub mod processor;

View File

@@ -287,6 +287,7 @@ pub enum AudioStreamPlan {
Transcode {
codec: AudioCodec,
bitrate_kbps: u16,
channels: Option<u32>,
},
Drop,
}
@@ -386,7 +387,12 @@ pub trait Analyzer: Send + Sync {
#[async_trait]
pub trait Planner: Send + Sync {
async fn plan(&self, analysis: &MediaAnalysis, output_path: &Path) -> Result<TranscodePlan>;
async fn plan(
&self,
analysis: &MediaAnalysis,
output_path: &Path,
profile: Option<&crate::db::LibraryProfile>,
) -> Result<TranscodePlan>;
}
#[async_trait]
@@ -530,7 +536,7 @@ fn temp_output_path_for(path: &Path) -> PathBuf {
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("output");
parent.join(format!("{filename}.alchemist-part"))
parent.join(format!("{filename}.alchemist.tmp"))
}
impl Pipeline {
@@ -650,7 +656,20 @@ impl Pipeline {
let config_snapshot = self.config.read().await.clone();
let hw_info = self.hardware_state.snapshot().await;
let planner = BasicPlanner::new(Arc::new(config_snapshot.clone()), hw_info.clone());
let mut plan = match planner.plan(&analysis, &output_path).await {
let profile = match self.db.get_profile_for_path(&job.input_path).await {
Ok(profile) => profile,
Err(err) => {
tracing::error!("Job {}: Failed to resolve library profile: {}", job.id, err);
let _ = self
.update_job_state(job.id, crate::db::JobState::Failed)
.await;
return Err(JobFailure::Transient);
}
};
let mut plan = match planner
.plan(&analysis, &output_path, profile.as_ref())
.await
{
Ok(plan) => plan,
Err(e) => {
tracing::error!("Job {}: Planner failed: {}", job.id, e);
@@ -912,11 +931,17 @@ impl Pipeline {
let _ = std::fs::remove_file(context.temp_output_path);
cleanup_temp_subtitle_output(job_id, context.plan).await;
let reason = if output_size == 0 {
"Empty output"
format!(
"size_reduction_insufficient|reduction=0.000,threshold={:.3},output_size=0",
config.transcode.size_reduction_threshold
)
} else {
"Inefficient reduction"
format!(
"size_reduction_insufficient|reduction={:.3},threshold={:.3},output_size={}",
reduction, config.transcode.size_reduction_threshold, output_size
)
};
let _ = self.db.add_decision(job_id, "skip", reason).await;
let _ = self.db.add_decision(job_id, "skip", &reason).await;
self.update_job_state(job_id, crate::db::JobState::Skipped)
.await?;
return Ok(());
@@ -937,6 +962,19 @@ impl Pipeline {
vmaf_score = score.vmaf;
if let Some(s) = vmaf_score {
tracing::info!("[Job {}] VMAF Score: {:.2}", job_id, s);
if let Some(threshold) = config.transcode.vmaf_min_score {
if s < threshold {
let _ = std::fs::remove_file(context.temp_output_path);
cleanup_temp_subtitle_output(job_id, context.plan).await;
return Err(crate::error::AlchemistError::QualityCheckFailed(
format!(
"VMAF score {:.1} fell below the minimum threshold of {:.1}. The original file has been preserved.",
s,
threshold
),
));
}
}
if s < config.quality.min_vmaf_score && config.quality.revert_on_low_quality
{
tracing::warn!(
@@ -997,6 +1035,14 @@ impl Pipeline {
encode_speed,
avg_bitrate: avg_bitrate_kbps,
vmaf_score,
output_codec: Some(
context
.execution_result
.actual_output_codec
.unwrap_or(context.execution_result.planned_output_codec)
.as_str()
.to_string(),
),
})
.await?;
@@ -1072,6 +1118,9 @@ impl Pipeline {
let message = format!("Finalization failed: {err}");
let _ = self.db.add_log("error", Some(job_id), &message).await;
if let crate::error::AlchemistError::QualityCheckFailed(reason) = err {
let _ = self.db.add_decision(job_id, "reject", reason).await;
}
if temp_output_path.exists() {
if let Err(cleanup_err) = tokio::fs::remove_file(temp_output_path).await {

View File

@@ -1,4 +1,4 @@
use crate::config::{Config, OutputCodec, SubtitleMode};
use crate::config::{AudioMode, Config, HdrMode, OutputCodec, QualityProfile, SubtitleMode};
use crate::error::Result;
use crate::media::pipeline::{
AudioCodec, AudioStreamPlan, Encoder, FallbackKind, FilterStep, MediaAnalysis, PlannedFallback,
@@ -49,8 +49,24 @@ impl EncoderInventory {
#[async_trait]
impl Planner for BasicPlanner {
async fn plan(&self, analysis: &MediaAnalysis, output_path: &Path) -> Result<TranscodePlan> {
async fn plan(
&self,
analysis: &MediaAnalysis,
output_path: &Path,
profile: Option<&crate::db::LibraryProfile>,
) -> Result<TranscodePlan> {
let container = normalize_container(output_path, &analysis.metadata.container);
let requested_codec = profile
.map(|profile| output_codec_from_profile(&profile.codec))
.unwrap_or(self.config.transcode.output_codec);
let quality_profile = profile
.map(|profile| quality_profile_from_profile(&profile.quality_profile))
.unwrap_or(self.config.transcode.quality_profile);
let hdr_mode = profile
.map(|profile| hdr_mode_from_profile(&profile.hdr_mode))
.unwrap_or(self.config.transcode.hdr_mode);
let audio_mode = profile.map(|profile| audio_mode_from_profile(&profile.audio_mode));
let crf_override = profile.and_then(|profile| profile.crf_override);
let available_encoders =
build_available_encoders(&self.config, self.hw_info.as_ref(), &self.encoder_caps);
@@ -58,49 +74,48 @@ impl Planner for BasicPlanner {
return Ok(skip_plan(
"No available encoders for current hardware policy".to_string(),
container,
self.config.transcode.output_codec,
requested_codec,
self.config.transcode.allow_fallback,
self.config.transcode.threads,
));
}
if !self.config.transcode.allow_fallback
&& !available_encoders
.has_requested_codec_without_fallback(self.config.transcode.output_codec)
&& !available_encoders.has_requested_codec_without_fallback(requested_codec)
{
return Ok(skip_plan(
format!(
"Preferred codec {} unavailable and fallback disabled",
self.config.transcode.output_codec.as_str()
requested_codec.as_str()
),
container,
self.config.transcode.output_codec,
requested_codec,
self.config.transcode.allow_fallback,
self.config.transcode.threads,
));
}
let decision = should_transcode(analysis, &self.config);
let decision = should_transcode(analysis, &self.config, requested_codec);
if let TranscodeDecision::Skip { reason } = &decision {
return Ok(skip_plan(
reason.clone(),
container,
self.config.transcode.output_codec,
requested_codec,
self.config.transcode.allow_fallback,
self.config.transcode.threads,
));
}
let Some((encoder, fallback)) = select_encoder(
self.config.transcode.output_codec,
requested_codec,
&available_encoders,
self.config.transcode.allow_fallback,
) else {
return Ok(skip_plan(
"No suitable encoder available".to_string(),
container,
self.config.transcode.output_codec,
requested_codec,
self.config.transcode.allow_fallback,
self.config.transcode.threads,
));
@@ -117,7 +132,7 @@ impl Planner for BasicPlanner {
return Ok(skip_plan(
reason,
container,
self.config.transcode.output_codec,
requested_codec,
self.config.transcode.allow_fallback,
self.config.transcode.threads,
))
@@ -129,15 +144,17 @@ impl Planner for BasicPlanner {
analysis.metadata.audio_channels,
analysis.metadata.audio_is_heavy,
&container,
audio_mode,
);
let filters = plan_filters(analysis, encoder, &self.config, &subtitles);
let (rate_control, encoder_preset) = encoder_runtime_settings(encoder, &self.config);
let filters = plan_filters(analysis, encoder, &self.config, &subtitles, hdr_mode);
let (rate_control, encoder_preset) =
encoder_runtime_settings(encoder, &self.config, quality_profile, crf_override);
Ok(TranscodePlan {
decision,
output_path: None,
container,
requested_codec: self.config.transcode.output_codec,
requested_codec,
output_codec: Some(encoder.output_codec()),
encoder: Some(encoder),
backend: Some(encoder.backend()),
@@ -195,15 +212,18 @@ fn normalize_container(output_path: &Path, input_container: &str) -> String {
.unwrap_or_else(|| "mkv".to_string())
}
fn should_transcode(analysis: &MediaAnalysis, config: &Config) -> TranscodeDecision {
fn should_transcode(
analysis: &MediaAnalysis,
config: &Config,
target_codec: OutputCodec,
) -> TranscodeDecision {
let metadata = &analysis.metadata;
let target_codec = config.transcode.output_codec;
let target_codec_str = target_codec.as_str();
if metadata.codec_name.eq_ignore_ascii_case(target_codec_str) && metadata.bit_depth == Some(10)
{
return TranscodeDecision::Skip {
reason: format!("Already {} 10-bit", target_codec_str),
reason: format!("already_target_codec|codec={target_codec_str},bit_depth=10"),
};
}
@@ -212,7 +232,10 @@ fn should_transcode(analysis: &MediaAnalysis, config: &Config) -> TranscodeDecis
&& metadata.bit_depth.is_some_and(|depth| depth <= 8)
{
return TranscodeDecision::Skip {
reason: "Already H.264".to_string(),
reason: format!(
"already_target_codec|codec=h264,bit_depth={}",
metadata.bit_depth.unwrap_or(8)
),
};
}
@@ -240,7 +263,7 @@ fn should_transcode(analysis: &MediaAnalysis, config: &Config) -> TranscodeDecis
if width == 0.0 || height == 0.0 {
return TranscodeDecision::Skip {
reason: "Incomplete metadata (resolution missing)".to_string(),
reason: "incomplete_metadata|missing=resolution".to_string(),
};
}
@@ -275,7 +298,7 @@ fn should_transcode(analysis: &MediaAnalysis, config: &Config) -> TranscodeDecis
if video_bitrate_available && normalized_bpp.is_some_and(|value| value < threshold) {
return TranscodeDecision::Skip {
reason: format!(
"BPP too low ({:.4} normalized < {:.2}), avoiding quality murder",
"bpp_below_threshold|bpp={:.3},threshold={:.3}",
normalized_bpp.unwrap_or_default(),
threshold
),
@@ -286,7 +309,7 @@ fn should_transcode(analysis: &MediaAnalysis, config: &Config) -> TranscodeDecis
if metadata.size_bytes < min_size_bytes {
return TranscodeDecision::Skip {
reason: format!(
"File too small ({}MB < {}MB) to justify transcode overhead",
"below_min_file_size|size_mb={},threshold_mb={}",
metadata.size_bytes / 1024 / 1024,
config.transcode.min_file_size_mb
),
@@ -533,24 +556,26 @@ fn cpu_fallback(requested_codec: OutputCodec, encoder: Encoder) -> PlannedFallba
}
}
fn encoder_runtime_settings(encoder: Encoder, config: &Config) -> (RateControl, Option<String>) {
match encoder {
fn encoder_runtime_settings(
encoder: Encoder,
config: &Config,
quality_profile: QualityProfile,
crf_override: Option<i32>,
) -> (RateControl, Option<String>) {
let (rate_control, encoder_preset) = match encoder {
Encoder::Av1Qsv | Encoder::HevcQsv | Encoder::H264Qsv => (
RateControl::QsvQuality {
value: parse_quality_u8(config.transcode.quality_profile.qsv_quality(), 23),
value: parse_quality_u8(quality_profile.qsv_quality(), 23),
},
None,
),
Encoder::Av1Nvenc | Encoder::HevcNvenc | Encoder::H264Nvenc => (
RateControl::Cq { value: 25 },
Some(config.transcode.quality_profile.nvenc_preset().to_string()),
Some(quality_profile.nvenc_preset().to_string()),
),
Encoder::Av1Videotoolbox | Encoder::HevcVideotoolbox | Encoder::H264Videotoolbox => (
RateControl::Cq {
value: parse_quality_u8(
config.transcode.quality_profile.videotoolbox_quality(),
65,
),
value: parse_quality_u8(quality_profile.videotoolbox_quality(), 65),
},
None,
),
@@ -590,7 +615,12 @@ fn encoder_runtime_settings(encoder: Encoder, config: &Config) -> (RateControl,
Encoder::Av1Amf | Encoder::HevcAmf | Encoder::H264Amf => {
(RateControl::Cq { value: 24 }, None)
}
}
};
(
apply_crf_override(rate_control, crf_override),
encoder_preset,
)
}
fn plan_audio(
@@ -598,7 +628,37 @@ fn plan_audio(
audio_channels: Option<u32>,
audio_is_heavy: bool,
container: &str,
audio_mode: Option<AudioMode>,
) -> AudioStreamPlan {
if let Some(audio_mode) = audio_mode {
return match audio_mode {
AudioMode::Copy => {
let Some(audio_codec) = audio_codec else {
return AudioStreamPlan::Copy;
};
if audio_copy_supported(container, audio_codec) {
AudioStreamPlan::Copy
} else {
AudioStreamPlan::Transcode {
codec: AudioCodec::Aac,
bitrate_kbps: audio_bitrate_kbps(AudioCodec::Aac, audio_channels),
channels: None,
}
}
}
AudioMode::Aac => AudioStreamPlan::Transcode {
codec: AudioCodec::Aac,
bitrate_kbps: audio_bitrate_kbps(AudioCodec::Aac, audio_channels),
channels: None,
},
AudioMode::AacStereo => AudioStreamPlan::Transcode {
codec: AudioCodec::Aac,
bitrate_kbps: audio_bitrate_kbps(AudioCodec::Aac, Some(2)),
channels: Some(2),
},
};
}
let Some(audio_codec) = audio_codec else {
return AudioStreamPlan::Copy;
};
@@ -613,6 +673,7 @@ fn plan_audio(
return AudioStreamPlan::Transcode {
codec,
bitrate_kbps: audio_bitrate_kbps(codec, audio_channels),
channels: None,
};
}
@@ -745,12 +806,11 @@ fn plan_filters(
encoder: Encoder,
config: &Config,
subtitles: &SubtitleStreamPlan,
hdr_mode: HdrMode,
) -> Vec<FilterStep> {
let mut filters = Vec::new();
if analysis.metadata.dynamic_range.is_hdr()
&& config.transcode.hdr_mode == crate::config::HdrMode::Tonemap
{
if analysis.metadata.dynamic_range.is_hdr() && hdr_mode == crate::config::HdrMode::Tonemap {
filters.push(FilterStep::Tonemap {
algorithm: config.transcode.tonemap_algorithm,
peak: config.transcode.tonemap_peak,
@@ -778,6 +838,49 @@ fn parse_quality_u8(value: &str, default_value: u8) -> u8 {
value.parse().unwrap_or(default_value)
}
fn apply_crf_override(rate_control: RateControl, crf_override: Option<i32>) -> RateControl {
let Some(crf_override) = crf_override else {
return rate_control;
};
let value = crf_override.clamp(0, u8::MAX as i32) as u8;
match rate_control {
RateControl::Crf { .. } => RateControl::Crf { value },
RateControl::Cq { .. } => RateControl::Cq { value },
RateControl::QsvQuality { .. } => RateControl::QsvQuality { value },
}
}
fn output_codec_from_profile(value: &str) -> OutputCodec {
match value.trim().to_ascii_lowercase().as_str() {
"hevc" | "h265" => OutputCodec::Hevc,
"h264" | "avc" => OutputCodec::H264,
_ => OutputCodec::Av1,
}
}
fn quality_profile_from_profile(value: &str) -> QualityProfile {
match value.trim().to_ascii_lowercase().as_str() {
"quality" => QualityProfile::Quality,
"speed" => QualityProfile::Speed,
_ => QualityProfile::Balanced,
}
}
fn hdr_mode_from_profile(value: &str) -> HdrMode {
match value.trim().to_ascii_lowercase().as_str() {
"tonemap" => HdrMode::Tonemap,
_ => HdrMode::Preserve,
}
}
fn audio_mode_from_profile(value: &str) -> AudioMode {
match value.trim().to_ascii_lowercase().as_str() {
"aac" => AudioMode::Aac,
"aac_stereo" => AudioMode::AacStereo,
_ => AudioMode::Copy,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -857,7 +960,7 @@ mod tests {
#[test]
fn heavy_audio_prefers_transcode() {
let plan = plan_audio(Some("flac"), Some(6), true, "mkv");
let plan = plan_audio(Some("flac"), Some(6), true, "mkv", None);
assert!(matches!(
plan,
AudioStreamPlan::Transcode {
@@ -876,6 +979,7 @@ mod tests {
Encoder::HevcVaapi,
&cfg,
&SubtitleStreamPlan::Drop,
HdrMode::Preserve,
);
assert!(matches!(
filters.as_slice(),

View File

@@ -22,7 +22,7 @@ use axum::{
use chrono::Utc;
use futures::{
stream::{self, Stream},
StreamExt,
FutureExt, StreamExt,
};
use rand::rngs::OsRng;
use rand::Rng;
@@ -79,6 +79,7 @@ pub struct AppState {
pub config_path: PathBuf,
pub config_mutable: bool,
pub hardware_state: HardwareState,
pub resources_cache: Arc<tokio::sync::Mutex<Option<(serde_json::Value, std::time::Instant)>>>,
login_rate_limiter: Mutex<HashMap<IpAddr, RateLimitEntry>>,
global_rate_limiter: Mutex<HashMap<IpAddr, RateLimitEntry>>,
}
@@ -150,20 +151,11 @@ pub async fn run_server(args: RunServerArgs) -> Result<()> {
config_path,
config_mutable,
hardware_state,
resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
login_rate_limiter: Mutex::new(HashMap::new()),
global_rate_limiter: Mutex::new(HashMap::new()),
});
let cleanup_db = state.db.clone();
tokio::spawn(async move {
loop {
if let Err(e) = cleanup_db.cleanup_sessions().await {
error!("Failed to cleanup sessions: {}", e);
}
tokio::time::sleep(Duration::from_secs(60 * 60)).await;
}
});
let app = app_router(state);
let addr = "0.0.0.0:3000";
@@ -191,6 +183,7 @@ fn app_router(state: Arc<AppState>) -> Router {
.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/stats/savings", get(savings_summary_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))
@@ -218,6 +211,14 @@ fn app_router(state: Arc<AppState>) -> Router {
"/api/settings/bundle",
get(get_settings_bundle_handler).put(update_settings_bundle_handler),
)
.route(
"/api/settings/preferences",
post(set_setting_preference_handler),
)
.route(
"/api/settings/preferences/:key",
get(get_setting_preference_handler),
)
.route(
"/api/settings/config",
get(get_settings_config_handler).put(update_settings_config_handler),
@@ -230,6 +231,19 @@ fn app_router(state: Arc<AppState>) -> Router {
"/api/settings/watch-dirs/:id",
delete(remove_watch_dir_handler),
)
.route(
"/api/watch-dirs/:id/profile",
axum::routing::patch(assign_watch_dir_profile_handler),
)
.route("/api/profiles/presets", get(get_profile_presets_handler))
.route(
"/api/profiles",
get(list_profiles_handler).post(create_profile_handler),
)
.route(
"/api/profiles/:id",
axum::routing::put(update_profile_handler).delete(delete_profile_handler),
)
.route(
"/api/settings/notifications",
get(get_notifications_handler).post(add_notification_handler),
@@ -265,6 +279,15 @@ fn app_router(state: Arc<AppState>) -> Router {
.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))
.route("/api/library/health", get(library_health_handler))
.route(
"/api/library/health/scan",
post(start_library_health_scan_handler),
)
.route(
"/api/library/health/issues",
get(get_library_health_issues_handler),
)
.route("/api/fs/browse", get(fs_browse_handler))
.route("/api/fs/recommendations", get(fs_recommendations_handler))
.route("/api/fs/preview", post(fs_preview_handler))
@@ -777,6 +800,48 @@ async fn get_settings_bundle_handler(State(state): State<Arc<AppState>>) -> impl
axum::Json(crate::settings::bundle_response(config)).into_response()
}
#[derive(serde::Deserialize)]
struct SettingPreferencePayload {
key: String,
value: String,
}
#[derive(serde::Serialize)]
struct SettingPreferenceResponse {
key: String,
value: String,
}
async fn set_setting_preference_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<SettingPreferencePayload>,
) -> impl IntoResponse {
let key = payload.key.trim();
if key.is_empty() {
return (StatusCode::BAD_REQUEST, "key must not be empty").into_response();
}
match state.db.set_preference(key, payload.value.as_str()).await {
Ok(_) => axum::Json(SettingPreferenceResponse {
key: key.to_string(),
value: payload.value,
})
.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn get_setting_preference_handler(
State(state): State<Arc<AppState>>,
Path(key): Path<String>,
) -> impl IntoResponse {
match state.db.get_preference(key.as_str()).await {
Ok(Some(value)) => axum::Json(SettingPreferenceResponse { key, value }).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn update_settings_bundle_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<Config>,
@@ -1234,6 +1299,13 @@ async fn detailed_stats_handler(State(state): State<Arc<AppState>>) -> impl Into
}
}
async fn savings_summary_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match state.db.get_savings_summary().await {
Ok(summary) => axum::Json(summary).into_response(),
Err(err) => config_read_error_response("load storage savings summary", &err),
}
}
async fn scan_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let config = state.config.read().await;
let mut dirs: Vec<std::path::PathBuf> = config
@@ -1380,6 +1452,135 @@ async fn ready_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse
}
}
async fn library_health_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match state.db.get_health_summary().await {
Ok(summary) => axum::Json(summary).into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn get_library_health_issues_handler(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
match state.db.get_jobs_with_health_issues().await {
Ok(jobs) => axum::Json(jobs).into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn run_library_health_scan(db: Arc<Db>) {
let result = std::panic::AssertUnwindSafe({
let db = db.clone();
async move {
let created_run_id = match db.create_health_scan_run().await {
Ok(id) => id,
Err(err) => {
error!("Failed to create library health scan run: {}", err);
return;
}
};
let jobs = match db.get_jobs_needing_health_check().await {
Ok(jobs) => jobs,
Err(err) => {
error!("Failed to load jobs for library health scan: {}", err);
let _ = db.complete_health_scan_run(created_run_id, 0, 0).await;
return;
}
};
let counters = Arc::new(Mutex::new((0_i64, 0_i64)));
let semaphore = Arc::new(tokio::sync::Semaphore::new(2));
stream::iter(jobs)
.for_each_concurrent(None, {
let db = db.clone();
let counters = counters.clone();
let semaphore = semaphore.clone();
move |job| {
let db = db.clone();
let counters = counters.clone();
let semaphore = semaphore.clone();
async move {
let Ok(permit) = semaphore.acquire_owned().await else {
error!("Library health scan semaphore closed unexpectedly");
return;
};
let _permit = permit;
match crate::media::health::HealthChecker::check_file(FsPath::new(
&job.output_path,
))
.await
{
Ok(issues) => {
if let Err(err) =
db.record_health_check(job.id, issues.clone()).await
{
error!(
"Failed to record library health result for job {}: {}",
job.id, err
);
return;
}
let mut guard = counters.lock().await;
guard.0 += 1;
if issues
.as_deref()
.is_some_and(|issues| !issues.trim().is_empty())
{
guard.1 += 1;
}
}
Err(err) => {
error!(
"Library health check was inconclusive for job {} ({}): {}",
job.id, job.output_path, err
);
}
}
}
}
})
.await;
let (files_checked, issues_found) = *counters.lock().await;
if let Err(err) = db
.complete_health_scan_run(created_run_id, files_checked, issues_found)
.await
{
error!(
"Failed to complete library health scan run {}: {}",
created_run_id, err
);
}
}
})
.catch_unwind()
.await;
if result.is_err() {
error!("Library health scan panicked");
}
}
async fn start_library_health_scan_handler(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let db = state.db.clone();
tokio::spawn(async move {
run_library_health_scan(db).await;
});
(
StatusCode::ACCEPTED,
axum::Json(serde_json::json!({ "status": "accepted" })),
)
.into_response()
}
#[derive(serde::Deserialize)]
struct LoginPayload {
username: String,
@@ -1643,7 +1844,13 @@ struct SystemResources {
}
async fn system_resources_handler(State(state): State<Arc<AppState>>) -> Response {
// Use a block to limit the scope of the lock
let mut cache = state.resources_cache.lock().await;
if let Some((value, cached_at)) = cache.as_ref() {
if cached_at.elapsed() < Duration::from_millis(500) {
return axum::Json(value.clone()).into_response();
}
}
let (cpu_percent, memory_used_mb, memory_total_mb, memory_percent, cpu_count) = {
let mut sys = match state.sys.lock() {
Ok(sys) => sys,
@@ -1656,16 +1863,11 @@ async fn system_resources_handler(State(state): State<Arc<AppState>>) -> Respons
.into_response();
}
};
// 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 {
@@ -1673,6 +1875,7 @@ async fn system_resources_handler(State(state): State<Arc<AppState>>) -> Respons
} else {
0.0
};
(
cpu_percent,
memory_used_mb,
@@ -1682,21 +1885,16 @@ async fn system_resources_handler(State(state): State<Arc<AppState>>) -> Respons
)
};
// Uptime
let uptime_seconds = state.start_time.elapsed().as_secs();
// Active jobs from database
let stats = match state.db.get_job_stats().await {
Ok(stats) => stats,
Err(err) => return config_read_error_response("load system resource stats", &err),
};
// Query GPU utilization (using spawn_blocking to avoid blocking)
let (gpu_utilization, gpu_memory_percent) = tokio::task::spawn_blocking(query_gpu_utilization)
.await
.unwrap_or((None, None));
axum::Json(SystemResources {
let value = match serde_json::to_value(SystemResources {
cpu_percent,
memory_used_mb,
memory_total_mb,
@@ -1707,8 +1905,20 @@ async fn system_resources_handler(State(state): State<Arc<AppState>>) -> Respons
cpu_count,
gpu_utilization,
gpu_memory_percent,
})
.into_response()
}) {
Ok(value) => value,
Err(err) => {
error!("Failed to serialize system resource payload: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to serialize system resource payload",
)
.into_response();
}
};
*cache = Some((value.clone(), Instant::now()));
axum::Json(value).into_response()
}
/// Query GPU utilization using nvidia-smi (NVIDIA) or other platform-specific tools
@@ -2170,6 +2380,102 @@ struct AddWatchDirPayload {
is_recursive: Option<bool>,
}
#[derive(serde::Serialize)]
struct LibraryProfileResponse {
id: i64,
name: String,
preset: String,
codec: String,
quality_profile: String,
hdr_mode: String,
audio_mode: String,
crf_override: Option<i32>,
notes: Option<String>,
created_at: chrono::DateTime<Utc>,
updated_at: chrono::DateTime<Utc>,
builtin: bool,
}
#[derive(serde::Deserialize)]
struct LibraryProfilePayload {
name: String,
preset: String,
codec: String,
quality_profile: String,
hdr_mode: String,
audio_mode: String,
crf_override: Option<i32>,
notes: Option<String>,
}
#[derive(serde::Deserialize)]
struct AssignWatchDirProfilePayload {
profile_id: Option<i64>,
}
fn is_builtin_profile_id(id: i64) -> bool {
crate::config::BUILT_IN_LIBRARY_PROFILES
.iter()
.any(|profile| profile.id == id)
}
fn library_profile_response(profile: crate::db::LibraryProfile) -> LibraryProfileResponse {
LibraryProfileResponse {
id: profile.id,
name: profile.name,
preset: profile.preset,
codec: profile.codec,
quality_profile: profile.quality_profile,
hdr_mode: profile.hdr_mode,
audio_mode: profile.audio_mode,
crf_override: profile.crf_override,
notes: profile.notes,
created_at: profile.created_at,
updated_at: profile.updated_at,
builtin: is_builtin_profile_id(profile.id),
}
}
fn validate_library_profile_payload(
payload: &LibraryProfilePayload,
) -> std::result::Result<(), &'static str> {
if payload.name.trim().is_empty() {
return Err("name must not be empty");
}
if payload.preset.trim().is_empty() {
return Err("preset must not be empty");
}
if payload.codec.trim().is_empty() {
return Err("codec must not be empty");
}
if payload.quality_profile.trim().is_empty() {
return Err("quality_profile must not be empty");
}
if payload.hdr_mode.trim().is_empty() {
return Err("hdr_mode must not be empty");
}
if payload.audio_mode.trim().is_empty() {
return Err("audio_mode must not be empty");
}
Ok(())
}
fn to_new_library_profile(payload: LibraryProfilePayload) -> crate::db::NewLibraryProfile {
crate::db::NewLibraryProfile {
name: payload.name.trim().to_string(),
preset: payload.preset.trim().to_string(),
codec: payload.codec.trim().to_ascii_lowercase(),
quality_profile: payload.quality_profile.trim().to_ascii_lowercase(),
hdr_mode: payload.hdr_mode.trim().to_ascii_lowercase(),
audio_mode: payload.audio_mode.trim().to_ascii_lowercase(),
crf_override: payload.crf_override,
notes: payload
.notes
.map(|notes| notes.trim().to_string())
.filter(|notes| !notes.is_empty()),
}
}
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(),
@@ -2250,6 +2556,148 @@ async fn remove_watch_dir_handler(
StatusCode::OK.into_response()
}
async fn list_profiles_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match state.db.get_all_profiles().await {
Ok(profiles) => axum::Json(
profiles
.into_iter()
.map(library_profile_response)
.collect::<Vec<_>>(),
)
.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn get_profile_presets_handler() -> impl IntoResponse {
let presets = crate::config::BUILT_IN_LIBRARY_PROFILES
.iter()
.map(|preset| {
serde_json::json!({
"id": preset.id,
"name": preset.name,
"preset": preset.preset,
"codec": preset.codec.as_str(),
"quality_profile": preset.quality_profile.as_str(),
"hdr_mode": preset.hdr_mode.as_str(),
"audio_mode": preset.audio_mode.as_str(),
"crf_override": preset.crf_override,
"notes": preset.notes,
"builtin": true
})
})
.collect::<Vec<_>>();
axum::Json(presets).into_response()
}
async fn create_profile_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<LibraryProfilePayload>,
) -> impl IntoResponse {
if let Err(message) = validate_library_profile_payload(&payload) {
return (StatusCode::BAD_REQUEST, message).into_response();
}
let new_profile = to_new_library_profile(payload);
let id = match state.db.create_profile(new_profile).await {
Ok(id) => id,
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
};
match state.db.get_profile(id).await {
Ok(Some(profile)) => (
StatusCode::CREATED,
axum::Json(library_profile_response(profile)),
)
.into_response(),
Ok(None) => StatusCode::CREATED.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn update_profile_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
axum::Json(payload): axum::Json<LibraryProfilePayload>,
) -> impl IntoResponse {
if is_builtin_profile_id(id) {
return (StatusCode::CONFLICT, "Built-in presets are read-only").into_response();
}
if let Err(message) = validate_library_profile_payload(&payload) {
return (StatusCode::BAD_REQUEST, message).into_response();
}
match state
.db
.update_profile(id, to_new_library_profile(payload))
.await
{
Ok(_) => match state.db.get_profile(id).await {
Ok(Some(profile)) => axum::Json(library_profile_response(profile)).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
},
Err(err) if is_row_not_found(&err) => StatusCode::NOT_FOUND.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn delete_profile_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> impl IntoResponse {
if is_builtin_profile_id(id) {
return (StatusCode::CONFLICT, "Built-in presets cannot be deleted").into_response();
}
match state.db.count_watch_dirs_using_profile(id).await {
Ok(count) if count > 0 => (
StatusCode::CONFLICT,
"Profile is still assigned to one or more watch folders",
)
.into_response(),
Ok(_) => match state.db.delete_profile(id).await {
Ok(_) => StatusCode::OK.into_response(),
Err(err) if is_row_not_found(&err) => StatusCode::NOT_FOUND.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
},
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn assign_watch_dir_profile_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
axum::Json(payload): axum::Json<AssignWatchDirProfilePayload>,
) -> impl IntoResponse {
if let Some(profile_id) = payload.profile_id {
match state.db.get_profile(profile_id).await {
Ok(Some(_)) => {}
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(err) => {
return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
match state
.db
.assign_profile_to_watch_dir(id, payload.profile_id)
.await
{
Ok(_) => match state.db.get_watch_dirs().await {
Ok(dirs) => dirs
.into_iter()
.find(|dir| dir.id == id)
.map(|dir| axum::Json(dir).into_response())
.unwrap_or_else(|| StatusCode::OK.into_response()),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
},
Err(err) if is_row_not_found(&err) => StatusCode::NOT_FOUND.into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
async fn restart_job_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
@@ -2812,6 +3260,7 @@ mod tests {
config_path: config_path.clone(),
config_mutable: true,
hardware_state,
resources_cache: Arc::new(tokio::sync::Mutex::new(None)),
login_rate_limiter: Mutex::new(HashMap::new()),
global_rate_limiter: Mutex::new(HashMap::new()),
});
@@ -3611,6 +4060,7 @@ mod tests {
encode_speed: 1.5,
avg_bitrate: 900.0,
vmaf_score: Some(95.0),
output_codec: Some("av1".to_string()),
})
.await?;

View File

@@ -71,6 +71,7 @@ impl ConfigWizard {
tonemap_peak: crate::config::default_tonemap_peak(),
tonemap_desat: crate::config::default_tonemap_desat(),
subtitle_mode: crate::config::SubtitleMode::Copy,
vmaf_min_score: None,
},
hardware: crate::config::HardwareConfig {
preferred_vendor,

View File

@@ -1,3 +1,4 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
@@ -9,4 +10,14 @@ export default defineConfig({
applyBaseStyles: false, // We will include our own global.css
}),
],
vite: {
resolve: {
alias: {
'eventemitter3': fileURLToPath(new URL('./src/vendor/eventemitter3.ts', import.meta.url)),
'prop-types': fileURLToPath(new URL('./src/vendor/prop-types.ts', import.meta.url)),
'react-transition-group': fileURLToPath(new URL('./src/vendor/react-transition-group.tsx', import.meta.url)),
'tiny-invariant': fileURLToPath(new URL('./src/vendor/tiny-invariant.ts', import.meta.url)),
},
},
},
});

73
web/bun.lock generated
View File

@@ -13,6 +13,7 @@
"lucide-react": "^0.300.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.15.4",
"tailwind-merge": "^2.0.0",
},
"devDependencies": {
@@ -84,6 +85,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@@ -308,6 +311,24 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -450,8 +471,32 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
@@ -474,6 +519,8 @@
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -502,12 +549,14 @@
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
@@ -574,6 +623,8 @@
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
@@ -826,6 +877,8 @@
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -836,12 +889,22 @@
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
@@ -928,6 +991,8 @@
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -992,6 +1057,8 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
@@ -1090,8 +1157,12 @@
"ofetch/ufo": ["ufo@1.6.2", "", {}, "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q=="],
"p-queue/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],

View File

@@ -21,6 +21,7 @@
"lucide-react": "^0.300.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.15.4",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {

View File

@@ -135,13 +135,13 @@ export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
aria-modal="true"
aria-labelledby="about-dialog-title"
tabIndex={-1}
className="w-full max-w-md bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative"
className="w-full max-w-md bg-helios-surface border border-helios-line/30 rounded-xl 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="p-3 bg-helios-surface-soft border border-helios-line/20 rounded-lg 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>

View File

@@ -378,7 +378,7 @@ export default function AppearanceSettings() {
onClick={() => handleSelect(theme.id)}
disabled={isActive || Boolean(savingThemeId)}
className={cn(
"group relative flex flex-col items-start gap-4 rounded-2xl border p-4 text-left transition-all duration-300 outline-none",
"group relative flex flex-col items-start gap-4 rounded-lg border p-4 text-left transition-all duration-300 outline-none",
isActive
? "border-helios-solar bg-helios-solar/10 shadow-[0_0_20px_rgba(var(--accent-primary),0.12)] ring-1 ring-helios-solar/30"
: "border-helios-line/40 bg-helios-surface/80 hover:border-helios-solar/40 hover:bg-helios-surface hover:shadow-xl hover:shadow-black/10"
@@ -386,7 +386,7 @@ export default function AppearanceSettings() {
>
<div className="flex w-full items-center justify-between gap-3">
<div
className="h-12 w-12 rounded-2xl border border-white/10 shadow-inner flex-shrink-0 flex items-center justify-center relative overflow-hidden"
className="h-12 w-12 rounded-lg border border-white/10 shadow-inner flex-shrink-0 flex items-center justify-center relative overflow-hidden"
data-color-profile={theme.id}
style={{
background: `linear-gradient(140deg, rgb(var(--bg-main)), rgb(var(--bg-panel)))`,

View File

@@ -106,7 +106,7 @@ export default function ConfigEditorSettings() {
<textarea
value={rawToml}
onChange={(e) => setRawToml(e.target.value)}
className="min-h-[520px] w-full rounded-2xl border border-helios-line/20 bg-helios-surface-soft px-4 py-4 font-mono text-sm leading-6 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
className="min-h-[520px] w-full rounded-lg border border-helios-line/20 bg-helios-surface-soft px-4 py-4 font-mono text-sm leading-6 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
spellCheck={false}
/>

View File

@@ -29,6 +29,11 @@ interface SettingsBundleResponse {
};
}
interface PreferenceResponse {
key: string;
value: string;
}
interface StatCardProps {
label: string;
value: number;
@@ -54,12 +59,12 @@ const DEFAULT_STATS = {
function StatCard({ label, value, icon: Icon, colorClass }: StatCardProps) {
return (
<div className="p-5 rounded-2xl bg-helios-surface border border-helios-line/40 shadow-sm relative overflow-hidden group hover:bg-helios-surface-soft transition-colors">
<div className={`absolute -top-2 -right-2 p-3 opacity-10 group-hover:opacity-20 transition-opacity ${colorClass}`}>
<Icon size={64} />
</div>
<div className="relative z-10 flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-helios-slate">{label}</span>
<div className="p-5 rounded-lg bg-helios-surface border border-helios-line/40 shadow-sm hover:bg-helios-surface-soft transition-colors">
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-helios-slate">
<Icon size={16} className={`${colorClass} opacity-60`} />
{label}
</span>
<span className={`text-3xl font-bold font-mono tracking-tight ${colorClass}`}>{value}</span>
</div>
</div>
@@ -105,9 +110,36 @@ export default function Dashboard() {
};
void fetchJobs();
void apiJson<SettingsBundleResponse>("/api/settings/bundle")
.then(setBundle)
.catch(() => undefined);
void (async () => {
try {
const bundleResponse = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
setBundle(bundleResponse);
if (
bundleResponse.settings.scanner.directories.length === 0
&& typeof window !== "undefined"
&& window.location.pathname !== "/setup"
) {
let setupComplete: string | null = null;
try {
const preference = await apiJson<PreferenceResponse>(
"/api/settings/preferences/setup_complete"
);
setupComplete = preference.value;
} catch (error) {
if (!(isApiError(error) && error.status === 404)) {
throw error;
}
}
if (setupComplete !== "true") {
window.location.href = "/setup";
}
}
} catch {
// Ignore setup redirect lookup failures here; dashboard data fetches handle their own UX.
}
})();
void apiJson<{ status: "paused" | "running" }>("/api/engine/status")
.then((data) => setEngineStatus(data.status))
.catch(() => undefined);
@@ -219,8 +251,8 @@ export default function Dashboard() {
return (
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-hidden">
{engineStatus === "paused" && (
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/10 px-5 py-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-amber-500">Engine Paused</div>
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-5 py-4">
<div className="text-[11px] font-semibold uppercase tracking-wide text-amber-500">Engine Paused</div>
<div className="mt-2 text-sm text-helios-ink">
The queue can still fill up, but Alchemist will not start encoding until you click <span className="font-bold">Start</span> in the header.
</div>
@@ -236,23 +268,23 @@ export default function Dashboard() {
{bundle && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">Library Roots</div>
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-xs font-medium uppercase tracking-wide text-helios-slate">Library Roots</div>
<div className="mt-2 text-2xl font-bold text-helios-ink">{bundle.settings.scanner.directories.length}</div>
</div>
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">Notification Targets</div>
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-xs font-medium uppercase tracking-wide text-helios-slate">Notification Targets</div>
<div className="mt-2 text-2xl font-bold text-helios-ink">{bundle.settings.notifications.targets.length}</div>
</div>
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">Schedule Windows</div>
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-xs font-medium uppercase tracking-wide text-helios-slate">Schedule Windows</div>
<div className="mt-2 text-2xl font-bold text-helios-ink">{bundle.settings.schedule.windows.length}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
<div className="lg:col-span-2 p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm flex flex-col">
<div className="lg:col-span-2 p-6 rounded-xl bg-helios-surface border border-helios-line/40 shadow-sm flex flex-col">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-helios-ink flex items-center gap-2">
<Activity size={20} className="text-helios-solar" />
@@ -263,7 +295,14 @@ export default function Dashboard() {
<div className="flex flex-col gap-3">
{jobsLoading && jobs.length === 0 ? (
<div className="text-center py-8 text-helios-slate animate-pulse">Loading activity...</div>
<div className="py-2">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={`h-10 w-full rounded-md bg-helios-surface-soft/60 animate-pulse ${index < 4 ? "mb-2" : ""}`}
/>
))}
</div>
) : jobs.length === 0 ? (
<div className="text-center py-8 text-helios-slate/60 italic">No recent activity found.</div>
) : (
@@ -299,7 +338,7 @@ export default function Dashboard() {
</div>
</div>
<div className="p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm h-full">
<div className="p-6 rounded-xl bg-helios-surface border border-helios-line/40 shadow-sm h-full">
<h3 className="text-lg font-bold text-helios-ink mb-6 flex items-center gap-2">
<Zap size={20} className="text-helios-solar" />
Quick Start
@@ -320,7 +359,7 @@ export default function Dashboard() {
</div>
</div>
<div className="p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm">
<div className="p-6 rounded-xl bg-helios-surface border border-helios-line/40 shadow-sm">
<div className="flex items-center gap-2 mb-6">
<Activity size={18} className="text-helios-solar" />
<h3 className="text-sm font-bold uppercase tracking-wider text-helios-slate">System Health</h3>

View File

@@ -87,15 +87,15 @@ export default function HardwareSettings() {
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 className="h-12 bg-helios-surface-soft rounded-lg w-full" />
<div className="h-40 bg-helios-surface-soft rounded-lg 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" aria-live="polite">
<div className="p-6 bg-red-500/10 border border-red-500/20 text-red-500 rounded-lg flex items-center gap-3" aria-live="polite">
<AlertCircle size={20} />
<span className="font-semibold">{error || "Hardware detection failed."}</span>
</div>
@@ -127,7 +127,7 @@ export default function HardwareSettings() {
</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="bg-helios-surface border border-helios-line/30 rounded-lg 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} />
@@ -140,7 +140,7 @@ export default function HardwareSettings() {
<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>
<span className="text-xs font-medium text-helios-slate uppercase tracking-wide 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>
@@ -148,7 +148,7 @@ export default function HardwareSettings() {
</div>
</div>
<div className="bg-helios-surface border border-helios-line/30 rounded-2xl p-5 shadow-sm">
<div className="bg-helios-surface border border-helios-line/30 rounded-lg 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} />
@@ -175,7 +175,7 @@ export default function HardwareSettings() {
</div>
{info.vendor === "Cpu" && (
<div className="p-4 bg-helios-solar/5 border border-helios-solar/10 rounded-2xl">
<div className="p-4 bg-helios-solar/5 border border-helios-solar/10 rounded-lg">
<div className="flex gap-3">
<AlertCircle className="text-helios-solar shrink-0" size={18} />
<div className="space-y-1">
@@ -189,7 +189,7 @@ export default function HardwareSettings() {
)}
{settings && (
<div className="bg-helios-surface border border-helios-line/30 rounded-2xl p-5 shadow-sm space-y-5">
<div className="bg-helios-surface border border-helios-line/30 rounded-lg p-5 shadow-sm space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-500">
@@ -213,7 +213,7 @@ export default function HardwareSettings() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-helios-line/10 pt-5">
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">Preferred Vendor</label>
<label className="text-xs font-medium uppercase tracking-wide text-helios-slate">Preferred Vendor</label>
<select
value={settings.preferred_vendor ?? ""}
onChange={(e) => setSettings({
@@ -232,7 +232,7 @@ export default function HardwareSettings() {
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">CPU Preset</label>
<label className="text-xs font-medium uppercase tracking-wide text-helios-slate">CPU Preset</label>
<select
value={settings.cpu_preset}
onChange={(e) => setSettings({ ...settings, cpu_preset: e.target.value })}
@@ -246,7 +246,7 @@ export default function HardwareSettings() {
</div>
</div>
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/60 p-4 flex items-center justify-between">
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/60 p-4 flex items-center justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-helios-slate">Allow CPU Fallback</p>
<p className="text-[10px] text-helios-slate mt-1">Permit software encoding when the preferred GPU path is unavailable.</p>
@@ -280,7 +280,7 @@ export default function HardwareSettings() {
<button
onClick={() => void saveAllSettings()}
disabled={saving}
className="flex items-center justify-center gap-2 bg-helios-solar text-helios-main font-bold px-5 py-3 rounded-xl hover:opacity-90 transition-opacity disabled:opacity-50"
className="flex items-center justify-center gap-2 bg-helios-solar text-helios-main font-bold px-5 py-3 rounded-md hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Apply"}

View File

@@ -30,6 +30,30 @@ function focusableElements(root: HTMLElement): HTMLElement[] {
);
}
export function humanizeSkipReason(reason: string): { human: string; technical: string } {
const [rawKey, technical = ""] = reason.split("|", 2);
const key = rawKey.trim();
const human = (() => {
switch (key) {
case "already_target_codec":
return "This file is already in the target format — no conversion needed.";
case "bpp_below_threshold":
return "This file is already efficiently compressed — transcoding it wouldn't save meaningful space.";
case "below_min_file_size":
return "This file is too small to be worth transcoding.";
case "already_10bit":
return "This file is already in high-quality 10-bit format — re-encoding it could reduce quality.";
case "size_reduction_insufficient":
return "Converting this file wouldn't make it meaningfully smaller, so Alchemist skipped it.";
default:
return reason;
}
})();
return { human, technical };
}
interface Job {
id: number;
input_path: string;
@@ -471,18 +495,22 @@ export default function JobManager() {
setConfirmState(config);
};
const focusedDecision = focusedJob?.job.decision_reason
? humanizeSkipReason(focusedJob.job.decision_reason)
: null;
return (
<div className="space-y-6 relative">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">Visible Active</div>
<div className="mt-2 text-2xl font-bold text-helios-ink">{activeCount}</div>
</div>
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">Visible Failed</div>
<div className="mt-2 text-2xl font-bold text-red-500">{failedCount}</div>
</div>
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-4">
<div className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">Visible Completed</div>
<div className="mt-2 text-2xl font-bold text-emerald-500">{completedCount}</div>
</div>
@@ -625,7 +653,7 @@ export default function JobManager() {
)}
{/* Table */}
<div className="bg-helios-surface/50 border border-helios-line/20 rounded-2xl overflow-hidden shadow-sm">
<div className="bg-helios-surface/50 border border-helios-line/20 rounded-lg 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>
@@ -644,10 +672,18 @@ export default function JobManager() {
</tr>
</thead>
<tbody className="divide-y divide-helios-line/10">
{jobs.length === 0 ? (
{loading && jobs.length === 0 ? (
Array.from({ length: 5 }).map((_, index) => (
<tr key={`loading-${index}`}>
<td colSpan={6} className="px-6 py-3">
<div className="h-10 w-full rounded-md bg-helios-surface-soft/60 animate-pulse" />
</td>
</tr>
))
) : jobs.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-helios-slate">
{loading ? "Loading jobs..." : "No jobs found"}
No jobs found
</td>
</tr>
) : (
@@ -869,7 +905,7 @@ export default function JobManager() {
aria-labelledby="job-details-title"
aria-describedby="job-details-path"
tabIndex={-1}
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"
className="w-full max-w-2xl bg-helios-surface border border-helios-line/20 rounded-lg 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">
@@ -886,7 +922,7 @@ export default function JobManager() {
</div>
<button
onClick={() => setFocusedJob(null)}
className="p-2 hover:bg-helios-line/10 rounded-xl transition-colors text-helios-slate"
className="p-2 hover:bg-helios-line/10 rounded-md transition-colors text-helios-slate"
>
<X size={20} />
</button>
@@ -1006,9 +1042,19 @@ export default function JobManager() {
<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 className="text-sm text-helios-ink leading-relaxed">
{focusedDecision?.human ?? focusedJob.job.decision_reason}
</p>
{focusedDecision?.technical ? (
<details className="mt-2">
<summary className="text-xs text-helios-slate cursor-pointer hover:text-helios-ink">
Technical details
</summary>
<span className="mt-2 inline-block font-mono text-xs text-helios-slate/70">
{focusedDecision.technical}
</span>
</details>
) : null}
</div>
)}

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from "react";
import { Activity } from "lucide-react";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
interface HealthSummary {
total_checked: number;
issues_found: number;
last_run: string | null;
}
function formatRelativeTime(value: string | null): string {
if (!value) {
return "Never scanned";
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return "Never scanned";
}
const diffMs = Date.now() - parsed.getTime();
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export default function LibraryDoctor() {
const [summary, setSummary] = useState<HealthSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const fetchSummary = async (silent = false) => {
try {
const data = await apiJson<HealthSummary>("/api/library/health");
setSummary(data);
setError(null);
return data;
} catch (err) {
const message = isApiError(err) ? err.message : "Failed to load library health summary.";
setError(message);
if (!silent) {
showToast({ kind: "error", title: "Library Doctor", message });
}
return null;
} finally {
setLoading(false);
}
};
useEffect(() => {
void fetchSummary();
}, []);
const startScan = async () => {
if (scanning) {
return;
}
setScanning(true);
const baseline = summary;
try {
await apiAction("/api/library/health/scan", { method: "POST" });
showToast({
kind: "success",
title: "Library Doctor",
message:
"Library scan started — this may take a while depending on your library size.",
});
const deadline = Date.now() + 10 * 60 * 1000;
let lastIssues = baseline?.issues_found ?? -1;
let stableReads = 0;
let observedNewRun = false;
while (Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, 5000));
const next = await fetchSummary(true);
if (!next) {
continue;
}
if (next.last_run && next.last_run !== baseline?.last_run) {
observedNewRun = true;
}
if (!observedNewRun) {
continue;
}
if (next.issues_found === lastIssues) {
stableReads += 1;
} else {
stableReads = 0;
lastIssues = next.issues_found;
}
if (stableReads >= 1) {
break;
}
}
} catch (err) {
const message = isApiError(err) ? err.message : "Failed to start library scan.";
setError(message);
showToast({ kind: "error", title: "Library Doctor", message });
} finally {
await fetchSummary(true);
setScanning(false);
}
};
if (loading) {
return (
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="h-5 w-36 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-4 h-4 w-48 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-3 h-4 w-32 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-6 h-10 w-32 animate-pulse rounded bg-helios-surface-soft/60" />
</div>
);
}
return (
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-helios-solar/10 p-2 text-helios-solar">
<Activity size={18} />
</div>
<div>
<h3 className="font-semibold text-helios-ink">Library Doctor</h3>
<p className="text-sm text-helios-slate">
{summary
? `${summary.total_checked} files checked · ${summary.issues_found} issues found`
: "No scan data yet"}
</p>
<p className="text-xs text-helios-slate mt-1">
{summary?.last_run
? `Last scan: ${formatRelativeTime(summary.last_run)}`
: "Never scanned"}
</p>
</div>
</div>
{error ? (
<div className="mt-4 rounded-lg border border-status-error/30 bg-status-error/10 px-4 py-3 text-sm text-status-error">
{error}
</div>
) : null}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={() => void startScan()}
disabled={scanning}
className="rounded-md bg-helios-solar px-4 py-2 text-sm font-semibold text-helios-main disabled:opacity-60"
>
{scanning ? "Scanning..." : "Scan Library"}
</button>
{summary && summary.issues_found > 0 ? (
<a
href="/jobs?tab=issues"
className="text-sm text-helios-solar hover:underline"
>
View Issues
</a>
) : null}
</div>
{summary && summary.issues_found === 0 && summary.last_run ? (
<div className="mt-4 text-sm text-status-success">
No issues found in your last scan
</div>
) : null}
</div>
);
}

View File

@@ -179,7 +179,7 @@ export default function LogViewer() {
});
return (
<div className="flex flex-col h-full rounded-2xl border border-helios-line/40 bg-[#0d1117] overflow-hidden shadow-2xl">
<div className="flex flex-col h-full rounded-lg border border-helios-line/40 bg-[#0d1117] overflow-hidden shadow-2xl">
<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" aria-live="polite">
<Terminal size={16} />

View File

@@ -85,7 +85,7 @@ export default function QualitySettings() {
</div>
)}
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/60 p-4 flex items-center justify-between">
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/60 p-4 flex items-center justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-helios-slate">Enable VMAF</p>
<p className="text-[10px] text-helios-slate mt-1">Compute a quality score after encoding.</p>
@@ -132,7 +132,7 @@ export default function QualitySettings() {
/>
</div>
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/60 p-4 flex items-center justify-between">
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/60 p-4 flex items-center justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-helios-slate">Revert on Low Quality</p>
<p className="text-[10px] text-helios-slate mt-1">Keep the source if the VMAF score drops below the threshold.</p>
@@ -161,7 +161,7 @@ export default function QualitySettings() {
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center gap-2 rounded-xl bg-helios-solar px-6 py-3 font-bold text-helios-main hover:opacity-90 transition-opacity disabled:opacity-50"
className="flex items-center gap-2 rounded-md bg-helios-solar px-6 py-3 font-bold text-helios-main hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={18} />
{saving ? "Saving..." : "Save Quality Settings"}

View File

@@ -123,13 +123,32 @@ export default function ResourceMonitor() {
};
if (!stats) {
if (!error) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-5 gap-3" aria-live="polite">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="min-w-0 p-3 rounded-lg bg-helios-surface border border-helios-line/40"
>
<div className="h-4 w-24 rounded-md bg-helios-surface-soft/60 animate-pulse" />
<div className="mt-4 h-7 w-20 rounded-md bg-helios-surface-soft/60 animate-pulse" />
<div className="mt-4 h-2 w-full rounded-full bg-helios-surface-soft/60 animate-pulse" />
<div className="mt-3 flex justify-between">
<div className="h-3 w-16 rounded-md bg-helios-surface-soft/60 animate-pulse" />
<div className="h-3 w-14 rounded-md bg-helios-surface-soft/60 animate-pulse" />
</div>
</div>
))}
</div>
);
}
return (
<div className={`p-6 rounded-2xl bg-white/5 border border-white/10 h-48 flex items-center justify-center ${error ? "" : "animate-pulse"}`}>
<div className="p-6 rounded-lg bg-helios-surface border border-helios-line/40 h-48 flex items-center justify-center">
<div className="text-center" aria-live="polite">
<div className={`text-sm ${error ? "text-red-400" : "text-white/40"}`}>
{error ? "Unable to load system stats." : "Loading system stats..."}
</div>
{error && <div className="text-[10px] text-white/40 mt-2">{error} Retrying automatically...</div>}
<div className="text-sm text-red-400">Unable to load system stats.</div>
<div className="text-[10px] text-helios-slate/60 mt-2">{error} Retrying automatically...</div>
</div>
</div>
);
@@ -140,10 +159,10 @@ export default function ResourceMonitor() {
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="min-w-0 p-3 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md"
className="min-w-0 p-3 rounded-lg bg-helios-surface border border-helios-line/40"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
<div className="flex items-center gap-2 text-helios-slate 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)}`}>
@@ -151,13 +170,13 @@ export default function ResourceMonitor() {
</span>
</div>
<div className="space-y-1">
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
<div className="h-2 w-full bg-helios-surface-soft/50 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">
<div className="flex justify-between text-xs text-helios-slate/60">
<span>CPU Cores</span>
<span>{stats.cpu_count} Logical</span>
</div>
@@ -168,10 +187,10 @@ export default function ResourceMonitor() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="min-w-0 p-3 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md"
className="min-w-0 p-3 rounded-lg bg-helios-surface border border-helios-line/40"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
<div className="flex items-center gap-2 text-helios-slate 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)}`}>
@@ -179,13 +198,13 @@ export default function ResourceMonitor() {
</span>
</div>
<div className="space-y-1">
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
<div className="h-2 w-full bg-helios-surface-soft/50 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">
<div className="flex justify-between text-xs text-helios-slate/60">
<span>{(stats.memory_used_mb / 1024).toFixed(1)} GB used</span>
<span>{(stats.memory_total_mb / 1024).toFixed(0)} GB total</span>
</div>
@@ -196,13 +215,13 @@ export default function ResourceMonitor() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="min-w-0 p-3 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md"
className="min-w-0 p-3 rounded-lg bg-helios-surface border border-helios-line/40"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
<div className="flex items-center gap-2 text-helios-slate 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">
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-helios-solar/10 text-helios-solar">
{stats.active_jobs} / {stats.concurrent_limit}
</span>
</div>
@@ -211,7 +230,7 @@ export default function ResourceMonitor() {
<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"
i < stats.active_jobs ? "bg-helios-solar h-6" : "bg-helios-surface-soft/50 h-2"
}`}
/>
))}
@@ -222,10 +241,10 @@ export default function ResourceMonitor() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="min-w-0 p-3 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md"
className="min-w-0 p-3 rounded-lg bg-helios-surface border border-helios-line/40"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
<div className="flex items-center gap-2 text-helios-slate text-sm font-medium">
<Cpu size={16} /> GPU
</div>
{stats.gpu_utilization != null ? (
@@ -233,11 +252,11 @@ export default function ResourceMonitor() {
{stats.gpu_utilization.toFixed(1)}%
</span>
) : (
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-white/10 text-white/40">N/A</span>
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-helios-surface-soft/50 text-helios-slate/60">N/A</span>
)}
</div>
<div className="space-y-1">
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
<div className="h-2 w-full bg-helios-surface-soft/50 rounded-full overflow-hidden">
{stats.gpu_utilization != null && (
<div
className={`h-full rounded-full transition-all duration-500 ${getBarColor(stats.gpu_utilization)}`}
@@ -245,7 +264,7 @@ export default function ResourceMonitor() {
/>
)}
</div>
<div className="flex justify-between text-xs text-white/40">
<div className="flex justify-between text-xs text-helios-slate/60">
<span>VRAM</span>
<span>{stats.gpu_memory_percent?.toFixed(0) || "-"}% used</span>
</div>
@@ -256,15 +275,15 @@ export default function ResourceMonitor() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="min-w-0 p-3 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-md flex flex-col justify-between"
className="min-w-0 p-3 rounded-lg bg-helios-surface border border-helios-line/40 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">
<div className="flex items-center gap-2 text-helios-slate 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>
<div className="text-2xl font-bold text-helios-ink">{formatUptime(stats.uptime_seconds)}</div>
</motion.div>
</div>
);

View File

@@ -0,0 +1,243 @@
import { useEffect, useMemo, useState } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
interface CodecSavings {
codec: string;
bytes_saved: number;
}
interface DailySavings {
date: string;
bytes_saved: number;
}
interface SavingsSummary {
total_input_bytes: number;
total_output_bytes: number;
total_bytes_saved: number;
savings_percent: number;
job_count: number;
savings_by_codec: CodecSavings[];
savings_over_time: DailySavings[];
}
const GIB = 1_073_741_824;
const TIB = 1_099_511_627_776;
function formatHeroStorage(bytes: number): string {
if (bytes >= TIB) {
return `${(bytes / TIB).toFixed(1)} TB`;
}
return `${(bytes / GIB).toFixed(1)} GB`;
}
function formatCompactStorage(bytes: number): string {
if (bytes >= GIB) {
return `${(bytes / GIB).toFixed(1)} GB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatChartDate(date: string): string {
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) {
return date;
}
return parsed.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export default function SavingsOverview() {
const [summary, setSummary] = useState<SavingsSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSummary = async () => {
try {
const data = await apiJson<SavingsSummary>("/api/stats/savings");
setSummary(data);
setError(null);
} catch (err) {
const message = isApiError(err) ? err.message : "Failed to load storage savings.";
setError(message);
showToast({ kind: "error", title: "Savings", message });
} finally {
setLoading(false);
}
};
void fetchSummary();
}, []);
const chartData = useMemo(
() =>
(summary?.savings_over_time ?? []).map((entry) => ({
date: entry.date,
label: formatChartDate(entry.date),
gb_saved: Number((entry.bytes_saved / GIB).toFixed(1)),
})),
[summary]
);
if (loading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="h-4 w-28 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-4 h-10 w-40 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-3 h-3 w-32 animate-pulse rounded bg-helios-surface-soft/60" />
</div>
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="h-4 w-28 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-4 h-10 w-40 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-3 h-3 w-32 animate-pulse rounded bg-helios-surface-soft/60" />
</div>
</div>
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="h-4 w-40 animate-pulse rounded bg-helios-surface-soft/60" />
<div className="mt-4 h-[200px] animate-pulse rounded bg-helios-surface-soft/40" />
</div>
</div>
);
}
if (error || !summary) {
return (
<div className="rounded-lg border border-status-error/30 bg-status-error/10 px-4 py-6 text-center text-sm text-status-error">
{error ?? "Unable to load storage savings."}
</div>
);
}
if (summary.job_count === 0) {
return (
<div className="rounded-lg border border-helios-line/40 bg-helios-surface px-6 py-8 text-center text-sm text-helios-slate">
No transcoding data yet savings will appear here once jobs complete.
</div>
);
}
const maxCodecSavings = Math.max(
...summary.savings_by_codec.map((entry) => entry.bytes_saved),
1
);
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="text-sm font-medium text-helios-slate">Total saved</div>
<div className="mt-3 font-mono text-4xl font-bold text-helios-solar">
{formatHeroStorage(summary.total_bytes_saved)}
</div>
<div className="mt-2 text-sm text-helios-slate">
saved across {summary.job_count} files
</div>
</div>
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="text-sm font-medium text-helios-slate">Average reduction</div>
<div className="mt-3 font-mono text-4xl font-bold text-helios-solar">
{summary.savings_percent.toFixed(1)}%
</div>
<div className="mt-2 text-sm text-helios-slate">smaller on average</div>
</div>
</div>
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="text-sm font-medium text-helios-slate">
Savings over the last 30 days
</div>
{chartData.length === 0 ? (
<div className="py-10 text-center text-sm text-helios-slate">No data yet</div>
) : (
<div className="mt-4 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<CartesianGrid
stroke="rgb(var(--border-subtle) / 0.25)"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fill: "rgb(var(--text-muted))", fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fill: "rgb(var(--text-muted))", fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value.toFixed(1)} GB`}
/>
<Tooltip
contentStyle={{
backgroundColor: "rgb(var(--bg-panel))",
border: "1px solid rgb(var(--border-subtle) / 0.4)",
borderRadius: "12px",
color: "rgb(var(--text-primary))",
}}
formatter={(value: number) => [`${value.toFixed(1)} GB`, "Saved"]}
labelFormatter={(label: string) => label}
/>
<Area
type="monotone"
dataKey="gb_saved"
stroke="rgb(var(--accent-primary))"
fill="rgba(var(--accent-primary), 0.2)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="text-sm font-medium text-helios-slate">Savings by codec</div>
{summary.savings_by_codec.length === 0 ? (
<div className="py-8 text-center text-sm text-helios-slate">
No transcoding data yet savings will appear here once jobs complete.
</div>
) : (
<div className="mt-4 flex flex-col gap-2">
{summary.savings_by_codec.map((entry) => (
<div
key={entry.codec}
className="grid grid-cols-[120px_minmax(0,1fr)_110px] items-center gap-3"
>
<div className="text-sm font-medium text-helios-ink">
{entry.codec}
</div>
<div className="h-3 rounded bg-helios-surface-soft">
<div
className="h-full rounded bg-helios-solar/70"
style={{
width: `${Math.max(
(entry.bytes_saved / maxCodecSavings) * 100,
4
)}%`,
}}
/>
</div>
<div className="text-right text-sm text-helios-slate">
{formatCompactStorage(entry.bytes_saved)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -94,7 +94,7 @@ export default function SettingsPanel() {
navItemRefs.current[tab.id] = node;
}}
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
className={`w-full flex items-center gap-3 px-4 py-3 rounded-md 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"
}`}
@@ -102,7 +102,7 @@ export default function SettingsPanel() {
{isActive && (
<motion.div
layoutId="active-tab"
className="absolute inset-0 bg-helios-surface-soft border border-helios-line/20 rounded-xl"
className="absolute inset-0 bg-helios-surface-soft border border-helios-line/20 rounded-md"
initial={false}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
@@ -136,9 +136,9 @@ export default function SettingsPanel() {
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-5 rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
<div className="bg-helios-surface border border-helios-line/20 rounded-xl p-6 sm:p-8 shadow-sm">
<div className="mb-5 rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-helios-slate/70">
Setup & Runtime Controls
</p>
<p className="mt-1 text-sm text-helios-ink">

View File

@@ -84,6 +84,11 @@ export default function SetupWizard() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, settings }),
});
void apiAction("/api/settings/preferences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "setup_complete", value: "true" }),
}).catch(() => undefined);
setStep(6);
setScanRunId((current) => current + 1);
} catch (err) {

View File

@@ -29,7 +29,21 @@ const navItems = [
<div
class="w-8 h-8 rounded-lg bg-helios-solar text-helios-main flex items-center justify-center font-bold"
>
A
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
class="h-5 w-5"
aria-hidden="true"
>
<path d="M10 3h4" />
<path d="M11 3v5.5l-5 7.5a3 3 0 0 0 2.5 5h7a3 3 0 0 0 2.5-5l-5-7.5V3" />
<path d="M9 14h6" />
<path d="M8.5 17h7" />
</svg>
</div>
<span class="font-bold text-lg text-helios-ink">Alchemist</span>
</a>
@@ -44,9 +58,9 @@ const navItems = [
<a
href={href}
class:list={[
"flex items-center gap-3 px-3 py-2 rounded-xl transition-colors whitespace-nowrap shrink-0",
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap shrink-0",
isActive
? "bg-helios-solar text-helios-main font-bold"
? "border-helios-solar bg-helios-solar/10 text-helios-ink font-semibold"
: "text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink",
]}
>
@@ -59,6 +73,8 @@ const navItems = [
</nav>
<div class="mt-auto hidden lg:block">
<SystemStatus client:load />
<div class="mt-3 border-t border-helios-line/30 pt-3">
<SystemStatus client:load />
</div>
</div>
</aside>

View File

@@ -139,7 +139,7 @@ export default function StatsCharts() {
}
const StatCard = ({ icon: Icon, label, value, subtext, colorClass }: StatCardProps) => (
<div className="p-6 rounded-2xl bg-helios-surface border border-helios-line/40 shadow-sm">
<div className="p-6 rounded-lg bg-helios-surface border border-helios-line/40 shadow-sm">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-helios-slate uppercase tracking-wide mb-1">{label}</p>
@@ -214,7 +214,7 @@ export default function StatsCharts() {
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Daily Activity Chart */}
<div className="p-6 rounded-2xl bg-helios-surface border border-helios-line/40">
<div className="p-6 rounded-lg bg-helios-surface border border-helios-line/40">
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
<BarChart3 size={20} className="text-blue-500" />
Daily Activity (Last 30 Days)
@@ -255,7 +255,7 @@ export default function StatsCharts() {
</div>
{/* Space Efficiency */}
<div className="p-6 rounded-2xl bg-helios-surface border border-helios-line/40">
<div className="p-6 rounded-lg bg-helios-surface border border-helios-line/40">
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
<Zap size={20} className="text-helios-solar" />
Space Efficiency
@@ -286,7 +286,7 @@ export default function StatsCharts() {
{/* Recent Jobs Table */}
{detailedStats.length > 0 && (
<div className="p-6 rounded-2xl bg-helios-surface border border-helios-line/40">
<div className="p-6 rounded-lg bg-helios-surface border border-helios-line/40">
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
<FileVideo size={20} className="text-amber-500" />
Recent Completed Jobs

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { Activity, Save } from "lucide-react";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
import LibraryDoctor from "./LibraryDoctor";
interface SystemSettingsPayload {
monitoring_poll_interval: number;
@@ -154,6 +155,10 @@ export default function SystemSettings() {
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
<div className="border-t border-helios-line/10 pt-6">
<LibraryDoctor />
</div>
</div>
);
}

View File

@@ -148,7 +148,7 @@ export default function SystemStatus() {
aria-modal="true"
aria-labelledby="system-status-title"
layoutId={layoutId}
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative outline-none"
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-xl shadow-2xl overflow-hidden relative outline-none"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
@@ -181,7 +181,7 @@ export default function SystemStatus() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<div className="bg-helios-surface-soft/50 rounded-2xl p-5 border border-helios-line/10 flex flex-col items-center text-center gap-2">
<div className="bg-helios-surface-soft/50 rounded-lg p-5 border border-helios-line/10 flex flex-col items-center text-center gap-2">
<Zap size={20} className="text-helios-solar opacity-80" />
<div className="flex flex-col">
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Concurrency</span>
@@ -198,7 +198,7 @@ export default function SystemStatus() {
</div>
</div>
<div className="bg-helios-surface-soft/50 rounded-2xl p-5 border border-helios-line/10 flex flex-col items-center text-center gap-2">
<div className="bg-helios-surface-soft/50 rounded-lg p-5 border border-helios-line/10 flex flex-col items-center text-center gap-2">
<Database size={20} className="text-blue-400 opacity-80" />
<div className="flex flex-col">
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Total Jobs</span>

View File

@@ -121,7 +121,7 @@ export default function TranscodeSettings() {
<button
onClick={() => setSettings({ ...settings, output_codec: "av1" })}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
"flex flex-col items-center gap-2 p-4 rounded-lg border transition-all",
settings.output_codec === "av1"
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
@@ -133,7 +133,7 @@ export default function TranscodeSettings() {
<button
onClick={() => setSettings({ ...settings, output_codec: "hevc" })}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
"flex flex-col items-center gap-2 p-4 rounded-lg border transition-all",
settings.output_codec === "hevc"
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
@@ -145,7 +145,7 @@ export default function TranscodeSettings() {
<button
onClick={() => setSettings({ ...settings, output_codec: "h264" })}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
"flex flex-col items-center gap-2 p-4 rounded-lg border transition-all",
settings.output_codec === "h264"
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
@@ -168,7 +168,7 @@ export default function TranscodeSettings() {
key={profile}
onClick={() => setSettings({ ...settings, quality_profile: profile })}
className={cn(
"flex flex-col items-center justify-center p-3 rounded-xl border transition-all h-20",
"flex flex-col items-center justify-center p-3 rounded-md border transition-all h-20",
settings.quality_profile === profile
? "bg-helios-solar/10 border-helios-solar text-helios-ink font-bold shadow-sm"
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
@@ -180,7 +180,7 @@ export default function TranscodeSettings() {
</div>
</div>
<div className="md:col-span-2 flex items-center justify-between rounded-2xl border border-helios-line/20 bg-helios-surface-soft/60 p-4">
<div className="md:col-span-2 flex items-center justify-between rounded-lg border border-helios-line/20 bg-helios-surface-soft/60 p-4">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-helios-slate">Allow Fallback</p>
<p className="text-[10px] text-helios-slate mt-1">If preferred codec is unavailable, use the best available fallback.</p>
@@ -225,7 +225,7 @@ export default function TranscodeSettings() {
<button
onClick={() => setSettings({ ...settings, hdr_mode: "preserve" })}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
"flex flex-col items-center gap-2 p-4 rounded-lg border transition-all",
settings.hdr_mode === "preserve"
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
@@ -237,7 +237,7 @@ export default function TranscodeSettings() {
<button
onClick={() => setSettings({ ...settings, hdr_mode: "tonemap" })}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-2xl border transition-all",
"flex flex-col items-center gap-2 p-4 rounded-lg border transition-all",
settings.hdr_mode === "tonemap"
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm ring-1 ring-helios-solar/20"
: "bg-helios-surface border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
@@ -365,7 +365,7 @@ export default function TranscodeSettings() {
<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"
className="flex items-center gap-2 bg-helios-solar text-helios-main font-bold px-6 py-3 rounded-md hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={18} />
{saving ? "Saving..." : "Save Settings"}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { FolderOpen, Trash2, Plus, Folder, Play } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { FolderOpen, Trash2, Plus, Folder, Play, Pencil } from "lucide-react";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog";
@@ -9,6 +9,31 @@ interface WatchDir {
id: number;
path: string;
is_recursive: boolean;
profile_id: number | null;
}
interface LibraryProfile {
id: number;
name: string;
preset: string;
codec: "av1" | "hevc" | "h264";
quality_profile: "speed" | "balanced" | "quality";
hdr_mode: "preserve" | "tonemap";
audio_mode: "copy" | "aac" | "aac_stereo";
crf_override: number | null;
notes: string | null;
builtin: boolean;
}
interface ProfileDraft {
name: string;
preset: string;
codec: "av1" | "hevc" | "h264";
quality_profile: "speed" | "balanced" | "quality";
hdr_mode: "preserve" | "tonemap";
audio_mode: "copy" | "aac" | "aac_stereo";
crf_override: string;
notes: string;
}
interface SettingsBundleResponse {
@@ -20,8 +45,23 @@ interface SettingsBundleResponse {
};
}
function draftFromProfile(profile: LibraryProfile): ProfileDraft {
return {
name: profile.builtin ? `${profile.name} Custom` : profile.name,
preset: profile.preset,
codec: profile.codec,
quality_profile: profile.quality_profile,
hdr_mode: profile.hdr_mode,
audio_mode: profile.audio_mode,
crf_override: profile.crf_override === null ? "" : String(profile.crf_override),
notes: profile.notes ?? "",
};
}
export default function WatchFolders() {
const [dirs, setDirs] = useState<WatchDir[]>([]);
const [profiles, setProfiles] = useState<LibraryProfile[]>([]);
const [presets, setPresets] = useState<LibraryProfile[]>([]);
const [libraryDirs, setLibraryDirs] = useState<string[]>([]);
const [path, setPath] = useState("");
const [libraryPath, setLibraryPath] = useState("");
@@ -29,36 +69,58 @@ export default function WatchFolders() {
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
const [syncingLibrary, setSyncingLibrary] = useState(false);
const [assigningDirId, setAssigningDirId] = useState<number | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingRemoveId, setPendingRemoveId] = useState<number | null>(null);
const [pickerOpen, setPickerOpen] = useState<null | "library" | "watch">(null);
const [customizeDir, setCustomizeDir] = useState<WatchDir | null>(null);
const [profileDraft, setProfileDraft] = useState<ProfileDraft | null>(null);
const builtinProfiles = useMemo(
() => profiles.filter((profile) => profile.builtin),
[profiles]
);
const customProfiles = useMemo(
() => profiles.filter((profile) => !profile.builtin),
[profiles]
);
const fetchBundle = async () => {
try {
const data = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
setLibraryDirs(data.settings.scanner.directories);
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to fetch library directories";
setError(message);
}
const data = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
setLibraryDirs(data.settings.scanner.directories);
};
const fetchDirs = async () => {
const data = await apiJson<WatchDir[]>("/api/settings/watch-dirs");
setDirs(data);
};
const fetchProfiles = async () => {
const data = await apiJson<LibraryProfile[]>("/api/profiles");
setProfiles(data);
};
const fetchPresets = async () => {
const data = await apiJson<LibraryProfile[]>("/api/profiles/presets");
setPresets(data);
};
const refreshAll = async () => {
try {
const data = await apiJson<WatchDir[]>("/api/settings/watch-dirs");
setDirs(data);
await Promise.all([fetchDirs(), fetchBundle(), fetchProfiles(), fetchPresets()]);
setError(null);
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to fetch watch directories";
const message = isApiError(e) ? e.message : "Failed to load watch folders";
setError(message);
showToast({ kind: "error", title: "Watch Folders", message });
} finally {
setLoading(false);
}
};
useEffect(() => {
void fetchDirs();
void fetchBundle();
void refreshAll();
}, []);
const triggerScan = async () => {
@@ -152,6 +214,101 @@ export default function WatchFolders() {
}
};
const assignProfile = async (dirId: number, profileId: number | null) => {
setAssigningDirId(dirId);
try {
await apiAction(`/api/watch-dirs/${dirId}/profile`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile_id: profileId }),
});
await fetchDirs();
setError(null);
showToast({
kind: "success",
title: "Profiles",
message: profileId === null ? "Watch folder now uses global settings." : "Profile assigned.",
});
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to assign profile";
setError(message);
showToast({ kind: "error", title: "Profiles", message });
} finally {
setAssigningDirId(null);
}
};
const openCustomizeModal = (dir: WatchDir) => {
const selectedProfile = profiles.find((profile) => profile.id === dir.profile_id);
const fallbackPreset =
presets.find((preset) => preset.preset === "balanced")
?? presets[0]
?? builtinProfiles[0]
?? selectedProfile;
const baseProfile = selectedProfile ?? fallbackPreset;
if (!baseProfile) {
showToast({
kind: "error",
title: "Profiles",
message: "Preset definitions are unavailable right now.",
});
return;
}
setCustomizeDir(dir);
setProfileDraft(draftFromProfile(baseProfile));
};
const saveCustomProfile = async (event: React.FormEvent) => {
event.preventDefault();
if (!customizeDir || !profileDraft) {
return;
}
setSavingProfile(true);
try {
const created = await apiJson<LibraryProfile>("/api/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: profileDraft.name,
preset: profileDraft.preset,
codec: profileDraft.codec,
quality_profile: profileDraft.quality_profile,
hdr_mode: profileDraft.hdr_mode,
audio_mode: profileDraft.audio_mode,
crf_override: profileDraft.crf_override.trim()
? Number(profileDraft.crf_override)
: null,
notes: profileDraft.notes.trim() || null,
}),
});
await apiAction(`/api/watch-dirs/${customizeDir.id}/profile`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile_id: created.id }),
});
await Promise.all([fetchProfiles(), fetchDirs()]);
setCustomizeDir(null);
setProfileDraft(null);
setError(null);
showToast({
kind: "success",
title: "Profiles",
message: "Custom profile created and assigned.",
});
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to save custom profile";
setError(message);
showToast({ kind: "error", title: "Profiles", message });
} finally {
setSavingProfile(false);
}
};
return (
<div className="space-y-6" aria-live="polite">
<div className="flex items-center justify-between mb-6">
@@ -181,7 +338,7 @@ export default function WatchFolders() {
)}
<form onSubmit={addDir} className="space-y-3">
<div className="space-y-3 rounded-2xl border border-helios-line/20 bg-helios-surface-soft/50 p-4">
<div className="space-y-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft/50 p-4">
<div>
<h3 className="text-sm font-bold text-helios-ink uppercase tracking-wider">Library Directories</h3>
<p className="text-[10px] text-helios-slate mt-1">
@@ -274,30 +431,72 @@ export default function WatchFolders() {
<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 key={dir.id} className="flex flex-col gap-3 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 justify-between gap-3">
<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>
<span className="rounded-full border border-helios-line/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-helios-slate">
{dir.is_recursive ? "Recursive" : "Top level"}
</span>
</div>
<span className="text-sm font-mono text-helios-ink truncate max-w-[400px]" title={dir.path}>
{dir.path}
</span>
<span className="rounded-full border border-helios-line/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-helios-slate">
{dir.is_recursive ? "Recursive" : "Top level"}
</span>
<button
onClick={() => setPendingRemoveId(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>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<select
value={dir.profile_id === null ? "" : String(dir.profile_id)}
onChange={(event) => {
const value = event.target.value;
void assignProfile(
dir.id,
value === "" ? null : Number(value)
);
}}
disabled={assigningDirId === dir.id}
className="w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-2.5 text-sm text-helios-ink outline-none focus:border-helios-solar disabled:opacity-60"
>
<option value="">No profile (use global settings)</option>
{builtinProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
{customProfiles.length > 0 ? (
<option value="divider" disabled>
</option>
) : null}
{customProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<button
type="button"
onClick={() => openCustomizeModal(dir)}
className="inline-flex items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2 text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft"
title="Customize profile"
>
<Pencil size={14} />
</button>
</div>
<button
onClick={() => setPendingRemoveId(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">
<div className="flex flex-col items-center justify-center py-10 text-center border-2 border-dashed border-helios-line/10 rounded-lg 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>
@@ -324,6 +523,152 @@ export default function WatchFolders() {
}}
/>
{customizeDir && profileDraft ? (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-xl border border-helios-line/20 bg-helios-surface p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-helios-ink">Customize Profile</h3>
<p className="text-sm text-helios-slate">
Create a custom profile for <span className="font-mono">{customizeDir.path}</span>.
</p>
</div>
<button
type="button"
onClick={() => {
setCustomizeDir(null);
setProfileDraft(null);
}}
className="rounded-lg border border-helios-line/20 px-3 py-2 text-sm text-helios-slate hover:bg-helios-surface-soft"
>
Close
</button>
</div>
<form onSubmit={saveCustomProfile} className="mt-6 space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
Name
</label>
<input
type="text"
value={profileDraft.name}
onChange={(event) => setProfileDraft({ ...profileDraft, name: event.target.value })}
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
Starting preset
</label>
<select
value={profileDraft.preset}
onChange={(event) => setProfileDraft({ ...profileDraft, preset: event.target.value as ProfileDraft["preset"] })}
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
>
{presets.map((preset) => (
<option key={preset.id} value={preset.preset}>
{preset.name}
</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
Codec
</label>
<select
value={profileDraft.codec}
onChange={(event) => setProfileDraft({ ...profileDraft, codec: event.target.value as ProfileDraft["codec"] })}
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
>
<option value="av1">AV1</option>
<option value="hevc">HEVC</option>
<option value="h264">H.264</option>
</select>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
Quality profile
</label>
<select
value={profileDraft.quality_profile}
onChange={(event) => setProfileDraft({ ...profileDraft, quality_profile: event.target.value as ProfileDraft["quality_profile"] })}
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
>
<option value="speed">Speed</option>
<option value="balanced">Balanced</option>
<option value="quality">Quality</option>
</select>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
HDR mode
</label>
<select
value={profileDraft.hdr_mode}
onChange={(event) => setProfileDraft({ ...profileDraft, hdr_mode: event.target.value as ProfileDraft["hdr_mode"] })}
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
>
<option value="preserve">Preserve</option>
<option value="tonemap">Tonemap</option>
</select>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
Audio mode
</label>
<select
value={profileDraft.audio_mode}
onChange={(event) => setProfileDraft({ ...profileDraft, audio_mode: event.target.value as ProfileDraft["audio_mode"] })}
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
>
<option value="copy">Copy</option>
<option value="aac">AAC</option>
<option value="aac_stereo">AAC Stereo</option>
</select>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
CRF override
</label>
<input
type="number"
value={profileDraft.crf_override}
onChange={(event) => setProfileDraft({ ...profileDraft, crf_override: event.target.value })}
placeholder="Leave blank to use the preset default"
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
/>
</div>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
Notes
</label>
<textarea
value={profileDraft.notes}
onChange={(event) => setProfileDraft({ ...profileDraft, notes: event.target.value })}
rows={3}
className="mt-2 w-full rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 text-helios-ink outline-none focus:border-helios-solar"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={savingProfile}
className="rounded-xl bg-helios-solar px-5 py-3 text-sm font-semibold text-helios-main disabled:opacity-60"
>
{savingProfile ? "Saving..." : "Save Custom Profile"}
</button>
</div>
</form>
</div>
</div>
) : null}
<ServerDirectoryPicker
open={pickerOpen !== null}
title={pickerOpen === "library" ? "Select Library Root" : "Select Extra Watch Folder"}

View File

@@ -57,7 +57,7 @@ export default function AdminAccountStep({
<label className="block text-sm font-medium text-helios-slate mb-2">Admin Password</label>
<input type="password" value={password} onChange={(e) => onPasswordChange(e.target.value)} className="w-full bg-helios-surface-soft border border-helios-line/40 rounded-xl px-4 py-3 text-helios-ink focus:border-helios-solar outline-none" placeholder="Choose a strong password" />
</div>
<label className="flex items-center justify-between rounded-2xl border border-helios-line/20 bg-helios-surface-soft/50 px-4 py-4">
<label className="flex items-center justify-between rounded-lg border border-helios-line/20 bg-helios-surface-soft/50 px-4 py-4">
<div>
<p className="text-sm font-semibold text-helios-ink">Anonymous Telemetry</p>
<p className="text-xs text-helios-slate mt-1">Help improve reliability and defaults with anonymous runtime signals.</p>
@@ -78,7 +78,7 @@ export default function AdminAccountStep({
type="button"
onClick={() => onThemeChange(theme.id)}
className={clsx(
"rounded-2xl border px-4 py-4 text-left transition-all",
"rounded-lg border px-4 py-4 text-left transition-all",
activeThemeId === theme.id
? "border-helios-solar bg-helios-solar/10 text-helios-ink"
: "border-helios-line/20 bg-helios-surface-soft/50 text-helios-slate hover:border-helios-solar/20"

View File

@@ -96,7 +96,7 @@ export default function LibraryStep({
<div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.8fr] gap-6">
<div className="space-y-5">
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface-soft/40 p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface-soft/40 p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink"><Sparkles size={16} className="text-helios-solar" />Suggested Server Folders</div>
@@ -106,7 +106,7 @@ export default function LibraryStep({
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recommendations.map((recommendation) => (
<button key={recommendation.path} type="button" onClick={() => addDirectory(recommendation.path)} className="rounded-2xl border border-helios-line/20 bg-helios-surface px-4 py-4 text-left hover:border-helios-solar/30 transition-all">
<button key={recommendation.path} type="button" onClick={() => addDirectory(recommendation.path)} className="rounded-lg border border-helios-line/20 bg-helios-surface px-4 py-4 text-left hover:border-helios-solar/30 transition-all">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-helios-ink">{recommendation.label}</span>
<span className="rounded-full border border-helios-line/20 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-helios-slate">{recommendation.media_hint}</span>
@@ -118,7 +118,7 @@ export default function LibraryStep({
</div>
</div>
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink"><FolderOpen size={16} className="text-helios-solar" />Selected Library Roots</div>
<div className="flex gap-2">
<input type="text" value={dirInput} onChange={(e) => onDirInputChange(e.target.value)} placeholder="Paste a server path or use Browse" className="flex-1 rounded-xl border border-helios-line/20 bg-helios-surface-soft px-4 py-3 font-mono text-sm text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none" />
@@ -126,7 +126,7 @@ export default function LibraryStep({
</div>
<div className="space-y-2">
{directories.map((dir) => (
<div key={dir} className="flex items-center justify-between rounded-2xl border border-helios-line/20 bg-helios-surface-soft/50 px-4 py-3">
<div key={dir} className="flex items-center justify-between rounded-lg border border-helios-line/20 bg-helios-surface-soft/50 px-4 py-3">
<div className="min-w-0">
<p className="font-mono text-sm text-helios-ink truncate" title={dir}>{dir}</p>
<p className="text-[11px] text-helios-slate mt-1">Watched recursively and used as a library root.</p>
@@ -139,7 +139,7 @@ export default function LibraryStep({
</div>
</div>
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink"><Search size={16} className="text-helios-solar" />Library Preview</div>
@@ -148,18 +148,18 @@ export default function LibraryStep({
<button type="button" onClick={() => void fetchPreview()} disabled={previewLoading || directories.length === 0} className="rounded-xl border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink hover:border-helios-solar/30 disabled:opacity-50">{previewLoading ? "Previewing..." : "Refresh Preview"}</button>
</div>
{previewError && <div className="rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">{previewError}</div>}
{previewError && <div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">{previewError}</div>}
{preview ? (
<div className="space-y-4">
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3">
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-emerald-500">Estimated Supported Media</p>
<p className="mt-2 text-2xl font-bold text-helios-ink">{preview.total_media_files}</p>
</div>
{preview.warnings.length > 0 && <div className="space-y-2">{preview.warnings.map((warning) => <div key={warning} className="rounded-2xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-500">{warning}</div>)}</div>}
{preview.warnings.length > 0 && <div className="space-y-2">{preview.warnings.map((warning) => <div key={warning} className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-500">{warning}</div>)}</div>}
<div className="space-y-3">
{preview.directories.map((directory) => (
<div key={directory.path} className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4">
<div key={directory.path} className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-mono text-sm text-helios-ink break-all">{directory.path}</p>
@@ -173,7 +173,7 @@ export default function LibraryStep({
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-helios-line/20 px-4 py-8 text-sm text-helios-slate text-center">Add one or more server folders to preview what Alchemist will scan.</div>
<div className="rounded-lg border border-dashed border-helios-line/20 px-4 py-8 text-sm text-helios-slate text-center">Add one or more server folders to preview what Alchemist will scan.</div>
)}
</div>
</div>

View File

@@ -26,7 +26,7 @@ export default function ProcessingStep({ transcode, files, quality, onTranscodeC
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="text-sm font-semibold text-helios-ink">Transcoding Target</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{(["av1", "hevc", "h264"] as const).map((codec) => (
@@ -34,7 +34,7 @@ export default function ProcessingStep({ transcode, files, quality, onTranscodeC
key={codec}
type="button"
onClick={() => updateTranscode({ output_codec: codec })}
className={clsx("rounded-2xl border px-4 py-4 text-left transition-all", transcode.output_codec === codec ? "border-helios-solar bg-helios-solar/10 text-helios-ink" : "border-helios-line/20 bg-helios-surface-soft/40 text-helios-slate")}
className={clsx("rounded-lg border px-4 py-4 text-left transition-all", transcode.output_codec === codec ? "border-helios-solar bg-helios-solar/10 text-helios-ink" : "border-helios-line/20 bg-helios-surface-soft/40 text-helios-slate")}
>
<div className="font-semibold uppercase">{codec}</div>
<div className="text-[10px] mt-2 opacity-80">{codec === "av1" ? "Best compression" : codec === "hevc" ? "Broad modern compatibility" : "Maximum playback compatibility"}</div>
@@ -49,13 +49,32 @@ export default function ProcessingStep({ transcode, files, quality, onTranscodeC
<option value="balanced">Balanced</option>
<option value="quality">Quality</option>
</select>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
Controls the balance between file size and visual quality. Lower numbers = better quality but larger files. Higher numbers = smaller files with slightly lower quality. The default works well for most people.
</p>
</div>
<RangeControl label="Concurrent Jobs" min={1} max={8} step={1} value={transcode.concurrent_jobs} onChange={(concurrent_jobs) => updateTranscode({ concurrent_jobs })} />
<RangeControl label={`Minimum Savings (${Math.round(transcode.size_reduction_threshold * 100)}%)`} min={0} max={0.9} step={0.05} value={transcode.size_reduction_threshold} onChange={(size_reduction_threshold) => updateTranscode({ size_reduction_threshold })} />
<div>
<RangeControl label="Concurrent Jobs" min={1} max={8} step={1} value={transcode.concurrent_jobs} onChange={(concurrent_jobs) => updateTranscode({ concurrent_jobs })} />
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
How many videos to convert at the same time. More means faster overall progress but uses more CPU and GPU resources. Start with 1 or 2 if you're not sure — you can always increase it later.
</p>
</div>
<div>
<RangeControl label={`Minimum Savings (${Math.round(transcode.size_reduction_threshold * 100)}%)`} min={0} max={0.9} step={0.05} value={transcode.size_reduction_threshold} onChange={(size_reduction_threshold) => updateTranscode({ size_reduction_threshold })} />
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
Alchemist will skip a file if the newly encoded version wouldn't be at least this much smaller than the original. This prevents pointless re-encoding of files that are already well-optimized.
</p>
</div>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
Bits Per Pixel determines how compressed a file must already be before Alchemist skips it. If a file is already very compressed, re-encoding it could reduce quality without saving much space. Leave this at the default unless you know what you're doing.
</p>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
Files smaller than this will be skipped entirely. Small files rarely benefit from transcoding and it's usually not worth the processing time.
</p>
</div>
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink"><FileCog size={16} className="text-helios-solar" />Output Rules</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">

View File

@@ -19,7 +19,7 @@ export default function ReviewStep({ setupSummary, settings, preview, error }: R
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{setupSummary.map((item) => <div key={item.label} className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4"><div className="text-[10px] font-bold uppercase tracking-wider text-helios-slate">{item.label}</div><div className="mt-2 text-2xl font-bold text-helios-ink">{item.value}</div></div>)}
{setupSummary.map((item) => <div key={item.label} className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4"><div className="text-[10px] font-bold uppercase tracking-wider text-helios-slate">{item.label}</div><div className="mt-2 text-2xl font-bold text-helios-ink">{item.value}</div></div>)}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
@@ -29,7 +29,7 @@ export default function ReviewStep({ setupSummary, settings, preview, error }: R
<ReviewCard title="Runtime" lines={[`Theme: ${settings.appearance.active_theme_id ?? "default"}`, `${settings.notifications.targets.length} notification targets`, `${settings.schedule.windows.length} schedule windows`, `Telemetry: ${settings.system.enable_telemetry ? "enabled" : "disabled"}`]} />
</div>
{error && <div className="rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">{error}</div>}
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">{error}</div>}
</motion.div>
);
}

View File

@@ -52,19 +52,24 @@ export default function RuntimeStep({
<div className="grid grid-cols-1 xl:grid-cols-[1fr_1fr] gap-6">
<div className="space-y-6">
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink"><Cpu size={16} className="text-helios-solar" />Hardware Policy</div>
{hardwareInfo && <div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-helios-ink">Detected <span className="font-bold">{hardwareInfo.vendor}</span> with {hardwareInfo.supported_codecs.join(", ").toUpperCase()} support.</div>}
{hardwareInfo && <div className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-helios-ink">Detected <span className="font-bold">{hardwareInfo.vendor}</span> with {hardwareInfo.supported_codecs.join(", ").toUpperCase()} support.</div>}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<LabeledSelect label="Preferred Vendor" value={hardware.preferred_vendor ?? ""} onChange={(preferred_vendor) => updateHardware({ preferred_vendor: preferred_vendor || null })} options={[{ value: "", label: "Auto detect" }, { value: "nvidia", label: "NVIDIA" }, { value: "amd", label: "AMD" }, { value: "intel", label: "Intel" }, { value: "apple", label: "Apple" }, { value: "cpu", label: "CPU" }]} />
<LabeledSelect label="CPU Preset" value={hardware.cpu_preset} onChange={(cpu_preset) => updateHardware({ cpu_preset: cpu_preset as SetupSettings["hardware"]["cpu_preset"] })} options={[{ value: "slow", label: "Slow" }, { value: "medium", label: "Medium" }, { value: "fast", label: "Fast" }, { value: "faster", label: "Faster" }]} />
<div>
<LabeledSelect label="CPU Preset" value={hardware.cpu_preset} onChange={(cpu_preset) => updateHardware({ cpu_preset: cpu_preset as SetupSettings["hardware"]["cpu_preset"] })} options={[{ value: "slow", label: "Slow" }, { value: "medium", label: "Medium" }, { value: "fast", label: "Fast" }, { value: "faster", label: "Faster" }]} />
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
How much effort your CPU puts into each encode. Slower presets produce smaller, better-quality files but take longer. 'Medium' is a sensible default for most setups.
</p>
</div>
</div>
<LabeledInput label="Explicit Device Path" value={hardware.device_path ?? ""} onChange={(device_path) => updateHardware({ device_path: device_path || null })} placeholder="/dev/dri/renderD128" />
<ToggleRow title="Allow CPU Fallback" body="Use software encoding if the preferred GPU path is unavailable." checked={hardware.allow_cpu_fallback} onChange={(allow_cpu_fallback) => updateHardware({ allow_cpu_fallback })} />
<ToggleRow title="Allow CPU Encoding" body="Permit CPU encoders even when GPU options exist." checked={hardware.allow_cpu_encoding} onChange={(allow_cpu_encoding) => updateHardware({ allow_cpu_encoding })} />
</div>
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink"><Calendar size={16} className="text-helios-solar" />Schedule Windows</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<LabeledInput label="Start" type="time" value={scheduleDraft.start_time} onChange={(start_time) => onScheduleDraftChange({ ...scheduleDraft, start_time })} />
@@ -83,7 +88,7 @@ export default function RuntimeStep({
<button type="button" onClick={addScheduleWindow} className="rounded-xl border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink">Add Schedule Window</button>
<div className="space-y-2">
{schedule.windows.map((window, index) => (
<div key={`${window.start_time}-${window.end_time}-${index}`} className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3 flex items-center justify-between gap-4">
<div key={`${window.start_time}-${window.end_time}-${index}`} className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-semibold text-helios-ink">{window.start_time} - {window.end_time}</div>
<div className="text-xs text-helios-slate mt-1">{window.days_of_week.map((day) => WEEKDAY_OPTIONS[day]).join(", ")}</div>
@@ -97,7 +102,7 @@ export default function RuntimeStep({
</div>
<div className="space-y-6">
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface p-5 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-helios-ink"><Bell size={16} className="text-helios-solar" />Notifications</div>
<ToggleRow title="Enable Notifications" body="Send alerts when jobs succeed or fail." checked={notifications.enabled} onChange={(enabled) => onNotificationsChange({ ...notifications, enabled })} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -118,7 +123,7 @@ export default function RuntimeStep({
<button type="button" onClick={addNotificationTarget} className="rounded-xl border border-helios-line/20 px-4 py-2 text-sm font-semibold text-helios-ink">Add Notification Target</button>
<div className="space-y-2">
{notifications.targets.map((target, index) => (
<div key={`${target.name}-${target.endpoint_url}-${index}`} className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3 flex items-center justify-between gap-4">
<div key={`${target.name}-${target.endpoint_url}-${index}`} className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-helios-ink">{target.name}</div>
<div className="text-xs text-helios-slate mt-1 truncate" title={target.endpoint_url}>{target.endpoint_url}</div>

View File

@@ -73,7 +73,7 @@ export default function ScanStep({ runId, onBackToReview }: ScanStepProps) {
</div>
{scanError && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-4 text-sm text-red-500 space-y-3">
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-4 text-sm text-red-500 space-y-3">
<p className="font-semibold">The initial scan hit an error.</p>
<p>{scanError}</p>
<div className="flex flex-col sm:flex-row gap-2">
@@ -92,8 +92,8 @@ export default function ScanStep({ runId, onBackToReview }: ScanStepProps) {
<div className="h-3 rounded-full border border-helios-line/20 bg-helios-surface-soft overflow-hidden">
<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 && <div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3 font-mono text-xs text-helios-slate">{scanStatus.current_folder}</div>}
{!scanStatus.is_running && <button type="button" onClick={() => { window.location.href = "/"; }} className="w-full rounded-2xl bg-helios-solar px-6 py-4 font-bold text-helios-main shadow-lg shadow-helios-solar/20 hover:opacity-90">Enter Dashboard</button>}
{scanStatus.current_folder && <div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-3 font-mono text-xs text-helios-slate">{scanStatus.current_folder}</div>}
{!scanStatus.is_running && <button type="button" onClick={() => { window.location.href = "/"; }} className="w-full rounded-lg bg-helios-solar px-6 py-4 font-bold text-helios-main shadow-lg shadow-helios-solar/20 hover:opacity-90">Enter Dashboard</button>}
</div>
)}
</motion.div>

View File

@@ -80,7 +80,7 @@ export function LabeledSelect({ label, value, onChange, options }: LabeledSelect
export function ToggleRow({ title, body, checked, onChange }: ToggleRowProps) {
return (
<label className="flex items-center justify-between gap-4 rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4">
<label className="flex items-center justify-between gap-4 rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4">
<div>
<p className="text-sm font-semibold text-helios-ink">{title}</p>
<p className="text-xs text-helios-slate mt-1">{body}</p>
@@ -92,7 +92,7 @@ export function ToggleRow({ title, body, checked, onChange }: ToggleRowProps) {
export function ReviewCard({ title, lines }: ReviewCardProps) {
return (
<div className="rounded-3xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-5">
<div className="rounded-xl border border-helios-line/20 bg-helios-surface-soft/40 px-5 py-5">
<div className="text-sm font-semibold text-helios-ink">{title}</div>
<div className="mt-3 space-y-2">
{lines.map((line) => (

View File

@@ -16,7 +16,7 @@ interface SetupFrameProps {
export default function SetupFrame({ step, configMutable, error, submitting, onBack, onNext, children }: SetupFrameProps) {
return (
<div className="bg-helios-surface border border-helios-line/60 rounded-3xl overflow-hidden shadow-2xl max-w-5xl w-full mx-auto">
<div className="bg-helios-surface border border-helios-line/60 rounded-xl overflow-hidden shadow-2xl max-w-5xl w-full mx-auto">
<div className="h-1 bg-helios-surface-soft w-full flex">
<motion.div className="bg-helios-solar h-full" initial={{ width: 0 }} animate={{ width: `${(step / SETUP_STEP_COUNT) * 100}%` }} />
</div>
@@ -31,17 +31,17 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
</div>
</div>
<div className="rounded-2xl border border-helios-line/20 bg-helios-surface-soft/50 px-4 py-3 text-xs text-helios-slate max-w-sm">
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/50 px-4 py-3 text-xs text-helios-slate max-w-sm">
<p className="font-bold uppercase tracking-wider text-helios-ink">Server-side selection</p>
<p className="mt-1">All folders here refer to the filesystem available to the Alchemist server process, not your browsers local machine.</p>
</div>
</header>
{!configMutable && <div className="mb-6 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">The config file is read-only right now. Setup cannot finish until the TOML file is writable.</div>}
{!configMutable && <div className="mb-6 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">The config file is read-only right now. Setup cannot finish until the TOML file is writable.</div>}
<AnimatePresence mode="wait">{children}</AnimatePresence>
{error && step < 6 && <div className="mt-6 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">{error}</div>}
{error && step < 6 && <div className="mt-6 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">{error}</div>}
{step < 6 && (
<div className="mt-8 flex items-center justify-between gap-4 border-t border-helios-line/20 pt-6">

View File

@@ -134,7 +134,7 @@ export default function ConfirmDialog({
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
tabIndex={-1}
className="w-full max-w-sm rounded-2xl border border-helios-line/30 bg-helios-surface p-6 shadow-2xl outline-none"
className="w-full max-w-sm rounded-lg border border-helios-line/30 bg-helios-surface p-6 shadow-2xl outline-none"
>
<h3 id="confirm-dialog-title" className="text-lg font-bold text-helios-ink">
{title}

View File

@@ -117,7 +117,7 @@ export default function ServerDirectoryPicker({
/>
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
<div className="w-full max-w-5xl rounded-3xl border border-helios-line/30 bg-helios-surface shadow-2xl overflow-hidden">
<div className="w-full max-w-5xl rounded-xl border border-helios-line/30 bg-helios-surface shadow-2xl overflow-hidden">
<div className="border-b border-helios-line/20 px-6 py-5 flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
@@ -177,7 +177,7 @@ export default function ServerDirectoryPicker({
key={recommendation.path}
type="button"
onClick={() => void loadBrowse(recommendation.path)}
className="w-full rounded-2xl border border-helios-line/20 bg-helios-surface px-3 py-3 text-left hover:border-helios-solar/30 hover:bg-helios-surface-soft transition-all"
className="w-full rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-3 text-left hover:border-helios-solar/30 hover:bg-helios-surface-soft transition-all"
>
<div className="flex items-start justify-between gap-3">
<div>
@@ -197,7 +197,7 @@ export default function ServerDirectoryPicker({
<section className="px-6 py-5 flex flex-col">
{error && (
<div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">
{error}
</div>
)}
@@ -217,7 +217,7 @@ export default function ServerDirectoryPicker({
))}
</div>
<div className="mb-4 rounded-2xl border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4">
<div className="mb-4 rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-helios-slate">
@@ -257,7 +257,7 @@ export default function ServerDirectoryPicker({
{loading && <div className="text-xs text-helios-slate animate-pulse">Loading</div>}
</div>
<div className="flex-1 overflow-y-auto rounded-2xl border border-helios-line/20 bg-helios-surface-soft/30">
<div className="flex-1 overflow-y-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft/30">
{browse.entries.length === 0 ? (
<div className="flex h-full min-h-[260px] items-center justify-center px-6 text-sm text-helios-slate">
No child directories were found here.

View File

@@ -1,6 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro";
import SavingsOverview from "../components/SavingsOverview.tsx";
import StatsCharts from "../components/StatsCharts.tsx";
import HeaderActions from "../components/HeaderActions.tsx";
---
@@ -23,6 +24,9 @@ import HeaderActions from "../components/HeaderActions.tsx";
<HeaderActions client:load />
</header>
<div class="mb-8">
<SavingsOverview client:load />
</div>
<StatsCharts client:load />
</main>
</div>

View File

@@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@400;500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=IBM+Plex+Mono:wght@400;600&display=swap");
@tailwind base;
@tailwind components;

34
web/src/vendor/eventemitter3.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
type Listener = (...args: unknown[]) => void;
export default class EventEmitter {
private listeners = new Map<string | symbol, Set<Listener>>();
on(event: string | symbol, listener: Listener) {
const set = this.listeners.get(event) ?? new Set<Listener>();
set.add(listener);
this.listeners.set(event, set);
return this;
}
off(event: string | symbol, listener: Listener) {
const set = this.listeners.get(event);
if (set) {
set.delete(listener);
if (set.size === 0) {
this.listeners.delete(event);
}
}
return this;
}
emit(event: string | symbol, ...args: unknown[]) {
const set = this.listeners.get(event);
if (!set) {
return false;
}
for (const listener of set) {
listener(...args);
}
return true;
}
}

17
web/src/vendor/prop-types.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
type Validator = (() => null) & { isRequired: () => null };
function validator(): Validator {
const fn = (() => null) as Validator;
fn.isRequired = () => null;
return fn;
}
const PropTypes = {
any: validator(),
array: validator(),
element: validator(),
object: validator(),
oneOfType: () => validator(),
};
export default PropTypes;

View File

@@ -0,0 +1,28 @@
import { Children, useEffect } from "react";
import type { ReactNode } from "react";
interface TransitionGroupProps {
children?: ReactNode;
component?: keyof JSX.IntrinsicElements | ((props: { children?: ReactNode }) => JSX.Element);
}
interface TransitionProps {
children?: ReactNode | (() => ReactNode);
onEnter?: (node?: unknown, isAppearing?: boolean) => void;
onExit?: (node?: unknown) => void;
}
export function TransitionGroup({
children,
component: Component = "span",
}: TransitionGroupProps) {
return <Component>{children}</Component>;
}
export function Transition({ children, onEnter }: TransitionProps) {
useEffect(() => {
onEnter?.(undefined, true);
}, [onEnter]);
return typeof children === "function" ? <>{children()}</> : <>{Children.only(children)}</>;
}

8
web/src/vendor/tiny-invariant.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
export default function invariant(
condition: unknown,
message = "Invariant failed"
): asserts condition {
if (!condition) {
throw new Error(message);
}
}

View File

@@ -4,7 +4,7 @@ export default {
theme: {
extend: {
fontFamily: {
display: ["\"Space Grotesk\"", "ui-sans-serif", "system-ui"],
display: ["\"DM Sans\"", "ui-sans-serif", "system-ui"],
mono: ["\"IBM Plex Mono\"", "ui-monospace", "monospace"],
},
colors: {