changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -0,0 +1,5 @@
pub mod musicbrainz;
pub mod subsonic;
pub use musicbrainz::MusicBrainzRecordingLookup;
pub use subsonic::SubsonicNowPlayingAdapter;

View 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))
}
}

View File

@@ -0,0 +1,3 @@
mod client;
pub use client::{MusicBrainzRecordingLookup, USER_AGENT, parse_first_recording};

View 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)
}
}

View 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()
}
}

View 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;

View 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)?))
}