Add track lookup by name/artist/album

This commit is contained in:
jude
2024-03-17 16:53:07 +00:00
parent 08cc752932
commit 3a196153ba
24 changed files with 931 additions and 2010 deletions

View File

@ -1,5 +1,3 @@
pub mod update_playlists;
pub mod update_tracks;
pub use update_playlists::update_playlists_daemon;
pub use update_tracks::update_tracks_daemon;

View File

@ -1,17 +1,30 @@
use crate::listenbrainz;
use crate::listenbrainz::StatsRange;
use crate::models::TrackedPlaylist;
use crate::subsonic::Subsonic;
use crate::track::Track;
use sqlx::postgres::PgPool;
use std::env;
use std::time::Duration;
use thiserror::Error;
use tokio::time::{interval, MissedTickBehavior};
pub async fn update_playlists_daemon() -> Result<(), Box<dyn std::error::Error>> {
#[derive(Debug, Error)]
pub enum UpdatePlaylistError {
#[error("Bad range specified")]
BadRange,
}
pub async fn update_playlists_daemon(
media_client: &Subsonic,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Playlist daemon starting...");
let database = PgPool::connect(&env::var("DATABASE_URL").unwrap())
.await
.unwrap();
let mut ticker = interval(Duration::from_secs(1800));
let mut ticker = interval(Duration::from_secs(env::var("UPDATE_INTERVAL")?.parse()?));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
@ -22,7 +35,7 @@ pub async fn update_playlists_daemon() -> Result<(), Box<dyn std::error::Error>>
match records_res {
Ok(records) => {
for record in records {
update_playlist(record).await;
update_playlist(media_client, record).await.unwrap(); // todo handle me
}
}
@ -35,4 +48,58 @@ pub async fn update_playlists_daemon() -> Result<(), Box<dyn std::error::Error>>
}
}
async fn update_playlist(_playlist: TrackedPlaylist) {}
async fn update_playlist(
media_client: &Subsonic,
playlist: TrackedPlaylist,
) -> Result<(), Box<dyn std::error::Error>> {
let range =
StatsRange::from_param(&playlist.tracking_type).ok_or(UpdatePlaylistError::BadRange)?;
let recordings = listenbrainz::recordings(playlist.tracking_user, range)
.await?
.recordings;
let mut tracks = vec![];
for recording in recordings {
let search_results = media_client
.song_search(format!(
"{} {}",
recording.track_name, recording.artist_name
))
.await?;
let filtered = search_results.iter().find(|s| {
alpha_compare(&s.title, &recording.track_name)
&& alpha_compare(&s.album, &recording.release_name)
});
match filtered {
Some(track) => {
tracks.push(track);
}
None => {
println!(
"Couldn't find matching track for {} - {}",
recording.track_name, recording.artist_name
)
}
}
}
Ok(())
}
fn alpha_compare(a: &str, b: &str) -> bool {
let normalized_a = a
.chars()
.filter(|c| c.is_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect::<Vec<char>>();
let normalized_b = b
.chars()
.filter(|c| c.is_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect::<Vec<char>>();
normalized_a == normalized_b
}

View File

@ -1,33 +0,0 @@
use navidrome::{
client::{library::Library, Navidrome},
models::navidrome::NavidromeTrack,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::{interval, MissedTickBehavior};
pub async fn update_tracks_daemon(
media_client: &Navidrome,
tracks_map: &mut Arc<RwLock<HashMap<String, NavidromeTrack>>>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Tracks daemon starting...");
let mut ticker = interval(Duration::from_secs(30));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
let mut tracks_map = tracks_map.write().await;
media_client.tracks().await?.iter().for_each(|t| {
if let Some(mb_id) = &t.musicbrainz_track_id {
tracks_map.insert(mb_id.clone(), t.clone());
}
});
println!("Found {} tracks", tracks_map.len());
ticker.tick().await;
}
}

View File

@ -47,6 +47,21 @@ impl StatsRange {
StatsRange::All => "all_time",
}
}
pub fn from_param(range: &str) -> Option<Self> {
match range {
"this_week" => Some(StatsRange::ThisWeek),
"this_month" => Some(StatsRange::ThisMonth),
"this_year" => Some(StatsRange::ThisYear),
"week" => Some(StatsRange::Week),
"month" => Some(StatsRange::Month),
"quarter" => Some(StatsRange::Quarter),
"half_yearly" => Some(StatsRange::HalfYear),
"year" => Some(StatsRange::Year),
"all_time" => Some(StatsRange::All),
_ => None,
}
}
}
#[derive(Deserialize)]
@ -70,6 +85,8 @@ pub struct RecordingsPayload {
#[derive(Deserialize)]
pub struct RecordingsEntry {
pub track_name: String,
pub release_name: String,
pub artist_name: String,
pub recording_mbid: String,
pub release_mbid: String,
}

View File

@ -1,11 +1,12 @@
mod daemon;
mod listenbrainz;
mod models;
mod subsonic;
mod track;
use navidrome::{client::builder::NavidromeBuilder, models::navidrome::NavidromeTrack};
use crate::daemon::{update_playlists_daemon, update_tracks_daemon};
use crate::daemon::update_playlists_daemon;
use crate::models::{PartialTrackedPlaylist, TrackedPlaylist};
use crate::subsonic::SubsonicBuilder;
use axum::extract::State;
use axum::routing::{get, put};
use axum::{extract, Json, Router};
@ -14,13 +15,12 @@ use sqlx::postgres::PgPool;
use sqlx::Error;
use std::collections::HashMap;
use std::env;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
struct TrackState {
tracks: Arc<RwLock<HashMap<String, NavidromeTrack>>>,
tracks: Arc<RwLock<HashMap<String, String>>>,
}
#[tokio::main]
@ -28,37 +28,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database = PgPool::connect(&env::var("DATABASE_URL")?).await?;
sqlx::migrate!().run(&database).await?;
let media_client = NavidromeBuilder::new()
.base(env::var("NAVIDROME_BASE")?)
.token(env::var("NAVIDROME_TOKEN")?)
let media_client = SubsonicBuilder::new()
.base(env::var("SUBSONIC_BASE")?)
.username(env::var("SUBSONIC_USERNAME")?)
.password(env::var("SUBSONIC_PASSWORD")?)
.build()?;
let mut tracks = Arc::new(RwLock::new(HashMap::new()));
let state = TrackState {
tracks: tracks.clone(),
};
tokio::spawn(async move {
update_playlists_daemon().await.unwrap();
});
tokio::spawn(async move {
update_tracks_daemon(&media_client, &mut tracks)
.await
.unwrap();
update_playlists_daemon(&media_client).await.unwrap();
});
let app = Router::new()
.route("/playlists", get(all_playlists))
.route("/playlists", put(create_playlist))
.with_state(database)
.with_state(state);
.with_state(database);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await?;
let listener = tokio::net::TcpListener::bind("localhost:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@ -44,8 +44,8 @@ pub struct TrackedPlaylist {
pub playlist_id: Option<UuidType>,
pub playlist_name: Option<String>,
pub playlist_size: i32,
pub tracking_user: Option<String>,
pub tracking_type: Option<String>,
pub tracking_user: String,
pub tracking_type: String,
pub reduce_duplication_on: Option<String>,
}

141
src/subsonic.rs Normal file
View File

@ -0,0 +1,141 @@
use crate::track::Track;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
pub struct Subsonic {
username: String,
salt: String,
hash: String,
base: String,
client: Client,
}
#[derive(Serialize, Deserialize)]
pub struct SubsonicResponse<T> {
#[serde(rename = "subsonic-response")]
subsonic_response: T,
}
#[derive(Serialize, Deserialize)]
struct SearchResponse {
#[serde(rename = "searchResult3")]
search_results: SearchResponseSongs,
}
#[derive(Serialize, Deserialize)]
struct SearchResponseSongs {
song: Vec<Track>,
}
impl Subsonic {
pub async fn song_search(
&self,
query: String,
) -> Result<Vec<Track>, Box<dyn std::error::Error>> {
Ok(self
.client
.get(format!("{}/search3", self.base))
.query(&[
("query", query.as_str()),
("artistCount", "0"),
("albumCount", "0"),
("u", self.username.as_str()),
("s", self.salt.as_str()),
("t", self.hash.as_str()),
("v", "16"),
("c", "playlistd"),
("f", "json"),
])
.send()
.await?
.json::<SubsonicResponse<SearchResponse>>()
.await?
.subsonic_response
.search_results
.song)
}
pub async fn update_playlist(
&self,
playlist_id: String,
add_ids: Vec<String>,
remove_indexes: Vec<usize>,
) -> Result<Vec<Track>, Box<dyn std::error::Error>> {
Ok(self
.client
.get(format!("{}/search3", self.base))
.query(&[
("u", self.username.as_str()),
("s", self.salt.as_str()),
("t", self.hash.as_str()),
("v", "16"),
("c", "playlistd"),
("f", "json"),
])
.send()
.await?
.json::<Vec<Track>>()
.await?)
}
}
#[derive(Default)]
pub struct SubsonicBuilder {
username: Option<String>,
password: Option<String>,
base: Option<String>,
}
#[derive(Debug, Error)]
enum SubsonicBuilderError {
#[error("Field not provided")]
MissingField,
}
impl SubsonicBuilder {
pub fn new() -> Self {
Self {
..Default::default()
}
}
pub fn username(mut self, username: String) -> Self {
self.username = Some(username);
self
}
pub fn password(mut self, password: String) -> Self {
self.password = Some(password);
self
}
pub fn base(mut self, base: String) -> Self {
self.base = Some(base);
self
}
pub fn build(mut self) -> Result<Subsonic, Box<dyn std::error::Error>> {
let salt = Uuid::new_v4().simple().to_string();
let hash = format!(
"{:?}",
md5::compute(
format!(
"{}{}",
self.password.ok_or(SubsonicBuilderError::MissingField)?,
salt
)
.as_bytes()
)
);
Ok(Subsonic {
username: self.username.ok_or(SubsonicBuilderError::MissingField)?,
salt,
hash,
base: self.base.ok_or(SubsonicBuilderError::MissingField)?,
client: Default::default(),
})
}
}

9
src/track.rs Normal file
View File

@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct Track {
pub id: String,
pub title: String,
pub album: String,
pub artist: String,
}