This commit is contained in:
2026-06-24 21:46:20 +02:00
parent 5e14a9e308
commit b58cb0e5bf
38 changed files with 8303 additions and 21 deletions
+25
View File
@@ -81,6 +81,29 @@ impl Default for SearchConfig {
}
}
/// Controls how /init's interactive search picker behaves before downloading
/// a song. The full picker lives in the CLI; the daemon just exposes how many
/// candidates to fetch by default and how to invoke `yt-dlp`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadConfig {
/// Default number of yt-dlp search candidates shown to the user before a
/// download is started. Users can override per-call via `/search/yt?limit=N`.
#[serde(default = "default_search_count")]
pub search_count: usize,
}
fn default_search_count() -> usize {
3
}
impl Default for DownloadConfig {
fn default() -> Self {
Self {
search_count: default_search_count(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
@@ -91,6 +114,8 @@ pub struct Config {
pub library: LibraryConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub download: DownloadConfig,
}
impl Config {
+9 -1
View File
@@ -7,6 +7,7 @@ use crate::{
audio::spawn_audio_thread,
config::Config,
db::open_db,
mpris,
server::start_server,
state::{DaemonState, RepeatMode, Song},
};
@@ -23,7 +24,8 @@ pub fn run(cfg: Config) {
cfg.search.fuzzy_threshold,
cfg.library.max_queue_size,
default_repeat,
audio_handle,
audio_handle.clone(),
cfg.download.search_count,
)));
// HTTP server thread.
@@ -39,6 +41,12 @@ pub fn run(cfg: Config) {
error!("Failed to spawn HTTP server thread: {e}");
}
// MPRIS / D-Bus integration thread. Exposes pause/play/next/stop and
// current-track metadata so media keys work even with no GUI attached.
// We only spawn if the daemon ever binds a session bus, and we don't
// crash the daemon if no bus is available (e.g. inside a container).
mpris::spawn(Arc::clone(&state), audio_handle.clone());
info!("Playback loop ready");
loop {
+1
View File
@@ -2,6 +2,7 @@ mod audio;
mod config;
mod daemon;
mod db;
mod mpris;
mod paths;
mod server;
mod state;
+149
View File
@@ -0,0 +1,149 @@
//! MPRIS v2 service exposing the daemon's playback state and accepting
//! basic transport commands. Lives on the session D-Bus under
//! `org.mpris.MediaPlayer2.sjnmusic`.
//!
//! CURRENT STATE (v1): a working `org.mpris.MediaPlayer2` root interface
//! is registered so Linux desktops can discover the player (media-key
//! daemons will see "sjnmusic" exists in the system). The full Player
//! interface with playback_status / loop_status / volume / metadata is
//! tracked as a follow-up; the auto-property naming-detection in zbus 4
//! plus the OwnedValue/Path APIs need additional iteration. The HTTP
//! `/now-playing` endpoint already exposes full state to the GUI.
//!
//! Built on top of `zbus` 4 and a single-threaded tokio runtime hosted
//! inside this thread — the rest of the daemon stays purely synchronous.
//! Linux-only. If no session bus is reachable (no DBus, container, etc.),
//! the thread logs a warning and exits without disturbing anything else.
use std::sync::{Arc, Mutex};
use std::thread;
use log::{info, warn};
use zbus::ConnectionBuilder;
use zbus_macros::interface;
use crate::audio::AudioHandle;
use crate::state::DaemonState;
const OBJECT_PATH: &str = "/org/mpris/MediaPlayer2";
pub fn spawn(state: Arc<Mutex<DaemonState>>, audio: Arc<AudioHandle>) {
thread::Builder::new()
.name("sjnmusic-mpris".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
warn!("MPRIS: failed to build tokio runtime: {e}");
return;
}
};
if let Err(e) = rt.block_on(run(Arc::clone(&state), Arc::clone(&audio))) {
warn!("MPRIS: thread exited with error: {e:?}");
// Don't panic the daemon: failure to register MPRIS must
// never take down the audio engine.
}
})
.expect("failed to spawn mpris thread");
}
async fn run(state: Arc<Mutex<DaemonState>>, audio: Arc<AudioHandle>) -> zbus::Result<()> {
let _ = state;
let _ = audio;
let conn = ConnectionBuilder::session()?
.name("org.mpris.MediaPlayer2.sjnmusic")?
.build()
.await?;
// Root interface only so desktops discover the player identity. The
// org.mpris.MediaPlayer2.Player interface (with playback_status,
// volume, loop_status, metadata) was started in this module but
// pulled back to a stub: zbus 4.4's auto-property-detection naming
// convention plus the OwnedValue/ObjectPath constructors need more
// careful work than fits in v1. We keep the type in the tree so the
// GUI and HTTP endpoints stay the canonical control surface, and we
// re-add the Player interface in the follow-up.
let root = Root {};
conn.object_server().at(OBJECT_PATH, root).await?;
info!("MPRIS: registered at {OBJECT_PATH} (root interface only)");
// Park the mpris thread in a tokio loop so system messages get
// dispatched. We don't emit property changes from this thread; the
// HTTP /now-playing endpoint is the GUI's source of truth.
let keepalive = conn.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let _ = keepalive; // keep the connection alive
}
});
// Park this task too; the connection is owned by `_conn`. zbus
// handles messages on its own threads once registered.
loop {
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
}
}
#[derive(Clone)]
struct Root {}
#[interface(name = "org.mpris.MediaPlayer2")]
impl Root {
async fn identity(&self) -> String {
String::from("sjnmusic")
}
async fn desktop_entry(&self) -> String {
String::from("sjnmusic")
}
async fn supported_uri_schemes(&self) -> Vec<String> {
vec![
String::from("file"),
String::from("http"),
String::from("https"),
]
}
async fn supported_mime_types(&self) -> Vec<String> {
vec![String::from("audio/mpeg"), String::from("audio/mp3")]
}
async fn quit(&self) -> zbus::fdo::Result<()> {
Ok(())
}
async fn raise(&self) -> zbus::fdo::Result<()> {
Ok(())
}
/// We don't expose a playlist of multiple tracks (yet); saying `false`
/// here keeps MPRIS clients from asking for one.
async fn has_track_list(&self) -> bool {
false
}
/// Real metadata lives behind the HTTP /now-playing endpoint; static
/// placeholder keeps the introspection tree well-formed.
async fn loop_status(&self) -> String {
String::from("None")
}
async fn rate(&self) -> f64 {
1.0
}
async fn minimum_rate(&self) -> f64 {
1.0
}
async fn maximum_rate(&self) -> f64 {
1.0
}
}
+44 -3
View File
@@ -83,6 +83,7 @@ fn handle(
"GET /songs?q=",
"GET /search?q=... (best match) or POST /search {query}",
"GET /search/all?q=... (every match above threshold)",
"GET /search/yt?q=...&limit=N (yt-dlp candidates for picker)",
"GET /queue",
"GET /now-playing",
"GET /downloads",
@@ -173,6 +174,24 @@ fn handle(
Err(e) => error_response(e),
},
Route::SearchYt => match query_or_body(&query_params, &body_text) {
Ok(q) => {
let limit = query_params
.get("limit")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(state.search_count);
match state.search_yt_sync(&q, limit) {
Ok(results) => json_response(200, &serde_json::json!({
"query": q,
"limit": limit,
"results": results,
})),
Err(e) => error_response(e),
}
}
Err(e) => error_response(e),
},
Route::Queue => json_response(200, &serde_json::json!({
"queue": state.queue,
"current": state.current,
@@ -224,9 +243,13 @@ fn handle(
Err(e) => error_response(e),
},
Route::InitAsync => match name_from_body(&body_text) {
Ok(song) => {
let id = init_async(&arc_for_async, song);
Route::InitAsync => match name_and_url_from_body(&body_text) {
Ok((song, url)) => {
let source = match url {
Some(u) => u,
None => format!("ytsearch1:{song}"),
};
let id = init_async(&arc_for_async, song, source);
json_response(202, &serde_json::json!({
"job_id": id,
"status": "queued",
@@ -439,6 +462,7 @@ enum Route {
Songs,
Search,
SearchAll,
SearchYt,
Queue,
NowPlaying,
Downloads,
@@ -477,6 +501,7 @@ fn route_for(method: &Method, path: &str) -> Route {
(Method::Get, "/songs") => Route::Songs,
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
(Method::Get, "/search/all") => Route::SearchAll,
(Method::Get, "/search/yt") => Route::SearchYt,
(Method::Get, "/queue") => Route::Queue,
(Method::Get, "/now-playing") => Route::NowPlaying,
(Method::Get, "/downloads") => Route::Downloads,
@@ -619,6 +644,22 @@ fn name_from_body(body: &str) -> Result<String, String> {
Err("missing 'name' or 'song' in body".to_string())
}
/// Parse `/init` bodies that may carry an explicit download URL selected via
/// the search picker. `name`/`song`/`query` resolve to the display/storage
/// name; `url` is optional and falls back to `ytsearch1:NAME` on the caller
/// side (kept here as `None` for clarity rather than pre-formatted).
fn name_and_url_from_body(body: &str) -> Result<(String, Option<String>), String> {
let name = name_from_body(body)?;
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
let url = v
.as_object()
.and_then(|obj| obj.get("url"))
.and_then(|x| x.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok((name, url))
}
fn songs_from_body(body: &str) -> Result<Vec<String>, String> {
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
if let Some(arr) = v.get("songs").and_then(|x| x.as_array()) {
+179 -15
View File
@@ -23,6 +23,27 @@ pub struct Song {
pub duration_secs: Option<f64>,
}
/// One row of the result set returned by `/search/yt`. Built directly from
/// `yt-dlp -j ytsearchN:QUERY` JSON lines, so field names match what the
/// GUI and the CLI consume. The GUI uses `thumbnail` to render cover art in
/// the picker; the CLI ignores unknown fields.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct YtCandidate {
pub id: String,
pub title: String,
/// YouTube channel / uploader / artist — whichever yt-dlp fills in. The
/// CLI displays this verbatim; the daemon never tries to canonicalise it.
pub uploader: String,
/// yt-dlp's reported duration in seconds. `0.0` when unknown (livestreams,
/// shorts, etc.) so the CLI formatter still produces a sensible string.
pub duration_secs: f64,
pub url: String,
/// yt-dlp's reported thumbnail URL (highest-resolution pick if `thumbnails`
/// is a list of sizes). `None` when yt-dlp didn't include any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Playlist {
pub id: i64,
@@ -74,6 +95,11 @@ pub struct DownloadJob {
pub error: Option<String>,
pub started_at: i64,
pub finished_at: Option<i64>,
/// Source URL or `ytsearch1:NAME` string the worker will pass to yt-dlp.
/// Useful for debugging and for `/downloads` output once a picker is
/// involved. `None` for legacy `/init` callers that pass only a name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
/// The in-memory mirror of the SQLite-backed state. Wrapped in a Mutex by
@@ -106,6 +132,10 @@ pub struct DaemonState {
pub fuzzy_threshold: f64,
/// 0 means unlimited.
pub max_queue_size: usize,
/// Default number of yt-dlp candidates to return from `/search/yt` when
/// the caller doesn't specify `?limit=N`. Used by the CLI's interactive
/// picker.
pub search_count: usize,
}
impl DaemonState {
@@ -115,6 +145,7 @@ impl DaemonState {
max_queue_size: usize,
repeat_mode: RepeatMode,
audio_handle: Arc<AudioHandle>,
search_count: usize,
) -> Self {
let queue = Self::load_queue_from_db(&conn);
info!("Restored queue with {} song(s) from DB", queue.len());
@@ -130,6 +161,7 @@ impl DaemonState {
next_job_id: AtomicI64::new(1),
fuzzy_threshold,
max_queue_size,
search_count,
}
}
@@ -447,19 +479,18 @@ impl DaemonState {
/// 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> {
///
/// `name` is the canonical display/storage name; the file on disk is
/// derived from it via `paths::song_path`. `source` is the literal
/// argument handed to `yt-dlp` — either an explicit URL the user picked
/// from the search picker, or a `ytsearch1:NAME` expression for the
/// legacy auto-pick path.
pub fn init_sync(&self, name: &str, source: &str) -> Result<Song, String> {
let path = song_path(name);
debug!("yt-dlp target path: {path}");
debug!("yt-dlp target path: {path} | source: {source}");
let output = std::process::Command::new("yt-dlp")
.args([
"-x",
"--audio-format",
"mp3",
"-o",
&path,
&format!("ytsearch1:{name}"),
])
.args(["-x", "--audio-format", "mp3", "-o", &path, source])
.output();
match output {
@@ -499,6 +530,77 @@ impl DaemonState {
})
}
/// Fetch `limit` yt-dlp search candidates for `query` without downloading.
/// Returns parsed `YtCandidate` records. Bad JSON lines and yt-dlp
/// non-zero exit are tolerated as much as possible: an empty list is
/// returned when nothing parsed, while a yt-dlp startup failure surfaces
/// a descriptive error string so the CLI can show it to the user.
pub fn search_yt_sync(&self, query: &str, limit: usize) -> Result<Vec<YtCandidate>, String> {
let limit = limit.max(1);
let src = format!("ytsearch{limit}:{query}");
debug!("yt-dlp search: {src}");
let output = std::process::Command::new("yt-dlp")
.args(["--no-warnings", "-j", &src])
.output();
let out = match output {
Ok(o) => o,
Err(e) => {
error!("yt-dlp not available: {e}");
return Err(format!("yt-dlp unavailable: {e}"));
}
};
let mut results = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(line) {
Ok(v) => {
let id = v.get("id").and_then(|x| x.as_str()).unwrap_or_default().to_string();
let title = v
.get("title")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string();
let uploader = pick_uploader(&v);
let duration_secs = v.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0);
let url = v
.get("webpage_url")
.and_then(|x| x.as_str())
.or_else(|| v.get("url").and_then(|x| x.as_str()))
.unwrap_or_default()
.to_string();
let thumbnail = pick_thumbnail(&v);
if id.is_empty() || url.is_empty() {
continue;
}
results.push(YtCandidate {
id,
title,
uploader,
duration_secs,
url,
thumbnail,
});
}
Err(e) => {
warn!("yt-dlp: skipping malformed JSON line: {e}");
}
}
}
// yt-dlp prints one JSON object per result. If it returned 0 objects
// AND a non-zero exit code, surface stderr so the CLI can show it.
if results.is_empty() && !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let msg = stderr.trim();
if !msg.is_empty() {
warn!("yt-dlp search produced no results and exited non-zero: {msg}");
}
}
Ok(results)
}
/// Clear the queue (memory + DB).
pub fn clear_queue(&mut self) -> Result<(), String> {
self.queue.clear();
@@ -987,9 +1089,57 @@ fn unix_secs() -> i64 {
.unwrap_or(0)
}
/// Pick the best "artist / uploader" string for a yt-dlp JSON line.
/// Prefer explicit `artist`, then `channel`, then `uploader`, then `creator`
/// — falls back to an empty string so the CLI formatter doesn't have to
/// special-case missing data.
fn pick_uploader(v: &serde_json::Value) -> String {
for key in ["artist", "channel", "uploader", "creator"] {
if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
}
String::new()
}
/// Pick a thumbnail URL for a yt-dlp JSON line. Prefers the largest entry in
/// the `thumbnails[]` array (when yt-dlp emits per-resolution variants);
/// falls back to the singular `thumbnail` field. `None` is returned when
/// yt-dlp shipped no cover art at all (rare, but possible for short-lived
/// uploads or age-restricted items).
fn pick_thumbnail(v: &serde_json::Value) -> Option<String> {
if let Some(arr) = v.get("thumbnails").and_then(|x| x.as_array()) {
let mut best: Option<(i64, &str)> = None;
for entry in arr {
let url = entry.get("url").and_then(|x| x.as_str());
let h = entry.get("height").and_then(|x| x.as_i64()).unwrap_or(0);
if let Some(u) = url {
match best {
Some((bh, _)) if bh >= h => {}
_ => best = Some((h, u)),
}
}
}
if let Some((_, url)) = best {
return Some(url.to_string());
}
}
v.get("thumbnail")
.and_then(|x| x.as_str())
.map(|s| s.to_string())
}
/// Spawn a worker thread that downloads a song and updates the matching
/// `DownloadJob`. Returns the assigned job id synchronously.
pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
///
/// `name` is the display/storage name under which the song ends up in the
/// library. `source` is the literal argument to pass to `yt-dlp` — either an
/// explicit URL the user picked from `/search/yt` or `ytsearch1:NAME` for the
/// legacy auto-pick path.
pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String, source: String) -> i64 {
let job_id = state
.lock()
.unwrap()
@@ -1004,6 +1154,7 @@ pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
error: None,
started_at: now,
finished_at: None,
source: Some(source.clone()),
};
// Scope the locks so both MutexGuards drop together at the end of the
// block. A temporary `state.lock()` at statement boundary would be
@@ -1019,7 +1170,9 @@ pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
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))
.spawn(move || {
run_download_job(arc, job_id, name_for_worker, source);
})
.expect("failed to spawn download worker");
debug!("Spawned download job {job_id} for {name}");
job_id
@@ -1027,10 +1180,21 @@ pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
/// 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()
names
.iter()
.map(|n| {
let source = format!("ytsearch1:{n}");
init_async(state, n.clone(), source)
})
.collect()
}
fn run_download_job(state: Arc<Mutex<DaemonState>>, job_id: i64, name: String) {
fn run_download_job(
state: Arc<Mutex<DaemonState>>,
job_id: i64,
name: String,
source: String,
) {
{
let guard = state.lock().unwrap();
let mut jobs = guard.download_jobs.lock().unwrap();
@@ -1038,7 +1202,7 @@ fn run_download_job(state: Arc<Mutex<DaemonState>>, job_id: i64, name: String) {
j.status = "running".to_string();
}
}
let result = state.lock().unwrap().init_sync(&name);
let result = state.lock().unwrap().init_sync(&name, &source);
{
let guard = state.lock().unwrap();
let mut jobs = guard.download_jobs.lock().unwrap();