From 98e3367822c050965c24a6f964f9e4cb90c76871 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Tue, 3 Mar 2026 14:56:00 -0500 Subject: [PATCH] Added basic airsonic support --- src/airsonic.rs | 200 ++++++++++++++++++++++++++++++++++++++++++++++++ src/db.rs | 11 +++ src/main.rs | 38 +++++++-- src/mpd.rs | 14 ++-- 4 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 src/airsonic.rs diff --git a/src/airsonic.rs b/src/airsonic.rs new file mode 100644 index 0000000..c97b66b --- /dev/null +++ b/src/airsonic.rs @@ -0,0 +1,200 @@ +use serde::Deserialize; + +pub struct AirsonicClient { + base_url: String, + user: String, + password: String, +} + +#[derive(Deserialize)] +struct SubsonicResponse { + #[serde(rename = "subsonic-response")] + subsonic_response: SubsonicEnvelope, +} + +#[derive(Deserialize)] +struct SubsonicEnvelope { + #[allow(dead_code)] + status: String, + #[serde(rename = "searchResult3")] + search_result3: Option, + playlists: Option, +} + +#[derive(Deserialize)] +struct SearchResult3 { + #[serde(default)] + song: Vec, +} + +#[derive(Deserialize)] +struct SongEntry { + id: String, + #[serde(rename = "musicBrainzId")] + mbid: Option, +} + +#[derive(Deserialize)] +struct PlaylistsWrapper { + #[serde(default)] + playlist: PlaylistList, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum PlaylistList { + Many(Vec), + One(PlaylistEntry), +} + +impl Default for PlaylistList { + fn default() -> Self { + PlaylistList::Many(Vec::new()) + } +} + +impl PlaylistList { + fn into_vec(self) -> Vec { + match self { + PlaylistList::Many(v) => v, + PlaylistList::One(e) => vec![e], + } + } +} + +#[derive(Deserialize)] +struct PlaylistEntry { + id: String, + name: String, +} + +impl AirsonicClient { + pub fn new() -> Result { + let base_url = std::env::var("AIRSONIC_URL") + .map_err(|_| "AIRSONIC_URL not set".to_string())?; + let user = std::env::var("AIRSONIC_USER") + .map_err(|_| "AIRSONIC_USER not set".to_string())?; + let password = std::env::var("AIRSONIC_PASSWORD") + .map_err(|_| "AIRSONIC_PASSWORD not set".to_string())?; + Ok(Self { base_url: base_url.trim_end_matches('/').to_string(), user, password }) + } + + fn api_url(&self, endpoint: &str, extra: &str) -> String { + format!( + "{}/rest/{}?u={}&p={}&v=1.16.1&c=playlists&f=json{}", + self.base_url, endpoint, self.user, self.password, extra + ) + } + + fn search_song(&self, query: &str) -> Result, String> { + let encoded = urlencoding::encode(query); + let url = self.api_url("search3", &format!("&query={encoded}&songCount=50&artistCount=0&albumCount=0")); + let body: String = ureq::get(&url) + .call() + .map_err(|e| format!("search3 request: {e}"))? + .body_mut() + .read_to_string() + .map_err(|e| format!("search3 read: {e}"))?; + let resp: SubsonicResponse = serde_json::from_str(&body) + .map_err(|e| format!("search3 parse: {e}"))?; + Ok(resp.subsonic_response.search_result3 + .map(|r| r.song) + .unwrap_or_default()) + } + + fn get_playlists(&self) -> Result, String> { + let url = self.api_url("getPlaylists", ""); + let body: String = ureq::get(&url) + .call() + .map_err(|e| format!("getPlaylists request: {e}"))? + .body_mut() + .read_to_string() + .map_err(|e| format!("getPlaylists read: {e}"))?; + let resp: SubsonicResponse = serde_json::from_str(&body) + .map_err(|e| format!("getPlaylists parse: {e}"))?; + Ok(resp.subsonic_response.playlists + .map(|p| p.playlist.into_vec()) + .unwrap_or_default()) + } + + fn create_or_update_playlist(&self, name: &str, existing_id: Option<&str>, song_ids: &[String]) -> Result<(), String> { + let id_params: String = song_ids.iter().map(|id| format!("&songId={id}")).collect(); + let extra = match existing_id { + Some(id) => format!("&playlistId={id}{id_params}"), + None => format!("&name={}{id_params}", urlencoding::encode(name)), + }; + let url = self.api_url("createPlaylist", &extra); + ureq::get(&url) + .call() + .map_err(|e| format!("createPlaylist request: {e}"))?; + Ok(()) + } + + pub fn create_playlist(&self, name: &str, tracks: &[String], conn: &rusqlite::Connection, verbose: bool) -> Result<(), String> { + let mut song_ids: Vec = Vec::new(); + + for track in tracks { + let (recording_mbid, title) = match crate::db::get_track_metadata(conn, track) { + Ok(Some((Some(mbid), title)) ) => (mbid, title), + Ok(Some((None, _))) => { + eprintln!("Airsonic: no recording MBID for {track}"); + continue; + } + Ok(None) => { + eprintln!("Airsonic: track not in DB: {track}"); + continue; + } + Err(e) => { + eprintln!("DB error for {track}: {e}"); + continue; + } + }; + + let query = title.as_deref().unwrap_or(&recording_mbid); + + if verbose { + eprintln!("Search: {query}"); + eprintln!(" recording MBID: {recording_mbid}"); + } + + let results = match self.search_song(query) { + Ok(r) => r, + Err(e) => { + eprintln!("Airsonic search error for {query}: {e}"); + continue; + } + }; + + if verbose { + eprintln!(" results: {}", results.len()); + for song in &results { + eprintln!(" id={} mbid={}", song.id, song.mbid.as_deref().unwrap_or("(none)")); + } + } + + match results.iter().find(|s| s.mbid.as_deref() == Some(&recording_mbid)) { + Some(song) => song_ids.push(song.id.clone()), + None => eprintln!("Airsonic: no match for {query} (mbid {recording_mbid})"), + } + } + + if song_ids.is_empty() { + return Err("no tracks matched on Airsonic".to_string()); + } + + // Check for existing playlist with the same name + let playlists = self.get_playlists()?; + let existing = playlists.iter().find(|p| p.name == name); + + match existing { + Some(p) => { + eprintln!("Updating existing playlist '{name}'"); + self.create_or_update_playlist(name, Some(&p.id), &song_ids) + } + None => { + eprintln!("Creating playlist '{name}'"); + self.create_or_update_playlist(name, None, &song_ids) + } + } + } +} diff --git a/src/db.rs b/src/db.rs index 0399ebf..61d65be 100644 --- a/src/db.rs +++ b/src/db.rs @@ -114,6 +114,17 @@ pub fn get_top_tracks_by_name( rows.collect() } +pub fn get_track_metadata(conn: &Connection, path: &str) -> Result, Option)>, rusqlite::Error> { + conn.query_row( + "SELECT recording_mbid, title FROM tracks WHERE path = ?1", + [path], + |row| Ok(Some((row.get(0)?, row.get(1)?))), + ).or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(other), + }) +} + pub fn insert_track( conn: &Connection, path: &str, diff --git a/src/main.rs b/src/main.rs index ed3e232..025b0bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod airsonic; mod db; mod filesystem; mod lastfm; @@ -27,7 +28,7 @@ fn db_path() -> PathBuf { fn usage(program: &str) -> ! { eprintln!("Usage:"); eprintln!(" {program} index [-v] [-f] "); - eprintln!(" {program} build [-v] [-m] [-s|-r] [-n COUNT] [file]"); + eprintln!(" {program} build [-v] [-m|-a] [-s|-r] [-n COUNT] [file]"); std::process::exit(1); } @@ -139,6 +140,7 @@ fn cmd_index(args: &[String]) { fn cmd_build(args: &[String]) { let verbose = args.iter().any(|a| a == "-v"); let mpd = args.iter().any(|a| a == "-m"); + let airsonic = args.iter().any(|a| a == "-a"); let shuffle = args.iter().any(|a| a == "-s"); let random = args.iter().any(|a| a == "-r"); @@ -147,12 +149,17 @@ fn cmd_build(args: &[String]) { std::process::exit(1); } + if mpd && airsonic { + eprintln!("Error: -m and -a are mutually exclusive"); + std::process::exit(1); + } + // Parse -n COUNT let mut count: usize = 20; let mut rest: Vec<&String> = Vec::new(); let mut iter = args.iter().skip(2); while let Some(arg) = iter.next() { - if arg == "-v" || arg == "-m" || arg == "-s" || arg == "-r" { + if arg == "-v" || arg == "-m" || arg == "-a" || arg == "-s" || arg == "-r" { continue; } else if arg == "-n" { match iter.next() { @@ -174,7 +181,7 @@ fn cmd_build(args: &[String]) { } if rest.len() > 1 { - eprintln!("Usage: {} build [-v] [-m] [-s|-r] [-n COUNT] [file]", args[0]); + eprintln!("Usage: {} build [-v] [-m|-a] [-s|-r] [-n COUNT] [file]", args[0]); std::process::exit(1); } @@ -217,7 +224,7 @@ fn cmd_build(args: &[String]) { } }; - build_playlist(&conn, &artist_mbid, &seed_name, count, verbose, mpd, shuffle, random); + build_playlist(&conn, &artist_mbid, &seed_name, count, verbose, mpd, airsonic, shuffle, random); } fn build_playlist( @@ -227,6 +234,7 @@ fn build_playlist( count: usize, verbose: bool, mpd: bool, + airsonic: bool, shuffle: bool, random: bool, ) { @@ -329,10 +337,12 @@ fn build_playlist( let tracks: Vec = selected.iter().map(|(_, _, p)| p.clone()).collect(); + let local_dir = env::var("LOCAL_MUSIC_DIR").unwrap_or_default(); + if mpd { - let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default(); - if music_dir.is_empty() { - eprintln!("Error: MPD_MUSIC_DIR not set"); + let mpd_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default(); + if local_dir.is_empty() || mpd_dir.is_empty() { + eprintln!("Error: LOCAL_MUSIC_DIR and MPD_MUSIC_DIR must be set"); std::process::exit(1); } let mut client = match mpd::MpdClient::connect() { @@ -342,7 +352,19 @@ fn build_playlist( std::process::exit(1); } }; - client.queue_playlist(&tracks, &music_dir); + client.queue_playlist(&tracks, &local_dir, &mpd_dir); + } else if airsonic { + let client = match airsonic::AirsonicClient::new() { + Ok(c) => c, + Err(e) => { + eprintln!("Airsonic error: {e}"); + std::process::exit(1); + } + }; + if let Err(e) = client.create_playlist(seed_name, &tracks, conn, verbose) { + eprintln!("Airsonic error: {e}"); + std::process::exit(1); + } } else { for track in &tracks { println!("{track}"); diff --git a/src/mpd.rs b/src/mpd.rs index 1d8de72..0d80472 100644 --- a/src/mpd.rs +++ b/src/mpd.rs @@ -83,7 +83,7 @@ impl MpdClient { } } - pub fn queue_playlist(&mut self, tracks: &[String], music_dir: &str) { + pub fn queue_playlist(&mut self, tracks: &[String], local_dir: &str, mpd_dir: &str) { if let Err(e) = self.send_command("clear") { eprintln!("MPD clear: {e}"); return; @@ -92,7 +92,7 @@ impl MpdClient { let mut failed: Vec = Vec::new(); for track in tracks { - let uri = Self::track_to_uri(track, music_dir); + let uri = Self::track_to_uri(track, local_dir, mpd_dir); let escaped = uri.replace('\\', "\\\\").replace('"', "\\\""); if self.send_command(&format!("add \"{escaped}\"")).is_err() { failed.push(uri.to_string()); @@ -117,10 +117,12 @@ impl MpdClient { } } - fn track_to_uri<'a>(track: &'a str, music_dir: &str) -> &'a str { - track - .strip_prefix(music_dir) + fn track_to_uri(track: &str, local_dir: &str, mpd_dir: &str) -> String { + let relative = track + .strip_prefix(local_dir) .map(|p| p.trim_start_matches('/')) - .unwrap_or(track) + .unwrap_or(track); + let mpd_base = mpd_dir.trim_end_matches('/'); + format!("{mpd_base}/{relative}") } }