mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
Add library health checks and expand dashboard management
This commit is contained in:
30
migrations/20260321010000_library_profiles.sql
Normal file
30
migrations/20260321010000_library_profiles.sql
Normal 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'));
|
||||
15
migrations/20260321011000_health_checks.sql
Normal file
15
migrations/20260321011000_health_checks.sql
Normal 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'));
|
||||
107
src/config.rs
107
src/config.rs
@@ -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
639
src/db.rs
@@ -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?;
|
||||
|
||||
|
||||
79
src/main.rs
79
src/main.rs
@@ -103,6 +103,10 @@ fn should_reload_config_for_event(event: ¬ify::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(),
|
||||
|
||||
@@ -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
57
src/media/health.rs
Normal 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())
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
500
src/server.rs
500
src/server.rs
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
73
web/bun.lock
generated
@@ -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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))`,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
189
web/src/components/LibraryDoctor.tsx
Normal file
189
web/src/components/LibraryDoctor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
243
web/src/components/SavingsOverview.tsx
Normal file
243
web/src/components/SavingsOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 browser’s 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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
34
web/src/vendor/eventemitter3.ts
vendored
Normal 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
17
web/src/vendor/prop-types.ts
vendored
Normal 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;
|
||||
28
web/src/vendor/react-transition-group.tsx
vendored
Normal file
28
web/src/vendor/react-transition-group.tsx
vendored
Normal 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
8
web/src/vendor/tiny-invariant.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function invariant(
|
||||
condition: unknown,
|
||||
message = "Invariant failed"
|
||||
): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user