use super::state::{Command, SharedState, is_terminal};
use crate::config::AppConfig;
use crate::queue::{JobStatus, collect_video_files, is_video_file};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
pub fn status(shared: &SharedState) -> Value {
let state = shared.lock().unwrap();
let queue = &state.queue.state;
let current = queue
.jobs
.get(queue.current_job_index)
.filter(|job| {
matches!(
job.status,
JobStatus::Encoding { .. } | JobStatus::Verifying
)
})
.map(|job| {
json!({
"id": state.queue.ids().get(queue.current_job_index),
"filename": job.filename(),
"status": status_json(&job.status),
})
});
let ready = queue
.jobs
.iter()
.filter(|j| matches!(j.status, JobStatus::Ready))
.count();
let (saved_bytes, saved_human) = queue.total_space_saved();
json!({
"version": env!("CARGO_PKG_VERSION"),
"encoder": state.config.encoder.display_name(),
"encoding_active": state.encoding_active,
"paused": state.paused,
"uptime_secs": state.started_at.elapsed().as_secs(),
"overall_progress": queue.overall_progress(),
"eta_secs": queue.estimated_time_remaining().map(|d| d.as_secs()),
"elapsed_secs": queue.elapsed_time().map(|d| d.as_secs()),
"counts": {
"total": queue.jobs.len(),
"ready": ready,
"converted": queue.converted_count,
"skipped": queue.skipped_count,
"errors": queue.error_count,
},
"total_space_saved": { "bytes": saved_bytes, "human": saved_human },
"current": current,
})
}
pub fn queue(shared: &SharedState) -> Value {
let state = shared.lock().unwrap();
let jobs: Vec<Value> = state
.queue
.jobs_with_ids()
.map(|(id, job)| {
let saved_percent = job.size_reduction().map(|(_, percent)| percent);
json!({
"id": id,
"filename": job.filename(),
"path": job.path.to_string_lossy(),
"output_path": job.output_path.as_ref().map(|p| p.to_string_lossy()),
"status": status_json(&job.status),
"resolution": job.resolution_string(),
"hdr": job.hdr_string(),
"remux_only": job.remux_only,
"source_size": job.source_size,
"output_size": job.output_size,
"saved_percent": saved_percent,
"source_deleted": job.source_deleted,
})
})
.collect();
json!({ "jobs": jobs })
}
pub fn queue_add(cmd_tx: &Sender<Command>, body: &Value) -> (u16, Value) {
let Some(path) = body.get("path").and_then(Value::as_str) else {
return (400, json!({"error": "missing 'path'"}));
};
let mode = body.get("mode").and_then(Value::as_str).unwrap_or("file");
let path = PathBuf::from(path);
if !path.exists() {
return (400, json!({"error": "path does not exist"}));
}
let mut files: Vec<PathBuf> = Vec::new();
match mode {
"file" => {
if !path.is_file() || !is_video_file(&path) {
return (400, json!({"error": "not a video file"}));
}
files.push(path);
}
"folder" => {
if !path.is_dir() {
return (400, json!({"error": "not a directory"}));
}
if let Ok(entries) = std::fs::read_dir(&path) {
files.extend(
entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| is_video_file(p)),
);
}
}
"folder_recursive" => {
if !path.is_dir() {
return (400, json!({"error": "not a directory"}));
}
collect_video_files(&path, &mut files);
}
other => return (400, json!({"error": format!("unknown mode '{other}'")})),
}
if files.is_empty() {
return (400, json!({"error": "no video files found"}));
}
files.sort();
let added = files.len();
if cmd_tx.send(Command::AddPaths(files)).is_err() {
return (500, json!({"error": "daemon is shutting down"}));
}
(200, json!({"added": added}))
}
pub fn queue_remove(shared: &SharedState, cmd_tx: &Sender<Command>, body: &Value) -> (u16, Value) {
let Some(id) = body.get("id").and_then(Value::as_u64) else {
return (400, json!({"error": "missing 'id'"}));
};
{
let state = shared.lock().unwrap();
if state.queue.job_by_id(id).is_none() {
return (404, json!({"error": "unknown job id"}));
}
if state.in_active_session(id) {
return (409, json!({"error": "job is part of the active encode session"}));
}
}
let _ = cmd_tx.send(Command::RemoveJob(id));
(200, json!({"ok": true}))
}
pub fn queue_pause(cmd_tx: &Sender<Command>, body: &Value) -> (u16, Value) {
let Some(paused) = body.get("paused").and_then(Value::as_bool) else {
return (400, json!({"error": "missing 'paused'"}));
};
let _ = cmd_tx.send(Command::SetPaused(paused));
(200, json!({"paused": paused}))
}
pub fn queue_cancel(cmd_tx: &Sender<Command>) -> (u16, Value) {
let _ = cmd_tx.send(Command::CancelEncoding);
(200, json!({"ok": true}))
}
pub fn queue_clear_finished(shared: &SharedState, cmd_tx: &Sender<Command>) -> (u16, Value) {
let removed = {
let state = shared.lock().unwrap();
state
.queue
.jobs_with_ids()
.filter(|(_, job)| is_terminal(&job.status))
.count()
};
let _ = cmd_tx.send(Command::ClearFinished);
(200, json!({"removed": removed}))
}
pub fn fs_browse(path: &str, show_hidden: bool) -> (u16, Value) {
let dir = if path.is_empty() {
std::env::var_os("HOME").map_or_else(|| PathBuf::from("/"), PathBuf::from)
} else {
PathBuf::from(path)
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return (400, json!({"error": "cannot read directory"}));
};
let mut dirs: Vec<Value> = Vec::new();
let mut files: Vec<Value> = Vec::new();
for entry in entries.filter_map(Result::ok) {
let name = entry.file_name().to_string_lossy().into_owned();
if !show_hidden && name.starts_with('.') {
continue;
}
let entry_path = entry.path();
if entry_path.is_dir() {
if !entry_path.is_symlink() {
dirs.push(json!({"name": name}));
}
} else if entry_path.is_file() {
let size = entry.metadata().ok().map(|m| m.len());
files.push(json!({
"name": name,
"size": size,
"is_video": is_video_file(&entry_path),
}));
}
}
let by_name = |a: &Value, b: &Value| {
let a = a["name"].as_str().unwrap_or("").to_lowercase();
let b = b["name"].as_str().unwrap_or("").to_lowercase();
a.cmp(&b)
};
dirs.sort_by(by_name);
files.sort_by(by_name);
(
200,
json!({
"path": dir.to_string_lossy(),
"parent": dir.parent().map(Path::to_string_lossy),
"dirs": dirs,
"files": files,
}),
)
}
pub fn settings_get(shared: &SharedState) -> Value {
let state = shared.lock().unwrap();
serde_json::to_value(&state.config).unwrap_or_else(|_| json!({}))
}
pub fn settings_post(cmd_tx: &Sender<Command>, body: &Value) -> (u16, Value) {
let mut config: AppConfig = match serde_json::from_value(body.clone()) {
Ok(c) => c,
Err(e) => return (400, json!({"error": format!("invalid settings: {e}")})),
};
config.sanitize();
if let Err(e) = config.save() {
return (500, json!({"error": format!("failed to save: {e}")}));
}
let saved = serde_json::to_value(&config).unwrap_or_else(|_| json!({}));
let _ = cmd_tx.send(Command::UpdateConfig(Box::new(config)));
(200, saved)
}
fn status_json(status: &JobStatus) -> Value {
match status {
JobStatus::Pending => json!({"kind": "pending"}),
JobStatus::Analyzing => json!({"kind": "analyzing"}),
JobStatus::AwaitingConfig => json!({"kind": "awaiting_config"}),
JobStatus::Ready => json!({"kind": "ready"}),
JobStatus::Encoding { progress } => json!({"kind": "encoding", "progress": progress}),
JobStatus::Verifying => json!({"kind": "verifying"}),
JobStatus::Done => json!({"kind": "done"}),
JobStatus::DoneWithVmaf { score } => json!({"kind": "done_vmaf", "vmaf": score}),
JobStatus::DoneVmafFailed { reason } => {
json!({"kind": "done_vmaf_failed", "reason": reason})
}
JobStatus::Skipped { reason } => json!({"kind": "skipped", "reason": reason}),
JobStatus::Error { message } => json!({"kind": "error", "message": message}),
JobStatus::QualityWarning { vmaf, threshold } => {
json!({"kind": "quality_warning", "vmaf": vmaf, "threshold": threshold})
}
}
}