add a bunch of qol features
This commit is contained in:
+626
-70
@@ -1,10 +1,14 @@
|
||||
use std::sync::Mutex;
|
||||
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)]
|
||||
@@ -12,6 +16,11 @@ 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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -21,6 +30,52 @@ pub struct Playlist {
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -29,18 +84,52 @@ pub struct DaemonState {
|
||||
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,
|
||||
}
|
||||
|
||||
const FUZZY_THRESHOLD: f64 = 0.65;
|
||||
|
||||
impl DaemonState {
|
||||
pub fn new(conn: Connection) -> Self {
|
||||
pub fn new(
|
||||
conn: Connection,
|
||||
fuzzy_threshold: f64,
|
||||
max_queue_size: usize,
|
||||
repeat_mode: RepeatMode,
|
||||
audio_handle: Arc<AudioHandle>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +149,7 @@ impl DaemonState {
|
||||
|
||||
fn load_queue_from_db(conn: &Connection) -> Vec<Song> {
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT s.id, s.name, s.path
|
||||
"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",
|
||||
) {
|
||||
@@ -76,21 +165,39 @@ impl DaemonState {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Public wrapper around `all_songs`: returns every song in the DB, sorted
|
||||
/// by name. Used by `DaemonState::search` (in-memory) and the `/songs`
|
||||
/// HTTP route.
|
||||
/// All songs sorted by name (used internally and by `/songs`).
|
||||
pub fn all_songs(&self) -> Vec<Song> {
|
||||
Self::list_songs(&self.lock_conn())
|
||||
Self::list_songs(&self.lock_conn(), None)
|
||||
}
|
||||
|
||||
fn list_songs(conn: &Connection) -> Vec<Song> {
|
||||
let mut stmt = match conn.prepare("SELECT id, name, path FROM songs ORDER BY name") {
|
||||
/// 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}");
|
||||
@@ -98,15 +205,22 @@ impl DaemonState {
|
||||
}
|
||||
};
|
||||
|
||||
stmt.query_map([], |row| {
|
||||
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(),
|
||||
})
|
||||
})
|
||||
.map(|it| it.filter_map(Result::ok).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
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> {
|
||||
@@ -122,8 +236,19 @@ impl DaemonState {
|
||||
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<()> {
|
||||
// Append at the end. Explicit max+1 so ordering survives deletes.
|
||||
let next: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM queue",
|
||||
@@ -162,28 +287,38 @@ impl DaemonState {
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Search the songs DB by name. Uses an exact (case-insensitive) match
|
||||
/// first, then falls back to a Jaro-Winkler fuzzy match.
|
||||
/// Best single match. Exact (case-insensitive) wins, then Jaro-Winkler
|
||||
/// above threshold.
|
||||
pub fn search(&self, query: &str) -> Option<Song> {
|
||||
debug!("Searching DB for: {query}");
|
||||
let query_lower = query.to_lowercase();
|
||||
let q = query.to_lowercase();
|
||||
let songs = self.all_songs();
|
||||
|
||||
// Exact match wins outright.
|
||||
if let Some(s) = songs.iter().find(|s| s.name.to_lowercase() == query_lower) {
|
||||
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());
|
||||
}
|
||||
|
||||
// Best fuzzy match above threshold.
|
||||
let best = songs
|
||||
.iter()
|
||||
.map(|s| (s.clone(), jaro_winkler(&s.name.to_lowercase(), &query_lower)))
|
||||
.filter(|(_, score)| *score > FUZZY_THRESHOLD)
|
||||
.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 {
|
||||
@@ -195,24 +330,57 @@ impl DaemonState {
|
||||
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());
|
||||
let conn = self.lock_conn();
|
||||
// Reset persisted queue to keep DB and memory consistent.
|
||||
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())?;
|
||||
// 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.
|
||||
/// 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}"))?;
|
||||
@@ -230,7 +398,6 @@ impl DaemonState {
|
||||
|
||||
{
|
||||
let conn = self.lock_conn();
|
||||
// Foreign keys remove queue & playlist entries automatically.
|
||||
conn.execute("DELETE FROM songs WHERE id = ?1", params![song.id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -251,9 +418,37 @@ impl DaemonState {
|
||||
Ok(song)
|
||||
}
|
||||
|
||||
/// Download a song via yt-dlp and register it in the DB.
|
||||
pub fn init(&self, name: String) -> Result<Song, String> {
|
||||
let path = song_path(&name);
|
||||
/// 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.
|
||||
pub fn init_sync(&self, name: &str) -> Result<Song, String> {
|
||||
let path = song_path(name);
|
||||
debug!("yt-dlp target path: {path}");
|
||||
|
||||
let output = std::process::Command::new("yt-dlp")
|
||||
@@ -287,12 +482,20 @@ impl DaemonState {
|
||||
}
|
||||
|
||||
let conn = self.lock_conn();
|
||||
let id = Self::insert_song(&conn, &name, &path).map_err(|e| e.to_string())?;
|
||||
info!("Registered song {name} (id={id}) at {path}");
|
||||
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: name.to_string(),
|
||||
path,
|
||||
duration_secs: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -304,43 +507,40 @@ impl DaemonState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pop the song at the head of the queue as having been played. Removes
|
||||
/// the same row from the in-memory queue and the `queue` table so that
|
||||
/// a daemon restart does not put already-played songs back into rotation.
|
||||
/// The DB is updated first — if the DELETE fails the in-memory queue
|
||||
/// is left untouched and the error is returned to the caller, keeping
|
||||
/// memory and DB consistent.
|
||||
pub fn pop_played(&mut self) -> Result<Option<Song>, String> {
|
||||
/// 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;
|
||||
// Scope the DB guard so it is dropped before mutating `self.queue`.
|
||||
// Use a MIN(position) subquery instead of `ORDER BY ... LIMIT 1`:
|
||||
// SQLite does not accept `ORDER BY` / `LIMIT` clauses on `DELETE`
|
||||
// statements (parse error: "near \"ORDER\": syntax error"), so the
|
||||
// standard syntax fails entirely. The subquery is portable across all
|
||||
// supported SQLite versions and is equivalent: it deletes the head
|
||||
// occurrence of `song_id` from the queue (oldest by position).
|
||||
let result = {
|
||||
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],
|
||||
)
|
||||
};
|
||||
result.map_err(|e| format!("failed to delete queue row from DB: {e}"))?;
|
||||
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)))
|
||||
}
|
||||
|
||||
/// Skip the song currently at the front of the queue. Thin wrapper around
|
||||
/// `pop_played` so the queue-pop SQL lives in exactly one place.
|
||||
pub fn skip(&mut self) -> Result<Option<Song>, String> {
|
||||
if let Some(skipped) = self.pop_played()? {
|
||||
if let Some(skipped) = self.pop_played(None)? {
|
||||
self.current = None;
|
||||
info!("Skipped {}", skipped.name);
|
||||
Ok(Some(skipped))
|
||||
} else {
|
||||
@@ -348,6 +548,101 @@ impl DaemonState {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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
|
||||
// ------------------------------------------------------------------
|
||||
@@ -378,7 +673,7 @@ impl DaemonState {
|
||||
|
||||
fn load_playlist_songs(conn: &Connection, playlist_id: i64) -> Vec<Song> {
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT s.id, s.name, s.path
|
||||
"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",
|
||||
@@ -391,6 +686,7 @@ impl DaemonState {
|
||||
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())
|
||||
@@ -431,6 +727,158 @@ impl DaemonState {
|
||||
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,
|
||||
@@ -457,7 +905,6 @@ impl DaemonState {
|
||||
Ok((pl, song))
|
||||
}
|
||||
|
||||
/// Load a playlist into the active queue (replacing the current queue).
|
||||
pub fn play_playlist(&mut self, name: &str) -> Result<Playlist, String> {
|
||||
let (playlist_id, songs) = {
|
||||
let conn = self.lock_conn();
|
||||
@@ -475,8 +922,10 @@ impl DaemonState {
|
||||
}
|
||||
(playlist_id, songs)
|
||||
};
|
||||
// Guard is dropped; safe to mutate self.queue without borrow conflict.
|
||||
// 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,
|
||||
@@ -501,3 +950,110 @@ impl DaemonState {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// Spawn a worker thread that downloads a song and updates the matching
|
||||
/// `DownloadJob`. Returns the assigned job id synchronously.
|
||||
pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: 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,
|
||||
};
|
||||
// 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))
|
||||
.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| init_async(state, n.clone())).collect()
|
||||
}
|
||||
|
||||
fn run_download_job(state: Arc<Mutex<DaemonState>>, job_id: i64, name: 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();
|
||||
}
|
||||
}
|
||||
let result = state.lock().unwrap().init_sync(&name);
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user