Get the daemon to actually work

This commit is contained in:
jude 2024-04-16 19:10:05 +01:00
parent 587f82932b
commit 19345e771c
9 changed files with 175 additions and 92 deletions

3
.gitignore vendored
View File

@ -1,6 +1,5 @@
/target /target
/navidrome/target .env
/listenbrainz/target
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839

View File

@ -10,14 +10,18 @@
</component> </component>
<component name="CargoProjects"> <component name="CargoProjects">
<cargoProject FILE="$PROJECT_DIR$/Cargo.toml" /> <cargoProject FILE="$PROJECT_DIR$/Cargo.toml" />
<cargoProject FILE="$PROJECT_DIR$/navidrome/Cargo.toml" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="52900e09-9584-4b6c-95ff-fbd4ed5d8b2c" name="Changes" comment="Add interface package. Start adding auth stuff for refreshing tokens."> <list default="true" id="52900e09-9584-4b6c-95ff-fbd4ed5d8b2c" name="Changes" comment="Add interface package. Start adding auth stuff for refreshing tokens.">
<change afterPath="$PROJECT_DIR$/migrations/20240416175952_drop_useless_columns.sql" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/playlist.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/daemon/mod.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/daemon/mod.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/daemon/update_playlists.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/daemon/update_playlists.rs" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/daemon/update_playlists.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/daemon/update_playlists.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/main.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/models.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/models.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/subsonic.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/subsonic.rs" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/subsonic.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/subsonic.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/track.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/track.rs" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -77,6 +81,7 @@
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/playlists.rs": "true", "org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/playlists.rs": "true",
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/lib.rs": "true", "org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/lib.rs": "true",
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/models.rs": "true", "org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/models.rs": "true",
"org.rust.first.attach.projects": "true",
"settings.editor.selected.configurable": "language.rust.cargo.check", "settings.editor.selected.configurable": "language.rust.cargo.check",
"vue.rearranger.settings.migration": "true" "vue.rearranger.settings.migration": "true"
}, },
@ -152,7 +157,10 @@
<workItem from="1694621211523" duration="3713000" /> <workItem from="1694621211523" duration="3713000" />
<workItem from="1708962769688" duration="6050000" /> <workItem from="1708962769688" duration="6050000" />
<workItem from="1710605458078" duration="1011000" /> <workItem from="1710605458078" duration="1011000" />
<workItem from="1710677603495" duration="13610000" /> <workItem from="1710677603495" duration="13803000" />
<workItem from="1710872266453" duration="225000" />
<workItem from="1713283733149" duration="180000" />
<workItem from="1713283935337" duration="7066000" />
</task> </task>
<task id="LOCAL-00001" summary="Structure"> <task id="LOCAL-00001" summary="Structure">
<created>1692008860369</created> <created>1692008860369</created>

View File

@ -0,0 +1,8 @@
ALTER TABLE "tracked_playlist"
RENAME COLUMN "rule_id" TO "id";
ALTER TABLE "tracked_playlist"
DROP COLUMN playlist_name;
ALTER TABLE "tracked_playlist"
DROP COLUMN reduce_duplication_on;

View File

@ -2,7 +2,6 @@ use crate::listenbrainz;
use crate::listenbrainz::StatsRange; use crate::listenbrainz::StatsRange;
use crate::models::TrackedPlaylist; use crate::models::TrackedPlaylist;
use crate::subsonic::Subsonic; use crate::subsonic::Subsonic;
use crate::track::Track;
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use std::env; use std::env;
use std::time::Duration; use std::time::Duration;
@ -13,6 +12,8 @@ use tokio::time::{interval, MissedTickBehavior};
pub enum UpdatePlaylistError { pub enum UpdatePlaylistError {
#[error("Bad range specified")] #[error("Bad range specified")]
BadRange, BadRange,
#[error("Operation requires a playlist ID")]
NoPlaylistId,
} }
pub async fn update_playlists_daemon( pub async fn update_playlists_daemon(
@ -52,8 +53,10 @@ async fn update_playlist(
media_client: &Subsonic, media_client: &Subsonic,
playlist: TrackedPlaylist, playlist: TrackedPlaylist,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let range = match playlist.playlist_id {
StatsRange::from_param(&playlist.tracking_type).ok_or(UpdatePlaylistError::BadRange)?; Some(playlist_id) => {
let range = StatsRange::from_param(&playlist.tracking_type)
.ok_or(UpdatePlaylistError::BadRange)?;
let recordings = listenbrainz::recordings(playlist.tracking_user, range) let recordings = listenbrainz::recordings(playlist.tracking_user, range)
.await? .await?
@ -68,14 +71,17 @@ async fn update_playlist(
)) ))
.await?; .await?;
let filtered = search_results.iter().find(|s| { let filtered = search_results
.iter()
.find(|s| {
alpha_compare(&s.title, &recording.track_name) alpha_compare(&s.title, &recording.track_name)
&& alpha_compare(&s.album, &recording.release_name) && alpha_compare(&s.album, &recording.release_name)
}); })
.cloned();
match filtered { match filtered {
Some(track) => { Some(track) => {
tracks.push(track.clone()); tracks.push(track.id);
} }
None => { None => {
println!( println!(
@ -86,12 +92,20 @@ async fn update_playlist(
} }
} }
let playlist = media_client.get_playlist(playlist_id.to_string()).await?;
media_client media_client
.update_playlist(playlist.playlist_id.unwrap().to_string(), vec![], vec![]) .update_playlist(
playlist_id.to_string(),
tracks,
(0..playlist.song_count).collect(),
)
.await?; .await?;
Ok(()) Ok(())
} }
None => Err(Box::new(UpdatePlaylistError::NoPlaylistId)),
}
}
fn alpha_compare(a: &str, b: &str) -> bool { fn alpha_compare(a: &str, b: &str) -> bool {
let normalized_a = a let normalized_a = a

View File

@ -1,6 +1,7 @@
mod daemon; mod daemon;
mod listenbrainz; mod listenbrainz;
mod models; mod models;
mod playlist;
mod subsonic; mod subsonic;
mod track; mod track;
@ -40,7 +41,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new() let app = Router::new()
.route("/playlists", get(all_playlists)) .route("/playlists", get(all_playlists))
.route("/playlists", put(create_playlist)) .route("/playlists", put(create_tracking_playlist))
.with_state(database); .with_state(database);
let listener = tokio::net::TcpListener::bind("localhost:3000").await?; let listener = tokio::net::TcpListener::bind("localhost:3000").await?;
@ -68,7 +69,7 @@ enum Response<T> {
Error(JsonError), Error(JsonError),
} }
async fn create_playlist( async fn create_tracking_playlist(
State(pool): State<PgPool>, State(pool): State<PgPool>,
extract::Json(partial): extract::Json<PartialTrackedPlaylist>, extract::Json(partial): extract::Json<PartialTrackedPlaylist>,
) -> Json<Response<TrackedPlaylist>> { ) -> Json<Response<TrackedPlaylist>> {

View File

@ -6,11 +6,10 @@ use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PartialTrackedPlaylist { pub struct PartialTrackedPlaylist {
pub playlist_name: Option<String>, pub playlist_id: Option<Uuid>,
pub playlist_size: i32, pub playlist_size: i32,
pub tracking_user: Option<String>, pub tracking_user: Option<String>,
pub tracking_type: Option<String>, pub tracking_type: Option<String>,
pub reduce_duplication_on: Option<String>,
} }
impl PartialTrackedPlaylist { impl PartialTrackedPlaylist {
@ -21,15 +20,16 @@ impl PartialTrackedPlaylist {
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
sqlx::query!( sqlx::query!(
r#"INSERT INTO "tracked_playlist" r#"
(rule_id, playlist_name, playlist_size, tracking_user, tracking_type, reduce_duplication_on) INSERT INTO "tracked_playlist"
VALUES ($1, $2, $3, $4, $5, $6)"#, (id, playlist_id, playlist_size, tracking_user, tracking_type)
VALUES ($1, $2, $3, $4, $5)
"#,
uuid, uuid,
self.playlist_name, self.playlist_id,
self.playlist_size, self.playlist_size,
self.tracking_user, self.tracking_user,
self.tracking_type, self.tracking_type,
self.reduce_duplication_on,
) )
.execute(pool) .execute(pool)
.await?; .await?;
@ -40,13 +40,11 @@ impl PartialTrackedPlaylist {
#[derive(Serialize)] #[derive(Serialize)]
pub struct TrackedPlaylist { pub struct TrackedPlaylist {
pub rule_id: Uuid, pub id: Uuid,
pub playlist_id: Option<UuidType>, pub playlist_id: Option<UuidType>,
pub playlist_name: Option<String>,
pub playlist_size: i32, pub playlist_size: i32,
pub tracking_user: String, pub tracking_user: String,
pub tracking_type: String, pub tracking_type: String,
pub reduce_duplication_on: Option<String>,
} }
impl TrackedPlaylist { impl TrackedPlaylist {
@ -62,7 +60,7 @@ impl TrackedPlaylist {
) -> Result<Self, Error> { ) -> Result<Self, Error> {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
r#"SELECT * FROM "tracked_playlist" WHERE rule_id = $1"#, r#"SELECT * FROM "tracked_playlist" WHERE id = $1"#,
uuid uuid
) )
.fetch_one(pool) .fetch_one(pool)

16
src/playlist.rs Normal file
View File

@ -0,0 +1,16 @@
use crate::track::Track;
use serde::{Deserialize, Serialize};
fn default_vec() -> Vec<Track> {
Vec::new()
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Playlist {
pub id: String,
pub name: String,
#[serde(rename = "songCount")]
pub song_count: usize,
#[serde(default = "default_vec")]
pub entry: Vec<Track>,
}

View File

@ -1,3 +1,4 @@
use crate::playlist::Playlist;
use crate::track::Track; use crate::track::Track;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -6,8 +7,7 @@ use uuid::Uuid;
pub struct Subsonic { pub struct Subsonic {
username: String, username: String,
salt: String, password: String,
hash: String,
base: String, base: String,
client: Client, client: Client,
} }
@ -29,25 +29,48 @@ struct SearchResponseSongs {
song: Vec<Track>, song: Vec<Track>,
} }
#[derive(Serialize, Deserialize)]
struct GetPlaylistResponse {
playlist: Playlist,
}
struct AuthParams {
salt: String,
hash: String,
}
impl Subsonic { impl Subsonic {
fn auth_params(&self) -> AuthParams {
let salt = Uuid::new_v4().simple().to_string();
let hash = format!(
"{:?}",
md5::compute(format!("{}{}", self.password, salt).into_bytes())
);
AuthParams { salt, hash }
}
pub async fn song_search( pub async fn song_search(
&self, &self,
query: String, query: String,
) -> Result<Vec<Track>, Box<dyn std::error::Error>> { ) -> Result<Vec<Track>, Box<dyn std::error::Error>> {
Ok(self let auth_params = self.auth_params();
.client let query_params = [
.get(format!("{}/search3", self.base))
.query(&[
("query", query.as_str()), ("query", query.as_str()),
("artistCount", "0"), ("artistCount", "0"),
("albumCount", "0"), ("albumCount", "0"),
("u", self.username.as_str()), ("u", self.username.as_str()),
("s", self.salt.as_str()), ("s", auth_params.salt.as_str()),
("t", self.hash.as_str()), ("t", auth_params.hash.as_str()),
("v", "16"), ("v", "16"),
("c", "playlistd"), ("c", "playlistd"),
("f", "json"), ("f", "json"),
]) ];
Ok(self
.client
.get(format!("{}/search3", self.base))
.query(&query_params)
.send() .send()
.await? .await?
.json::<SubsonicResponse<SearchResponse>>() .json::<SubsonicResponse<SearchResponse>>()
@ -57,37 +80,67 @@ impl Subsonic {
.song) .song)
} }
pub async fn get_playlist(
&self,
playlist_id: String,
) -> Result<Playlist, Box<dyn std::error::Error>> {
let auth_params = self.auth_params();
let get_params = [
("u", self.username.to_string()),
("s", auth_params.salt.to_string()),
("t", auth_params.hash.to_string()),
("v", String::from("16")),
("c", String::from("playlistd")),
("f", String::from("json")),
("id", playlist_id),
];
Ok(self
.client
.get(format!("{}/getPlaylist", self.base))
.query(&get_params)
.send()
.await?
.json::<SubsonicResponse<GetPlaylistResponse>>()
.await?
.subsonic_response
.playlist)
}
pub async fn update_playlist( pub async fn update_playlist(
&self, &self,
playlist_id: String, playlist_id: String,
add: Vec<String>, add: Vec<String>,
remove: Vec<usize>, remove: Vec<usize>,
) -> Result<Vec<Track>, Box<dyn std::error::Error>> { ) -> Result<Playlist, Box<dyn std::error::Error>> {
let mut query_params = vec![ let auth_params = self.auth_params();
let mut update_query = vec![
("u", self.username.to_string()), ("u", self.username.to_string()),
("s", self.salt.to_string()), ("s", auth_params.salt.to_string()),
("t", self.hash.to_string()), ("t", auth_params.hash.to_string()),
("v", String::from("16")), ("v", String::from("16")),
("c", String::from("playlistd")), ("c", String::from("playlistd")),
("f", String::from("json")), ("f", String::from("json")),
("playlistId", playlist_id.clone()),
]; ];
for id in add { for id in add {
query_params.push(("songIdToAdd", id)); update_query.push(("songIdToAdd", id));
} }
for index in remove { for index in remove {
query_params.push(("songIndexToRemove", index.to_string())); update_query.push(("songIndexToRemove", index.to_string()));
} }
Ok(self self.client
.client .get(format!("{}/updatePlaylist", self.base))
.get(format!("{}/search3", self.base)) .query(&update_query)
.query(&query_params)
.send() .send()
.await? .await?;
.json::<Vec<Track>>()
.await?) self.get_playlist(playlist_id).await
} }
} }
@ -127,23 +180,9 @@ impl SubsonicBuilder {
} }
pub fn build(self) -> Result<Subsonic, Box<dyn std::error::Error>> { pub fn build(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 { Ok(Subsonic {
username: self.username.ok_or(SubsonicBuilderError::MissingField)?, username: self.username.ok_or(SubsonicBuilderError::MissingField)?,
salt, password: self.password.ok_or(SubsonicBuilderError::MissingField)?,
hash,
base: self.base.ok_or(SubsonicBuilderError::MissingField)?, base: self.base.ok_or(SubsonicBuilderError::MissingField)?,
client: Default::default(), client: Default::default(),
}) })

View File

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