Added basic airsonic support
This commit is contained in:
200
src/airsonic.rs
Normal file
200
src/airsonic.rs
Normal file
@@ -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<SearchResult3>,
|
||||||
|
playlists: Option<PlaylistsWrapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchResult3 {
|
||||||
|
#[serde(default)]
|
||||||
|
song: Vec<SongEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SongEntry {
|
||||||
|
id: String,
|
||||||
|
#[serde(rename = "musicBrainzId")]
|
||||||
|
mbid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PlaylistsWrapper {
|
||||||
|
#[serde(default)]
|
||||||
|
playlist: PlaylistList,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum PlaylistList {
|
||||||
|
Many(Vec<PlaylistEntry>),
|
||||||
|
One(PlaylistEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlaylistList {
|
||||||
|
fn default() -> Self {
|
||||||
|
PlaylistList::Many(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaylistList {
|
||||||
|
fn into_vec(self) -> Vec<PlaylistEntry> {
|
||||||
|
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<Self, String> {
|
||||||
|
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<Vec<SongEntry>, 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<Vec<PlaylistEntry>, 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<String> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/db.rs
11
src/db.rs
@@ -114,6 +114,17 @@ pub fn get_top_tracks_by_name(
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_track_metadata(conn: &Connection, path: &str) -> Result<Option<(Option<String>, Option<String>)>, 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(
|
pub fn insert_track(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
|||||||
38
src/main.rs
38
src/main.rs
@@ -1,3 +1,4 @@
|
|||||||
|
mod airsonic;
|
||||||
mod db;
|
mod db;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod lastfm;
|
mod lastfm;
|
||||||
@@ -27,7 +28,7 @@ fn db_path() -> PathBuf {
|
|||||||
fn usage(program: &str) -> ! {
|
fn usage(program: &str) -> ! {
|
||||||
eprintln!("Usage:");
|
eprintln!("Usage:");
|
||||||
eprintln!(" {program} index [-v] [-f] <directory>");
|
eprintln!(" {program} index [-v] [-f] <directory>");
|
||||||
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ fn cmd_index(args: &[String]) {
|
|||||||
fn cmd_build(args: &[String]) {
|
fn cmd_build(args: &[String]) {
|
||||||
let verbose = args.iter().any(|a| a == "-v");
|
let verbose = args.iter().any(|a| a == "-v");
|
||||||
let mpd = args.iter().any(|a| a == "-m");
|
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 shuffle = args.iter().any(|a| a == "-s");
|
||||||
let random = args.iter().any(|a| a == "-r");
|
let random = args.iter().any(|a| a == "-r");
|
||||||
|
|
||||||
@@ -147,12 +149,17 @@ fn cmd_build(args: &[String]) {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mpd && airsonic {
|
||||||
|
eprintln!("Error: -m and -a are mutually exclusive");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse -n COUNT
|
// Parse -n COUNT
|
||||||
let mut count: usize = 20;
|
let mut count: usize = 20;
|
||||||
let mut rest: Vec<&String> = Vec::new();
|
let mut rest: Vec<&String> = Vec::new();
|
||||||
let mut iter = args.iter().skip(2);
|
let mut iter = args.iter().skip(2);
|
||||||
while let Some(arg) = iter.next() {
|
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;
|
continue;
|
||||||
} else if arg == "-n" {
|
} else if arg == "-n" {
|
||||||
match iter.next() {
|
match iter.next() {
|
||||||
@@ -174,7 +181,7 @@ fn cmd_build(args: &[String]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rest.len() > 1 {
|
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);
|
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(
|
fn build_playlist(
|
||||||
@@ -227,6 +234,7 @@ fn build_playlist(
|
|||||||
count: usize,
|
count: usize,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
mpd: bool,
|
mpd: bool,
|
||||||
|
airsonic: bool,
|
||||||
shuffle: bool,
|
shuffle: bool,
|
||||||
random: bool,
|
random: bool,
|
||||||
) {
|
) {
|
||||||
@@ -329,10 +337,12 @@ fn build_playlist(
|
|||||||
|
|
||||||
let tracks: Vec<String> = selected.iter().map(|(_, _, p)| p.clone()).collect();
|
let tracks: Vec<String> = selected.iter().map(|(_, _, p)| p.clone()).collect();
|
||||||
|
|
||||||
|
let local_dir = env::var("LOCAL_MUSIC_DIR").unwrap_or_default();
|
||||||
|
|
||||||
if mpd {
|
if mpd {
|
||||||
let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default();
|
let mpd_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default();
|
||||||
if music_dir.is_empty() {
|
if local_dir.is_empty() || mpd_dir.is_empty() {
|
||||||
eprintln!("Error: MPD_MUSIC_DIR not set");
|
eprintln!("Error: LOCAL_MUSIC_DIR and MPD_MUSIC_DIR must be set");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let mut client = match mpd::MpdClient::connect() {
|
let mut client = match mpd::MpdClient::connect() {
|
||||||
@@ -342,7 +352,19 @@ fn build_playlist(
|
|||||||
std::process::exit(1);
|
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 {
|
} else {
|
||||||
for track in &tracks {
|
for track in &tracks {
|
||||||
println!("{track}");
|
println!("{track}");
|
||||||
|
|||||||
14
src/mpd.rs
14
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") {
|
if let Err(e) = self.send_command("clear") {
|
||||||
eprintln!("MPD clear: {e}");
|
eprintln!("MPD clear: {e}");
|
||||||
return;
|
return;
|
||||||
@@ -92,7 +92,7 @@ impl MpdClient {
|
|||||||
let mut failed: Vec<String> = Vec::new();
|
let mut failed: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for track in tracks {
|
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('"', "\\\"");
|
let escaped = uri.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
if self.send_command(&format!("add \"{escaped}\"")).is_err() {
|
if self.send_command(&format!("add \"{escaped}\"")).is_err() {
|
||||||
failed.push(uri.to_string());
|
failed.push(uri.to_string());
|
||||||
@@ -117,10 +117,12 @@ impl MpdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn track_to_uri<'a>(track: &'a str, music_dir: &str) -> &'a str {
|
fn track_to_uri(track: &str, local_dir: &str, mpd_dir: &str) -> String {
|
||||||
track
|
let relative = track
|
||||||
.strip_prefix(music_dir)
|
.strip_prefix(local_dir)
|
||||||
.map(|p| p.trim_start_matches('/'))
|
.map(|p| p.trim_start_matches('/'))
|
||||||
.unwrap_or(track)
|
.unwrap_or(track);
|
||||||
|
let mpd_base = mpd_dir.trim_end_matches('/');
|
||||||
|
format!("{mpd_base}/{relative}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user