Fix config loading, modularize crates, and add valid config template

This commit is contained in:
2026-07-21 21:09:04 +02:00
parent 66844ea01e
commit 4b731d406a
22 changed files with 6084 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "riptune-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Async Subsonic API client (auth, browsing, streaming URLs)"
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
md-5 = "0.10"
rand = "0.8"
url = "2"
+225
View File
@@ -0,0 +1,225 @@
//! Lightweight async wrapper over the Subsonic REST API (compatible with
//! Navidrome, Airsonic, Gonic, etc).
//!
//! Handles the legacy salted-token auth scheme (`t = md5(password + salt)`), since
//! not all servers support Navidrome's newer API-key auth yet. See
//! <http://www.subsonic.org/pages/api.jsp> for the wire format.
pub mod models;
use md5::{Digest, Md5};
use rand::Rng;
use thiserror::Error;
const API_VERSION: &str = "1.16.1";
const CLIENT_NAME: &str = "riptune";
#[derive(Debug, Error)]
pub enum SubsonicError {
#[error("request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("server returned error {code}: {message}")]
Api { code: i32, message: String },
#[error("invalid server URL: {0}")]
InvalidUrl(#[from] url::ParseError),
}
#[derive(Clone)]
pub struct SubsonicClient {
http: reqwest::Client,
base_url: url::Url,
username: String,
password: String,
}
impl SubsonicClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, SubsonicError> {
Ok(Self {
http: reqwest::Client::new(),
base_url: url::Url::parse(base_url)?,
username: username.to_string(),
password: password.to_string(),
})
}
/// Builds the auth query params required on every request: `u`, `t`, `s`, `v`, `c`, `f`.
fn auth_params(&self) -> Vec<(String, String)> {
let salt: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(12)
.map(char::from)
.collect();
let mut hasher = Md5::new();
hasher.update(format!("{}{}", self.password, salt));
let token = format!("{:x}", hasher.finalize());
vec![
("u".into(), self.username.clone()),
("t".into(), token),
("s".into(), salt),
("v".into(), API_VERSION.into()),
("c".into(), CLIENT_NAME.into()),
("f".into(), "json".into()),
]
}
/// Returns a fully-authenticated streaming URL for a track ID. Handed directly
/// to the audio thread's HTTP fetcher — no extra round trip needed.
pub fn stream_url(&self, track_id: &str) -> Result<String, SubsonicError> {
let mut url = self.base_url.join("rest/stream")?;
{
let mut qp = url.query_pairs_mut();
for (k, v) in self.auth_params() {
qp.append_pair(&k, &v);
}
qp.append_pair("id", track_id);
}
Ok(url.to_string())
}
/// Sends an authenticated GET to `rest/{endpoint}`, unwraps the
/// `subsonic-response` envelope, and either deserializes the payload into
/// `T` or converts a server-side error into `SubsonicError::Api`.
///
/// All Subsonic endpoints share this envelope shape, so every real method
/// (`get_artists`, `get_album`, `ping`, ...) is a thin wrapper around this.
async fn call<T: serde::de::DeserializeOwned>(
&self,
endpoint: &str,
extra_params: &[(&str, &str)],
) -> Result<T, SubsonicError> {
let mut url = self.base_url.join(&format!("rest/{endpoint}"))?;
{
let mut qp = url.query_pairs_mut();
for (k, v) in self.auth_params() {
qp.append_pair(&k, &v);
}
for (k, v) in extra_params {
qp.append_pair(k, v);
}
}
let body: serde_json::Value = self.http.get(url).send().await?.json().await?;
let root = body.get("subsonic-response").ok_or_else(|| SubsonicError::Api {
code: -1,
message: "response was missing the subsonic-response envelope".into(),
})?;
let status = root.get("status").and_then(|s| s.as_str()).unwrap_or("");
if status != "ok" {
let error = root.get("error");
let code = error.and_then(|e| e.get("code")).and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
let message = error
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("server returned an unspecified error")
.to_string();
return Err(SubsonicError::Api { code, message });
}
serde_json::from_value(root.clone()).map_err(|e| SubsonicError::Api {
code: -1,
message: format!("failed to parse response: {e}"),
})
}
/// Cheapest possible round trip to confirm the URL, auth, and network
/// path all work — hits `rest/ping` and checks for a non-error status.
pub async fn ping(&self) -> Result<(), SubsonicError> {
#[derive(serde::Deserialize)]
struct PingResponse {
#[allow(dead_code)]
status: String,
}
self.call::<PingResponse>("ping", &[]).await?;
Ok(())
}
pub async fn get_artists(&self) -> Result<Vec<models::Artist>, SubsonicError> {
// We fetch the raw JSON value first so we can robustly extract the array
// regardless of whether the server uses a flat index or nested structure.
let response: serde_json::Value = self.call("getArtists", &[]).await?;
let mut artists = Vec::new();
// Navigate safely through 'artists' -> 'index' array
if let Some(index_array) = response.get("artists").and_then(|a| a.get("index")).and_then(|i| i.as_array()) {
for group in index_array {
if let Some(artist_list) = group.get("artist").and_then(|a| a.as_array()) {
for artist_val in artist_list {
if let Ok(artist) = serde_json::from_value::<models::Artist>(artist_val.clone()) {
artists.push(artist);
}
}
}
}
}
// Fallback: Check if the server responds with a root level 'index' block directly
else if let Some(index_array) = response.get("index").and_then(|i| i.as_array()) {
for group in index_array {
if let Some(artist_list) = group.get("artist").and_then(|a| a.as_array()) {
for artist_val in artist_list {
if let Ok(artist) = serde_json::from_value::<models::Artist>(artist_val.clone()) {
artists.push(artist);
}
}
}
}
}
Ok(artists)
}
pub async fn get_album(&self, album_id: &str) -> Result<models::Album, SubsonicError> {
#[derive(serde::Deserialize)]
struct Wrapper {
album: models::Album,
}
let wrapper: Wrapper = self.call("getAlbum", &[("id", album_id)]).await?;
Ok(wrapper.album)
}
/// Fetches the album list for a single artist (rest/getArtist.view). Distinct
/// from `get_album`, which fetches one album's full track listing.
pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<models::AlbumSummary>, SubsonicError> {
#[derive(serde::Deserialize)]
struct Wrapper {
artist: ArtistDetail,
}
#[derive(serde::Deserialize)]
struct ArtistDetail {
#[serde(default)]
album: Vec<models::AlbumSummary>,
}
let wrapper: Wrapper = self.call("getArtist", &[("id", artist_id)]).await?;
Ok(wrapper.artist.album)
}
pub async fn star(&self, _track_id: &str) -> Result<(), SubsonicError> {
// TODO: GET rest/star
todo!("wire up star.view")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_url_includes_required_auth_params() {
let client = SubsonicClient::new("https://music.example.com", "alice", "hunter2").unwrap();
let url = client.stream_url("track-123").unwrap();
assert!(url.starts_with("https://music.example.com/rest/stream"));
for param in ["u=alice", "t=", "s=", "v=", "c=riptune", "f=json", "id=track-123"] {
assert!(url.contains(param), "expected `{param}` in {url}");
}
}
#[test]
fn stream_url_never_leaks_the_raw_password() {
let client = SubsonicClient::new("https://music.example.com", "alice", "hunter2").unwrap();
let url = client.stream_url("track-123").unwrap();
assert!(!url.contains("hunter2"), "raw password must never appear in the URL: {url}");
}
}
+44
View File
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artist {
pub id: String,
pub name: String,
#[serde(rename = "albumCount", default)]
pub album_count: u32,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumSummary {
pub id: String,
pub name: String,
#[serde(rename = "songCount", default)]
pub song_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
pub id: String,
pub name: String,
pub artist: String,
#[serde(rename = "artistId")]
pub artist_id: String,
#[serde(default)]
pub song: Vec<Track>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Track {
pub id: String,
pub title: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(default)]
pub album: Option<String>,
#[serde(rename = "duration", default)]
pub duration_secs: Option<u32>,
#[serde(default)]
pub starred: Option<String>,
}