1259 lines
45 KiB
Rust
1259 lines
45 KiB
Rust
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<f64>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct Playlist {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub songs: Vec<Song>,
|
|
}
|
|
|
|
/// 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<Self> {
|
|
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<i64>,
|
|
pub error: Option<String>,
|
|
pub started_at: i64,
|
|
pub finished_at: Option<i64>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<Connection>,
|
|
/// Songs pending play, in order. The first element is the next to play.
|
|
pub queue: Vec<Song>,
|
|
/// Currently playing song, if any.
|
|
pub current: Option<Song>,
|
|
/// `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<AudioHandle>,
|
|
/// 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<Song>,
|
|
/// `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<HashMap<i64, DownloadJob>>,
|
|
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<AudioHandle>,
|
|
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<Song> {
|
|
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<f64>>(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<Song> {
|
|
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<Song> {
|
|
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<Song> {
|
|
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<Song> {
|
|
Ok(Song {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
path: row.get(2)?,
|
|
duration_secs: row.get::<_, Option<f64>>(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<i64> {
|
|
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<Song> {
|
|
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<Song> {
|
|
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<Song, String> {
|
|
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<Song, String> {
|
|
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<Song, String> {
|
|
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<Mutex<DaemonState>>,
|
|
name: &str,
|
|
source: &str,
|
|
) -> Result<Song, String> {
|
|
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<Vec<YtCandidate>, 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::<serde_json::Value>(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<f64>,
|
|
) -> Result<Option<Song>, 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<Option<Song>, 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<HistoryEntry> {
|
|
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<DownloadJob> {
|
|
let mut jobs: Vec<DownloadJob> = 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<TopSong> = 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<Playlist> {
|
|
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<Song> {
|
|
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<f64>>(3).ok().flatten(),
|
|
})
|
|
})
|
|
.map(|it| it.filter_map(Result::ok).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn create_playlist(&self, name: &str) -> Result<Playlist, String> {
|
|
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<Playlist, 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 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<Playlist, String> {
|
|
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<Playlist, String> {
|
|
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<Playlist, String> {
|
|
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<Playlist, String> {
|
|
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<Playlist, String> {
|
|
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<String>,
|
|
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<TopSong>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct TopSong {
|
|
pub song_id: i64,
|
|
pub name: Option<String>,
|
|
pub plays: i64,
|
|
pub total_secs: i64,
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// Free helpers / async download dispatch (outside `impl DaemonState`
|
|
// because `&Arc<Mutex<Self>>` 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<String> {
|
|
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<Mutex<DaemonState>>, 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<Mutex<DaemonState>>, names: Vec<String>) -> Vec<i64> {
|
|
names
|
|
.iter()
|
|
.map(|n| {
|
|
let source = format!("ytsearch1:{n}");
|
|
init_async(state, n.clone(), source)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn run_download_job(
|
|
state: Arc<Mutex<DaemonState>>,
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|