Add track lookup by name/artist/album
This commit is contained in:
@ -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;
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
|
40
src/main.rs
40
src/main.rs
@ -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(())
|
||||
}
|
||||
|
@ -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
141
src/subsonic.rs
Normal 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
9
src/track.rs
Normal 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,
|
||||
}
|
Reference in New Issue
Block a user