17
crates/adapters/music/Cargo.toml
Normal file
17
crates/adapters/music/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "music"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
async-trait.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
md-5.workspace = true
|
||||
rand.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
5
crates/adapters/music/src/lib.rs
Normal file
5
crates/adapters/music/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod musicbrainz;
|
||||
pub mod subsonic;
|
||||
|
||||
pub use musicbrainz::MusicBrainzRecordingLookup;
|
||||
pub use subsonic::SubsonicNowPlayingAdapter;
|
||||
62
crates/adapters/music/src/musicbrainz/client.rs
Normal file
62
crates/adapters/music/src/musicbrainz/client.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::RecordingLookupPort;
|
||||
use domain::song::RecordingId;
|
||||
|
||||
const SEARCH_URL: &str = "https://musicbrainz.org/ws/2/recording";
|
||||
pub const USER_AGENT: &str = concat!("k-mood/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
pub struct MusicBrainzRecordingLookup {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl MusicBrainzRecordingLookup {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self { http }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchResult {
|
||||
#[serde(default)]
|
||||
recordings: Vec<Recording>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Recording {
|
||||
id: String,
|
||||
}
|
||||
|
||||
pub fn parse_first_recording(body: &str) -> Option<RecordingId> {
|
||||
let result: SearchResult = serde_json::from_str(body).ok()?;
|
||||
let first = result.recordings.first()?;
|
||||
RecordingId::new(&first.id).ok()
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RecordingLookupPort for MusicBrainzRecordingLookup {
|
||||
async fn find_recording(
|
||||
&self,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
) -> Result<Option<RecordingId>, DomainError> {
|
||||
let query = format!(r#"recording:"{title}" AND artist:"{artist}""#);
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.get(SEARCH_URL)
|
||||
.query(&[("query", query.as_str()), ("fmt", "json"), ("limit", "1")])
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let Ok(response) = response else {
|
||||
tracing::debug!("musicbrainz lookup failed");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Ok(body) = response.text().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(parse_first_recording(&body))
|
||||
}
|
||||
}
|
||||
3
crates/adapters/music/src/musicbrainz/mod.rs
Normal file
3
crates/adapters/music/src/musicbrainz/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod client;
|
||||
|
||||
pub use client::{MusicBrainzRecordingLookup, USER_AGENT, parse_first_recording};
|
||||
76
crates/adapters/music/src/subsonic/client.rs
Normal file
76
crates/adapters/music/src/subsonic/client.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use md5::{Digest, Md5};
|
||||
use rand::Rng;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::NowPlayingPort;
|
||||
use domain::song::Song;
|
||||
|
||||
use super::credential::SubsonicCredential;
|
||||
use super::response::parse_now_playing;
|
||||
|
||||
const API_VERSION: &str = "1.16.1";
|
||||
const CLIENT_NAME: &str = "k-mood";
|
||||
const SALT_BYTES: usize = 12;
|
||||
|
||||
pub struct SubsonicNowPlayingAdapter {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SubsonicNowPlayingAdapter {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self { http }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_token(password: &str, salt: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(password.as_bytes());
|
||||
hasher.update(salt.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn random_salt() -> String {
|
||||
let mut bytes = [0u8; SALT_BYTES];
|
||||
rand::rng().fill(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NowPlayingPort for SubsonicNowPlayingAdapter {
|
||||
fn provider(&self) -> &str {
|
||||
"subsonic"
|
||||
}
|
||||
|
||||
async fn now_playing(&self, credential: &[u8]) -> Result<Option<Song>, DomainError> {
|
||||
let credential = SubsonicCredential::parse(credential)?;
|
||||
let salt = random_salt();
|
||||
let token = auth_token(&credential.password, &salt);
|
||||
|
||||
let url = format!("{}/rest/getNowPlaying", credential.base_url());
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("u", credential.username.as_str()),
|
||||
("t", token.as_str()),
|
||||
("s", salt.as_str()),
|
||||
("v", API_VERSION),
|
||||
("c", CLIENT_NAME),
|
||||
("f", "json"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(%e, "subsonic request failed");
|
||||
DomainError::InvalidInput("could not reach the music provider".into())
|
||||
})?;
|
||||
|
||||
let body = response.text().await.map_err(|e| {
|
||||
tracing::warn!(%e, "subsonic response could not be read");
|
||||
DomainError::InvalidInput("could not read the music provider's response".into())
|
||||
})?;
|
||||
|
||||
parse_now_playing(&body)
|
||||
}
|
||||
}
|
||||
43
crates/adapters/music/src/subsonic/credential.rs
Normal file
43
crates/adapters/music/src/subsonic/credential.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SubsonicCredential {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl SubsonicCredential {
|
||||
pub fn parse(bytes: &[u8]) -> Result<Self, DomainError> {
|
||||
let credential: Self = serde_json::from_slice(bytes).map_err(|_| {
|
||||
DomainError::InvalidInput(
|
||||
"subsonic credential must carry a url, username and password".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if credential.url.trim().is_empty()
|
||||
|| credential.username.trim().is_empty()
|
||||
|| credential.password.is_empty()
|
||||
{
|
||||
return Err(DomainError::InvalidInput(
|
||||
"subsonic credential must carry a url, username and password".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
self.url.trim().trim_end_matches('/')
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SubsonicCredential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SubsonicCredential")
|
||||
.field("url", &self.url)
|
||||
.field("username", &self.username)
|
||||
.field("password", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
7
crates/adapters/music/src/subsonic/mod.rs
Normal file
7
crates/adapters/music/src/subsonic/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod client;
|
||||
mod credential;
|
||||
mod response;
|
||||
|
||||
pub use client::{SubsonicNowPlayingAdapter, auth_token};
|
||||
pub use credential::SubsonicCredential;
|
||||
pub use response::parse_now_playing;
|
||||
62
crates/adapters/music/src/subsonic/response.rs
Normal file
62
crates/adapters/music/src/subsonic/response.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::song::Song;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Envelope {
|
||||
#[serde(rename = "subsonic-response")]
|
||||
response: SubsonicResponse,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SubsonicResponse {
|
||||
status: String,
|
||||
#[serde(rename = "nowPlaying")]
|
||||
now_playing: Option<NowPlaying>,
|
||||
error: Option<SubsonicError>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SubsonicError {
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NowPlaying {
|
||||
#[serde(default)]
|
||||
entry: Vec<NowPlayingEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NowPlayingEntry {
|
||||
title: Option<String>,
|
||||
artist: Option<String>,
|
||||
album: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_now_playing(body: &str) -> Result<Option<Song>, DomainError> {
|
||||
let envelope: Envelope = serde_json::from_str(body)
|
||||
.map_err(|_| DomainError::InvalidInput("unrecognised subsonic response".into()))?;
|
||||
|
||||
if envelope.response.status != "ok" {
|
||||
let message = envelope
|
||||
.response
|
||||
.error
|
||||
.and_then(|error| error.message)
|
||||
.unwrap_or_else(|| "subsonic rejected the request".into());
|
||||
return Err(DomainError::InvalidInput(message));
|
||||
}
|
||||
|
||||
let Some(entry) = envelope
|
||||
.response
|
||||
.now_playing
|
||||
.and_then(|playing| playing.entry.into_iter().next())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (Some(title), Some(artist)) = (entry.title, entry.artist) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(Song::new(title, artist, entry.album, None)?))
|
||||
}
|
||||
30
crates/adapters/music/tests/musicbrainz_test.rs
Normal file
30
crates/adapters/music/tests/musicbrainz_test.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use music::musicbrainz::parse_first_recording;
|
||||
|
||||
#[test]
|
||||
fn the_first_recording_is_taken_as_the_match() {
|
||||
let body = r#"{"recordings":[
|
||||
{"id":"f5c7e7a2-0000-4000-8000-000000000001","title":"Paranoid Android"},
|
||||
{"id":"f5c7e7a2-0000-4000-8000-000000000002","title":"Paranoid Android (live)"}
|
||||
]}"#;
|
||||
|
||||
let recording = parse_first_recording(body).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
recording.value().to_string(),
|
||||
"f5c7e7a2-0000-4000-8000-000000000001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_yields_nothing_rather_than_an_error() {
|
||||
assert!(parse_first_recording(r#"{"recordings":[]}"#).is_none());
|
||||
assert!(parse_first_recording(r#"{}"#).is_none());
|
||||
assert!(parse_first_recording("not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_id_that_is_not_a_uuid_is_ignored() {
|
||||
let body = r#"{"recordings":[{"id":"not-a-uuid"}]}"#;
|
||||
|
||||
assert!(parse_first_recording(body).is_none());
|
||||
}
|
||||
81
crates/adapters/music/tests/subsonic_test.rs
Normal file
81
crates/adapters/music/tests/subsonic_test.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use music::subsonic::{SubsonicCredential, auth_token, parse_now_playing};
|
||||
|
||||
#[test]
|
||||
fn the_auth_token_is_md5_of_password_and_salt() {
|
||||
assert_eq!(
|
||||
auth_token("hunter2", "c19b2d"),
|
||||
"1b41ecef65ff7799cf7a84cf2d505e08"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_salt_produces_a_different_token() {
|
||||
assert_ne!(auth_token("hunter2", "aaa"), auth_token("hunter2", "bbb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_needs_a_url_username_and_password() {
|
||||
let valid = br#"{"url":"https://music.example/","username":"gabriel","password":"hunter2"}"#;
|
||||
let credential = SubsonicCredential::parse(valid).unwrap();
|
||||
|
||||
assert_eq!(credential.base_url(), "https://music.example");
|
||||
assert_eq!(credential.username, "gabriel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incomplete_credential_is_rejected() {
|
||||
assert!(SubsonicCredential::parse(br#"{"url":"https://music.example"}"#).is_err());
|
||||
assert!(SubsonicCredential::parse(br#"{"url":"","username":"g","password":"p"}"#).is_err());
|
||||
assert!(SubsonicCredential::parse(b"not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_never_reveals_its_password_in_debug_output() {
|
||||
let credential = SubsonicCredential::parse(
|
||||
br#"{"url":"https://music.example","username":"gabriel","password":"hunter2"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!format!("{credential:?}").contains("hunter2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_playing_track_becomes_a_song() {
|
||||
let body = r#"{"subsonic-response":{"status":"ok","version":"1.16.1","nowPlaying":{"entry":[
|
||||
{"title":"Paranoid Android","artist":"Radiohead","album":"OK Computer"}
|
||||
]}}}"#;
|
||||
|
||||
let song = parse_now_playing(body).unwrap().unwrap();
|
||||
|
||||
assert_eq!(song.title().value(), "Paranoid Android");
|
||||
assert_eq!(song.artist().value(), "Radiohead");
|
||||
assert_eq!(song.album().map(|a| a.value()), Some("OK Computer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_playing_is_not_an_error() {
|
||||
let empty = r#"{"subsonic-response":{"status":"ok","version":"1.16.1"}}"#;
|
||||
let no_entries = r#"{"subsonic-response":{"status":"ok","nowPlaying":{"entry":[]}}}"#;
|
||||
|
||||
assert!(parse_now_playing(empty).unwrap().is_none());
|
||||
assert!(parse_now_playing(no_entries).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_call_is_an_error_even_though_subsonic_answers_with_http_200() {
|
||||
let body = r#"{"subsonic-response":{"status":"failed","version":"1.16.1",
|
||||
"error":{"code":40,"message":"Wrong username or password."}}}"#;
|
||||
|
||||
let result = parse_now_playing(body);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_track_missing_its_artist_is_skipped_rather_than_half_stored() {
|
||||
let body = r#"{"subsonic-response":{"status":"ok","nowPlaying":{"entry":[
|
||||
{"title":"Untitled"}
|
||||
]}}}"#;
|
||||
|
||||
assert!(parse_now_playing(body).unwrap().is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user