release: v0.2.2-stable

This commit is contained in:
brooklyn
2026-01-09 10:49:26 -05:00
parent bf1d217e16
commit 1357a7dce8
11 changed files with 794 additions and 217 deletions

34
Cargo.lock generated
View File

@@ -29,6 +29,7 @@ name = "alchemist"
version = "0.1.1"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"axum 0.7.9",
"chrono",
@@ -39,6 +40,7 @@ dependencies = [
"mime_guess",
"notify",
"num_cpus",
"rand",
"rayon",
"reqwest",
"rust-embed",
@@ -128,6 +130,18 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -306,6 +320,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1667,6 +1690,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"

View File

@@ -1,6 +1,6 @@
[package]
name = "alchemist"
version = "0.1.1"
version = "0.2.2"
edition = "2021"
license = "GPL-3.0"
@@ -39,3 +39,5 @@ reqwest = { version = "0.12", features = ["json"] }
rust-embed = { version = "8", features = ["axum"] }
mime_guess = "2.0"
async-trait = "0.1"
argon2 = "0.5.3"
rand = "0.8"

View File

@@ -0,0 +1,19 @@
-- Add users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Add sessions table
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Index for session cleanup and lookup
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);

View File

@@ -509,4 +509,71 @@ impl Db {
.await?;
Ok(row.map(|r| r.0))
}
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)
.bind(password_hash)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(user)
}
pub async fn has_users(&self) -> Result<bool> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await?;
Ok(count.0 > 0)
}
pub async fn create_session(&self, user_id: i64, token: &str, expires_at: DateTime<Utc>) -> Result<()> {
sqlx::query("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)")
.bind(token)
.bind(user_id)
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_session(&self, token: &str) -> Result<Option<Session>> {
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ? AND expires_at > CURRENT_TIMESTAMP")
.bind(token)
.fetch_optional(&self.pool)
.await?;
Ok(session)
}
pub async fn cleanup_sessions(&self) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP")
.execute(&self.pool)
.await?;
Ok(())
}
}
// Auth related structs
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct Session {
pub token: String,
pub user_id: i64,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}

View File

@@ -39,14 +39,12 @@ async fn main() -> Result<()> {
.init();
// Startup Banner
info!("╔═══════════════════════════════════════════════════════════════╗");
info!("║ ALCHEMIST ║");
info!("║ Video Transcoding Automation ║");
info!(
" Version {} ║",
env!("CARGO_PKG_VERSION")
);
info!("╚═══════════════════════════════════════════════════════════════╝");
info!(" ______ __ ______ __ __ ______ __ __ __ ______ ______ ");
info!("/\\ __ \\ /\\ \\ /\\ ___\\ /\\ \\_\\ \\ /\\ ___\\ /\\ \"-./ \\ /\\ \\ /\\ ___\\ /\\__ _\\");
info!("\\ \\ __ \\ \\ \\ \\____ \\ \\ \\____ \\ \\ __ \\ \\ \\ __\\ \\ \\ \\-./\\ \\ \\ \\ \\ \\ \\___ \\ \\/_/\\ \\/");
info!(" \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\_\\");
info!(" \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_____/ \\/_/ \\/_/ \\/_/ \\/_____/ \\/_/");
info!("");
info!("");
info!("System Information:");
info!(

View File

@@ -23,6 +23,14 @@ use tokio::sync::{broadcast, RwLock};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use tracing::info;
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rand::rngs::OsRng;
use rand::Rng;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::Utc;
#[derive(RustEmbed)]
#[folder = "web/dist/"]
@@ -34,7 +42,7 @@ pub struct AppState {
pub agent: Arc<Agent>,
pub transcoder: Arc<Transcoder>,
pub tx: broadcast::Sender<AlchemistEvent>,
pub setup_required: bool,
pub setup_required: Arc<AtomicBool>,
pub start_time: Instant,
}
@@ -52,7 +60,7 @@ pub async fn run_server(
agent,
transcoder,
tx,
setup_required,
setup_required: Arc::new(AtomicBool::new(setup_required)),
start_time: std::time::Instant::now(),
});
@@ -80,6 +88,7 @@ pub async fn run_server(
// Setup Routes
.route("/api/setup/status", get(setup_status_handler))
.route("/api/setup/complete", post(setup_complete_handler))
.route("/api/auth/login", post(login_handler))
.route(
"/api/ui/preferences",
get(get_preferences_handler).post(update_preferences_handler),
@@ -103,7 +112,7 @@ pub async fn run_server(
async fn setup_status_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
axum::Json(serde_json::json!({
"setup_required": state.setup_required
"setup_required": state.setup_required.load(Ordering::Relaxed)
}))
}
@@ -163,6 +172,8 @@ async fn update_transcode_settings_handler(
#[derive(serde::Deserialize)]
struct SetupConfig {
username: String,
password: String,
size_reduction_threshold: f64,
min_file_size_mb: u64,
concurrent_jobs: usize,
@@ -174,42 +185,64 @@ async fn setup_complete_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<SetupConfig>,
) -> impl IntoResponse {
if !state.setup_required {
if !state.setup_required.load(Ordering::Relaxed) {
return (StatusCode::FORBIDDEN, "Setup already completed").into_response();
}
// Create config object
let mut config = Config::default();
config.transcode.concurrent_jobs = payload.concurrent_jobs;
config.transcode.size_reduction_threshold = payload.size_reduction_threshold;
config.transcode.min_file_size_mb = payload.min_file_size_mb;
config.hardware.allow_cpu_encoding = payload.allow_cpu_encoding;
config.scanner.directories = payload.directories;
// Create User
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = match argon2.hash_password(payload.password.as_bytes(), &salt) {
Ok(h) => h.to_string(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Hashing failed: {}", e)).into_response(),
};
let user_id = match state.db.create_user(&payload.username, &password_hash).await {
Ok(id) => id,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create user: {}", e)).into_response(),
};
// Create Initial Session
let token: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect();
let expires_at = Utc::now() + chrono::Duration::days(30);
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create session: {}", e)).into_response();
}
// Save Config
let mut config_lock = state.config.write().await;
config_lock.transcode.concurrent_jobs = payload.concurrent_jobs;
config_lock.transcode.size_reduction_threshold = payload.size_reduction_threshold;
config_lock.transcode.min_file_size_mb = payload.min_file_size_mb;
config_lock.hardware.allow_cpu_encoding = payload.allow_cpu_encoding;
config_lock.scanner.directories = payload.directories;
// Serialize to TOML
let toml_string = match toml::to_string_pretty(&config) {
let toml_string = match toml::to_string_pretty(&*config_lock) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize config: {}", e),
)
.into_response()
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize config: {}", e)).into_response(),
};
// Write to file
if let Err(e) = std::fs::write("config.toml", toml_string) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write config.toml: {}", e),
)
.into_response();
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write config.toml: {}", e)).into_response();
}
// Update Setup State (Hot Reload)
state.setup_required.store(false, Ordering::Relaxed);
// Start Scan (optional, but good for UX)
let dirs = config_lock.scanner.directories.iter().map(std::path::PathBuf::from).collect();
let _ = state.agent.scan_and_enqueue(dirs).await;
info!("Configuration saved via web setup. Restarting recommended.");
info!("Configuration saved via web setup. Auth info created.");
axum::Json(serde_json::json!({ "status": "saved" })).into_response()
axum::Json(serde_json::json!({ "status": "saved", "token": token })).into_response()
}
#[derive(serde::Deserialize, serde::Serialize)]
@@ -444,41 +477,80 @@ async fn ready_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse
}
}
#[derive(serde::Deserialize)]
struct LoginPayload {
username: String,
password: String,
}
async fn login_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<LoginPayload>,
) -> impl IntoResponse {
let user = match state.db.get_user_by_username(&payload.username).await {
Ok(Some(u)) => u,
_ => return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
};
let parsed_hash = match PasswordHash::new(&user.password_hash) {
Ok(h) => h,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid hash format").into_response(),
};
if Argon2::default()
.verify_password(payload.password.as_bytes(), &parsed_hash)
.is_err()
{
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
// Create session
let token: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect();
let expires_at = Utc::now() + chrono::Duration::days(30);
if let Err(e) = state.db.create_session(user.id, &token, expires_at).await {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create session: {}", e)).into_response();
}
axum::Json(serde_json::json!({ "token": token })).into_response()
}
async fn auth_middleware(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Response {
let path = req.uri().path();
// Allow setup routes without auth
if path.starts_with("/api/setup") {
// Allow setup, login, and static assets without auth (simplified check)
if path.starts_with("/api/setup") || path.starts_with("/api/auth/login") || path.starts_with("/login") || path == "/" || path == "/index.html" || path.starts_with("/assets") {
return next.run(req).await;
}
if let Ok(password) = std::env::var("ALCHEMIST_PASSWORD") {
if !password.is_empty() {
// For static assets, we might want to bypass auth or require cookie auth
// For now, implementing simple bearer token check from original code
// NOTE: Browser won't send Bearer token for initial page load naturally.
// We might need to move to Cookie auth or allow basic auth.
// But for now, let's keep it as is or allow "/" to be public if needed?
// The user didn't specify auth changes. I'll leave the middleware applied globally.
// Check for Bearer token
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
let authorized = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.map(|s| s == format!("Bearer {}", password))
.unwrap_or(false);
if !authorized {
// If requesting HTML, maybe return 401 asking for auth?
// Or just 401.
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
if let Some(auth_str) = auth_header {
if auth_str.starts_with("Bearer ") {
let token = &auth_str[7..];
if let Ok(Some(_session)) = state.db.get_session(token).await {
return next.run(req).await;
}
}
}
next.run(req).await
// Attempt to verify if any users exist. If not, maybe allow access or force setup?
// Actually, if we are in setup mode, setup routes are allowed.
// If setup is done but no users? That shouldn't happen if setup creates a user.
// If we are strictly enforcing auth:
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
}
async fn sse_handler(

View File

@@ -8,16 +8,28 @@ import {
CheckCircle,
AlertTriangle,
Server,
Save
Save,
User,
Lock,
Video,
Info
} from 'lucide-react';
import clsx from 'clsx';
interface ConfigState {
// Auth
username: string;
password: string;
// Transcode
size_reduction_threshold: number;
min_file_size_mb: number;
concurrent_jobs: number;
directories: string[];
output_codec: "av1" | "hevc";
quality_profile: "quality" | "balanced" | "speed";
// Hardware
allow_cpu_encoding: boolean;
// Scanner
directories: string[];
}
export default function SetupWizard() {
@@ -26,17 +38,32 @@ export default function SetupWizard() {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Tooltip state
const [activeTooltip, setActiveTooltip] = useState<string | null>(null);
const [config, setConfig] = useState<ConfigState>({
username: '',
password: '',
size_reduction_threshold: 0.3,
min_file_size_mb: 100,
concurrent_jobs: 2,
output_codec: 'av1',
quality_profile: 'balanced',
directories: ['/media/movies'],
allow_cpu_encoding: false
});
const [dirInput, setDirInput] = useState('');
const handleNext = () => setStep(s => Math.min(s + 1, 4));
const handleNext = () => {
if (step === 1 && (!config.username || !config.password)) {
setError("Please fill in both username and password.");
return;
}
setError(null);
setStep(s => Math.min(s + 1, 5));
};
const handleBack = () => setStep(s => Math.max(s - 1, 1));
const addDirectory = () => {
@@ -69,32 +96,50 @@ export default function SetupWizard() {
});
if (!res.ok) {
throw new Error(await res.text());
const text = await res.text();
throw new Error(text);
}
const data = await res.json();
if (data.token) {
// Save token
localStorage.setItem('alchemist_token', data.token);
// Also set basic auth for legacy (optional) or just rely on new check
}
setSuccess(true);
// Auto redirect after short delay
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (err: any) {
setError(err.message || "Failed to save configuration");
} finally {
setLoading(false);
}
};
if (success) {
return (
<div className="flex flex-col items-center justify-center p-12 text-center max-w-lg mx-auto">
<div className="w-16 h-16 bg-status-success/20 rounded-full flex items-center justify-center mb-6 text-status-success">
<CheckCircle size={32} />
</div>
<h2 className="text-2xl font-bold text-helios-ink mb-4">Configuration Saved!</h2>
<p className="text-helios-slate mb-8">
Alchemist has been successfully configured.
<br /><br />
<span className="font-bold text-helios-ink">Please restart the server (or Docker container) to apply changes.</span>
</p>
</div>
);
}
// Total steps = 5
// 1: Account
// 2: Transcoding (Codec, Profile)
// 3: Thresholds
// 4: Hardware
// 5: Review & Save
// Combined Steps for brevity:
// 1: Account
// 2: Transcode Rules (Codec, Profile, Thresholds)
// 3: Hardware & Directories
// 4: Review
// Let's stick to 4 logical steps but grouped differently?
// User requested "username and password be the first step".
// Step 1: Account
// Step 2: Codec & Quality (New)
// Step 3: Performance & Directories (Merged old 2/3)
// Step 4: Review
return (
<div className="bg-helios-surface border border-helios-line/60 rounded-2xl overflow-hidden shadow-2xl max-w-2xl w-full mx-auto">
@@ -103,7 +148,7 @@ export default function SetupWizard() {
<motion.div
className="bg-helios-solar h-full"
initial={{ width: 0 }}
animate={{ width: `${(step / 4) * 100}%` }}
animate={{ width: `${(step / 5) * 100}%` }}
/>
</div>
@@ -126,10 +171,143 @@ export default function SetupWizard() {
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
<User size={20} className="text-helios-solar" />
Create Account
</h2>
<p className="text-sm text-helios-slate">
Secure your Alchemist dashboard with a username and password.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-helios-slate mb-2">Username</label>
<div className="relative">
<User className="absolute left-3 top-2.5 text-helios-slate opacity-50" size={18} />
<input
type="text"
value={config.username}
onChange={(e) => setConfig({ ...config, username: e.target.value })}
className="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-10 pr-3 py-2 text-helios-ink focus:border-helios-solar outline-none"
placeholder="admin"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-helios-slate mb-2">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 text-helios-slate opacity-50" size={18} />
<input
type="password"
value={config.password}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
className="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-10 pr-3 py-2 text-helios-ink focus:border-helios-solar outline-none"
placeholder="••••••••"
/>
</div>
</div>
</div>
</motion.div>
)}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
<Video size={20} className="text-helios-solar" />
Transcoding Preferences
</h2>
<div className="space-y-6">
{/* Codec Selector */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Output Codec</label>
<div className="relative group">
<Info size={14} className="text-helios-slate cursor-help hover:text-helios-solar transition-colors" />
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 w-48 p-2 bg-helios-ink text-helios-main text-[10px] rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Determines the video format for processed files. AV1 provides better compression but requires newer hardware.
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setConfig({ ...config, output_codec: "av1" })}
className={clsx(
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all relative group",
config.output_codec === "av1"
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm"
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft/80"
)}
>
<span className="font-bold">AV1</span>
<span className="text-xs text-center opacity-70">Best compression. Requires Arc/RTX 4000+.</span>
{/* Hover Tooltip */}
<div className="absolute inset-0 bg-helios-ink/90 text-helios-main p-4 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs text-center">
Excellent efficiency. Ideal for Intel Arc GPUs.
</div>
</button>
<button
onClick={() => setConfig({ ...config, output_codec: "hevc" })}
className={clsx(
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all relative group",
config.output_codec === "hevc"
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm"
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft/80"
)}
>
<span className="font-bold">HEVC</span>
<span className="text-xs text-center opacity-70">Broad compatibility. Fast encoding.</span>
{/* Hover Tooltip */}
<div className="absolute inset-0 bg-helios-ink/90 text-helios-main p-4 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs text-center">
Standard H.265. Compatible with most modern devices.
</div>
</button>
</div>
</div>
{/* Quality Profile */}
<div className="space-y-3">
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Quality Profile</label>
<div className="grid grid-cols-3 gap-3">
{(["speed", "balanced", "quality"] as const).map((profile) => (
<button
key={profile}
onClick={() => setConfig({ ...config, quality_profile: profile })}
className={clsx(
"p-3 rounded-lg border capitalize transition-all",
config.quality_profile === profile
? "bg-helios-solar/10 border-helios-solar text-helios-ink font-bold"
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate"
)}
>
{profile}
</button>
))}
</div>
</div>
</div>
</motion.div>
)}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
<Settings size={20} className="text-helios-solar" />
Transcoding Rules
Processing Rules
</h2>
<div className="space-y-4">
@@ -146,7 +324,7 @@ export default function SetupWizard() {
onChange={(e) => setConfig({ ...config, size_reduction_threshold: parseFloat(e.target.value) })}
className="w-full accent-helios-solar h-2 bg-helios-surface-soft rounded-lg appearance-none cursor-pointer"
/>
<p className="text-xs text-helios-slate mt-1">Files must shrink by at least this amount to be kept.</p>
<p className="text-xs text-helios-slate mt-1">Files will be reverted if they don't shrink by this much.</p>
</div>
<div>
@@ -164,9 +342,9 @@ export default function SetupWizard() {
</motion.div>
)}
{step === 2 && (
{step === 4 && (
<motion.div
key="step2"
key="step4"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
@@ -174,7 +352,7 @@ export default function SetupWizard() {
>
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
<Cpu size={20} className="text-helios-solar" />
Hardware & Performance
Environment
</h2>
<div className="space-y-4">
@@ -192,80 +370,40 @@ export default function SetupWizard() {
/>
</div>
<div className="flex items-center gap-4 p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40">
<div className="flex-1">
<span className="block text-sm font-medium text-helios-ink">Allow CPU Encoding</span>
<span className="text-xs text-helios-slate">Fallback to software encoding if GPU is unavailable. Warning: Slow.</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<div className="space-y-4 pt-4 border-t border-helios-line/20">
<label className="block text-sm font-medium text-helios-slate mb-2">Media Directories</label>
<div className="flex gap-2">
<input
type="checkbox"
checked={config.allow_cpu_encoding}
onChange={(e) => setConfig({ ...config, allow_cpu_encoding: e.target.checked })}
className="sr-only peer"
type="text"
placeholder="/path/to/media"
value={dirInput}
onChange={(e) => setDirInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addDirectory()}
className="flex-1 bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none font-mono text-sm"
/>
<div className="w-11 h-6 bg-helios-surface rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-helios-solar"></div>
</label>
<button
onClick={addDirectory}
className="px-4 py-2 bg-helios-surface-soft hover:bg-helios-surface-soft/80 text-helios-ink rounded-lg font-medium transition-colors"
>
Add
</button>
</div>
<div className="space-y-2 max-h-32 overflow-y-auto">
{config.directories.map((dir, i) => (
<div key={i} className="flex items-center justify-between p-2 rounded-lg bg-helios-surface-soft/50 border border-helios-line/20 group">
<span className="font-mono text-xs text-helios-ink truncate">{dir}</span>
<button onClick={() => removeDirectory(dir)} className="text-status-error hover:text-status-error/80">×</button>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
{step === 3 && (
{step === 5 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
<FolderOpen size={20} className="text-helios-solar" />
Media Directories
</h2>
<div className="space-y-4">
<div className="flex gap-2">
<input
type="text"
placeholder="/path/to/media"
value={dirInput}
onChange={(e) => setDirInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addDirectory()}
className="flex-1 bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none font-mono text-sm"
/>
<button
onClick={addDirectory}
className="px-4 py-2 bg-helios-surface-soft hover:bg-helios-surface-soft/80 text-helios-ink rounded-lg font-medium transition-colors"
>
Add
</button>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{config.directories.length === 0 && (
<p className="text-center text-helios-slate italic py-4">No directories added yet</p>
)}
{config.directories.map((dir, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-helios-surface-soft/50 border border-helios-line/20 group">
<span className="font-mono text-sm text-helios-ink truncate">{dir}</span>
<button
onClick={() => removeDirectory(dir)}
className="opacity-0 group-hover:opacity-100 p-1 text-status-error hover:bg-status-error/10 rounded transition-all"
>
<span className="sr-only">Remove</span>
×
</button>
</div>
))}
</div>
</div>
</motion.div>
)}
{step === 4 && (
<motion.div
key="step4"
key="step5"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
@@ -279,28 +417,28 @@ export default function SetupWizard() {
<div className="space-y-4 text-sm text-helios-slate">
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 space-y-3">
<div className="flex justify-between">
<span>Reduction Threshold</span>
<span className="text-helios-ink font-mono">{Math.round(config.size_reduction_threshold * 100)}%</span>
<span>User</span>
<span className="text-helios-ink font-bold">{config.username}</span>
</div>
<div className="flex justify-between">
<span>Min File Size</span>
<span className="text-helios-ink font-mono">{config.min_file_size_mb} MB</span>
<span>Codec</span>
<span className="text-helios-ink font-mono uppercase">{config.output_codec}</span>
</div>
<div className="flex justify-between">
<span>Profile</span>
<span className="text-helios-ink capitalize">{config.quality_profile}</span>
</div>
<div className="flex justify-between">
<span>Concurrency</span>
<span className="text-helios-ink font-mono">{config.concurrent_jobs} jobs</span>
</div>
<div className="flex justify-between">
<span>CPU Encoding</span>
<span className={config.allow_cpu_encoding ? "text-status-warning" : "text-helios-ink"}>
{config.allow_cpu_encoding ? "Enabled" : "Disabled"}
</span>
</div>
<div className="pt-2 border-t border-helios-line/20">
<span className="block mb-1">Directories:</span>
<ul className="list-disc list-inside font-mono text-xs text-helios-ink">
{config.directories.map(d => <li key={d}>{d}</li>)}
</ul>
<span className="block mb-1">Directories ({config.directories.length}):</span>
<div className="flex flex-wrap gap-1">
{config.directories.map(d => (
<span key={d} className="px-1.5 py-0.5 bg-helios-surface border border-helios-line/30 rounded text-xs font-mono">{d}</span>
))}
</div>
</div>
</div>
@@ -327,7 +465,7 @@ export default function SetupWizard() {
Back
</button>
{step < 4 ? (
{step < 5 ? (
<button
onClick={handleNext}
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-helios-solar text-helios-main font-semibold hover:opacity-90 transition-opacity"
@@ -338,11 +476,11 @@ export default function SetupWizard() {
) : (
<button
onClick={handleSubmit}
disabled={loading}
disabled={loading || success}
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-status-success text-white font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{loading ? "Saving..." : "Save Configuration"}
{!loading && <Save size={18} />}
{loading ? "Activating..." : success ? "Redirecting..." : "Launch Alchemist"}
{!loading && !success && <Save size={18} />}
</button>
)}
</div>

View File

@@ -52,9 +52,7 @@ const navItems = [
}
</nav>
<div
class="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40"
>
<div class="mt-auto">
<SystemStatus client:load />
</div>
</aside>

View File

@@ -1,14 +1,25 @@
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Activity, X, Zap, CheckCircle2, AlertTriangle, Database } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface Stats {
active: number;
concurrent_limit: number;
completed: number;
failed: number;
total: number;
}
export default function SystemStatus() {
const [stats, setStats] = useState<Stats | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// Initial fetch
useEffect(() => {
const fetchStats = async () => {
try {
@@ -17,7 +28,10 @@ export default function SystemStatus() {
const data = await res.json();
setStats({
active: data.active || 0,
concurrent_limit: data.concurrent_limit || 1
concurrent_limit: data.concurrent_limit || 1,
completed: data.completed || 0,
failed: data.failed || 0,
total: data.total || 0,
});
}
} catch (e) {
@@ -26,7 +40,6 @@ export default function SystemStatus() {
};
fetchStats();
// Poll every 5 seconds
const interval = setInterval(fetchStats, 5000);
return () => clearInterval(interval);
}, []);
@@ -34,7 +47,7 @@ export default function SystemStatus() {
if (!stats) {
return (
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">System Status</span>
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Loading Status...</span>
<span className="w-2 h-2 rounded-full bg-helios-slate/50 animate-pulse"></span>
</div>
);
@@ -45,55 +58,170 @@ export default function SystemStatus() {
const percentage = Math.min((stats.active / stats.concurrent_limit) * 100, 100);
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
</span>
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Engine Status</span>
</div>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md ${isActive ? 'bg-status-success/10 text-status-success' : 'bg-helios-slate/10 text-helios-slate'}`}>
{isActive ? 'ONLINE' : 'IDLE'}
</span>
</div>
<div className="space-y-1.5">
<div className="flex items-end justify-between text-helios-ink">
<span className="text-xs font-medium opacity-80">Active Jobs</span>
<div className="flex items-baseline gap-0.5">
<span className={`text-lg font-bold ${isFull ? 'text-status-warning' : 'text-helios-solar'}`}>
{stats.active}
</span>
<span className="text-xs text-helios-slate">
/ {stats.concurrent_limit}
<>
{/* Compact Sidebar View */}
<motion.div
layoutId="status-container"
onClick={() => setIsExpanded(true)}
className="flex flex-col gap-3 cursor-pointer group p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 shadow-sm"
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
</span>
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider group-hover:text-helios-inc transition-colors">Engine Status</span>
</div>
<motion.div layoutId="status-badge" className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md ${isActive ? 'bg-status-success/10 text-status-success' : 'bg-helios-slate/10 text-helios-slate'}`}>
{isActive ? 'ONLINE' : 'IDLE'}
</motion.div>
</div>
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
<div
className={`h-full transition-all duration-700 ease-out rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
style={{ width: `${percentage}%` }}
/>
{/* Tick marks for job slots */}
<div className="absolute inset-0 flex justify-between px-[1px]">
<div className="space-y-1.5">
<div className="flex items-end justify-between text-helios-ink">
<span className="text-xs font-medium opacity-80">Active Jobs</span>
<div className="flex items-baseline gap-0.5">
<motion.span layoutId="active-count" className={`text-lg font-bold ${isFull ? 'text-status-warning' : 'text-helios-solar'}`}>
{stats.active}
</motion.span>
<span className="text-xs text-helios-slate">
/ {stats.concurrent_limit}
</span>
</div>
</div>
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
<motion.div
layoutId="progress-bar"
className={`h-full transition-all duration-700 ease-out rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
style={{ width: `${percentage}%` }}
/>
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
<div key={i} className="w-[1px] h-full bg-helios-main/20" style={{ left: `${((i + 1) / stats.concurrent_limit) * 100}%` }} />
<div key={i} className="absolute top-0 bottom-0 w-[1px] bg-helios-main/20" style={{ left: `${((i + 1) / stats.concurrent_limit) * 100}%` }} />
))}
</div>
</div>
</div>
</motion.div>
{isActive && (
<div className="text-[10px] text-helios-slate flex items-center gap-1.5 animate-pulse">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
<span>Processing media...</span>
</div>
)}
</div>
{/* Expanded Modal View */}
<AnimatePresence>
{isExpanded && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsExpanded(false)}
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-md flex items-center justify-center p-4"
>
{/* Modal Card */}
<motion.div
layoutId="status-container"
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative"
onClick={(e) => e.stopPropagation()}
>
{/* Header Background Effect */}
<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-8">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-helios-surface-soft rounded-xl border border-helios-line/20 shadow-sm">
<Activity className="text-helios-solar" size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-helios-ink tracking-tight">System Status</h2>
<div className="flex items-center gap-2 mt-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
<span className="text-xs font-medium text-helios-slate uppercase tracking-wide">
{isActive ? 'Engine Running' : 'Engine Idle'}
</span>
</div>
</div>
</div>
<button
onClick={() => setIsExpanded(false)}
className="p-2 hover:bg-helios-surface-soft rounded-full text-helios-slate hover:text-helios-ink transition-colors"
>
<X size={20} />
</button>
</div>
{/* Main Metrics Grid */}
<div className="grid 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">
<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>
<div className="flex items-baseline justify-center gap-1 mt-1">
<motion.span layoutId="active-count" className="text-3xl font-bold text-helios-ink">
{stats.active}
</motion.span>
<span className="text-sm font-medium text-helios-slate opacity-60">
/ {stats.concurrent_limit}
</span>
</div>
</div>
{/* Big Progress Bar */}
<div className="w-full h-2 bg-helios-line/10 rounded-full mt-2 overflow-hidden relative">
<motion.div
layoutId="progress-bar"
className={`h-full rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
style={{ width: `${percentage}%` }}
/>
</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">
<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>
<span className="text-3xl font-bold text-helios-ink mt-1">
{stats.total}
</span>
</div>
<div className="text-[10px] text-helios-slate mt-1 px-2 py-0.5 bg-helios-line/10 rounded-md">
Lifetime
</div>
</div>
</div>
{/* Secondary Metrics Row */}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 rounded-xl bg-status-success/5 border border-status-success/10 flex flex-col items-center justify-center text-center">
<CheckCircle2 size={16} className="text-status-success mb-1" />
<span className="text-lg font-bold text-helios-ink">{stats.completed}</span>
<span className="text-[10px] font-bold text-status-success uppercase tracking-wider">Completed</span>
</div>
<div className="p-3 rounded-xl bg-status-error/5 border border-status-error/10 flex flex-col items-center justify-center text-center">
<AlertTriangle size={16} className="text-status-error mb-1" />
<span className="text-lg font-bold text-helios-ink">{stats.failed}</span>
<span className="text-[10px] font-bold text-status-error uppercase tracking-wider">Failed</span>
</div>
<div className="p-3 rounded-xl bg-helios-surface-soft border border-helios-line/10 flex flex-col items-center justify-center text-center opacity-60">
<Activity size={16} className="text-helios-slate mb-1" />
<span className="text-lg font-bold text-helios-ink">--</span>
<span className="text-[10px] font-bold text-helios-slate uppercase tracking-wider">Est. Time</span>
</div>
</div>
<div className="mt-6 pt-6 border-t border-helios-line/10 text-center">
<p className="text-xs text-helios-slate/60">
System metrics update automatically every 5 seconds.
</p>
</div>
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}

View File

@@ -32,11 +32,38 @@ const { title } = Astro.props;
}
}
function checkAuth() {
const isAuthPage = window.location.pathname.startsWith('/login') || window.location.pathname.startsWith('/setup');
const token = localStorage.getItem('alchemist_token');
if (!isAuthPage && !token) {
// Check if setup is needed
fetch('/api/setup/status')
.then(res => res.json())
.then(data => {
if (data.setup_required) {
window.location.href = '/setup';
} else {
window.location.href = '/login';
}
})
.catch(() => {
// If API fails (server down?), maybe just stay? Or go to login?
// Safest to go to login
window.location.href = '/login';
});
}
}
// Run on initial load
initTheme();
checkAuth();
// Run on view transition navigation
document.addEventListener('astro:after-swap', initTheme);
document.addEventListener('astro:after-swap', () => {
initTheme();
checkAuth();
});
</script>
<script>
// Check setup status on load

94
web/src/pages/login.astro Normal file
View File

@@ -0,0 +1,94 @@
---
import Layout from "../layouts/Layout.astro";
import { User, Lock, ArrowRight, AlertTriangle } from "lucide-react";
---
<Layout title="Alchemist | Login">
<div class="min-h-screen bg-helios-main flex items-center justify-center p-4">
<div class="w-full max-w-md bg-helios-surface border border-helios-line/60 rounded-2xl shadow-2xl overflow-hidden p-8">
<div class="flex flex-col items-center mb-8">
<div class="w-12 h-12 rounded-xl bg-helios-solar text-helios-main flex items-center justify-center font-bold text-2xl mb-4">
A
</div>
<h1 class="text-2xl font-bold text-helios-ink">Welcome Back</h1>
<p class="text-helios-slate">Sign in to your dashboard</p>
</div>
<form id="login-form" class="space-y-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-helios-slate mb-2">Username</label>
<div class="relative">
<input
type="text"
name="username"
required
class="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-4 pr-3 py-3 text-helios-ink focus:border-helios-solar outline-none transition-colors"
placeholder="Enter your username"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-helios-slate mb-2">Password</label>
<div class="relative">
<input
type="password"
name="password"
required
class="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-4 pr-3 py-3 text-helios-ink focus:border-helios-solar outline-none transition-colors"
placeholder="••••••••"
/>
</div>
</div>
</div>
<div id="error-msg" class="hidden p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm flex items-center gap-2">
<span>Invalid credentials</span>
</div>
<button
type="submit"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-helios-solar text-helios-main font-bold hover:opacity-90 transition-opacity"
>
Sign In
<ArrowRight size={18} />
</button>
</form>
</div>
</div>
</Layout>
<script>
const form = document.getElementById('login-form') as HTMLFormElement;
const errorMsg = document.getElementById('error-msg');
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData);
// Reset error
errorMsg?.classList.add('hidden');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
const { token } = await res.json();
localStorage.setItem('alchemist_token', token);
window.location.href = '/';
} else {
errorMsg?.classList.remove('hidden');
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Invalid username or password";
}
} catch (err) {
console.error(err);
errorMsg?.classList.remove('hidden');
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Connection failed";
}
});
</script>