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)?))
|
||||
}
|
||||
Reference in New Issue
Block a user