diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index 55bb7b3..f615fb0 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 90a3234..d8b77b5 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -13,10 +13,14 @@ + + + + @@ -161,7 +165,8 @@ - + + 1692008860369 diff --git a/Cargo.lock b/Cargo.lock index d01e2fb..b2b3744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,21 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -201,6 +216,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.5", +] + [[package]] name = "config" version = "0.14.0" @@ -832,6 +861,29 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.5.0" @@ -1406,6 +1458,7 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" name = "playlistd" version = "0.1.0" dependencies = [ + "chrono", "config", "getrandom", "log", @@ -2781,6 +2834,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.5", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 654ca8c..35908ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ getrandom = "0.2.12" thiserror = "1.0.58" config = "0.14.0" log = "0.4.21" +chrono = "0.4.38" [package.metadata.deb] depends = "$auto" diff --git a/migrations/20240418195028_meta_tracker.sql b/migrations/20240418195028_meta_tracker.sql new file mode 100644 index 0000000..96bf2c8 --- /dev/null +++ b/migrations/20240418195028_meta_tracker.sql @@ -0,0 +1,9 @@ +CREATE TABLE "create_tracked_playlist" +( + id UUID NOT NULL PRIMARY KEY, -- Internal ID + name_template VARCHAR(250) NOT NULL, -- Name of the playlist in subsonic + last_name VARCHAR(250), -- Name of last playlist created by this rule + playlist_size INT NOT NULL, -- Max number of tracks to populate + tracking_user VARCHAR(250) NOT NULL, -- User to track stats for + tracking_type VARCHAR(14) NOT NULL -- Matches time period params from listenbrainz +); diff --git a/src/daemon/create_playlists.rs b/src/daemon/create_playlists.rs new file mode 100644 index 0000000..45f270d --- /dev/null +++ b/src/daemon/create_playlists.rs @@ -0,0 +1,100 @@ +use crate::models::CreateTrackedPlaylist; +use crate::subsonic::Subsonic; +use crate::CONFIG; +use chrono::Local; +use log::error; +use sqlx::postgres::PgPool; +use std::str::FromStr; +use std::time::Duration; +use thiserror::Error; +use tokio::time::{interval, MissedTickBehavior}; +use uuid::Uuid; + +pub async fn create_playlists_daemon( + media_client: impl AsRef + Clone, +) -> Result<(), Box> { + println!("Create playlist daemon starting..."); + + let database = PgPool::connect( + CONFIG + .get() + .unwrap() + .get::("database.url")? + .as_str(), + ) + .await + .unwrap(); + + let mut ticker = interval(Duration::from_secs( + CONFIG.get().unwrap().get::("daemon.interval")?, + )); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + let records_res = sqlx::query_as!( + CreateTrackedPlaylist, + r#"SELECT * FROM "create_tracked_playlist""# + ) + .fetch_all(&database) + .await; + + match records_res { + Ok(records) => { + for record in records { + // todo handle me + create_playlist(media_client.clone(), record, database.clone()) + .await + .unwrap(); + } + } + + Err(e) => { + println!("Could not fetch create_tracked_playlist: {:?}", e); + } + } + + ticker.tick().await; + } +} + +async fn create_playlist( + media_client: impl AsRef, + playlist_rule: CreateTrackedPlaylist, + database: PgPool, +) -> Result<(), Box> { + let now = Local::now(); + let new_name = now.format(&playlist_rule.name_template).to_string(); + + if Some(&new_name) != playlist_rule.last_name.as_ref() { + // Create playlist + let playlist = media_client.as_ref().create_playlist(&new_name).await?; + let uuid = Uuid::new_v4(); + let playlist_uuid = Uuid::from_str(&playlist.id)?; + + sqlx::query!( + r#" + INSERT INTO "tracked_playlist" (id, playlist_id, playlist_size, tracking_user, tracking_type) + VALUES ($1, $2, $3, $4, $5) + "#, + uuid, + playlist_uuid, + playlist_rule.playlist_size, + playlist_rule.tracking_user, + playlist_rule.tracking_type, + ) + .execute(&database) + .await?; + + sqlx::query!( + r#" + UPDATE "create_tracked_playlist" SET last_name = $1 WHERE id = $2 + "#, + new_name, + playlist_rule.id, + ) + .execute(&database) + .await?; + } + + Ok(()) +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 8239189..e5f4dd7 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,3 +1,5 @@ +pub mod create_playlists; pub mod update_playlists; +pub use create_playlists::create_playlists_daemon; pub use update_playlists::update_playlists_daemon; diff --git a/src/daemon/update_playlists.rs b/src/daemon/update_playlists.rs index cee905a..c6b020c 100644 --- a/src/daemon/update_playlists.rs +++ b/src/daemon/update_playlists.rs @@ -19,7 +19,7 @@ pub enum UpdatePlaylistError { pub async fn update_playlists_daemon( media_client: impl AsRef + Clone, ) -> Result<(), Box> { - println!("Playlist daemon starting..."); + println!("Track playlist daemon starting..."); let database = PgPool::connect( CONFIG @@ -67,9 +67,10 @@ async fn update_playlist( let range = StatsRange::from_param(&playlist.tracking_type) .ok_or(UpdatePlaylistError::BadRange)?; - let recordings = listenbrainz::recordings(playlist.tracking_user, range) - .await? - .recordings; + let recordings = + listenbrainz::recordings(playlist.tracking_user, range, playlist.playlist_size) + .await? + .recordings; let mut tracks = vec![]; for recording in recordings { @@ -77,7 +78,7 @@ async fn update_playlist( .as_ref() .song_search(format!( "{} {}", - recording.track_name, recording.artist_name + recording.track_name, recording.artists[0].artist_credit_name )) .await?; diff --git a/src/listenbrainz.rs b/src/listenbrainz.rs index 4e95371..5f1b673 100644 --- a/src/listenbrainz.rs +++ b/src/listenbrainz.rs @@ -82,11 +82,17 @@ pub struct RecordingsPayload { pub recordings: Vec, } +#[derive(Deserialize)] +pub struct Artist { + pub artist_credit_name: String, +} + #[derive(Deserialize)] pub struct RecordingsEntry { pub track_name: String, pub release_name: String, pub artist_name: String, + pub artists: Vec, pub recording_mbid: String, pub release_mbid: String, } @@ -94,13 +100,15 @@ pub struct RecordingsEntry { pub async fn recordings( user: impl Display, range: StatsRange, + count: i32, ) -> Result { let url = format!( - "{}/stats/user/{}/{}?range={}", + "{}/stats/user/{}/{}?range={}&count={}", BASE, user, StatsType::Recordings.stub(), - range.param() + range.param(), + count ); Ok(reqwest::get(url) diff --git a/src/main.rs b/src/main.rs index 3c73f68..3ef02dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,13 +5,13 @@ mod playlist; mod subsonic; mod track; -use crate::daemon::update_playlists_daemon; -use crate::models::{CreateTrackedPlaylist, PartialTrackedPlaylist, TrackedPlaylist}; +use crate::daemon::{create_playlists_daemon, update_playlists_daemon}; +use crate::models::{PartialTrackedPlaylist, TrackedPlaylist}; use crate::subsonic::{Subsonic, SubsonicBuilder}; use config::Config; use rocket::serde::json::{json, Json}; use rocket::{get, post, routes, serde::json::Value as JsonValue, State}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::str::FromStr; use std::sync::{Arc, OnceLock}; @@ -49,16 +49,24 @@ async fn main() -> Result<(), Box> { .build()?, ); - let _media_client = media_client.clone(); - tokio::spawn(async move { - update_playlists_daemon(_media_client).await.unwrap(); - }); + { + let _media_client = media_client.clone(); + tokio::spawn(async move { + update_playlists_daemon(_media_client).await.unwrap(); + }); + } + { + let _media_client = media_client.clone(); + tokio::spawn(async move { + create_playlists_daemon(_media_client).await.unwrap(); + }); + } rocket::build() .manage(media_client) .manage(database) .mount( - "/", + "/api/playlist", routes![ create_tracking_playlist, add_tracking_playlist, @@ -98,11 +106,20 @@ enum Response { Error(JsonError), } +#[derive(Deserialize)] +pub struct CreatePlaylist { + pub playlist_name: String, + pub playlist_size: i32, + pub tracking_user: Option, + pub tracking_type: Option, +} + +/// Create a new playlist and attach a tracker #[post("/create", data = "")] async fn create_tracking_playlist( media_client: &State>, pool: &State, - create_playlist: Json, + create_playlist: Json, ) -> Json> { match media_client .create_playlist(create_playlist.playlist_name.clone()) @@ -132,7 +149,8 @@ async fn create_tracking_playlist( } } -#[post("/add", data = "")] +/// Add tracking to an existing playlist +#[post("/track", data = "")] async fn add_tracking_playlist( pool: &State, playlist: Json, @@ -143,6 +161,7 @@ async fn add_tracking_playlist( } } +/// Get all tracked playlists #[get("/")] async fn all_playlists(pool: &State) -> JsonValue { match TrackedPlaylist::all(pool.inner()).await { diff --git a/src/models.rs b/src/models.rs index c4c343c..b6a581d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,14 +4,6 @@ use sqlx::types::Uuid as UuidType; use sqlx::{Executor, Postgres}; use uuid::Uuid; -#[derive(Deserialize)] -pub struct CreateTrackedPlaylist { - pub playlist_name: String, - pub playlist_size: i32, - pub tracking_user: Option, - pub tracking_type: Option, -} - #[derive(Deserialize)] pub struct PartialTrackedPlaylist { pub playlist_id: Uuid, @@ -75,3 +67,34 @@ impl TrackedPlaylist { .await } } + +#[derive(Serialize)] +pub struct CreateTrackedPlaylist { + pub id: Uuid, + pub name_template: String, + pub last_name: Option, + pub playlist_size: i32, + pub tracking_user: String, + pub tracking_type: String, +} + +impl CreateTrackedPlaylist { + pub async fn all(pool: impl Executor<'_, Database = Postgres>) -> Result, Error> { + sqlx::query_as!(Self, r#"SELECT * FROM "create_tracked_playlist""#) + .fetch_all(pool) + .await + } + + pub async fn rule( + pool: impl Executor<'_, Database = Postgres>, + uuid: Uuid, + ) -> Result { + sqlx::query_as!( + Self, + r#"SELECT * FROM "create_tracked_playlist" WHERE id = $1"#, + uuid + ) + .fetch_one(pool) + .await + } +} diff --git a/src/subsonic.rs b/src/subsonic.rs index 15b7009..afc7ee2 100644 --- a/src/subsonic.rs +++ b/src/subsonic.rs @@ -6,6 +6,10 @@ use std::fmt::{Display, Formatter}; use thiserror::Error; use uuid::Uuid; +fn default_vec() -> Vec { + Vec::new() +} + #[derive(Clone)] pub struct Subsonic { username: String, @@ -28,6 +32,7 @@ struct SearchResponse { #[derive(Serialize, Deserialize)] struct SearchResponseSongs { + #[serde(default = "default_vec")] song: Vec, } @@ -121,7 +126,10 @@ impl Subsonic { .playlist) } - pub async fn create_playlist(&self, playlist_name: String) -> Result { + pub async fn create_playlist( + &self, + playlist_name: impl ToString, + ) -> Result { let auth_params = self.auth_params(); let get_params = [ @@ -131,7 +139,7 @@ impl Subsonic { ("v", String::from("16")), ("c", String::from("playlistd")), ("f", String::from("json")), - ("name", playlist_name), + ("name", playlist_name.to_string()), ]; Ok(self