Add track lookup by name/artist/album
This commit is contained in:
parent
08cc752932
commit
3a196153ba
6
.idea/sqldialects.xml
Normal file
6
.idea/sqldialects.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/migrations/20230816135241_initial.sql" dialect="PostgreSQL" />
|
||||
</component>
|
||||
</project>
|
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
<option name="autoReloadType" value="ALL" />
|
||||
</component>
|
||||
<component name="CMakeSettings">
|
||||
<configurations>
|
||||
@ -13,15 +13,24 @@
|
||||
<cargoProject FILE="$PROJECT_DIR$/navidrome/Cargo.toml" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="52900e09-9584-4b6c-95ff-fbd4ed5d8b2c" name="Changes" comment="Move listenbrainz user to env var">
|
||||
<change afterPath="$PROJECT_DIR$/navidrome/src/client/auth.rs" afterDir="false" />
|
||||
<list default="true" id="52900e09-9584-4b6c-95ff-fbd4ed5d8b2c" name="Changes" comment="Add interface package. Start adding auth stuff for refreshing tokens.">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Cargo.lock" beforeDir="false" afterPath="$PROJECT_DIR$/Cargo.lock" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Cargo.toml" beforeDir="false" afterPath="$PROJECT_DIR$/Cargo.toml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/migrations/20230816135241_initial.sql" beforeDir="false" afterPath="$PROJECT_DIR$/migrations/20230816135241_initial.sql" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/Cargo.lock" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/Cargo.lock" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/Cargo.toml" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/Cargo.toml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/src/client/auth.rs" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/src/client/builder.rs" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/src/client/builder.rs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/src/client/library.rs" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/src/client/library.rs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/src/client/mod.rs" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/src/client/mod.rs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/src/client/playlists.rs" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/src/client/playlists.rs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/src/models/mod.rs" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/src/models/mod.rs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/navidrome/src/models/navidrome.rs" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/src/models/subsonic.rs" 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_tracks.rs" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/listenbrainz.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/listenbrainz.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" />
|
||||
</list>
|
||||
@ -40,6 +49,9 @@
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="FormatOnSaveOptions">
|
||||
<option name="myRunOnSave" value="true" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
@ -57,35 +69,41 @@
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.cidr.known.project.marker": "true",
|
||||
"WebServerToolWindowFactoryState": "false",
|
||||
"cf.first.check.clang-format": "false",
|
||||
"cidr.known.project.marker": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"last_opened_file_path": "/home/jude/Documents/navidrome-playlists",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"org.rust.cargo.project.model.PROJECT_DISCOVERY": "true",
|
||||
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/mod.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/models.rs": "true",
|
||||
"settings.editor.selected.configurable": "language.rust.cargo.check",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"Cargo.Run.executor": "Run",
|
||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.cidr.known.project.marker": "true",
|
||||
"RunOnceActivity.rust.reset.selective.auto.import": "true",
|
||||
"WebServerToolWindowFactoryState": "false",
|
||||
"cf.first.check.clang-format": "false",
|
||||
"cidr.known.project.marker": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"last_opened_file_path": "/home/jude/Documents/navidrome-playlists",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"org.rust.cargo.project.model.PROJECT_DISCOVERY": "true",
|
||||
"org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "",
|
||||
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/mod.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/models.rs": "true",
|
||||
"settings.editor.selected.configurable": "language.rust.cargo.check",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
},
|
||||
"keyToStringList": {
|
||||
"DatabaseDriversLRU": [
|
||||
"postgresql"
|
||||
"keyToStringList": {
|
||||
"DatabaseDriversLRU": [
|
||||
"postgresql"
|
||||
],
|
||||
"com.intellij.ide.scratch.LRUPopupBuilder$1/SQL Dialect": [
|
||||
"PostgreSQL"
|
||||
]
|
||||
}
|
||||
}</component>
|
||||
}]]></component>
|
||||
<component name="RecentsManager">
|
||||
<key name="CopyFile.RECENT_KEYS">
|
||||
<recent name="$PROJECT_DIR$/src/daemon" />
|
||||
@ -100,6 +118,14 @@
|
||||
<configuration name="Run" type="CargoCommandRunConfiguration" factoryName="Cargo Command" nameIsGenerated="true">
|
||||
<option name="command" value="run" />
|
||||
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||
<envs>
|
||||
<env name="DATABASE_URL" value="postgres:///navidrome-playlists" />
|
||||
<env name="LISTENBRAINZ_USER" value="jellywx" />
|
||||
<env name="SUBSONIC_BASE" value="https://navidrome.jellypro.xyz/rest" />
|
||||
<env name="SUBSONIC_PASSWORD" value="u7P4xQYZEvC9pFva" />
|
||||
<env name="SUBSONIC_USERNAME" value="jude" />
|
||||
<env name="UPDATE_INTERVAL" value="240" />
|
||||
</envs>
|
||||
<option name="emulateTerminal" value="false" />
|
||||
<option name="channel" value="DEFAULT" />
|
||||
<option name="requiredFeatures" value="true" />
|
||||
@ -107,12 +133,6 @@
|
||||
<option name="withSudo" value="false" />
|
||||
<option name="buildTarget" value="REMOTE" />
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs>
|
||||
<env name="DATABASE_URL" value="postgres:///navidrome-playlists" />
|
||||
<env name="NAVIDROME_BASE" value="https://navidrome.jellypro.xyz" />
|
||||
<env name="NAVIDROME_TOKEN" value="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG0iOnRydWUsImV4cCI6MTY5NDM2MjkyNywiaWF0IjoxNjk0MjcyNzE1LCJpc3MiOiJORCIsInN1YiI6Imp1ZGUiLCJ1aWQiOiIyZjljNTFiMi03MThmLTRiZmYtYjhkYi03MzE0ODdmZmFhYmIifQ.XNpfkjJI7wXq4EzGM-s7dQAXyCBAyN4Dmy4frbRMXPU" />
|
||||
<env name="LISTENBRAINZ_USER" value="jellywx" />
|
||||
</envs>
|
||||
<option name="isRedirectInput" value="false" />
|
||||
<option name="redirectInputPath" value="" />
|
||||
<method v="2">
|
||||
@ -143,7 +163,11 @@
|
||||
<workItem from="1694290396939" duration="1743000" />
|
||||
<workItem from="1694333344521" duration="1478000" />
|
||||
<workItem from="1694450589184" duration="2108000" />
|
||||
<workItem from="1694534880857" duration="6011000" />
|
||||
<workItem from="1694534880857" duration="6060000" />
|
||||
<workItem from="1694621211523" duration="3713000" />
|
||||
<workItem from="1708962769688" duration="6050000" />
|
||||
<workItem from="1710605458078" duration="1011000" />
|
||||
<workItem from="1710677603495" duration="12586000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Structure">
|
||||
<created>1692008860369</created>
|
||||
@ -168,7 +192,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1694288928392</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="4" />
|
||||
<task id="LOCAL-00004" summary="Add interface package. Start adding auth stuff for refreshing tokens.">
|
||||
<option name="closed" value="true" />
|
||||
<created>1694543753828</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1694543753828</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="5" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@ -178,7 +210,8 @@
|
||||
<MESSAGE value="Structure" />
|
||||
<MESSAGE value="Track media server songs list" />
|
||||
<MESSAGE value="Move listenbrainz user to env var" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Move listenbrainz user to env var" />
|
||||
<MESSAGE value="Add interface package. Start adding auth stuff for refreshing tokens." />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Add interface package. Start adding auth stuff for refreshing tokens." />
|
||||
</component>
|
||||
<component name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
|
1051
Cargo.lock
generated
1051
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@ -6,13 +6,15 @@ authors = ["Jude Southworth (judesouthworth@pm.me)"]
|
||||
license = "AGPL-3.0 only"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.6.20", features = ["json"] }
|
||||
axum = { version = "0.7", features = ["json"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
sqlx = { version = "0.7.1", features = ["runtime-tokio", "postgres", "uuid"] }
|
||||
reqwest = { version = "0.11.18", features = ["json"] }
|
||||
serde = { version = "1.0.183", features = ["derive"] }
|
||||
navidrome = { path = "navidrome" }
|
||||
uuid = { version = "1.4.1", features = ["v4", "serde"] }
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
uuid = { version = "1.7", features = ["v4", "serde"] }
|
||||
md5 = "0.7.0"
|
||||
getrandom = "0.2.12"
|
||||
thiserror = "1.0.58"
|
||||
|
||||
[package.metadata.deb]
|
||||
depends = "$auto"
|
||||
|
@ -1,9 +1,10 @@
|
||||
CREATE TABLE "tracked_playlist" (
|
||||
CREATE TABLE "tracked_playlist"
|
||||
(
|
||||
rule_id UUID NOT NULL PRIMARY KEY, -- Internal ID
|
||||
playlist_id UUID, -- UUID of the playlist in navidrome
|
||||
playlist_name VARCHAR(250), -- Name of the playlist in navidrome
|
||||
playlist_id UUID, -- UUID of the playlist in subsonic
|
||||
playlist_name VARCHAR(250), -- Name of the playlist in subsonic
|
||||
playlist_size INT NOT NULL, -- Max number of tracks to populate
|
||||
tracking_user VARCHAR(250), -- User to track stats for
|
||||
tracking_type VARCHAR(14), -- Matches time period params from listenbrainz
|
||||
tracking_user VARCHAR(250) NOT NULL, -- User to track stats for
|
||||
tracking_type VARCHAR(14) NOT NULL, -- Matches time period params from listenbrainz
|
||||
reduce_duplication_on VARCHAR(250) -- Allow e.g preferring songs from different albums or artists
|
||||
);
|
||||
|
1144
navidrome/Cargo.lock
generated
1144
navidrome/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "navidrome"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11.18", features = ["json"] }
|
||||
serde = { version = "1.0.183", features = ["derive"] }
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
async-trait = "0.1.73"
|
@ -1,6 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Auth {
|
||||
fn login(&mut self) -> Result<(), reqwest::Error>;
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
use crate::client::Navidrome;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
|
||||
pub struct NavidromeBuilder {
|
||||
base: Option<String>,
|
||||
token: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NavidromeBuilderError {
|
||||
MissingParam,
|
||||
Reqwest(reqwest::Error),
|
||||
}
|
||||
|
||||
impl Display for NavidromeBuilderError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for NavidromeBuilderError {}
|
||||
|
||||
impl NavidromeBuilder {
|
||||
pub fn new() -> NavidromeBuilder {
|
||||
return NavidromeBuilder {
|
||||
base: None,
|
||||
token: None,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn base(&mut self, base: impl ToString) -> &mut NavidromeBuilder {
|
||||
self.base = Some(base.to_string());
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn token(&mut self, token: impl ToString) -> &mut NavidromeBuilder {
|
||||
self.token = Some(token.to_string());
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn username(&mut self, username: impl ToString) -> &mut NavidromeBuilder {
|
||||
self.username = Some(username.to_string());
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn password(&mut self, password: impl ToString) -> &mut NavidromeBuilder {
|
||||
self.password = Some(password.to_string());
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn build(&self) -> Result<Navidrome, NavidromeBuilderError> {
|
||||
let client = reqwest::ClientBuilder::new()
|
||||
.build()
|
||||
.map_err(|e| NavidromeBuilderError::Reqwest(e))?;
|
||||
|
||||
return Ok(Navidrome {
|
||||
base: self
|
||||
.base
|
||||
.clone()
|
||||
.ok_or(NavidromeBuilderError::MissingParam)?,
|
||||
http: client,
|
||||
token: self.token.clone(),
|
||||
username: self.username.ok_or(NavidromeBuilderError::MissingParam)?,
|
||||
password: self.password.ok_or(NavidromeBuilderError::MissingParam)?,
|
||||
});
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
use crate::client::{MediaResult as Result, Navidrome, QualifyPath};
|
||||
use crate::models::generic::Playlist;
|
||||
use crate::models::navidrome::{NavidromePlaylist, NavidromeTrack};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Library {
|
||||
async fn tracks(&self) -> Result<Vec<NavidromeTrack>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Library for Navidrome {
|
||||
async fn tracks(&self) -> Result<Vec<NavidromeTrack>> {
|
||||
self.http
|
||||
.get(self.path("api/song"))
|
||||
.header("X-Nd-Authorization", format!("Bearer {}", self.token))
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<NavidromeTrack>>()
|
||||
.await
|
||||
}
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
pub mod builder;
|
||||
pub mod library;
|
||||
pub mod playlists;
|
||||
|
||||
trait QualifyPath {
|
||||
fn path(&self, path: impl Display) -> String;
|
||||
}
|
||||
|
||||
pub type MediaResult<U> = Result<U, reqwest::Error>;
|
||||
|
||||
pub struct Navidrome {
|
||||
base: String,
|
||||
http: reqwest::Client,
|
||||
token: Option<String>,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl QualifyPath for Navidrome {
|
||||
fn path(&self, path: impl Display) -> String {
|
||||
format!("{}/{}", self.base, path)
|
||||
}
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
use crate::client::{MediaResult as Result, Navidrome, QualifyPath};
|
||||
use crate::models::generic::Playlist;
|
||||
use crate::models::navidrome::{NavidromePlaylist, NavidromeTrack};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Playlists {
|
||||
async fn playlists(&self) -> Result<Vec<Playlist>>;
|
||||
async fn playlist(&self, id: String) -> Result<Playlist>;
|
||||
async fn tracks(&self, id: String) -> Result<Vec<NavidromeTrack>>;
|
||||
async fn create_playlist(&self) -> Result<Playlist>;
|
||||
async fn delete_playlist(&self) -> Result<()>;
|
||||
async fn update_playlists(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Playlists for Navidrome {
|
||||
async fn playlists(&self) -> Result<Vec<Playlist>> {
|
||||
self.http
|
||||
.get(self.path("api/playlist"))
|
||||
.header("X-Nd-Authorization", format!("Bearer {}", self.token))
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<NavidromePlaylist>>()
|
||||
.await
|
||||
.map(|pv| pv.iter().map(|p| p.into()).collect())
|
||||
}
|
||||
|
||||
async fn playlist(&self, id: String) -> Result<Playlist> {
|
||||
self.http
|
||||
.get(self.path(format!("api/playlist/{}", id)))
|
||||
.header("X-Nd-Authorization", format!("Bearer {}", self.token))
|
||||
.send()
|
||||
.await?
|
||||
.json::<NavidromePlaylist>()
|
||||
.await
|
||||
.map(|pv| (&pv).into())
|
||||
}
|
||||
|
||||
async fn tracks(&self, id: String) -> Result<Vec<NavidromeTrack>> {
|
||||
self.http
|
||||
.get(self.path(format!("api/playlist/{}/tracks", id)))
|
||||
.header("X-Nd-Authorization", format!("Bearer {}", self.token))
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<NavidromeTrack>>()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_playlist(&self) -> Result<Playlist> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self) -> Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn update_playlists(&self) -> Result<()> {
|
||||
todo!()
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
pub mod models;
|
||||
pub mod client;
|
@ -1,9 +0,0 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
pub struct Playlist {
|
||||
// items: dyn Iterator<Item = Song>,
|
||||
pub name: String,
|
||||
pub comment: Option<String>,
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
pub file: Option<String>,
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
pub mod generic;
|
||||
pub mod navidrome;
|
@ -1,70 +0,0 @@
|
||||
use crate::models::generic::Playlist;
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
id: String, // todo should be a uuid
|
||||
#[serde(rename = "isAdmin")]
|
||||
is_admin: bool,
|
||||
name: String,
|
||||
#[serde(rename = "subsonicSalt")]
|
||||
subsonic_salt: String,
|
||||
#[serde(rename = "subsonicToken")]
|
||||
subsonic_token: String,
|
||||
token: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct NavidromePlaylist {
|
||||
name: String,
|
||||
comment: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
created_at: NaiveDateTime,
|
||||
path: String,
|
||||
public: bool,
|
||||
size: u64,
|
||||
duration: f64,
|
||||
#[serde(rename = "evaluatedAt")]
|
||||
evaluated_at: NaiveDateTime,
|
||||
id: String,
|
||||
#[serde(rename = "ownerId")]
|
||||
owner_id: String,
|
||||
#[serde(rename = "ownerName")]
|
||||
owner_name: String,
|
||||
#[serde(rename = "songCount")]
|
||||
song_count: u64,
|
||||
sync: bool,
|
||||
#[serde(rename = "updatedAt")]
|
||||
updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl From<&NavidromePlaylist> for Playlist {
|
||||
fn from(value: &NavidromePlaylist) -> Self {
|
||||
Playlist {
|
||||
name: value.name.clone(),
|
||||
comment: Some(value.comment.clone()),
|
||||
created_at: Some(value.created_at),
|
||||
file: Some(value.path.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct NavidromeTrack {
|
||||
title: String,
|
||||
artist: String,
|
||||
#[serde(rename = "albumArtist")]
|
||||
album_artist: String,
|
||||
album: String,
|
||||
#[serde(rename = "albumId")]
|
||||
album_id: String,
|
||||
#[serde(rename = "artistId")]
|
||||
artist_id: String,
|
||||
id: String,
|
||||
#[serde(rename = "mbzTrackId")]
|
||||
pub musicbrainz_track_id: Option<String>,
|
||||
#[serde(rename = "mbzReleaseTrackId")]
|
||||
musicbrainz_release_track_id: Option<String>,
|
||||
}
|
@ -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,
|
||||
}
|
Loading…
Reference in New Issue
Block a user