mek sock to http server and add playlsit funktion

This commit is contained in:
2026-06-23 17:42:43 +02:00
parent 3784b1d31d
commit 792d41517e
14 changed files with 1600 additions and 230 deletions
+10 -5
View File
@@ -1,3 +1,4 @@
use log::debug;
use rodio::{Decoder, OutputStream, Sink};
use std::fs::File;
use std::io::BufReader;
@@ -15,17 +16,21 @@ impl AudioEngine {
}
}
pub fn play(&mut self, path: &str) {
let (_stream, handle) = OutputStream::try_default().unwrap();
let sink = Sink::try_new(&handle).unwrap();
pub fn play(&mut self, path: &str) -> Result<(), String> {
let (_stream, handle) = OutputStream::try_default()
.map_err(|e| format!("could not open audio output: {e}"))?;
let sink = Sink::try_new(&handle).map_err(|e| format!("could not create sink: {e}"))?;
let file = File::open(path).unwrap();
let source = Decoder::new(BufReader::new(file)).unwrap();
let file = File::open(path).map_err(|e| format!("could not open file: {e}"))?;
let source =
Decoder::new(BufReader::new(file)).map_err(|e| format!("decode error: {e}"))?;
sink.append(source);
debug!("rodio sink appended for {path}");
self.sink = Some(sink);
self._stream = Some(_stream);
Ok(())
}
pub fn is_playing(&self) -> bool {
+83
View File
@@ -0,0 +1,83 @@
use std::fs;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use crate::paths::config_path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
/// Bind host. Use "127.0.0.1" for local-only, "0.0.0.0" to expose on the network.
pub host: String,
/// Port to bind the HTTP server on.
pub port: u16,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 14567,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
/// Log filter: trace, debug, info, warn, error
pub level: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub logging: LoggingConfig,
}
impl Config {
/// Load configuration from the TOML file. If the file does not exist, create
/// it with sensible defaults so the daemon can be configured on first run.
pub fn load() -> Self {
let path = config_path();
if !std::path::Path::new(&path).exists() {
warn!("Config not found at {path}, creating default config");
let cfg = Config::default();
if let Ok(s) = toml::to_string_pretty(&cfg) {
if let Err(e) = fs::write(&path, s) {
warn!("Failed to write default config: {e}");
} else {
info!("Wrote default config to {path}");
}
}
return cfg;
}
match fs::read_to_string(&path) {
Ok(text) => match toml::from_str::<Config>(&text) {
Ok(cfg) => {
debug!("Loaded config from {path}: {:?}", cfg);
cfg
}
Err(e) => {
warn!("Failed to parse config at {path}: {e}. Falling back to defaults.");
Config::default()
}
},
Err(e) => {
warn!("Failed to read config at {path}: {e}. Falling back to defaults.");
Config::default()
}
}
}
}
+77 -14
View File
@@ -1,36 +1,99 @@
use std::sync::{Arc, Mutex};
use std::thread;
use log::{debug, error, info, warn};
use crate::{
state::DaemonState,
socket::start_socket,
db::init_db,
audio::AudioEngine,
config::Config,
db::open_db,
server::start_server,
state::DaemonState,
};
pub fn run() {
let state = Arc::new(Mutex::new(DaemonState::new()));
pub fn run(cfg: Config) {
info!("Starting sjnmusicd");
init_db();
let conn = open_db();
let state = Arc::new(Mutex::new(DaemonState::new(conn)));
let socket_state = state.clone();
thread::spawn(move || {
start_socket(socket_state);
});
// HTTP server thread.
let server_state = state.clone();
let host = cfg.server.host.clone();
let port = cfg.server.port;
let server_handle = thread::Builder::new()
.name("sjnmusic-http".into())
.spawn(move || {
start_server(server_state, &host, port);
});
if let Err(e) = server_handle {
error!("Failed to spawn HTTP server thread: {e}");
}
// Audio playback loop (runs on the main thread).
let mut audio = AudioEngine::new();
info!("Playback loop ready");
loop {
{
let mut state = state.lock().unwrap();
// Clear "now playing" once the audio engine reports the song has
// ended so /now-playing and /queue reflect reality while the
// next iteration decides what's up next.
if !audio.is_playing() {
if let Some(prev) = state.current.take() {
debug!("Playback finished for {}", prev.name);
}
}
if !state.queue.is_empty() && !audio.is_playing() {
let song = state.queue.remove(0);
audio.play(&song.path);
state.current = Some(song);
// Snapshot the next song first, attempt audio.play, and only
// remove from the queue (memory + DB) when playback actually
// starts so decode/init failures don't silently lose it.
let next = state.queue[0].clone();
debug!("Starting playback: {} ({})", next.name, next.path);
match audio.play(&next.path) {
Ok(()) => match state.pop_played() {
Ok(Some(popped)) => {
let name = popped.name.clone();
state.current = Some(popped);
debug!("DB queue row removed for {name}");
info!("Now playing: {name}");
}
Ok(None) => {
// Should not happen — we peeked a non-empty queue.
state.current = Some(next.clone());
warn!(
"pop_played returned None after successful audio.play for {}",
next.name
);
}
Err(e) => {
// pop_played is DB-first; on Err the in-memory
// queue has NOT been mutated, so the song is
// already at index 0 — do not re-insert or we
// would duplicate it.
error!(
"Played {} but failed to update DB queue ({e}); \
leaving in-memory queue untouched",
next.name
);
state.current = Some(next);
}
},
Err(e) => {
// The previous "now playing" is no longer accurate.
state.current = None;
error!(
"Failed to play {} ({e}); leaving in queue",
next.path
);
}
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
thread::sleep(std::time::Duration::from_millis(200));
}
}
+48 -10
View File
@@ -1,16 +1,54 @@
use log::{debug, info};
use rusqlite::Connection;
use crate::paths::db_path;
use crate::paths::{db_path, ensure_dirs};
pub fn init_db() {
let conn = Connection::open(db_path()).unwrap();
/// Open (and configure) the SQLite database, creating tables on first run.
pub fn open_db() -> Connection {
ensure_dirs();
let conn = Connection::open(db_path()).expect("Failed to open songs DB");
conn.execute(
// Reasonable defaults for a small local DB.
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;",
)
.expect("Failed to set DB pragmas");
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS songs (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE,
path TEXT
)",
[],
).unwrap();
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
path TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS queue (
position INTEGER PRIMARY KEY AUTOINCREMENT,
song_id INTEGER NOT NULL,
FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist_songs (
playlist_id INTEGER NOT NULL,
position INTEGER NOT NULL,
song_id INTEGER NOT NULL,
PRIMARY KEY (playlist_id, position),
FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_pl
ON playlist_songs(playlist_id, position);",
)
.expect("Failed to create DB schema");
debug!("Database initialized at {}", db_path());
info!("Database ready");
conn
}
+28 -6
View File
@@ -1,11 +1,33 @@
mod daemon;
mod socket;
mod protocol;
mod state;
mod db;
mod audio;
mod config;
mod daemon;
mod db;
mod paths;
mod server;
mod state;
fn main() {
daemon::run();
// Initialise logging first so subsequent startup steps are visible.
let cfg = config::Config::load();
env_logger::Builder::from_default_env()
.filter_level(parse_level(&cfg.logging.level))
.format_timestamp_secs()
.init();
log::info!("Loaded configuration");
log::debug!("Config: {:?}", cfg);
paths::ensure_dirs();
daemon::run(cfg);
}
fn parse_level(s: &str) -> log::LevelFilter {
match s.to_lowercase().as_str() {
"trace" => log::LevelFilter::Trace,
"debug" => log::LevelFilter::Debug,
"info" => log::LevelFilter::Info,
"warn" | "warning" => log::LevelFilter::Warn,
"error" => log::LevelFilter::Error,
_ => log::LevelFilter::Info,
}
}
+11
View File
@@ -34,3 +34,14 @@ pub fn db_path() -> String {
p.push("songs.db");
p.to_string_lossy().to_string()
}
pub fn config_path() -> String {
let mut p = base_dir();
p.push("config.toml");
p.to_string_lossy().to_string()
}
pub fn ensure_dirs() {
let _ = std::fs::create_dir_all(base_dir());
let _ = std::fs::create_dir_all(songs_dir());
}
-10
View File
@@ -1,10 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub enum Command {
Play(String),
Add(String),
Del(String),
Init(String),
Search(String),
}
+352
View File
@@ -0,0 +1,352 @@
use std::sync::{Arc, Mutex};
use log::{debug, error, info, warn};
use serde::Serialize;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use crate::state::DaemonState;
#[derive(Serialize)]
struct ApiError {
error: String,
}
fn json_response<T: Serialize>(status: u16, body: &T) -> Response<std::io::Cursor<Vec<u8>>> {
let bytes = serde_json::to_vec(body).unwrap_or_else(|e| {
error!("JSON serialization failed: {e}");
br#"{"error":"serialization failed"}"#.to_vec()
});
Response::from_data(bytes)
.with_status_code(StatusCode(status))
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
)
}
fn error_response(msg: impl Into<String>) -> Response<std::io::Cursor<Vec<u8>>> {
json_response(400, &ApiError { error: msg.into() })
}
fn parse_body(body: &str) -> serde_json::Result<serde_json::Value> {
if body.trim().is_empty() {
Ok(serde_json::Value::Null)
} else {
serde_json::from_str(body)
}
}
pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) {
let addr = format!("{host}:{port}");
let server = match Server::http(&addr) {
Ok(s) => {
info!("HTTP server listening on http://{addr}");
info!("Set RUST_LOG=debug for verbose logs");
s
}
Err(e) => {
error!("Failed to bind HTTP server on {addr}: {e}");
return;
}
};
for request in server.incoming_requests() {
let state = state.clone();
let url = request.url().to_string();
let method = request.method().clone();
debug!("{} {}", method, url);
if let Err(e) = handle(request, state, &method, &url) {
warn!("Failed to respond: {e}");
}
}
}
/// Owns the request so we can call `request.respond()` at the end. Reads the
/// body (for POST/PUT/DELETE), routes, and replies with JSON.
fn handle(
mut request: Request,
state: Arc<Mutex<DaemonState>>,
method: &Method,
url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let path = url.split('?').next().unwrap_or(url);
let route = route_for(method, path);
// /help short-circuit: no body, no state.
if matches!(route, Route::Help) {
let resp = json_response(200, &serde_json::json!({
"endpoints": [
"GET /help",
"GET /songs",
"GET /search?q=... or POST /search {query}",
"GET /queue",
"GET /now-playing",
"POST /play {query}",
"POST /add {query}",
"POST /del {query}",
"POST /init {song}",
"POST /skip",
"POST /queue/clear",
"GET /playlists",
"POST /playlists {name}",
"GET /playlists/{name}",
"POST /playlists/{name}/add {query}",
"POST /playlists/{name}/play",
"DELETE /playlists/{name}",
]
}));
return Ok(request.respond(resp)?);
}
let mut body_text = String::new();
if matches!(method, Method::Post | Method::Delete | Method::Put) {
let _ = request.as_reader().read_to_string(&mut body_text)?;
}
debug!("Body: {}", body_text);
let query_params = parse_query(url);
let mut state = match state.lock() {
Ok(s) => s,
Err(p) => {
error!("State mutex poisoned: {p}");
let resp = error_response("internal state error");
return Ok(request.respond(resp)?);
}
};
let response = match route {
Route::Help => unreachable!("handled above"),
Route::Songs => json_response(200, &serde_json::json!({
"songs": state.all_songs(),
})),
Route::Search => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.search(&q) {
Some(song) => json_response(200, &song),
None => json_response(404, &ApiError {
error: format!("song not found: {q}"),
}),
},
Err(e) => error_response(e),
},
Route::Queue => json_response(200, &serde_json::json!({
"queue": state.queue,
"current": state.current,
})),
Route::NowPlaying => json_response(200, &serde_json::json!({
"current": state.current,
"queue_len": state.queue.len(),
})),
Route::Play => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.play(&q) {
Ok(song) => json_response(200, &song),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::Add => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.add(&q) {
Ok(song) => json_response(200, &song),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::Del => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.delete(&q) {
Ok(song) => json_response(200, &song),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::Init => match name_from_body(&body_text) {
Ok(song) => match state.init(song) {
Ok(s) => json_response(200, &s),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::Skip => match state.skip() {
Ok(Some(s)) => json_response(200, &s),
Ok(None) => json_response(200, &serde_json::json!({"skipped": null})),
Err(e) => error_response(e),
},
Route::QueueClear => match state.clear_queue() {
Ok(()) => json_response(200, &serde_json::json!({"cleared": true})),
Err(e) => error_response(e),
},
Route::PlaylistsList => json_response(200, &state.list_playlists()),
Route::PlaylistCreate => match name_from_body(&body_text) {
Ok(name) => match state.create_playlist(&name) {
Ok(pl) => json_response(200, &pl),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::PlaylistGet(name) => match state.get_playlist(&name) {
Ok(pl) => json_response(200, &pl),
Err(e) => error_response(e),
},
Route::PlaylistAdd(name) => match query_or_body(&query_params, &body_text) {
Ok(q) => match state.add_to_playlist(&name, &q) {
Ok((pl, song)) => json_response(200, &serde_json::json!({
"playlist": pl,
"added": song,
})),
Err(e) => error_response(e),
},
Err(e) => error_response(e),
},
Route::PlaylistPlay(name) => match state.play_playlist(&name) {
Ok(pl) => json_response(200, &pl),
Err(e) => error_response(e),
},
Route::PlaylistDelete(name) => match state.delete_playlist(&name) {
Ok(()) => json_response(200, &serde_json::json!({"deleted": name})),
Err(e) => error_response(e),
},
Route::NotFound => json_response(404, &ApiError {
error: format!("no route for {method} {path}"),
}),
};
Ok(request.respond(response)?)
}
enum Route {
Help,
Songs,
Search,
Queue,
NowPlaying,
Play,
Add,
Del,
Init,
Skip,
QueueClear,
PlaylistsList,
PlaylistCreate,
PlaylistGet(String),
PlaylistAdd(String),
PlaylistPlay(String),
PlaylistDelete(String),
NotFound,
}
fn route_for(method: &Method, path: &str) -> Route {
let p = path.trim_end_matches('/');
match (method, p) {
(Method::Get, "") | (Method::Get, "/help") => Route::Help,
(Method::Get, "/songs") => Route::Songs,
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
(Method::Get, "/queue") => Route::Queue,
(Method::Get, "/now-playing") => Route::NowPlaying,
(Method::Post, "/play") => Route::Play,
(Method::Post, "/add") => Route::Add,
(Method::Post, "/del") => Route::Del,
(Method::Post, "/init") => Route::Init,
(Method::Post, "/skip") => Route::Skip,
(Method::Post, "/queue/clear") => Route::QueueClear,
(Method::Get, "/playlists") => Route::PlaylistsList,
(Method::Post, "/playlists") => Route::PlaylistCreate,
(Method::Get, path) if path.starts_with("/playlists/") => {
let rest = &path["/playlists/".len()..];
let (name, suffix) = split_playlist_route(rest);
match suffix {
"/add" => Route::PlaylistAdd(name),
"/play" => Route::PlaylistPlay(name),
"" => Route::PlaylistGet(name),
_ => Route::NotFound,
}
}
(Method::Delete, path) if path.starts_with("/playlists/") => {
let name = path["/playlists/".len()..].to_string();
Route::PlaylistDelete(name)
}
_ => Route::NotFound,
}
}
fn split_playlist_route(rest: &str) -> (String, &'static str) {
if let Some(name) = rest.strip_suffix("/add") {
return (name.to_string(), "/add");
}
if let Some(name) = rest.strip_suffix("/play") {
return (name.to_string(), "/play");
}
(rest.to_string(), "")
}
fn parse_query(url: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
if let Some(q) = url.split('?').nth(1) {
for pair in q.split('&') {
if let Some((k, v)) = pair.split_once('=') {
out.insert(urldecode(k), urldecode(v));
}
}
}
out
}
/// Percent-decodes using `url` crate's `percent_encoding` so that UTF-8
/// sequences survive intact.
fn urldecode(s: &str) -> String {
use percent_encoding::percent_decode_str;
percent_decode_str(s)
.decode_utf8_lossy()
.into_owned()
.replace('+', " ")
}
fn query_or_body(
query_params: &std::collections::HashMap<String, String>,
body: &str,
) -> Result<String, String> {
if let Some(q) = query_params.get("q") {
if !q.is_empty() {
return Ok(q.clone());
}
}
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
if let Some(obj) = v.as_object() {
for key in ["query", "name", "song", "q"] {
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
if !s.is_empty() {
return Ok(s.to_string());
}
}
}
}
Err("missing 'query' parameter".to_string())
}
fn name_from_body(body: &str) -> Result<String, String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
if let Some(obj) = v.as_object() {
for key in ["name", "song", "query"] {
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
if !s.is_empty() {
return Ok(s.to_string());
}
}
}
}
Err("missing 'name' or 'song' in body".to_string())
}
-62
View File
@@ -1,62 +0,0 @@
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
use std::sync::{Arc, Mutex};
use std::thread;
use crate::{protocol::Command, state::DaemonState};
pub fn start_socket(state: Arc<Mutex<DaemonState>>) {
let _ = std::fs::remove_file("/tmp/sjnmusic.sock");
let listener = UnixListener::bind("/tmp/sjnmusic.sock").unwrap();
for stream in listener.incoming() {
let state = state.clone();
thread::spawn(move || {
let mut stream = stream.unwrap();
let mut buf = [0; 2048];
let size = stream.read(&mut buf).unwrap();
let msg = String::from_utf8_lossy(&buf[..size]);
if let Ok(cmd) = serde_json::from_str::<Command>(&msg) {
let response = handle(cmd, state);
let _ = stream.write_all(response.as_bytes());
} else {
let _ = stream.write_all(b"ERROR: Invalid JSON");
}
});
}
}
fn handle(cmd: Command, state: Arc<Mutex<DaemonState>>) -> String {
let mut state = state.lock().unwrap();
match cmd {
Command::Play(query) => {
state.play(&query);
format!("OK: Playing {}", query)
}
Command::Add(query) => {
state.add(&query);
format!("OK: Added {}", query)
}
Command::Del(query) => {
state.delete(&query);
format!("OK: Deleted {}", query)
}
Command::Init(song) => {
state.init(song.clone());
format!("OK: Downloading {}", song)
}
Command::Search(query) => {
if let Some(song) = state.search(&query) {
serde_json::to_string(&song).unwrap_or_else(|_| "ERROR: Serialization failed".to_string())
} else {
"ERROR: Song not found".to_string()
}
}
}
}
+447 -93
View File
@@ -1,139 +1,493 @@
use std::process::Command as SysCommand;
use strsim::jaro_winkler;
use serde::{Serialize, Deserialize};
use std::sync::Mutex;
#[derive(Clone, Serialize, Deserialize)]
use log::{debug, error, info, warn};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use strsim::jaro_winkler;
use crate::paths::song_path;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Song {
pub id: i64,
pub name: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Playlist {
pub id: i64,
pub name: String,
pub songs: Vec<Song>,
}
/// 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>,
}
const FUZZY_THRESHOLD: f64 = 0.65;
impl DaemonState {
pub fn new() -> Self {
pub fn new(conn: Connection) -> Self {
let queue = Self::load_queue_from_db(&conn);
info!("Restored queue with {} song(s) from DB", queue.len());
Self {
queue: Vec::new(),
conn: Mutex::new(conn),
queue,
current: None,
}
}
pub fn play(&mut self, query: &str) {
if let Some(song) = self.search(query) {
self.queue.insert(0, song);
} else {
eprintln!("Song nicht gefunden: {}", query);
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()
}
}
}
pub fn add(&mut self, query: &str) {
if let Some(song) = self.search(query) {
self.queue.push(song);
} else {
eprintln!("Song nicht gefunden: {}", query);
}
// ------------------------------------------------------------------
// 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
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)?,
})
})
.map(|it| it.filter_map(Result::ok).collect())
.unwrap_or_default()
}
pub fn delete(&mut self, query: &str) {
if let Some(song) = self.search(query) {
let _ = std::fs::remove_file(&song.path);
self.queue.retain(|s| s.name != song.name);
} else {
eprintln!("Song nicht gefunden: {}", query);
}
/// 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.
pub fn all_songs(&self) -> Vec<Song> {
Self::list_songs(&self.lock_conn())
}
pub fn init(&mut self, name: String) {
let path = crate::paths::song_path(&name);
fn list_songs(conn: &Connection) -> Vec<Song> {
let mut stmt = match conn.prepare("SELECT id, name, path FROM songs ORDER BY name") {
Ok(s) => s,
Err(e) => {
error!("Failed to list songs: {e}");
return Vec::new();
}
};
let result = SysCommand::new("yt-dlp")
stmt.query_map([], |row| {
Ok(Song {
id: row.get(0)?,
name: row.get(1)?,
path: row.get(2)?,
})
})
.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 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",
[],
|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(())
}
// ------------------------------------------------------------------
// 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.
pub fn search(&self, query: &str) -> Option<Song> {
debug!("Searching DB for: {query}");
let query_lower = 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) {
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)
.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
}
/// 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())?;
}
info!("Play: {} (id={})", song.name, song.id);
Ok(song)
}
/// Append a song to the end of the queue.
pub fn add(&mut self, query: &str) -> Result<Song, String> {
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();
// Foreign keys remove queue & playlist entries automatically.
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)
}
/// 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);
debug!("yt-dlp target path: {path}");
let output = std::process::Command::new("yt-dlp")
.args([
"-x",
"--audio-format",
"mp3",
"-o",
&path,
&format!("ytsearch1:{}", name),
&format!("ytsearch1:{name}"),
])
.output();
if result.is_err() {
eprintln!("INIT failed: yt-dlp not available or download error");
return;
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}"));
}
}
println!("INIT finished: {}", path);
if !std::path::Path::new(&path).exists() {
return Err(format!("download finished but file missing: {path}"));
}
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}");
Ok(Song {
id,
name,
path,
})
}
pub fn search(&self, query: &str) -> Option<Song> {
use std::fs;
let songs_dir = crate::paths::songs_dir();
let query_lower = query.to_lowercase();
// Alle .mp3 Dateien aus songs_dir lesen
let entries = match fs::read_dir(&songs_dir) {
Ok(entries) => entries,
Err(_) => return None,
/// 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 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> {
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`.
let result = {
let conn = self.lock_conn();
conn.execute(
"DELETE FROM queue WHERE song_id = ?1 ORDER BY position ASC LIMIT 1",
params![id],
)
};
result.map_err(|e| format!("failed to delete queue row from DB: {e}"))?;
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()? {
info!("Skipped {}", skipped.name);
Ok(Some(skipped))
} else {
Ok(None)
}
}
// ------------------------------------------------------------------
// 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 mut best_match: Option<(String, f64)> = None;
const MIN_SCORE: f64 = 0.65; // Threshold - nicht zu aggressiv
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);
for entry in entries {
if let Ok(entry) = entry {
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
if let Some(filename) = entry.file_name().to_str() {
// .mp3 entfernen
let song_name = filename.strip_suffix(".mp3")
.or_else(|| filename.strip_suffix(".webm"))
.unwrap_or(filename);
let song_name_lower = song_name.to_lowercase();
// Exakte Übereinstimmung (case-insensitive)
if song_name_lower == query_lower {
return Some(Song {
name: song_name.to_string(),
path: entry.path().to_string_lossy().to_string(),
});
}
// Fuzzy match mit Jaro-Winkler
let score = jaro_winkler(&song_name_lower, &query_lower);
if score > MIN_SCORE {
if best_match.is_none() || score > best_match.as_ref().unwrap().1 {
best_match = Some((
entry.path().to_string_lossy().to_string(),
score
));
}
}
}
}
}
}
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
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)?,
})
})
.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(())
}
// Wenn fuzzy match gefunden, returne es
if let Some((path, _)) = best_match {
if let Some(filename) = std::path::Path::new(&path)
.file_name()
.and_then(|f| f.to_str()) {
let song_name = filename.strip_suffix(".mp3")
.or_else(|| filename.strip_suffix(".webm"))
.unwrap_or(filename);
return Some(Song {
name: song_name.to_string(),
path,
});
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))
}
/// 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();
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)
};
// Guard is dropped; safe to mutate self.queue without borrow conflict.
self.queue = songs.clone();
info!("Playing playlist {name} ({} songs)", songs.len());
Ok(Playlist {
id: playlist_id,
name: name.to_string(),
songs,
})
}
None
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),
})
}
}