use std::collections::HashMap; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use log::{debug, error, info, warn}; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use strsim::jaro_winkler; use crate::audio::{probe_duration_secs, AudioHandle}; use crate::paths::song_path; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Song { pub id: i64, pub name: String, pub path: String, /// Optional playback duration in seconds (filled in on download probe or /// before first play). `None` until the daemon has had a chance to look /// it up. #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_secs: Option, } /// One row of the result set returned by `/search/yt`. Built directly from /// `yt-dlp -j ytsearchN:QUERY` JSON lines, so field names match what the /// GUI and the CLI consume. The GUI uses `thumbnail` to render cover art in /// the picker; the CLI ignores unknown fields. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct YtCandidate { pub id: String, pub title: String, /// YouTube channel / uploader / artist — whichever yt-dlp fills in. The /// CLI displays this verbatim; the daemon never tries to canonicalise it. pub uploader: String, /// yt-dlp's reported duration in seconds. `0.0` when unknown (livestreams, /// shorts, etc.) so the CLI formatter still produces a sensible string. pub duration_secs: f64, pub url: String, /// yt-dlp's reported thumbnail URL (highest-resolution pick if `thumbnails` /// is a list of sizes). `None` when yt-dlp didn't include any. #[serde(default, skip_serializing_if = "Option::is_none")] pub thumbnail: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Playlist { pub id: i64, pub name: String, pub songs: Vec, } /// Repeat behaviour: `Off` is the default; `One` re-queues the same track when /// it ends; `All` reloads the latest snapshot of songs once the queue empties. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RepeatMode { Off, One, All, } impl Default for RepeatMode { fn default() -> Self { RepeatMode::Off } } impl RepeatMode { pub fn parse(s: &str) -> Option { match s.trim().to_lowercase().as_str() { "off" | "0" | "false" => Some(RepeatMode::Off), "one" | "1" | "single" => Some(RepeatMode::One), "all" | "loop" => Some(RepeatMode::All), _ => None, } } pub fn as_str(&self) -> &'static str { match self { RepeatMode::Off => "off", RepeatMode::One => "one", RepeatMode::All => "all", } } } /// One in-flight or completed yt-dlp job spawned by /init or /init/batch. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DownloadJob { pub id: i64, pub name: String, pub status: String, // "queued" | "running" | "done" | "failed" pub song_id: Option, pub error: Option, pub started_at: i64, pub finished_at: Option, /// Source URL or `ytsearch1:NAME` string the worker will pass to yt-dlp. /// Useful for debugging and for `/downloads` output once a picker is /// involved. `None` for legacy `/init` callers that pass only a name. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, } /// The in-memory mirror of the SQLite-backed state. Wrapped in a Mutex by /// the daemon so HTTP request handlers and the playback loop can share it. pub struct DaemonState { pub conn: Mutex, /// Songs pending play, in order. The first element is the next to play. pub queue: Vec, /// Currently playing song, if any. pub current: Option, /// `Send + Sync` handle to the audio thread (which owns rodio's /// `OutputStream`, a `!Send` platform type). We never touch rodio /// directly from HTTP or the playback loop — we enqueue commands. pub audio_handle: Arc, /// User-selected repeat mode. pub repeat_mode: RepeatMode, /// Snapshot taken when entering repeat-all mode. When the queue drains in /// this mode we restore this snapshot so playback loops without the user /// having to refill the queue manually. pub cycle_snapshot: Vec, /// `true` once the queue row for `current` has been popped at start of /// play. Reset to `false` whenever we move on (success, failure, skip, /// or natural end). Prevents double-removal of the queue row while the /// audio thread transitions from "pending" to "playing". pub popped_for_current: bool, /// In-flight and recently completed download jobs. pub download_jobs: Mutex>, pub next_job_id: AtomicI64, /// Fuzzy match threshold; configurable so users can tune strictness. pub fuzzy_threshold: f64, /// 0 means unlimited. pub max_queue_size: usize, /// Default number of yt-dlp candidates to return from `/search/yt` when /// the caller doesn't specify `?limit=N`. Used by the CLI's interactive /// picker. pub search_count: usize, } impl DaemonState { pub fn new( conn: Connection, fuzzy_threshold: f64, max_queue_size: usize, repeat_mode: RepeatMode, audio_handle: Arc, search_count: usize, ) -> Self { let queue = Self::load_queue_from_db(&conn); info!("Restored queue with {} song(s) from DB", queue.len()); Self { conn: Mutex::new(conn), queue, current: None, audio_handle, repeat_mode, cycle_snapshot: Vec::new(), popped_for_current: false, download_jobs: Mutex::new(HashMap::new()), next_job_id: AtomicI64::new(1), fuzzy_threshold, max_queue_size, search_count, } } fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> { match self.conn.lock() { Ok(g) => g, Err(p) => { warn!("DB mutex was poisoned, recovering"); p.into_inner() } } } // ------------------------------------------------------------------ // DB -> memory helpers // ------------------------------------------------------------------ fn load_queue_from_db(conn: &Connection) -> Vec { let mut stmt = match conn.prepare( "SELECT s.id, s.name, s.path, s.duration_secs FROM queue q JOIN songs s ON s.id = q.song_id ORDER BY q.position ASC", ) { Ok(s) => s, Err(e) => { error!("Failed to prepare queue load: {e}"); return Vec::new(); } }; stmt.query_map([], |row| { Ok(Song { id: row.get(0)?, name: row.get(1)?, path: row.get(2)?, duration_secs: row.get::<_, Option>(3).ok().flatten(), }) }) .map(|it| it.filter_map(Result::ok).collect()) .unwrap_or_default() } /// All songs sorted by name (used internally and by `/songs`). pub fn all_songs(&self) -> Vec { Self::list_songs(&self.lock_conn(), None) } /// Used by `/songs?q=...` to filter without a separate search round-trip. pub fn list_songs_filtered(&self, q: Option<&str>) -> Vec { let pattern = q.map(|q| { // Escape `%`, `_`, and `\` so users can search for those literally. q.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") }); Self::list_songs(&self.lock_conn(), pattern.as_deref()) } fn list_songs(conn: &Connection, filter: Option<&str>) -> Vec { let (sql, has_filter) = match filter { Some(_) => ( "SELECT id, name, path, duration_secs FROM songs \ WHERE name LIKE ? ESCAPE '\\' ORDER BY name", true, ), None => ("SELECT id, name, path, duration_secs FROM songs ORDER BY name", false), }; let mut stmt = match conn.prepare(sql) { Ok(s) => s, Err(e) => { error!("Failed to list songs: {e}"); return Vec::new(); } }; let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result { Ok(Song { id: row.get(0)?, name: row.get(1)?, path: row.get(2)?, duration_secs: row.get::<_, Option>(3).ok().flatten(), }) }; let mapped = if has_filter { stmt.query_map(params![format!("%{}%", filter.unwrap_or(""))], map_row) } else { stmt.query_map([], map_row) }; mapped .map(|it| it.filter_map(Result::ok).collect()) .unwrap_or_default() } fn insert_song(conn: &Connection, name: &str, path: &str) -> rusqlite::Result { conn.execute( "INSERT OR IGNORE INTO songs (name, path) VALUES (?1, ?2)", params![name, path], )?; let id: i64 = conn.query_row( "SELECT id FROM songs WHERE name = ?1", params![name], |row| row.get(0), )?; Ok(id) } fn update_duration_secs( conn: &Connection, song_id: i64, duration_secs: f64, ) -> rusqlite::Result<()> { conn.execute( "UPDATE songs SET duration_secs = ?1 WHERE id = ?2", params![duration_secs, song_id], )?; Ok(()) } fn queue_append_db(conn: &Connection, song_id: i64) -> rusqlite::Result<()> { let next: i64 = conn .query_row( "SELECT COALESCE(MAX(position), 0) + 1 FROM queue", [], |row| row.get(0), ) .unwrap_or(1); conn.execute( "INSERT INTO queue (position, song_id) VALUES (?1, ?2)", params![next, song_id], )?; Ok(()) } fn queue_clear_db(conn: &Connection) -> rusqlite::Result<()> { conn.execute("DELETE FROM queue", [])?; Ok(()) } fn playlist_append_db( conn: &Connection, playlist_id: i64, song_id: i64, ) -> rusqlite::Result<()> { let next: i64 = conn .query_row( "SELECT COALESCE(MAX(position), 0) + 1 FROM playlist_songs WHERE playlist_id = ?1", params![playlist_id], |row| row.get(0), ) .unwrap_or(1); conn.execute( "INSERT INTO playlist_songs (playlist_id, position, song_id) VALUES (?1, ?2, ?3)", params![playlist_id, next, song_id], )?; Ok(()) } fn write_history( conn: &Connection, song_id: i64, played_at: i64, duration_secs_played: i64, ) -> rusqlite::Result<()> { conn.execute( "INSERT INTO history (song_id, played_at, duration_secs_played) VALUES (?1, ?2, ?3)", params![song_id, played_at, duration_secs_played], )?; Ok(()) } // ------------------------------------------------------------------ // Public API used by both HTTP server and playback loop // ------------------------------------------------------------------ /// Best single match. Exact (case-insensitive) wins, then Jaro-Winkler /// above threshold. pub fn search(&self, query: &str) -> Option { let q = query.to_lowercase(); let songs = self.all_songs(); if let Some(s) = songs.iter().find(|s| s.name.to_lowercase() == q) { debug!("Exact DB match: {} (id={})", s.name, s.id); return Some(s.clone()); } let best = songs .iter() .map(|s| (s.clone(), jaro_winkler(&s.name.to_lowercase(), &q))) .filter(|(_, score)| *score > self.fuzzy_threshold) .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); if let Some((song, score)) = best { debug!("Fuzzy DB match: {} (score={:.3})", song.name, score); return Some(song); } warn!("No DB match for query: {query}"); None } /// All matches above the fuzzy threshold, sorted by score descending. pub fn search_all(&self, query: &str) -> Vec { let q = query.to_lowercase(); let songs = self.all_songs(); let mut scored: Vec<(Song, f64)> = Vec::new(); for s in songs.iter() { let s_lower = s.name.to_lowercase(); if s_lower == q { scored.retain(|x| x.0.id != s.id); scored.insert(0, (s.clone(), 1.0)); } else { let sc = jaro_winkler(&s_lower, &q); if sc > self.fuzzy_threshold { scored.push((s.clone(), sc)); } } } scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); scored.into_iter().map(|(s, _)| s).collect() } /// Insert a song at the front of the queue and start playing it. pub fn play(&mut self, query: &str) -> Result { let song = self .search(query) .ok_or_else(|| format!("song not found: {query}"))?; self.queue.insert(0, song.clone()); // DB writes scoped so the lock guard drops before we mutate // `cycle_snapshot` (which would otherwise re-borrow `self`). { let conn = self.lock_conn(); Self::queue_clear_db(&conn).map_err(|e| e.to_string())?; for s in &self.queue { Self::queue_append_db(&conn, s.id).map_err(|e| e.to_string())?; } } self.cycle_snapshot = self.queue.clone(); info!("Play: {} (id={})", song.name, song.id); Ok(song) } /// Append a song to the end of the queue. Honours `max_queue_size`. pub fn add(&mut self, query: &str) -> Result { if self.max_queue_size > 0 && self.queue.len() >= self.max_queue_size { return Err(format!( "queue full ({} of {})", self.queue.len(), self.max_queue_size )); } let song = self .search(query) .ok_or_else(|| format!("song not found: {query}"))?; self.queue.push(song.clone()); Self::queue_append_db(&self.lock_conn(), song.id).map_err(|e| e.to_string())?; info!("Added to queue: {} (id={})", song.name, song.id); Ok(song) } /// Delete a song from the DB, filesystem, and any queue entries. pub fn delete(&mut self, query: &str) -> Result { let song = self .search(query) .ok_or_else(|| format!("song not found: {query}"))?; { let conn = self.lock_conn(); conn.execute("DELETE FROM songs WHERE id = ?1", params![song.id]) .map_err(|e| e.to_string())?; } if let Err(e) = std::fs::remove_file(&song.path) { warn!("Could not delete file {}: {e}", song.path); } self.queue.retain(|s| s.id != song.id); if self .current .as_ref() .map(|c| c.id == song.id) .unwrap_or(false) { self.current = None; } info!("Deleted song {} (id={})", song.name, song.id); Ok(song) } /// In-place shuffle of the current queue. pub fn shuffle_queue(&mut self) -> Result<(), String> { if self.queue.len() < 2 { return Ok(()); } use rand::seq::SliceRandom; let mut rng = rand::thread_rng(); self.queue.shuffle(&mut rng); { let conn = self.lock_conn(); Self::queue_clear_db(&conn).map_err(|e| e.to_string())?; for s in &self.queue { Self::queue_append_db(&conn, s.id).map_err(|e| e.to_string())?; } } // Keep cycle_snapshot in sync so a later repeat-all still loops over // something sensible. self.cycle_snapshot = self.queue.clone(); info!("Queue shuffled ({} songs)", self.queue.len()); Ok(()) } // ------------------------------------------------------------------ // Downloads (synchronous helper used by worker thread) // ------------------------------------------------------------------ /// Synchronous yt-dlp + DB insert. Runs in a worker thread spawned by /// `init_async` and does NOT hold the daemon state mutex for the /// blocking subprocess call. /// /// `name` is the canonical display/storage name; the file on disk is /// derived from it via `paths::song_path`. `source` is the literal /// argument handed to `yt-dlp` — either an explicit URL the user picked /// from the search picker, or a `ytsearch1:NAME` expression for the /// legacy auto-pick path. /// /// Lives as an associated function (not an `&self` method) so callers /// literally cannot keep the state mutex locked while `yt-dlp` runs. /// Earlier revisions used `&self` here and were invoked as /// `state.lock().unwrap().init_sync(...)`, which held the mutex for /// the entire subprocess and froze every HTTP request (including the /// GUI's `/now-playing` and `/downloads` polls and any subsequent /// `/init` calls) until the download finished. pub fn init_sync( state: &Arc>, name: &str, source: &str, ) -> Result { let path = song_path(name); debug!("yt-dlp target path: {path} | source: {source}"); // yt-dlp runs lock-free — the state mutex is NOT held during the // blocking subprocess, so the HTTP server keeps serving other // routes (downloads polling, search, play, …) while a song // downloads in the background. let output = std::process::Command::new("yt-dlp") .args(["-x", "--audio-format", "mp3", "-o", &path, source]) .output(); match output { Ok(out) => { if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); error!("yt-dlp failed for {name}: {stderr}"); return Err(format!("yt-dlp failed: {stderr}")); } info!("yt-dlp finished for {name}"); } Err(e) => { error!("yt-dlp not available: {e}"); return Err(format!("yt-dlp unavailable: {e}")); } } if !std::path::Path::new(&path).exists() { return Err(format!("download finished but file missing: {path}")); } // Acquire the state lock only briefly, for the DB inserts. The // lock is never held across the `yt-dlp` call above. `.unwrap()` // matches the rest of `state.rs`/`daemon.rs` and a poisoning // here would mean the daemon is already unrecoverable. let daemon = state.lock().unwrap(); let conn = daemon.lock_conn(); let id = Self::insert_song(&conn, name, &path).map_err(|e| e.to_string())?; if let Some(secs) = probe_duration_secs(&path) { if let Err(e) = Self::update_duration_secs(&conn, id, secs) { warn!("Could not update duration_secs for {name}: {e}"); } info!("Registered song {name} (id={id}, {secs:.1}s) at {path}"); } else { info!("Registered song {name} (id={id}) at {path}"); } Ok(Song { id, name: name.to_string(), path, duration_secs: None, }) } /// Fetch `limit` yt-dlp search candidates for `query` without downloading. /// Returns parsed `YtCandidate` records. Bad JSON lines and yt-dlp /// non-zero exit are tolerated as much as possible: an empty list is /// returned when nothing parsed, while a yt-dlp startup failure surfaces /// a descriptive error string so the CLI can show it to the user. /// /// Lives as an associated function (no `&self`) so the HTTP handler can /// release the daemon state mutex before spawning yt-dlp. Earlier /// revisions invoked this as `state.search_yt_sync(...)` *while* /// holding the single-threaded `tiny_http` request handler's state /// guard, which froze the entire daemon (including `/init`, `/queue`, /// `/now-playing`) for the full duration of a yt-dlp search — typically /// 30+ seconds on a slow network. Mirrors the lock-free pattern already /// used for `init_sync` below. pub fn search_yt_sync(query: &str, limit: usize) -> Result, String> { let limit = limit.max(1); let src = format!("ytsearch{limit}:{query}"); debug!("yt-dlp search: {src}"); let output = std::process::Command::new("yt-dlp") .args(["--no-warnings", "-j", &src]) .output(); let out = match output { Ok(o) => o, Err(e) => { error!("yt-dlp not available: {e}"); return Err(format!("yt-dlp unavailable: {e}")); } }; let mut results = Vec::new(); for line in String::from_utf8_lossy(&out.stdout).lines() { let line = line.trim(); if line.is_empty() { continue; } match serde_json::from_str::(line) { Ok(v) => { let id = v.get("id").and_then(|x| x.as_str()).unwrap_or_default().to_string(); let title = v .get("title") .and_then(|x| x.as_str()) .unwrap_or_default() .to_string(); let uploader = pick_uploader(&v); let duration_secs = v.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0); let url = v .get("webpage_url") .and_then(|x| x.as_str()) .or_else(|| v.get("url").and_then(|x| x.as_str())) .unwrap_or_default() .to_string(); let thumbnail = pick_thumbnail(&v); if id.is_empty() || url.is_empty() { continue; } results.push(YtCandidate { id, title, uploader, duration_secs, url, thumbnail, }); } Err(e) => { warn!("yt-dlp: skipping malformed JSON line: {e}"); } } } // yt-dlp prints one JSON object per result. If it returned 0 objects // AND a non-zero exit code, surface stderr so the CLI can show it. if results.is_empty() && !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); let msg = stderr.trim(); if !msg.is_empty() { warn!("yt-dlp search produced no results and exited non-zero: {msg}"); } } Ok(results) } /// Clear the queue (memory + DB). pub fn clear_queue(&mut self) -> Result<(), String> { self.queue.clear(); Self::queue_clear_db(&self.lock_conn()).map_err(|e| e.to_string())?; info!("Queue cleared"); Ok(()) } /// Pop the song at the head of the queue. Only writes history when /// `duration_secs_played` is `Some(_)`. Pass `None` for purely /// housekeeping pops (start-of-song, /skip, etc). pub fn pop_played( &mut self, duration_secs_played: Option, ) -> Result, String> { if self.queue.is_empty() { return Ok(None); } let id = self.queue[0].id; let conn = self.lock_conn(); conn.execute( "DELETE FROM queue WHERE song_id = ?1 AND position = ( SELECT MIN(position) FROM queue WHERE song_id = ?1 )", params![id], ) .map_err(|e| format!("failed to delete queue row from DB: {e}"))?; if let Some(secs) = duration_secs_played { let secs_i = (secs.max(0.0)) as i64; if let Err(e) = Self::write_history(&conn, id, unix_secs(), secs_i) { warn!("failed to write history for song {id}: {e}"); } } drop(conn); Ok(Some(self.queue.remove(0))) } pub fn skip(&mut self) -> Result, String> { if let Some(skipped) = self.pop_played(None)? { self.current = None; info!("Skipped {}", skipped.name); Ok(Some(skipped)) } else { Ok(None) } } // ------------------------------------------------------------------ // History / stats // ------------------------------------------------------------------ pub fn list_history(&self, limit: usize) -> Vec { let conn = self.lock_conn(); let limit = limit.max(1) as i64; let mut stmt = match conn.prepare( "SELECT h.id, h.song_id, s.name, h.played_at, h.duration_secs_played FROM history h LEFT JOIN songs s ON s.id = h.song_id ORDER BY h.played_at DESC LIMIT ?1", ) { Ok(s) => s, Err(e) => { error!("Failed to list history: {e}"); return Vec::new(); } }; stmt.query_map(params![limit], |row| { Ok(HistoryEntry { id: row.get(0)?, song_id: row.get(1)?, song_name: row.get(2)?, played_at: row.get(3)?, duration_secs_played: row.get(4)?, }) }) .map(|it| it.filter_map(Result::ok).collect()) .unwrap_or_default() } /// Snapshot of the current jobs (most-recently-started first). pub fn list_downloads(&self) -> Vec { let mut jobs: Vec = self .download_jobs .lock() .unwrap_or_else(|p| p.into_inner()) .values() .cloned() .collect(); jobs.sort_by(|a, b| b.started_at.cmp(&a.started_at)); jobs } pub fn stats(&self) -> Stats { let conn = self.lock_conn(); let total_plays: i64 = conn .query_row("SELECT COUNT(*) FROM history", [], |r| r.get(0)) .unwrap_or(0); let total_secs: i64 = conn .query_row( "SELECT COALESCE(SUM(duration_secs_played), 0) FROM history", [], |r| r.get(0), ) .unwrap_or(0); let mut stmt = match conn.prepare( "SELECT h.song_id, s.name, COUNT(*) AS plays, COALESCE(SUM(h.duration_secs_played), 0) AS total_secs FROM history h LEFT JOIN songs s ON s.id = h.song_id GROUP BY h.song_id ORDER BY plays DESC, total_secs DESC LIMIT 10", ) { Ok(s) => s, Err(e) => { error!("Failed to compute stats: {e}"); return Stats { total_plays, total_secs, top_songs: Vec::new(), }; } }; let top_songs: Vec = stmt .query_map([], |row| { Ok(TopSong { song_id: row.get(0)?, name: row.get(1)?, plays: row.get(2)?, total_secs: row.get(3)?, }) }) .map(|it| it.filter_map(Result::ok).collect()) .unwrap_or_default(); Stats { total_plays, total_secs, top_songs, } } // ------------------------------------------------------------------ // Playlists // ------------------------------------------------------------------ pub fn list_playlists(&self) -> Vec { let conn = self.lock_conn(); let mut stmt = match conn.prepare("SELECT id, name FROM playlists ORDER BY name") { Ok(s) => s, Err(e) => { error!("Failed to list playlists: {e}"); return Vec::new(); } }; let ids: Vec<(i64, String)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) .map(|it| it.filter_map(Result::ok).collect()) .unwrap_or_default(); drop(stmt); ids.into_iter() .map(|(id, name)| { let songs = Self::load_playlist_songs(&conn, id); Playlist { id, name, songs } }) .collect() } fn load_playlist_songs(conn: &Connection, playlist_id: i64) -> Vec { let mut stmt = match conn.prepare( "SELECT s.id, s.name, s.path, s.duration_secs FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = ?1 ORDER BY ps.position ASC", ) { Ok(s) => s, Err(_) => return Vec::new(), }; stmt.query_map(params![playlist_id], |row| { Ok(Song { id: row.get(0)?, name: row.get(1)?, path: row.get(2)?, duration_secs: row.get::<_, Option>(3).ok().flatten(), }) }) .map(|it| it.filter_map(Result::ok).collect()) .unwrap_or_default() } pub fn create_playlist(&self, name: &str) -> Result { let conn = self.lock_conn(); conn.execute( "INSERT OR IGNORE INTO playlists (name) VALUES (?1)", params![name], ) .map_err(|e| e.to_string())?; let id: i64 = conn .query_row( "SELECT id FROM playlists WHERE name = ?1", params![name], |row| row.get(0), ) .map_err(|e| e.to_string())?; info!("Created playlist {name} (id={id})"); Ok(Playlist { id, name: name.to_string(), songs: Vec::new(), }) } pub fn delete_playlist(&self, name: &str) -> Result<(), String> { let conn = self.lock_conn(); let affected = conn .execute("DELETE FROM playlists WHERE name = ?1", params![name]) .map_err(|e| e.to_string())?; if affected == 0 { return Err(format!("playlist not found: {name}")); } info!("Deleted playlist {name}"); Ok(()) } pub fn remove_from_playlist(&self, playlist: &str, song_id: i64) -> Result { let conn = self.lock_conn(); let playlist_id: i64 = conn .query_row( "SELECT id FROM playlists WHERE name = ?1", params![playlist], |row| row.get(0), ) .map_err(|_| format!("playlist not found: {playlist}"))?; let affected = conn .execute( "DELETE FROM playlist_songs WHERE playlist_id = ?1 AND song_id = ?2", params![playlist_id, song_id], ) .map_err(|e| e.to_string())?; if affected == 0 { return Err(format!("song {song_id} not in playlist {playlist}")); } Self::compact_playlist_positions(&conn, playlist_id).map_err(|e| e.to_string())?; info!("Removed song {song_id} from playlist {playlist}"); Ok(Playlist { id: playlist_id, name: playlist.to_string(), songs: Self::load_playlist_songs(&conn, playlist_id), }) } pub fn reorder_playlist( &self, playlist: &str, from: usize, to: usize, ) -> Result { if from == 0 || to == 0 { return Err("positions are 1-based".to_string()); } let conn = self.lock_conn(); let playlist_id: i64 = conn .query_row( "SELECT id FROM playlists WHERE name = ?1", params![playlist], |row| row.get(0), ) .map_err(|_| format!("playlist not found: {playlist}"))?; let songs = Self::load_playlist_songs(&conn, playlist_id); if from > songs.len() || to > songs.len() { return Err(format!( "position out of range (playlist has {} songs)", songs.len() )); } let mut order = songs.clone(); let moved = order.remove(from - 1); order.insert(to - 1, moved); let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; for (idx, s) in order.iter().enumerate() { tx.execute( "UPDATE playlist_songs SET position = ?1 WHERE playlist_id = ?2 AND song_id = ?3", params![idx as i64 + 1, playlist_id, s.id], ) .map_err(|e| e.to_string())?; } tx.commit().map_err(|e| e.to_string())?; info!("Reordered playlist {playlist}: {from} -> {to}"); Ok(Playlist { id: playlist_id, name: playlist.to_string(), songs: Self::load_playlist_songs(&conn, playlist_id), }) } fn compact_playlist_positions(conn: &Connection, playlist_id: i64) -> rusqlite::Result<()> { let songs = Self::load_playlist_songs(conn, playlist_id); for (idx, s) in songs.iter().enumerate() { conn.execute( "UPDATE playlist_songs SET position = ?1 WHERE playlist_id = ?2 AND song_id = ?3", params![idx as i64 + 1, playlist_id, s.id], )?; } Ok(()) } pub fn rename_playlist(&self, old: &str, new: &str) -> Result { let conn = self.lock_conn(); let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; let affected = tx .execute( "UPDATE playlists SET name = ?1 WHERE name = ?2", params![new, old], ) .map_err(|e| e.to_string())?; if affected == 0 { tx.rollback().ok(); return Err(format!("playlist not found: {old}")); } let playlist_id: i64 = match tx.query_row( "SELECT id FROM playlists WHERE name = ?1", params![new], |row| row.get(0), ) { Ok(id) => id, Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { tx.rollback().ok(); return Err(format!("playlist name already exists: {new}")); } Err(e) => { tx.rollback().ok(); return Err(e.to_string()); } }; tx.commit().map_err(|e| e.to_string())?; info!("Renamed playlist {old} -> {new}"); Ok(Playlist { id: playlist_id, name: new.to_string(), songs: Self::load_playlist_songs(&conn, playlist_id), }) } pub fn duplicate_playlist(&self, src: &str, dest: &str) -> Result { let conn = self.lock_conn(); let playlist_id: i64 = match conn.query_row( "SELECT id FROM playlists WHERE name = ?1", params![src], |row| row.get(0), ) { Ok(id) => id, Err(_) => return Err(format!("playlist not found: {src}")), }; let songs = Self::load_playlist_songs(&conn, playlist_id); let new_id = match self.create_playlist(dest) { Ok(pl) => pl.id, Err(e) => return Err(e), }; for s in &songs { Self::playlist_append_db(&conn, new_id, s.id).map_err(|e| e.to_string())?; } info!("Duplicated playlist {src} -> {dest} ({} songs)", songs.len()); Ok(Playlist { id: new_id, name: dest.to_string(), songs, }) } pub fn add_to_playlist( &self, playlist: &str, query: &str, ) -> Result<(Playlist, Song), String> { let song = self .search(query) .ok_or_else(|| format!("song not found: {query}"))?; let conn = self.lock_conn(); let playlist_id: i64 = conn .query_row( "SELECT id FROM playlists WHERE name = ?1", params![playlist], |row| row.get(0), ) .map_err(|_| format!("playlist not found: {playlist}"))?; Self::playlist_append_db(&conn, playlist_id, song.id).map_err(|e| e.to_string())?; info!("Added {} to playlist {}", song.name, playlist); let pl = Playlist { id: playlist_id, name: playlist.to_string(), songs: Self::load_playlist_songs(&conn, playlist_id), }; Ok((pl, song)) } pub fn play_playlist(&mut self, name: &str) -> Result { let (playlist_id, songs) = { let conn = self.lock_conn(); let playlist_id: i64 = conn .query_row( "SELECT id FROM playlists WHERE name = ?1", params![name], |row| row.get(0), ) .map_err(|_| format!("playlist not found: {name}"))?; let songs = Self::load_playlist_songs(&conn, playlist_id); Self::queue_clear_db(&conn).map_err(|e| e.to_string())?; for s in &songs { Self::queue_append_db(&conn, s.id).map_err(|e| e.to_string())?; } (playlist_id, songs) }; // DB guard dropped; safe to mutate self.queue / cycle_snapshot // without re-borrowing self. self.queue = songs.clone(); self.cycle_snapshot = songs.clone(); info!("Playing playlist {name} ({} songs)", songs.len()); Ok(Playlist { id: playlist_id, name: name.to_string(), songs, }) } pub fn get_playlist(&self, name: &str) -> Result { let conn = self.lock_conn(); let playlist_id: i64 = conn .query_row( "SELECT id FROM playlists WHERE name = ?1", params![name], |row| row.get(0), ) .map_err(|_| format!("playlist not found: {name}"))?; Ok(Playlist { id: playlist_id, name: name.to_string(), songs: Self::load_playlist_songs(&conn, playlist_id), }) } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HistoryEntry { pub id: i64, pub song_id: i64, pub song_name: Option, pub played_at: i64, pub duration_secs_played: i64, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Stats { pub total_plays: i64, pub total_secs: i64, pub top_songs: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TopSong { pub song_id: i64, pub name: Option, pub plays: i64, pub total_secs: i64, } // -------------------------------------------------------------------- // Free helpers / async download dispatch (outside `impl DaemonState` // because `&Arc>` is not a valid method-receiver type). // -------------------------------------------------------------------- fn unix_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// Pick the best "artist / uploader" string for a yt-dlp JSON line. /// Prefer explicit `artist`, then `channel`, then `uploader`, then `creator` /// — falls back to an empty string so the CLI formatter doesn't have to /// special-case missing data. fn pick_uploader(v: &serde_json::Value) -> String { for key in ["artist", "channel", "uploader", "creator"] { if let Some(s) = v.get(key).and_then(|x| x.as_str()) { let s = s.trim(); if !s.is_empty() { return s.to_string(); } } } String::new() } /// Pick a thumbnail URL for a yt-dlp JSON line. Prefers the largest entry in /// the `thumbnails[]` array (when yt-dlp emits per-resolution variants); /// falls back to the singular `thumbnail` field. `None` is returned when /// yt-dlp shipped no cover art at all (rare, but possible for short-lived /// uploads or age-restricted items). fn pick_thumbnail(v: &serde_json::Value) -> Option { if let Some(arr) = v.get("thumbnails").and_then(|x| x.as_array()) { let mut best: Option<(i64, &str)> = None; for entry in arr { let url = entry.get("url").and_then(|x| x.as_str()); let h = entry.get("height").and_then(|x| x.as_i64()).unwrap_or(0); if let Some(u) = url { match best { Some((bh, _)) if bh >= h => {} _ => best = Some((h, u)), } } } if let Some((_, url)) = best { return Some(url.to_string()); } } v.get("thumbnail") .and_then(|x| x.as_str()) .map(|s| s.to_string()) } /// Spawn a worker thread that downloads a song and updates the matching /// `DownloadJob`. Returns the assigned job id synchronously. /// /// `name` is the display/storage name under which the song ends up in the /// library. `source` is the literal argument to pass to `yt-dlp` — either an /// explicit URL the user picked from `/search/yt` or `ytsearch1:NAME` for the /// legacy auto-pick path. pub fn init_async(state: &Arc>, name: String, source: String) -> i64 { let job_id = state .lock() .unwrap() .next_job_id .fetch_add(1, Ordering::SeqCst); let now = unix_secs(); let job = DownloadJob { id: job_id, name: name.clone(), status: "queued".to_string(), song_id: None, error: None, started_at: now, finished_at: None, source: Some(source.clone()), }; // Scope the locks so both MutexGuards drop together at the end of the // block. A temporary `state.lock()` at statement boundary would be // dropped before the inner `download_jobs.lock()` finishes using it. { let guard = state.lock().unwrap(); let mut jobs = guard.download_jobs.lock().unwrap(); jobs.insert(job_id, job); } // Clone the name so the closure can take ownership while we still print // the original in the debug log below. let name_for_worker = name.clone(); let arc = Arc::clone(state); std::thread::Builder::new() .name(format!("sjnmusic-dl-{job_id}")) .spawn(move || { run_download_job(arc, job_id, name_for_worker, source); }) .expect("failed to spawn download worker"); debug!("Spawned download job {job_id} for {name}"); job_id } /// Spawn one worker per name. Returns the assigned ids in the same order. pub fn init_batch(state: &Arc>, names: Vec) -> Vec { names .iter() .map(|n| { let source = format!("ytsearch1:{n}"); init_async(state, n.clone(), source) }) .collect() } fn run_download_job( state: Arc>, job_id: i64, name: String, source: String, ) { { let guard = state.lock().unwrap(); let mut jobs = guard.download_jobs.lock().unwrap(); if let Some(j) = jobs.get_mut(&job_id) { j.status = "running".to_string(); } } // `init_sync` is now an associated function that manages its own // locking: it runs `yt-dlp` lock-free and only grabs the state mutex // for a brief DB insert at the end. Do NOT call it as // `state.lock().unwrap().init_sync(...)` — that would re-introduce // the freeze-while-downloading bug for both the CLI and the GUI. let result = DaemonState::init_sync(&state, &name, &source); { let guard = state.lock().unwrap(); let mut jobs = guard.download_jobs.lock().unwrap(); if let Some(j) = jobs.get_mut(&job_id) { j.finished_at = Some(unix_secs()); match &result { Ok(song) => { j.status = "done".to_string(); j.song_id = Some(song.id); } Err(e) => { j.status = "failed".to_string(); j.error = Some(e.clone()); } } } } }