1use std::collections::BTreeMap;
8use std::io::Cursor;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Duration;
12
13use axum::extract::{Query, State};
14use axum::http::{HeaderMap, StatusCode, header};
15use axum::response::{IntoResponse, Response};
16use axum::routing::get;
17use koan_core::config::Config;
18use koan_core::db::connection::Database;
19use koan_core::db::queries;
20use koan_core::index::metadata::extract_cover_art;
21use serde::Deserialize;
22use tokio::io::AsyncReadExt as _;
23
24const SUBSONIC_API_VERSION: &str = "1.16.1";
25const SUBSONIC_XMLNS: &str = "http://subsonic.org/restapi";
26
27#[derive(Clone)]
32struct AppState {
33 db_path: PathBuf,
34 username: String,
35 password: String,
36}
37
38impl AppState {
39 fn open_db(&self) -> Result<Database, SubsonicError> {
40 Database::open(&self.db_path).map_err(|e| SubsonicError::from(e.to_string()))
41 }
42}
43
44#[derive(Debug, Clone, Copy)]
49enum SubsonicErrorCode {
50 Generic = 0,
51 MissingParameter = 10,
52 WrongAuth = 40,
53 NotFound = 70,
54}
55
56#[derive(Debug)]
57struct SubsonicError {
58 code: SubsonicErrorCode,
59 message: String,
60}
61
62impl SubsonicError {
63 fn wrong_auth() -> Self {
64 Self {
65 code: SubsonicErrorCode::WrongAuth,
66 message: "Wrong username or password".into(),
67 }
68 }
69
70 fn missing_param(name: &str) -> Self {
71 Self {
72 code: SubsonicErrorCode::MissingParameter,
73 message: format!("Required parameter '{}' is missing", name),
74 }
75 }
76
77 fn not_found(what: &str) -> Self {
78 Self {
79 code: SubsonicErrorCode::NotFound,
80 message: format!("{} not found", what),
81 }
82 }
83
84 fn internal(msg: impl Into<String>) -> Self {
85 Self {
86 code: SubsonicErrorCode::Generic,
87 message: msg.into(),
88 }
89 }
90}
91
92impl From<String> for SubsonicError {
93 fn from(s: String) -> Self {
94 Self {
95 code: SubsonicErrorCode::Generic,
96 message: s,
97 }
98 }
99}
100
101impl IntoResponse for SubsonicError {
102 fn into_response(self) -> Response {
103 SubsonicResponse::error(false, &self)
105 }
106}
107
108#[derive(Debug, Deserialize)]
113struct SubsonicParams {
114 u: Option<String>,
115 t: Option<String>,
116 s: Option<String>,
117 p: Option<String>,
118 #[allow(dead_code)]
119 v: Option<String>,
120 #[allow(dead_code)]
121 c: Option<String>,
122 f: Option<String>,
123}
124
125impl SubsonicParams {
126 fn wants_json(&self) -> bool {
127 self.f.as_deref() == Some("json")
128 }
129}
130
131struct SubsonicResponse;
136
137impl SubsonicResponse {
138 fn ok(json: bool) -> XmlBuilder {
139 XmlBuilder {
140 json,
141 children: Vec::new(),
142 }
143 }
144
145 fn error(json: bool, err: &SubsonicError) -> Response {
146 if json {
147 let body = serde_json::json!({
148 "subsonic-response": {
149 "status": "failed",
150 "version": SUBSONIC_API_VERSION,
151 "error": {
152 "code": err.code as i32,
153 "message": err.message,
154 }
155 }
156 });
157 (
158 StatusCode::OK,
159 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
160 serde_json::to_string(&body).unwrap(),
161 )
162 .into_response()
163 } else {
164 let xml = format!(
165 r#"<?xml version="1.0" encoding="UTF-8"?>
166<subsonic-response xmlns="{}" status="failed" version="{}">
167 <error code="{}" message="{}"/>
168</subsonic-response>"#,
169 SUBSONIC_XMLNS,
170 SUBSONIC_API_VERSION,
171 err.code as i32,
172 xml_escape(&err.message),
173 );
174 (
175 StatusCode::OK,
176 [(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
177 xml,
178 )
179 .into_response()
180 }
181 }
182}
183
184struct XmlBuilder {
189 json: bool,
190 children: Vec<XmlNode>,
191}
192
193#[derive(Clone)]
194struct XmlNode {
195 tag: String,
196 attrs: Vec<(String, String)>,
197 children: Vec<XmlNode>,
198 is_array: bool,
199 array_child_tag: Option<String>,
200}
201
202impl XmlNode {
203 fn new(tag: &str) -> Self {
204 Self {
205 tag: tag.into(),
206 attrs: Vec::new(),
207 children: Vec::new(),
208 is_array: false,
209 array_child_tag: None,
210 }
211 }
212
213 fn attr(mut self, key: &str, value: &str) -> Self {
214 self.attrs.push((key.into(), value.into()));
215 self
216 }
217
218 fn attr_opt(self, key: &str, value: Option<&str>) -> Self {
219 match value {
220 Some(v) => self.attr(key, v),
221 None => self,
222 }
223 }
224
225 fn attr_opt_i32(self, key: &str, value: Option<i32>) -> Self {
226 match value {
227 Some(v) => self.attr(key, &v.to_string()),
228 None => self,
229 }
230 }
231
232 fn attr_opt_i64(self, key: &str, value: Option<i64>) -> Self {
233 match value {
234 Some(v) => self.attr(key, &v.to_string()),
235 None => self,
236 }
237 }
238
239 fn child(mut self, node: XmlNode) -> Self {
240 self.children.push(node);
241 self
242 }
243
244 fn array_of(mut self, child_tag: &str) -> Self {
245 self.is_array = true;
246 self.array_child_tag = Some(child_tag.into());
247 self
248 }
249
250 fn to_xml(&self, indent: usize) -> String {
251 let pad = " ".repeat(indent);
252 let mut s = format!("<{}", self.tag);
253 for (k, v) in &self.attrs {
254 s.push_str(&format!(" {}=\"{}\"", k, xml_escape(v)));
255 }
256 if self.children.is_empty() {
257 s.push_str("/>");
258 return format!("{}{}", pad, s);
259 }
260 s.push('>');
261 let mut out = format!("{}{}\n", pad, s);
262 for child in &self.children {
263 out.push_str(&child.to_xml(indent + 1));
264 out.push('\n');
265 }
266 out.push_str(&format!("{}</{}>", pad, self.tag));
267 out
268 }
269
270 fn to_json_value(&self) -> serde_json::Value {
271 let mut obj = serde_json::Map::new();
272 for (k, v) in &self.attrs {
273 obj.insert(k.clone(), serde_json::Value::String(v.clone()));
274 }
275 if self.is_array {
276 let child_tag = self.array_child_tag.as_deref().unwrap_or("item");
277 let arr: Vec<serde_json::Value> =
278 self.children.iter().map(|c| c.to_json_value()).collect();
279 obj.insert(child_tag.into(), serde_json::Value::Array(arr));
280 } else {
281 let mut groups: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
282 for child in &self.children {
283 groups
284 .entry(child.tag.clone())
285 .or_default()
286 .push(child.to_json_value());
287 }
288 for (tag, values) in groups {
289 if values.len() == 1 {
290 obj.insert(tag, values.into_iter().next().unwrap());
291 } else {
292 obj.insert(tag, serde_json::Value::Array(values));
293 }
294 }
295 }
296 serde_json::Value::Object(obj)
297 }
298}
299
300impl XmlBuilder {
301 fn child(mut self, node: XmlNode) -> Self {
302 self.children.push(node);
303 self
304 }
305
306 fn build(self) -> Response {
307 if self.json {
308 let mut inner = serde_json::Map::new();
309 inner.insert("status".into(), serde_json::Value::String("ok".into()));
310 inner.insert(
311 "version".into(),
312 serde_json::Value::String(SUBSONIC_API_VERSION.into()),
313 );
314 for child in &self.children {
315 inner.insert(child.tag.clone(), child.to_json_value());
316 }
317 let wrapper = serde_json::json!({ "subsonic-response": inner });
318 (
319 StatusCode::OK,
320 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
321 serde_json::to_string(&wrapper).unwrap(),
322 )
323 .into_response()
324 } else {
325 let mut xml = format!(
326 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<subsonic-response xmlns=\"{}\" status=\"ok\" version=\"{}\">\n",
327 SUBSONIC_XMLNS, SUBSONIC_API_VERSION,
328 );
329 for child in &self.children {
330 xml.push_str(&child.to_xml(1));
331 xml.push('\n');
332 }
333 xml.push_str("</subsonic-response>");
334 (
335 StatusCode::OK,
336 [(header::CONTENT_TYPE, "application/xml; charset=utf-8")],
337 xml,
338 )
339 .into_response()
340 }
341 }
342}
343
344fn xml_escape(s: &str) -> String {
345 s.replace('&', "&")
346 .replace('"', """)
347 .replace('<', "<")
348 .replace('>', ">")
349 .replace('\'', "'")
350}
351
352fn validate_auth(params: &SubsonicParams, state: &AppState) -> Result<(), SubsonicError> {
357 let username = params
358 .u
359 .as_deref()
360 .ok_or_else(|| SubsonicError::missing_param("u"))?;
361
362 if username != state.username {
363 return Err(SubsonicError::wrong_auth());
364 }
365
366 if let (Some(token), Some(salt)) = (params.t.as_deref(), params.s.as_deref()) {
368 let expected = format!("{:x}", md5::compute(format!("{}{}", state.password, salt)));
369 use subtle::ConstantTimeEq;
370 if token.as_bytes().ct_eq(expected.as_bytes()).into() {
371 return Ok(());
372 }
373 return Err(SubsonicError::wrong_auth());
374 }
375
376 if let Some(ref p) = params.p {
378 let plain = if let Some(hex) = p.strip_prefix("enc:") {
379 hex_decode(hex)
380 } else {
381 p.clone()
382 };
383 use subtle::ConstantTimeEq;
384 if plain.as_bytes().ct_eq(state.password.as_bytes()).into() {
385 return Ok(());
386 }
387 return Err(SubsonicError::wrong_auth());
388 }
389
390 Err(SubsonicError::missing_param("t and s, or p"))
391}
392
393fn hex_decode(hex: &str) -> String {
394 let bytes: Vec<u8> = (0..hex.len())
395 .step_by(2)
396 .filter_map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
397 .collect();
398 String::from_utf8_lossy(&bytes).into_owned()
399}
400
401fn track_to_xml_node(track: &queries::TrackRow) -> XmlNode {
406 let duration_secs = track.duration_ms.map(|ms| ms / 1000);
407 let (suffix, content_type) = track
408 .codec
409 .as_deref()
410 .map(codec_to_mime)
411 .unwrap_or(("bin", "application/octet-stream"));
412 XmlNode::new("song")
413 .attr("id", &track.id.to_string())
414 .attr("title", &track.title)
415 .attr("album", &track.album_title)
416 .attr("artist", &track.artist_name)
417 .attr_opt_i32("track", track.track_number)
418 .attr_opt_i32("discNumber", track.disc)
419 .attr_opt_i64("duration", duration_secs)
420 .attr_opt_i32("bitRate", track.bitrate)
421 .attr_opt("suffix", Some(suffix))
422 .attr_opt("contentType", Some(content_type))
423 .attr_opt("genre", track.genre.as_deref())
424 .attr_opt(
425 "albumId",
426 track.album_id.map(|id| id.to_string()).as_deref(),
427 )
428 .attr_opt(
429 "artistId",
430 track.artist_id.map(|id| id.to_string()).as_deref(),
431 )
432}
433
434fn year_from_date(date: Option<&str>) -> Option<String> {
435 date.and_then(|d| {
436 if d.len() >= 4 {
437 Some(d[..4].to_string())
438 } else {
439 None
440 }
441 })
442}
443
444fn album_to_xml_node(album: &queries::AlbumRow, track_count: Option<i32>) -> XmlNode {
445 let year_str = year_from_date(album.date.as_deref());
446 let count = track_count.unwrap_or(0);
447 XmlNode::new("album")
448 .attr("id", &album.id.to_string())
449 .attr("name", &album.title)
450 .attr("artist", &album.artist_name)
451 .attr("artistId", &album.artist_id.to_string())
452 .attr("songCount", &count.to_string())
453 .attr_opt("year", year_str.as_deref())
454}
455
456fn album_counts_by_artist(db: &Database) -> BTreeMap<i64, i64> {
457 let albums = queries::all_albums(&db.conn).unwrap_or_default();
458 let mut map: BTreeMap<i64, i64> = BTreeMap::new();
459 for album in albums {
460 *map.entry(album.artist_id).or_insert(0) += 1;
461 }
462 map
463}
464
465fn codec_to_mime(codec: &str) -> (&str, &str) {
466 match codec.to_uppercase().as_str() {
467 "FLAC" => ("flac", "audio/flac"),
468 "MP3" => ("mp3", "audio/mpeg"),
469 "AAC" | "M4A" => ("m4a", "audio/mp4"),
470 "OPUS" => ("opus", "audio/opus"),
471 "VORBIS" | "OGG" => ("ogg", "audio/ogg"),
472 "WAV" => ("wav", "audio/wav"),
473 "AIFF" => ("aiff", "audio/aiff"),
474 "WAVPACK" | "WV" => ("wv", "audio/x-wavpack"),
475 "APE" => ("ape", "audio/x-ape"),
476 _ => ("bin", "application/octet-stream"),
477 }
478}
479
480fn extension_to_mime(ext: &str) -> &str {
481 match ext.to_lowercase().as_str() {
482 "flac" => "audio/flac",
483 "mp3" => "audio/mpeg",
484 "m4a" | "aac" | "mp4" => "audio/mp4",
485 "opus" => "audio/opus",
486 "ogg" => "audio/ogg",
487 "wav" => "audio/wav",
488 "aiff" | "aif" => "audio/aiff",
489 "wv" => "audio/x-wavpack",
490 "ape" => "audio/x-ape",
491 _ => "application/octet-stream",
492 }
493}
494
495fn track_file_path(track: &queries::TrackRow) -> Option<&str> {
497 track.path.as_deref().or(track.cached_path.as_deref())
498}
499
500#[derive(Debug, Deserialize)]
505struct IdParam {
506 id: Option<String>,
507 #[serde(flatten)]
508 auth: SubsonicParams,
509}
510
511#[derive(Debug, Deserialize)]
512#[serde(rename_all = "camelCase")]
513struct AlbumListParams {
514 #[serde(rename = "type")]
515 list_type: Option<String>,
516 size: Option<i64>,
517 offset: Option<i64>,
518 #[serde(flatten)]
519 auth: SubsonicParams,
520}
521
522#[derive(Debug, Deserialize)]
523#[serde(rename_all = "camelCase")]
524struct Search3Params {
525 #[serde(flatten)]
526 auth: SubsonicParams,
527 query: Option<String>,
528 artist_count: Option<u32>,
529 album_count: Option<u32>,
530 song_count: Option<u32>,
531}
532
533#[derive(Debug, Deserialize)]
534struct StreamParams {
535 #[serde(flatten)]
536 auth: SubsonicParams,
537 id: Option<i64>,
538}
539
540#[derive(Debug, Deserialize)]
541struct CoverArtParams {
542 #[serde(flatten)]
543 auth: SubsonicParams,
544 id: Option<i64>,
545 size: Option<u32>,
546}
547
548#[derive(Debug, Deserialize)]
549#[serde(rename_all = "camelCase")]
550struct StarParams {
551 #[serde(flatten)]
552 auth: SubsonicParams,
553 id: Option<i64>,
554}
555
556#[derive(Debug, Deserialize)]
557#[serde(rename_all = "camelCase")]
558struct ScrobbleParams {
559 #[serde(flatten)]
560 auth: SubsonicParams,
561 id: Option<i64>,
562 time: Option<i64>,
563}
564
565#[derive(Debug, Deserialize)]
566#[serde(rename_all = "camelCase")]
567struct RandomSongsParams {
568 #[serde(flatten)]
569 auth: SubsonicParams,
570 size: Option<u32>,
571 genre: Option<String>,
572}
573
574#[derive(Debug, Deserialize)]
575#[serde(rename_all = "camelCase")]
576struct SimilarSongs2Params {
577 #[serde(flatten)]
578 auth: SubsonicParams,
579 id: Option<i64>,
580 count: Option<usize>,
581}
582
583async fn ping(
588 State(state): State<Arc<AppState>>,
589 Query(params): Query<SubsonicParams>,
590) -> Response {
591 if let Err(e) = validate_auth(¶ms, &state) {
592 return SubsonicResponse::error(params.wants_json(), &e);
593 }
594 SubsonicResponse::ok(params.wants_json()).build()
595}
596
597async fn get_license(
598 State(state): State<Arc<AppState>>,
599 Query(params): Query<SubsonicParams>,
600) -> Response {
601 if let Err(e) = validate_auth(¶ms, &state) {
602 return SubsonicResponse::error(params.wants_json(), &e);
603 }
604 SubsonicResponse::ok(params.wants_json())
605 .child(
606 XmlNode::new("license")
607 .attr("valid", "true")
608 .attr("email", "koan@localhost"),
609 )
610 .build()
611}
612
613async fn get_artists(
614 State(state): State<Arc<AppState>>,
615 Query(params): Query<SubsonicParams>,
616) -> Response {
617 if let Err(e) = validate_auth(¶ms, &state) {
618 return SubsonicResponse::error(params.wants_json(), &e);
619 }
620 let json = params.wants_json();
621
622 let db = match state.open_db() {
623 Ok(db) => db,
624 Err(e) => return SubsonicResponse::error(json, &e),
625 };
626
627 let artists = match queries::all_artists(&db.conn) {
628 Ok(a) => a,
629 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
630 };
631
632 let mut index_map: BTreeMap<String, Vec<&queries::ArtistRow>> = BTreeMap::new();
634 for artist in &artists {
635 let letter = artist
636 .sort_name
637 .as_deref()
638 .unwrap_or(&artist.name)
639 .chars()
640 .next()
641 .map(|c| {
642 let upper = c.to_uppercase().to_string();
643 if upper
644 .chars()
645 .next()
646 .is_some_and(|ch| ch.is_ascii_alphabetic())
647 {
648 upper
649 } else {
650 "#".to_string()
651 }
652 })
653 .unwrap_or_else(|| "#".to_string());
654 index_map.entry(letter).or_default().push(artist);
655 }
656
657 let album_counts = album_counts_by_artist(&db);
658
659 let mut artists_node = XmlNode::new("artists").array_of("index");
660 for (letter, group) in &index_map {
661 let mut index_node = XmlNode::new("index")
662 .attr("name", letter)
663 .array_of("artist");
664 for artist in group {
665 let count = album_counts.get(&artist.id).copied().unwrap_or(0);
666 index_node = index_node.child(
667 XmlNode::new("artist")
668 .attr("id", &artist.id.to_string())
669 .attr("name", &artist.name)
670 .attr("albumCount", &count.to_string()),
671 );
672 }
673 artists_node = artists_node.child(index_node);
674 }
675
676 SubsonicResponse::ok(json).child(artists_node).build()
677}
678
679async fn get_artist(State(state): State<Arc<AppState>>, Query(params): Query<IdParam>) -> Response {
680 if let Err(e) = validate_auth(¶ms.auth, &state) {
681 return SubsonicResponse::error(params.auth.wants_json(), &e);
682 }
683 let json = params.auth.wants_json();
684
685 let artist_id: i64 = match params.id.as_deref().and_then(|s| s.parse().ok()) {
686 Some(id) => id,
687 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
688 };
689
690 let db = match state.open_db() {
691 Ok(db) => db,
692 Err(e) => return SubsonicResponse::error(json, &e),
693 };
694
695 let all = match queries::all_artists(&db.conn) {
696 Ok(a) => a,
697 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
698 };
699 let artist = match all.into_iter().find(|a| a.id == artist_id) {
700 Some(a) => a,
701 None => return SubsonicResponse::error(json, &SubsonicError::not_found("Artist")),
702 };
703
704 let albums = match queries::albums_for_artist(&db.conn, artist_id) {
705 Ok(a) => a,
706 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
707 };
708
709 let mut artist_node = XmlNode::new("artist")
710 .attr("id", &artist.id.to_string())
711 .attr("name", &artist.name)
712 .attr("albumCount", &albums.len().to_string())
713 .array_of("album");
714
715 for album in &albums {
716 artist_node = artist_node.child(album_to_xml_node(album, album.total_tracks));
717 }
718
719 SubsonicResponse::ok(json).child(artist_node).build()
720}
721
722async fn get_album(State(state): State<Arc<AppState>>, Query(params): Query<IdParam>) -> Response {
723 if let Err(e) = validate_auth(¶ms.auth, &state) {
724 return SubsonicResponse::error(params.auth.wants_json(), &e);
725 }
726 let json = params.auth.wants_json();
727
728 let album_id: i64 = match params.id.as_deref().and_then(|s| s.parse().ok()) {
729 Some(id) => id,
730 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
731 };
732
733 let db = match state.open_db() {
734 Ok(db) => db,
735 Err(e) => return SubsonicResponse::error(json, &e),
736 };
737
738 let album = match queries::get_album(&db.conn, album_id) {
739 Ok(Some(a)) => a,
740 _ => return SubsonicResponse::error(json, &SubsonicError::not_found("Album")),
741 };
742
743 let tracks = queries::tracks_for_album(&db.conn, album_id).unwrap_or_default();
744 let mut album_node = album_to_xml_node(&album, Some(tracks.len() as i32)).array_of("song");
745
746 for track in &tracks {
747 album_node = album_node.child(track_to_xml_node(track));
748 }
749
750 SubsonicResponse::ok(json).child(album_node).build()
751}
752
753async fn get_album_list2(
754 State(state): State<Arc<AppState>>,
755 Query(params): Query<AlbumListParams>,
756) -> Response {
757 if let Err(e) = validate_auth(¶ms.auth, &state) {
758 return SubsonicResponse::error(params.auth.wants_json(), &e);
759 }
760 let json = params.auth.wants_json();
761
762 let list_type = params.list_type.as_deref().unwrap_or("alphabeticalByName");
763 let size = params.size.unwrap_or(20).min(500) as usize;
764 let offset = params.offset.unwrap_or(0).max(0) as usize;
765
766 let db = match state.open_db() {
767 Ok(db) => db,
768 Err(e) => return SubsonicResponse::error(json, &e),
769 };
770
771 let mut albums = match queries::all_albums(&db.conn) {
772 Ok(a) => a,
773 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
774 };
775
776 match list_type {
777 "alphabeticalByName" => albums.sort_by(|a, b| a.title.cmp(&b.title)),
778 "alphabeticalByArtist" => albums.sort_by(|a, b| {
779 a.artist_name
780 .cmp(&b.artist_name)
781 .then(a.title.cmp(&b.title))
782 }),
783 "newest" => albums.sort_by(|a, b| b.date.cmp(&a.date)),
784 "random" => {
785 use std::collections::hash_map::DefaultHasher;
786 use std::hash::{Hash, Hasher};
787 let seed = std::time::SystemTime::now()
788 .duration_since(std::time::UNIX_EPOCH)
789 .unwrap_or_default()
790 .as_secs();
791 albums.sort_by(|a, b| {
792 let mut ha = DefaultHasher::new();
793 (a.id, seed).hash(&mut ha);
794 let mut hb = DefaultHasher::new();
795 (b.id, seed).hash(&mut hb);
796 ha.finish().cmp(&hb.finish())
797 });
798 }
799 _ => {}
800 }
801
802 let page: Vec<_> = albums.into_iter().skip(offset).take(size).collect();
803
804 let mut list_node = XmlNode::new("albumList2").array_of("album");
805 for album in &page {
806 list_node = list_node.child(album_to_xml_node(album, album.total_tracks));
807 }
808
809 SubsonicResponse::ok(json).child(list_node).build()
810}
811
812async fn get_song(State(state): State<Arc<AppState>>, Query(params): Query<IdParam>) -> Response {
813 if let Err(e) = validate_auth(¶ms.auth, &state) {
814 return SubsonicResponse::error(params.auth.wants_json(), &e);
815 }
816 let json = params.auth.wants_json();
817
818 let track_id: i64 = match params.id.as_deref().and_then(|s| s.parse().ok()) {
819 Some(id) => id,
820 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
821 };
822
823 let db = match state.open_db() {
824 Ok(db) => db,
825 Err(e) => return SubsonicResponse::error(json, &e),
826 };
827
828 let track = match queries::get_track_row(&db.conn, track_id) {
829 Ok(Some(t)) => t,
830 _ => return SubsonicResponse::error(json, &SubsonicError::not_found("Song")),
831 };
832
833 SubsonicResponse::ok(json)
834 .child(track_to_xml_node(&track))
835 .build()
836}
837
838async fn search3(
843 State(state): State<Arc<AppState>>,
844 Query(params): Query<Search3Params>,
845) -> Response {
846 if let Err(e) = validate_auth(¶ms.auth, &state) {
847 return SubsonicResponse::error(params.auth.wants_json(), &e);
848 }
849 let json = params.auth.wants_json();
850
851 let query = match params.query.as_deref() {
852 Some(q) => q,
853 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("query")),
854 };
855
856 let artist_count = params.artist_count.unwrap_or(20);
857 let album_count = params.album_count.unwrap_or(20);
858 let song_count = params.song_count.unwrap_or(20);
859
860 let db = match state.open_db() {
861 Ok(db) => db,
862 Err(e) => return SubsonicResponse::error(json, &e),
863 };
864
865 let total_needed = (artist_count + album_count + song_count).max(100);
866 let tracks = match queries::search_tracks_paged(&db.conn, query, total_needed, 0) {
867 Ok(t) => t,
868 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
869 };
870
871 let mut result_node = XmlNode::new("searchResult3");
872
873 let mut seen_artists = std::collections::HashSet::new();
875 let mut artist_n = 0u32;
876 for t in &tracks {
877 if artist_n >= artist_count {
878 break;
879 }
880 if let Some(aid) = t.artist_id
881 && seen_artists.insert(aid)
882 {
883 result_node = result_node.child(
884 XmlNode::new("artist")
885 .attr("id", &aid.to_string())
886 .attr("name", &t.artist_name),
887 );
888 artist_n += 1;
889 }
890 }
891
892 let mut seen_albums = std::collections::HashSet::new();
894 let mut album_n = 0u32;
895 for t in &tracks {
896 if album_n >= album_count {
897 break;
898 }
899 if let Some(alid) = t.album_id
900 && seen_albums.insert(alid)
901 {
902 result_node = result_node.child(
903 XmlNode::new("album")
904 .attr("id", &alid.to_string())
905 .attr("name", &t.album_title)
906 .attr("artist", &t.album_artist_name),
907 );
908 album_n += 1;
909 }
910 }
911
912 for t in tracks.iter().take(song_count as usize) {
914 result_node = result_node.child(track_to_xml_node(t));
915 }
916
917 SubsonicResponse::ok(json).child(result_node).build()
918}
919
920async fn stream(
925 State(state): State<Arc<AppState>>,
926 Query(params): Query<StreamParams>,
927 headers: HeaderMap,
928) -> Result<Response, SubsonicError> {
929 validate_auth(¶ms.auth, &state)?;
930
931 let track_id = params
932 .id
933 .ok_or_else(|| SubsonicError::missing_param("id"))?;
934
935 let db = state.open_db()?;
936 let track = queries::get_track_row(&db.conn, track_id)
937 .map_err(|e| SubsonicError::internal(e.to_string()))?
938 .ok_or_else(|| SubsonicError::not_found("Track"))?;
939
940 let local_path = track_file_path(&track).map(PathBuf::from);
942 let local_exists = if let Some(ref p) = local_path {
943 tokio::fs::metadata(p).await.is_ok()
944 } else {
945 false
946 };
947
948 if !local_exists {
949 if let Some(ref remote_id) = track.remote_id {
951 return proxy_stream_from_upstream(remote_id, &track, &headers).await;
952 }
953 return Err(SubsonicError::not_found(
954 "Track has no local file and no remote source",
955 ));
956 }
957
958 let path = local_path.unwrap();
959 let metadata = tokio::fs::metadata(&path).await.map_err(|e| {
960 if e.kind() == std::io::ErrorKind::NotFound {
961 SubsonicError::not_found("File not found on disk")
962 } else {
963 SubsonicError::internal(e.to_string())
964 }
965 })?;
966 let total_size = metadata.len();
967
968 let content_type = path
969 .extension()
970 .and_then(|e| e.to_str())
971 .map(extension_to_mime)
972 .unwrap_or("application/octet-stream");
973
974 if let Some(range_header) = headers.get(header::RANGE) {
976 let range_str = range_header
977 .to_str()
978 .map_err(|_| SubsonicError::internal("invalid range header"))?;
979
980 if let Some((start, end)) = parse_range(range_str, total_size) {
981 let length = end - start + 1;
982
983 let mut file = tokio::fs::File::open(&path)
984 .await
985 .map_err(|e| SubsonicError::internal(e.to_string()))?;
986 tokio::io::AsyncSeekExt::seek(&mut file, std::io::SeekFrom::Start(start))
987 .await
988 .map_err(|e| SubsonicError::internal(e.to_string()))?;
989
990 let stream = tokio_util::io::ReaderStream::new(file.take(length));
991 let body = axum::body::Body::from_stream(stream);
992
993 return Response::builder()
994 .status(StatusCode::PARTIAL_CONTENT)
995 .header(header::CONTENT_TYPE, content_type)
996 .header(header::CONTENT_LENGTH, length)
997 .header(
998 header::CONTENT_RANGE,
999 format!("bytes {}-{}/{}", start, end, total_size),
1000 )
1001 .header(header::ACCEPT_RANGES, "bytes")
1002 .body(body)
1003 .map_err(|e| SubsonicError::internal(e.to_string()));
1004 }
1005 }
1006
1007 let file = tokio::fs::File::open(&path)
1009 .await
1010 .map_err(|e| SubsonicError::internal(e.to_string()))?;
1011 let stream = tokio_util::io::ReaderStream::new(file);
1012 let body = axum::body::Body::from_stream(stream);
1013
1014 Response::builder()
1015 .status(StatusCode::OK)
1016 .header(header::CONTENT_TYPE, content_type)
1017 .header(header::CONTENT_LENGTH, total_size)
1018 .header(header::ACCEPT_RANGES, "bytes")
1019 .body(body)
1020 .map_err(|e| SubsonicError::internal(e.to_string()))
1021}
1022
1023async fn proxy_stream_from_upstream(
1026 remote_id: &str,
1027 track: &queries::TrackRow,
1028 client_headers: &HeaderMap,
1029) -> Result<Response, SubsonicError> {
1030 let cfg = Config::load().unwrap_or_default();
1031 let client = match koan_core::helpers::subsonic_client(&cfg) {
1032 Some(c) => c,
1033 None => return Err(SubsonicError::not_found("Remote server not configured")),
1034 };
1035 let upstream_url = client.stream_url(remote_id);
1036
1037 let http = reqwest::Client::builder()
1039 .timeout(Duration::from_secs(30))
1040 .build()
1041 .unwrap();
1042 let mut req = http.get(&upstream_url);
1043
1044 if let Some(range) = client_headers.get(header::RANGE)
1046 && let Ok(range_str) = range.to_str()
1047 {
1048 req = req.header("Range", range_str);
1049 }
1050
1051 let upstream_resp = req
1052 .send()
1053 .await
1054 .map_err(|e| SubsonicError::internal(format!("upstream error: {}", e)))?;
1055
1056 let status = upstream_resp.status();
1057 let content_type = track
1058 .codec
1059 .as_deref()
1060 .map(|c| codec_to_mime(c).1)
1061 .unwrap_or("application/octet-stream");
1062
1063 let mut builder = Response::builder().status(status.as_u16());
1064 builder = builder.header(header::CONTENT_TYPE, content_type);
1065
1066 if let Some(cl) = upstream_resp.headers().get(header::CONTENT_LENGTH) {
1068 builder = builder.header(header::CONTENT_LENGTH, cl);
1069 }
1070 if let Some(cr) = upstream_resp.headers().get(header::CONTENT_RANGE) {
1071 builder = builder.header(header::CONTENT_RANGE, cr);
1072 }
1073 builder = builder.header(header::ACCEPT_RANGES, "bytes");
1074
1075 let body = axum::body::Body::from_stream(upstream_resp.bytes_stream());
1076 builder
1077 .body(body)
1078 .map_err(|e| SubsonicError::internal(e.to_string()))
1079}
1080
1081fn parse_range(range: &str, total: u64) -> Option<(u64, u64)> {
1082 let bytes_prefix = range.strip_prefix("bytes=")?;
1083 let mut parts = bytes_prefix.splitn(2, '-');
1084 let start_str = parts.next()?.trim();
1085 let end_str = parts.next()?.trim();
1086
1087 if start_str.is_empty() {
1088 let suffix: u64 = end_str.parse().ok()?;
1089 let start = total.saturating_sub(suffix);
1090 Some((start, total - 1))
1091 } else {
1092 let start: u64 = start_str.parse().ok()?;
1093 let end = if end_str.is_empty() {
1094 total - 1
1095 } else {
1096 end_str.parse::<u64>().ok()?.min(total - 1)
1097 };
1098 if start > end || start >= total {
1099 return None;
1100 }
1101 Some((start, end))
1102 }
1103}
1104
1105async fn get_cover_art(
1110 State(state): State<Arc<AppState>>,
1111 Query(params): Query<CoverArtParams>,
1112) -> Result<Response, SubsonicError> {
1113 validate_auth(¶ms.auth, &state)?;
1114
1115 let track_id = params
1116 .id
1117 .ok_or_else(|| SubsonicError::missing_param("id"))?;
1118
1119 let db = state.open_db()?;
1120 let track = queries::get_track_row(&db.conn, track_id)
1121 .map_err(|e| SubsonicError::internal(e.to_string()))?
1122 .ok_or_else(|| SubsonicError::not_found("Track"))?;
1123
1124 let file_path = track_file_path(&track)
1125 .ok_or_else(|| SubsonicError::not_found("Track has no local file"))?;
1126
1127 let path = PathBuf::from(file_path);
1128 let art_bytes = extract_cover_art(&path)
1129 .ok_or_else(|| SubsonicError::not_found("No cover art embedded"))?;
1130
1131 let (content_type, is_png) = if art_bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
1132 ("image/png", true)
1133 } else {
1134 ("image/jpeg", false)
1135 };
1136
1137 let final_bytes = if let Some(size) = params.size {
1138 resize_image(&art_bytes, size, is_png)?
1139 } else {
1140 art_bytes
1141 };
1142
1143 Ok((
1144 StatusCode::OK,
1145 [
1146 (header::CONTENT_TYPE, content_type),
1147 (header::CACHE_CONTROL, "max-age=86400"),
1148 ],
1149 final_bytes,
1150 )
1151 .into_response())
1152}
1153
1154fn resize_image(data: &[u8], size: u32, output_png: bool) -> Result<Vec<u8>, SubsonicError> {
1155 let img = image::load_from_memory(data)
1156 .map_err(|e| SubsonicError::internal(format!("image decode error: {}", e)))?;
1157 let resized = img.resize(size, size, image::imageops::FilterType::Lanczos3);
1158 let format = if output_png {
1159 image::ImageFormat::Png
1160 } else {
1161 image::ImageFormat::Jpeg
1162 };
1163 let mut buf = Cursor::new(Vec::new());
1164 resized
1165 .write_to(&mut buf, format)
1166 .map_err(|e| SubsonicError::internal(format!("image encode error: {}", e)))?;
1167 Ok(buf.into_inner())
1168}
1169
1170async fn star(State(state): State<Arc<AppState>>, Query(params): Query<StarParams>) -> Response {
1175 if let Err(e) = validate_auth(¶ms.auth, &state) {
1176 return SubsonicResponse::error(params.auth.wants_json(), &e);
1177 }
1178 let json = params.auth.wants_json();
1179 toggle_star(&state, params.id, json, queries::add_favourite)
1180}
1181
1182async fn unstar(State(state): State<Arc<AppState>>, Query(params): Query<StarParams>) -> Response {
1183 if let Err(e) = validate_auth(¶ms.auth, &state) {
1184 return SubsonicResponse::error(params.auth.wants_json(), &e);
1185 }
1186 let json = params.auth.wants_json();
1187 toggle_star(&state, params.id, json, queries::remove_favourite)
1188}
1189
1190fn toggle_star(
1191 state: &AppState,
1192 id: Option<i64>,
1193 json: bool,
1194 op: fn(&rusqlite::Connection, &std::path::Path) -> rusqlite::Result<()>,
1195) -> Response {
1196 let track_id = match id {
1197 Some(id) => id,
1198 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1199 };
1200
1201 let db = match state.open_db() {
1202 Ok(db) => db,
1203 Err(e) => return SubsonicResponse::error(json, &e),
1204 };
1205
1206 let track = match queries::get_track_row(&db.conn, track_id) {
1207 Ok(Some(t)) => t,
1208 Ok(None) => return SubsonicResponse::error(json, &SubsonicError::not_found("Track")),
1209 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1210 };
1211
1212 let path_str = track_file_path(&track).unwrap_or("");
1213 if let Err(e) = op(&db.conn, std::path::Path::new(path_str)) {
1214 return SubsonicResponse::error(json, &SubsonicError::from(e.to_string()));
1215 }
1216
1217 SubsonicResponse::ok(json).build()
1218}
1219
1220async fn get_starred2(
1221 State(state): State<Arc<AppState>>,
1222 Query(params): Query<SubsonicParams>,
1223) -> Response {
1224 if let Err(e) = validate_auth(¶ms, &state) {
1225 return SubsonicResponse::error(params.wants_json(), &e);
1226 }
1227 let json = params.wants_json();
1228
1229 let db = match state.open_db() {
1230 Ok(db) => db,
1231 Err(e) => return SubsonicResponse::error(json, &e),
1232 };
1233
1234 let favourites = match queries::load_favourites(&db.conn) {
1235 Ok(f) => f,
1236 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1237 };
1238
1239 let mut starred_node = XmlNode::new("starred2").array_of("song");
1240 for fav_path in &favourites {
1241 let path_str = fav_path.to_string_lossy();
1242 if let Ok(Some(track_id)) = queries::track_id_by_path(&db.conn, &path_str)
1243 && let Ok(Some(track)) = queries::get_track_row(&db.conn, track_id)
1244 {
1245 starred_node = starred_node.child(track_to_xml_node(&track));
1246 }
1247 }
1248
1249 SubsonicResponse::ok(json).child(starred_node).build()
1250}
1251
1252async fn scrobble(
1253 State(state): State<Arc<AppState>>,
1254 Query(params): Query<ScrobbleParams>,
1255) -> Response {
1256 if let Err(e) = validate_auth(¶ms.auth, &state) {
1257 return SubsonicResponse::error(params.auth.wants_json(), &e);
1258 }
1259 let json = params.auth.wants_json();
1260
1261 let track_id = match params.id {
1262 Some(id) => id,
1263 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1264 };
1265
1266 let db = match state.open_db() {
1267 Ok(db) => db,
1268 Err(e) => return SubsonicResponse::error(json, &e),
1269 };
1270
1271 match queries::get_track_row(&db.conn, track_id) {
1272 Ok(Some(_)) => {}
1273 Ok(None) => return SubsonicResponse::error(json, &SubsonicError::not_found("Track")),
1274 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1275 }
1276
1277 let result = if let Some(time_ms) = params.time {
1278 db.conn
1279 .execute(
1280 "INSERT INTO play_history (track_id, played_at, duration_ms) VALUES (?1, ?2, NULL)",
1281 rusqlite::params![track_id, time_ms / 1000],
1282 )
1283 .map(|_| ())
1284 .map_err(|e| e.into())
1285 } else {
1286 queries::record_play(&db.conn, track_id, None)
1287 };
1288
1289 if let Err(e) = result {
1290 return SubsonicResponse::error(
1291 json,
1292 &SubsonicError::from(format!("Database error: {}", e)),
1293 );
1294 }
1295
1296 SubsonicResponse::ok(json).build()
1297}
1298
1299async fn get_random_songs(
1300 State(state): State<Arc<AppState>>,
1301 Query(params): Query<RandomSongsParams>,
1302) -> Response {
1303 if let Err(e) = validate_auth(¶ms.auth, &state) {
1304 return SubsonicResponse::error(params.auth.wants_json(), &e);
1305 }
1306 let json = params.auth.wants_json();
1307
1308 let size = params.size.unwrap_or(10);
1309 let genre = params.genre.as_deref();
1310 let fetch_count = if genre.is_some() { size * 5 } else { size };
1311
1312 let db = match state.open_db() {
1313 Ok(db) => db,
1314 Err(e) => return SubsonicResponse::error(json, &e),
1315 };
1316
1317 let tracks = match queries::random_tracks(&db.conn, fetch_count, None) {
1318 Ok(t) => t,
1319 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1320 };
1321
1322 let mut node = XmlNode::new("randomSongs").array_of("song");
1323 let mut count = 0u32;
1324 for t in &tracks {
1325 if count >= size {
1326 break;
1327 }
1328 if genre.is_some_and(|g| t.genre.as_deref() != Some(g)) {
1329 continue;
1330 }
1331 node = node.child(track_to_xml_node(t));
1332 count += 1;
1333 }
1334
1335 SubsonicResponse::ok(json).child(node).build()
1336}
1337
1338async fn get_similar_songs2(
1339 State(state): State<Arc<AppState>>,
1340 Query(params): Query<SimilarSongs2Params>,
1341) -> Response {
1342 if let Err(e) = validate_auth(¶ms.auth, &state) {
1343 return SubsonicResponse::error(params.auth.wants_json(), &e);
1344 }
1345 let json = params.auth.wants_json();
1346
1347 let track_id = match params.id {
1348 Some(id) => id,
1349 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1350 };
1351 let count = params.count.unwrap_or(50);
1352
1353 let db = match state.open_db() {
1354 Ok(db) => db,
1355 Err(e) => return SubsonicResponse::error(json, &e),
1356 };
1357
1358 let track = match queries::get_track_row(&db.conn, track_id) {
1359 Ok(Some(t)) => t,
1360 Ok(None) => return SubsonicResponse::error(json, &SubsonicError::not_found("Track")),
1361 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1362 };
1363
1364 let artist_id = match track.artist_id {
1365 Some(id) => id,
1366 None => {
1367 return SubsonicResponse::ok(json)
1368 .child(XmlNode::new("similarSongs2"))
1369 .build();
1370 }
1371 };
1372
1373 let similar = match queries::get_similar_artists(&db.conn, artist_id) {
1374 Ok(s) => s,
1375 Err(e) => return SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1376 };
1377
1378 let mut node = XmlNode::new("similarSongs2").array_of("song");
1379 let mut total = 0usize;
1380 for (artist_row, _score) in &similar {
1381 if total >= count {
1382 break;
1383 }
1384 let artist_tracks = match queries::tracks_for_artist(&db.conn, artist_row.id) {
1385 Ok(t) => t,
1386 Err(_) => continue,
1387 };
1388 for t in &artist_tracks {
1389 if total >= count {
1390 break;
1391 }
1392 node = node.child(track_to_xml_node(t));
1393 total += 1;
1394 }
1395 }
1396
1397 SubsonicResponse::ok(json).child(node).build()
1398}
1399
1400async fn get_music_folders(
1405 State(state): State<Arc<AppState>>,
1406 Query(params): Query<SubsonicParams>,
1407) -> Response {
1408 if let Err(e) = validate_auth(¶ms, &state) {
1409 return SubsonicResponse::error(params.wants_json(), &e);
1410 }
1411 let json = params.wants_json();
1412
1413 SubsonicResponse::ok(json)
1414 .child(
1415 XmlNode::new("musicFolders").child(
1416 XmlNode::new("musicFolder")
1417 .attr("id", "1")
1418 .attr("name", "Music"),
1419 ),
1420 )
1421 .build()
1422}
1423
1424async fn get_genres(
1429 State(state): State<Arc<AppState>>,
1430 Query(params): Query<SubsonicParams>,
1431) -> Response {
1432 if let Err(e) = validate_auth(¶ms, &state) {
1433 return SubsonicResponse::error(params.wants_json(), &e);
1434 }
1435 let json = params.wants_json();
1436
1437 let db = match state.open_db() {
1438 Ok(db) => db,
1439 Err(e) => return SubsonicResponse::error(json, &e),
1440 };
1441
1442 let tracks = queries::all_tracks(&db.conn).unwrap_or_default();
1443 let mut genre_counts: std::collections::BTreeMap<String, usize> =
1444 std::collections::BTreeMap::new();
1445 for t in &tracks {
1446 if let Some(ref g) = t.genre {
1447 *genre_counts.entry(g.clone()).or_default() += 1;
1448 }
1449 }
1450
1451 let mut genres_node = XmlNode::new("genres").array_of("genre");
1452 for (name, count) in &genre_counts {
1453 genres_node = genres_node.child(
1454 XmlNode::new("genre")
1455 .attr("songCount", &count.to_string())
1456 .attr("albumCount", "0")
1457 .attr("value", name),
1458 );
1459 }
1460
1461 SubsonicResponse::ok(json).child(genres_node).build()
1462}
1463
1464async fn get_playlists(
1469 State(state): State<Arc<AppState>>,
1470 Query(params): Query<SubsonicParams>,
1471) -> Response {
1472 if let Err(e) = validate_auth(¶ms, &state) {
1473 return SubsonicResponse::error(params.wants_json(), &e);
1474 }
1475 let json = params.wants_json();
1476
1477 let db = match state.open_db() {
1478 Ok(db) => db,
1479 Err(e) => return SubsonicResponse::error(json, &e),
1480 };
1481
1482 let snaps = queries::list_snapshots(&db.conn).unwrap_or_default();
1483
1484 let mut playlists_node = XmlNode::new("playlists").array_of("playlist");
1485 for snap in &snaps {
1486 playlists_node = playlists_node.child(
1487 XmlNode::new("playlist")
1488 .attr("id", &snap.name)
1489 .attr("name", &snap.name)
1490 .attr("songCount", &snap.track_count.to_string())
1491 .attr("owner", &state.username)
1492 .attr("public", "false")
1493 .attr("created", &snap.created_at),
1494 );
1495 }
1496
1497 SubsonicResponse::ok(json).child(playlists_node).build()
1498}
1499
1500#[derive(Debug, Deserialize)]
1501struct PlaylistIdParam {
1502 id: Option<String>,
1503 #[serde(flatten)]
1504 auth: SubsonicParams,
1505}
1506
1507async fn get_playlist(
1508 State(state): State<Arc<AppState>>,
1509 Query(params): Query<PlaylistIdParam>,
1510) -> Response {
1511 if let Err(e) = validate_auth(¶ms.auth, &state) {
1512 return SubsonicResponse::error(params.auth.wants_json(), &e);
1513 }
1514 let json = params.auth.wants_json();
1515
1516 let name = match params.id.as_deref() {
1517 Some(n) => n,
1518 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1519 };
1520
1521 let db = match state.open_db() {
1522 Ok(db) => db,
1523 Err(e) => return SubsonicResponse::error(json, &e),
1524 };
1525
1526 let snap = match queries::load_snapshot(&db.conn, name) {
1527 Ok(Some(s)) => s,
1528 _ => return SubsonicResponse::error(json, &SubsonicError::not_found("Playlist")),
1529 };
1530
1531 let mut playlist_node = XmlNode::new("playlist")
1532 .attr("id", &snap.name)
1533 .attr("name", &snap.name)
1534 .attr("songCount", &snap.items.len().to_string())
1535 .attr("owner", &state.username)
1536 .attr("public", "false")
1537 .attr("created", &snap.created_at)
1538 .array_of("entry");
1539
1540 for item in &snap.items {
1542 if let Ok(Some(tid)) = queries::track_id_by_path(&db.conn, &item.path)
1543 && let Ok(Some(track)) = queries::get_track_row(&db.conn, tid)
1544 {
1545 playlist_node = playlist_node.child(track_to_xml_node(&track));
1546 }
1547 }
1548
1549 SubsonicResponse::ok(json).child(playlist_node).build()
1550}
1551
1552#[derive(Debug, Deserialize)]
1553struct CreatePlaylistParams {
1554 name: Option<String>,
1555 #[serde(rename = "playlistId")]
1556 playlist_id: Option<String>,
1557 #[serde(rename = "songId")]
1558 song_id: Option<Vec<String>>,
1559 #[serde(flatten)]
1560 auth: SubsonicParams,
1561}
1562
1563async fn create_playlist(
1564 State(state): State<Arc<AppState>>,
1565 Query(params): Query<CreatePlaylistParams>,
1566) -> Response {
1567 if let Err(e) = validate_auth(¶ms.auth, &state) {
1568 return SubsonicResponse::error(params.auth.wants_json(), &e);
1569 }
1570 let json = params.auth.wants_json();
1571
1572 let name = match params.name.as_deref().or(params.playlist_id.as_deref()) {
1573 Some(n) => n,
1574 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("name")),
1575 };
1576
1577 let db = match state.open_db() {
1578 Ok(db) => db,
1579 Err(e) => return SubsonicResponse::error(json, &e),
1580 };
1581
1582 let mut items = Vec::new();
1584 if let Some(ref ids) = params.song_id {
1585 for id_str in ids {
1586 if let Ok(tid) = id_str.parse::<i64>()
1587 && let Ok(Some(track)) = queries::get_track_row(&db.conn, tid)
1588 {
1589 let path = track
1590 .path
1591 .as_deref()
1592 .or(track.cached_path.as_deref())
1593 .unwrap_or("");
1594 items.push(koan_core::db::queries::playback_state::PersistedQueueItem {
1595 path: path.to_string(),
1596 title: track.title,
1597 artist: track.artist_name,
1598 album_artist: track.album_artist_name,
1599 album: track.album_title,
1600 year: None,
1601 codec: track.codec,
1602 track_number: track.track_number.map(|n| n as i64),
1603 disc: track.disc.map(|n| n as i64),
1604 duration_ms: track.duration_ms.map(|d| d as u64),
1605 db_id: Some(tid),
1606 });
1607 }
1608 }
1609 }
1610
1611 if let Err(e) = queries::save_snapshot(&db.conn, name, &items, None, 0) {
1612 return SubsonicResponse::error(json, &SubsonicError::from(e.to_string()));
1613 }
1614
1615 SubsonicResponse::ok(json).build()
1616}
1617
1618async fn delete_playlist(
1619 State(state): State<Arc<AppState>>,
1620 Query(params): Query<PlaylistIdParam>,
1621) -> Response {
1622 if let Err(e) = validate_auth(¶ms.auth, &state) {
1623 return SubsonicResponse::error(params.auth.wants_json(), &e);
1624 }
1625 let json = params.auth.wants_json();
1626
1627 let name = match params.id.as_deref() {
1628 Some(n) => n,
1629 None => return SubsonicResponse::error(json, &SubsonicError::missing_param("id")),
1630 };
1631
1632 let db = match state.open_db() {
1633 Ok(db) => db,
1634 Err(e) => return SubsonicResponse::error(json, &e),
1635 };
1636
1637 match queries::delete_snapshot(&db.conn, name) {
1638 Ok(true) => SubsonicResponse::ok(json).build(),
1639 Ok(false) => SubsonicResponse::error(json, &SubsonicError::not_found("Playlist")),
1640 Err(e) => SubsonicResponse::error(json, &SubsonicError::from(e.to_string())),
1641 }
1642}
1643
1644fn register_subsonic_routes(router: axum::Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
1650 router
1651 .route("/rest/ping", get(ping))
1653 .route("/rest/ping.view", get(ping))
1654 .route("/rest/getLicense", get(get_license))
1655 .route("/rest/getLicense.view", get(get_license))
1656 .route("/rest/getArtists", get(get_artists))
1657 .route("/rest/getArtists.view", get(get_artists))
1658 .route("/rest/getArtist", get(get_artist))
1659 .route("/rest/getArtist.view", get(get_artist))
1660 .route("/rest/getAlbum", get(get_album))
1661 .route("/rest/getAlbum.view", get(get_album))
1662 .route("/rest/getAlbumList2", get(get_album_list2))
1663 .route("/rest/getAlbumList2.view", get(get_album_list2))
1664 .route("/rest/getSong", get(get_song))
1665 .route("/rest/getSong.view", get(get_song))
1666 .route("/rest/search3", get(search3))
1668 .route("/rest/search3.view", get(search3))
1669 .route("/rest/stream", get(stream))
1671 .route("/rest/stream.view", get(stream))
1672 .route("/rest/getCoverArt", get(get_cover_art))
1673 .route("/rest/getCoverArt.view", get(get_cover_art))
1674 .route("/rest/star", get(star))
1676 .route("/rest/star.view", get(star))
1677 .route("/rest/unstar", get(unstar))
1678 .route("/rest/unstar.view", get(unstar))
1679 .route("/rest/getStarred2", get(get_starred2))
1680 .route("/rest/getStarred2.view", get(get_starred2))
1681 .route("/rest/scrobble", get(scrobble))
1682 .route("/rest/scrobble.view", get(scrobble))
1683 .route("/rest/getRandomSongs", get(get_random_songs))
1684 .route("/rest/getRandomSongs.view", get(get_random_songs))
1685 .route("/rest/getSimilarSongs2", get(get_similar_songs2))
1686 .route("/rest/getSimilarSongs2.view", get(get_similar_songs2))
1687 .route("/rest/getMusicFolders", get(get_music_folders))
1689 .route("/rest/getMusicFolders.view", get(get_music_folders))
1690 .route("/rest/getGenres", get(get_genres))
1691 .route("/rest/getGenres.view", get(get_genres))
1692 .route("/rest/getPlaylists", get(get_playlists))
1694 .route("/rest/getPlaylists.view", get(get_playlists))
1695 .route("/rest/getPlaylist", get(get_playlist))
1696 .route("/rest/getPlaylist.view", get(get_playlist))
1697 .route("/rest/createPlaylist", get(create_playlist))
1698 .route("/rest/createPlaylist.view", get(create_playlist))
1699 .route("/rest/deletePlaylist", get(delete_playlist))
1700 .route("/rest/deletePlaylist.view", get(delete_playlist))
1701}
1702
1703pub fn subsonic_router(db_path: PathBuf) -> Option<axum::Router> {
1708 let cfg = Config::load().unwrap_or_default();
1709
1710 if cfg.remote.username.is_empty() {
1711 log::warn!(
1712 "Subsonic API disabled: remote.username is not configured. \
1713 Set remote credentials in config.toml to enable the Subsonic API."
1714 );
1715 return None;
1716 }
1717
1718 let password = koan_core::helpers::get_remote_password(&cfg).unwrap_or_default();
1719 if password.is_empty() {
1720 log::warn!(
1721 "Subsonic API disabled: remote password is empty. \
1722 Set remote credentials to enable the Subsonic API."
1723 );
1724 return None;
1725 }
1726
1727 let state = Arc::new(AppState {
1728 db_path,
1729 username: cfg.remote.username.clone(),
1730 password,
1731 });
1732
1733 Some(register_subsonic_routes(axum::Router::new()).with_state(state))
1734}
1735
1736#[cfg(test)]
1741mod tests {
1742 use super::*;
1743 use axum::body::Body;
1744 use axum::http::Request;
1745 use koan_core::db::queries::TrackMeta;
1746 use tower::ServiceExt;
1747
1748 fn test_state() -> (Arc<AppState>, tempfile::TempDir) {
1749 let dir = tempfile::tempdir().unwrap();
1750 let db_path = dir.path().join("test.db");
1751 let db = Database::open(&db_path).unwrap();
1752 koan_core::db::schema::create_tables(&db.conn).unwrap();
1753
1754 let state = Arc::new(AppState {
1755 db_path,
1756 username: "testuser".into(),
1757 password: "testpass".into(),
1758 });
1759 (state, dir)
1760 }
1761
1762 fn build_test_router(state: Arc<AppState>) -> axum::Router {
1763 register_subsonic_routes(axum::Router::new()).with_state(state)
1764 }
1765
1766 fn auth_query(extra: &str) -> String {
1767 let salt = "abc123";
1768 let token = format!("{:x}", md5::compute(format!("testpass{}", salt)));
1769 let base = format!("u=testuser&t={}&s={}&v=1.16.1&c=test", token, salt);
1770 if extra.is_empty() {
1771 base
1772 } else {
1773 format!("{}&{}", base, extra)
1774 }
1775 }
1776
1777 fn seed_data(state: &AppState) {
1778 let db = Database::open(&state.db_path).unwrap();
1779 let meta = TrackMeta {
1780 title: "Test Song".into(),
1781 artist: "Test Artist".into(),
1782 album: "Test Album".into(),
1783 album_artist: Some("Test Artist".into()),
1784 track_number: Some(1),
1785 disc: Some(1),
1786 duration_ms: Some(240_000),
1787 codec: Some("FLAC".into()),
1788 sample_rate: Some(44100),
1789 bit_depth: Some(16),
1790 channels: Some(2),
1791 bitrate: Some(1411),
1792 genre: Some("Rock".into()),
1793 path: Some("/music/test.flac".into()),
1794 date: Some("2020".into()),
1795 label: None,
1796 size_bytes: None,
1797 mtime: None,
1798 source: "local".into(),
1799 remote_id: None,
1800 remote_url: None,
1801 };
1802 queries::upsert_track(&db.conn, &meta).unwrap();
1803 }
1804
1805 async fn get_response(app: axum::Router, uri: &str) -> (StatusCode, String) {
1806 let resp = app
1807 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1808 .await
1809 .unwrap();
1810 let status = resp.status();
1811 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1812 .await
1813 .unwrap();
1814 (status, String::from_utf8(body.to_vec()).unwrap())
1815 }
1816
1817 #[test]
1820 fn test_xml_escape() {
1821 assert_eq!(xml_escape("A&B"), "A&B");
1822 assert_eq!(xml_escape("a<b>c"), "a<b>c");
1823 assert_eq!(xml_escape(r#"say "hi""#), "say "hi"");
1824 }
1825
1826 #[test]
1827 fn test_codec_to_mime() {
1828 assert_eq!(codec_to_mime("FLAC"), ("flac", "audio/flac"));
1829 assert_eq!(codec_to_mime("MP3"), ("mp3", "audio/mpeg"));
1830 assert_eq!(codec_to_mime("AAC"), ("m4a", "audio/mp4"));
1831 assert_eq!(codec_to_mime("Opus"), ("opus", "audio/opus"));
1832 }
1833
1834 #[test]
1835 fn test_extension_to_mime() {
1836 assert_eq!(extension_to_mime("flac"), "audio/flac");
1837 assert_eq!(extension_to_mime("mp3"), "audio/mpeg");
1838 assert_eq!(extension_to_mime("m4a"), "audio/mp4");
1839 assert_eq!(extension_to_mime("FLAC"), "audio/flac");
1840 }
1841
1842 #[test]
1843 fn test_hex_decode() {
1844 assert_eq!(hex_decode("68656c6c6f"), "hello");
1845 assert_eq!(hex_decode(""), "");
1846 }
1847
1848 #[test]
1849 fn test_parse_range_full() {
1850 assert_eq!(parse_range("bytes=0-999", 5000), Some((0, 999)));
1851 }
1852
1853 #[test]
1854 fn test_parse_range_open_end() {
1855 assert_eq!(parse_range("bytes=1000-", 5000), Some((1000, 4999)));
1856 }
1857
1858 #[test]
1859 fn test_parse_range_suffix() {
1860 assert_eq!(parse_range("bytes=-500", 5000), Some((4500, 4999)));
1861 }
1862
1863 #[test]
1864 fn test_parse_range_out_of_bounds() {
1865 assert_eq!(parse_range("bytes=5000-6000", 5000), None);
1866 }
1867
1868 #[test]
1869 fn test_parse_range_clamps_end() {
1870 assert_eq!(parse_range("bytes=4000-9999", 5000), Some((4000, 4999)));
1871 }
1872
1873 #[tokio::test]
1876 async fn test_ping_ok() {
1877 let (state, _dir) = test_state();
1878 let app = build_test_router(state);
1879 let (status, body) = get_response(app, &format!("/rest/ping?{}", auth_query(""))).await;
1880 assert_eq!(status, StatusCode::OK);
1881 assert!(body.contains("status=\"ok\""));
1882 }
1883
1884 #[tokio::test]
1885 async fn test_ping_json() {
1886 let (state, _dir) = test_state();
1887 let app = build_test_router(state);
1888 let (status, body) =
1889 get_response(app, &format!("/rest/ping?{}", auth_query("f=json"))).await;
1890 assert_eq!(status, StatusCode::OK);
1891 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
1892 assert_eq!(parsed["subsonic-response"]["status"], "ok");
1893 }
1894
1895 #[tokio::test]
1896 async fn test_ping_wrong_password() {
1897 let (state, _dir) = test_state();
1898 let app = build_test_router(state);
1899 let (status, body) = get_response(
1900 app,
1901 "/rest/ping?u=testuser&t=wrongtoken&s=abc&v=1.16.1&c=test",
1902 )
1903 .await;
1904 assert_eq!(status, StatusCode::OK);
1905 assert!(body.contains("status=\"failed\""));
1906 assert!(body.contains("code=\"40\""));
1907 }
1908
1909 #[tokio::test]
1910 async fn test_ping_wrong_username() {
1911 let (state, _dir) = test_state();
1912 let app = build_test_router(state);
1913 let salt = "abc123";
1914 let token = format!("{:x}", md5::compute(format!("testpass{}", salt)));
1915 let (_, body) = get_response(
1916 app,
1917 &format!(
1918 "/rest/ping?u=wronguser&t={}&s={}&v=1.16.1&c=test",
1919 token, salt
1920 ),
1921 )
1922 .await;
1923 assert!(body.contains("status=\"failed\""));
1924 assert!(body.contains("code=\"40\""));
1925 }
1926
1927 #[tokio::test]
1928 async fn test_legacy_password_auth() {
1929 let (state, _dir) = test_state();
1930 let app = build_test_router(state);
1931 let (_, body) = get_response(app, "/rest/ping?u=testuser&p=testpass&v=1.16.1&c=test").await;
1932 assert!(body.contains("status=\"ok\""));
1933 }
1934
1935 #[tokio::test]
1936 async fn test_get_license() {
1937 let (state, _dir) = test_state();
1938 let app = build_test_router(state);
1939 let (_, body) = get_response(app, &format!("/rest/getLicense?{}", auth_query(""))).await;
1940 assert!(body.contains("license"));
1941 assert!(body.contains("valid=\"true\""));
1942 }
1943
1944 #[tokio::test]
1945 async fn test_get_artists_empty() {
1946 let (state, _dir) = test_state();
1947 let app = build_test_router(state);
1948 let (_, body) = get_response(app, &format!("/rest/getArtists?{}", auth_query(""))).await;
1949 assert!(body.contains("status=\"ok\""));
1950 assert!(body.contains("<artists"));
1951 }
1952
1953 #[tokio::test]
1954 async fn test_get_artists_with_data() {
1955 let (state, _dir) = test_state();
1956 seed_data(&state);
1957 let app = build_test_router(state);
1958 let (_, body) = get_response(app, &format!("/rest/getArtists?{}", auth_query(""))).await;
1959 assert!(body.contains("Test Artist"));
1960 }
1961
1962 #[tokio::test]
1963 async fn test_get_artist_by_id() {
1964 let (state, _dir) = test_state();
1965 seed_data(&state);
1966
1967 let db = Database::open(&state.db_path).unwrap();
1968 let artists = queries::all_artists(&db.conn).unwrap();
1969 let artist = &artists[0];
1970
1971 let app = build_test_router(state);
1972 let (_, body) = get_response(
1973 app,
1974 &format!("/rest/getArtist?{}&id={}", auth_query(""), artist.id),
1975 )
1976 .await;
1977 assert!(body.contains("Test Artist"));
1978 assert!(body.contains("Test Album"));
1979 }
1980
1981 #[tokio::test]
1982 async fn test_get_album_by_id() {
1983 let (state, _dir) = test_state();
1984 seed_data(&state);
1985
1986 let db = Database::open(&state.db_path).unwrap();
1987 let albums = queries::all_albums(&db.conn).unwrap();
1988 let album = &albums[0];
1989
1990 let app = build_test_router(state);
1991 let (_, body) = get_response(
1992 app,
1993 &format!("/rest/getAlbum?{}&id={}", auth_query(""), album.id),
1994 )
1995 .await;
1996 assert!(body.contains("Test Album"));
1997 assert!(body.contains("Test Song"));
1998 }
1999
2000 #[tokio::test]
2001 async fn test_get_song_by_id() {
2002 let (state, _dir) = test_state();
2003 seed_data(&state);
2004
2005 let db = Database::open(&state.db_path).unwrap();
2006 let albums = queries::all_albums(&db.conn).unwrap();
2007 let tracks = queries::tracks_for_album(&db.conn, albums[0].id).unwrap();
2008 let track = &tracks[0];
2009
2010 let app = build_test_router(state);
2011 let (_, body) = get_response(
2012 app,
2013 &format!("/rest/getSong?{}&id={}", auth_query(""), track.id),
2014 )
2015 .await;
2016 assert!(body.contains("Test Song"));
2017 assert!(body.contains("Test Artist"));
2018 }
2019
2020 #[tokio::test]
2021 async fn test_get_album_list2() {
2022 let (state, _dir) = test_state();
2023 seed_data(&state);
2024
2025 let app = build_test_router(state);
2026 let (_, body) = get_response(
2027 app,
2028 &format!(
2029 "/rest/getAlbumList2?{}&type=alphabeticalByName&size=10",
2030 auth_query("")
2031 ),
2032 )
2033 .await;
2034 assert!(body.contains("Test Album"));
2035 }
2036
2037 #[tokio::test]
2038 async fn test_get_song_not_found() {
2039 let (state, _dir) = test_state();
2040 let app = build_test_router(state);
2041 let (_, body) =
2042 get_response(app, &format!("/rest/getSong?{}&id=99999", auth_query(""))).await;
2043 assert!(body.contains("status=\"failed\""));
2044 assert!(body.contains("code=\"70\""));
2045 }
2046
2047 #[tokio::test]
2048 async fn test_json_response_format() {
2049 let (state, _dir) = test_state();
2050 seed_data(&state);
2051
2052 let db = Database::open(&state.db_path).unwrap();
2053 let albums = queries::all_albums(&db.conn).unwrap();
2054
2055 let app = build_test_router(state);
2056 let (_, body) = get_response(
2057 app,
2058 &format!(
2059 "/rest/getAlbum?{}&id={}",
2060 auth_query("f=json"),
2061 albums[0].id
2062 ),
2063 )
2064 .await;
2065
2066 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
2067 assert_eq!(parsed["subsonic-response"]["status"], "ok");
2068 assert!(parsed["subsonic-response"]["album"].is_object());
2069 }
2070
2071 #[tokio::test]
2072 async fn test_view_suffix_routes() {
2073 let (state, _dir) = test_state();
2074 let app = build_test_router(state);
2075 let (status, body) =
2076 get_response(app, &format!("/rest/ping.view?{}", auth_query(""))).await;
2077 assert_eq!(status, StatusCode::OK);
2078 assert!(body.contains("status=\"ok\""));
2079 }
2080
2081 #[tokio::test]
2084 async fn test_get_music_folders() {
2085 let (state, _dir) = test_state();
2086 let app = build_test_router(state);
2087 let (_, body) =
2088 get_response(app, &format!("/rest/getMusicFolders?{}", auth_query(""))).await;
2089 assert!(body.contains("musicFolder"));
2090 assert!(body.contains("Music"));
2091 }
2092
2093 #[tokio::test]
2094 async fn test_get_genres() {
2095 let (state, _dir) = test_state();
2096 seed_data(&state);
2097 let app = build_test_router(state);
2098 let (_, body) = get_response(app, &format!("/rest/getGenres?{}", auth_query(""))).await;
2099 assert!(body.contains("Rock"));
2100 }
2101
2102 #[tokio::test]
2103 async fn test_search3() {
2104 let (state, _dir) = test_state();
2105 seed_data(&state);
2106 let app = build_test_router(state);
2107 let (_, body) =
2108 get_response(app, &format!("/rest/search3?{}&query=Test", auth_query(""))).await;
2109 assert!(body.contains("Test Song"));
2110 assert!(body.contains("Test Artist"));
2111 }
2112
2113 #[tokio::test]
2114 async fn test_star_and_get_starred() {
2115 let (state, _dir) = test_state();
2116 seed_data(&state);
2117
2118 let db = Database::open(&state.db_path).unwrap();
2119 let tracks = queries::all_tracks(&db.conn).unwrap();
2120 let track_id = tracks[0].id;
2121
2122 let app = build_test_router(state.clone());
2123 let (_, body) = get_response(
2124 app,
2125 &format!("/rest/star?{}&id={}", auth_query(""), track_id),
2126 )
2127 .await;
2128 assert!(body.contains("status=\"ok\""));
2129
2130 let app = build_test_router(state);
2131 let (_, body) = get_response(app, &format!("/rest/getStarred2?{}", auth_query(""))).await;
2132 assert!(body.contains("Test Song"));
2133 }
2134
2135 #[tokio::test]
2136 async fn test_playlists_crud() {
2137 let (state, _dir) = test_state();
2138 seed_data(&state);
2139
2140 let app = build_test_router(state.clone());
2142 let (_, body) = get_response(
2143 app,
2144 &format!("/rest/createPlaylist?{}&name=testmix", auth_query("")),
2145 )
2146 .await;
2147 assert!(
2148 body.contains("status=\"ok\""),
2149 "createPlaylist failed: {}",
2150 body
2151 );
2152
2153 let app = build_test_router(state.clone());
2155 let (_, body) = get_response(app, &format!("/rest/getPlaylists?{}", auth_query(""))).await;
2156 assert!(body.contains("testmix"));
2157
2158 let app = build_test_router(state.clone());
2160 let (_, body) = get_response(
2161 app,
2162 &format!("/rest/getPlaylist?{}&id=testmix", auth_query("")),
2163 )
2164 .await;
2165 assert!(body.contains("testmix"));
2166
2167 let app = build_test_router(state.clone());
2169 let (_, body) = get_response(
2170 app,
2171 &format!("/rest/deletePlaylist?{}&id=testmix", auth_query("")),
2172 )
2173 .await;
2174 assert!(body.contains("status=\"ok\""));
2175
2176 let app = build_test_router(state);
2178 let (_, body) = get_response(
2179 app,
2180 &format!("/rest/getPlaylist?{}&id=testmix", auth_query("")),
2181 )
2182 .await;
2183 assert!(body.contains("status=\"failed\""));
2184 }
2185
2186 #[tokio::test]
2187 async fn test_scrobble() {
2188 let (state, _dir) = test_state();
2189 seed_data(&state);
2190
2191 let db = Database::open(&state.db_path).unwrap();
2192 let tracks = queries::all_tracks(&db.conn).unwrap();
2193
2194 let app = build_test_router(state);
2195 let (_, body) = get_response(
2196 app,
2197 &format!("/rest/scrobble?{}&id={}", auth_query(""), tracks[0].id),
2198 )
2199 .await;
2200 assert!(body.contains("status=\"ok\""));
2201 }
2202
2203 #[tokio::test]
2204 async fn test_get_random_songs() {
2205 let (state, _dir) = test_state();
2206 seed_data(&state);
2207 let app = build_test_router(state);
2208 let (_, body) = get_response(
2209 app,
2210 &format!("/rest/getRandomSongs?{}&size=5", auth_query("")),
2211 )
2212 .await;
2213 assert!(body.contains("randomSongs"));
2214 }
2215}