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