add a bunch of qol features

This commit is contained in:
2026-06-24 20:02:07 +02:00
parent 1b430aab74
commit 5e14a9e308
11 changed files with 1881 additions and 246 deletions
+626 -70
View File
@@ -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());
}
}
}
}
}