Add track lookup by name/artist/album

This commit is contained in:
jude 2024-03-17 16:53:07 +00:00
parent 08cc752932
commit 3a196153ba
24 changed files with 931 additions and 2010 deletions

6
.idea/sqldialects.xml Normal file
View 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>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="AutoImportSettings"> <component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="ALL" />
</component> </component>
<component name="CMakeSettings"> <component name="CMakeSettings">
<configurations> <configurations>
@ -13,15 +13,24 @@
<cargoProject FILE="$PROJECT_DIR$/navidrome/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="Move listenbrainz user to env var"> <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$/navidrome/src/client/auth.rs" 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$/Cargo.lock" beforeDir="false" afterPath="$PROJECT_DIR$/Cargo.lock" 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$/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$/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/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/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_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/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/models.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/models.rs" afterDir="false" />
</list> </list>
@ -40,6 +49,9 @@
</list> </list>
</option> </option>
</component> </component>
<component name="FormatOnSaveOptions">
<option name="myRunOnSave" value="true" />
</component>
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component> </component>
@ -57,35 +69,41 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;, "Cargo.Run.executor": "Run",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.OpenProjectViewOnStart": "true",
&quot;RunOnceActivity.cidr.known.project.marker&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;WebServerToolWindowFactoryState&quot;: &quot;false&quot;, "RunOnceActivity.cidr.known.project.marker": "true",
&quot;cf.first.check.clang-format&quot;: &quot;false&quot;, "RunOnceActivity.rust.reset.selective.auto.import": "true",
&quot;cidr.known.project.marker&quot;: &quot;true&quot;, "WebServerToolWindowFactoryState": "false",
&quot;git-widget-placeholder&quot;: &quot;master&quot;, "cf.first.check.clang-format": "false",
&quot;last_opened_file_path&quot;: &quot;/home/jude/Documents/navidrome-playlists&quot;, "cidr.known.project.marker": "true",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "git-widget-placeholder": "master",
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, "last_opened_file_path": "/home/jude/Documents/navidrome-playlists",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.detected.package.tslint": "true",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;org.rust.cargo.project.model.PROJECT_DISCOVERY&quot;: &quot;true&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/mod.rs&quot;: &quot;true&quot;, "nodejs_package_manager_path": "npm",
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/playlists.rs&quot;: &quot;true&quot;, "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true",
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/lib.rs&quot;: &quot;true&quot;, "org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "",
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/models.rs&quot;: &quot;true&quot;, "org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/mod.rs": "true",
&quot;settings.editor.selected.configurable&quot;: &quot;language.rust.cargo.check&quot;, "org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/playlists.rs": "true",
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; "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"
}, },
&quot;keyToStringList&quot;: { "keyToStringList": {
&quot;DatabaseDriversLRU&quot;: [ "DatabaseDriversLRU": [
&quot;postgresql&quot; "postgresql"
],
"com.intellij.ide.scratch.LRUPopupBuilder$1/SQL Dialect": [
"PostgreSQL"
] ]
} }
}</component> }]]></component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/daemon" /> <recent name="$PROJECT_DIR$/src/daemon" />
@ -100,6 +118,14 @@
<configuration name="Run" type="CargoCommandRunConfiguration" factoryName="Cargo Command" nameIsGenerated="true"> <configuration name="Run" type="CargoCommandRunConfiguration" factoryName="Cargo Command" nameIsGenerated="true">
<option name="command" value="run" /> <option name="command" value="run" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" /> <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="emulateTerminal" value="false" />
<option name="channel" value="DEFAULT" /> <option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" /> <option name="requiredFeatures" value="true" />
@ -107,12 +133,6 @@
<option name="withSudo" value="false" /> <option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" /> <option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" /> <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="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" /> <option name="redirectInputPath" value="" />
<method v="2"> <method v="2">
@ -143,7 +163,11 @@
<workItem from="1694290396939" duration="1743000" /> <workItem from="1694290396939" duration="1743000" />
<workItem from="1694333344521" duration="1478000" /> <workItem from="1694333344521" duration="1478000" />
<workItem from="1694450589184" duration="2108000" /> <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>
<task id="LOCAL-00001" summary="Structure"> <task id="LOCAL-00001" summary="Structure">
<created>1692008860369</created> <created>1692008860369</created>
@ -168,7 +192,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1694288928392</updated> <updated>1694288928392</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -178,7 +210,8 @@
<MESSAGE value="Structure" /> <MESSAGE value="Structure" />
<MESSAGE value="Track media server songs list" /> <MESSAGE value="Track media server songs list" />
<MESSAGE value="Move listenbrainz user to env var" /> <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>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />

1051
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,15 @@ authors = ["Jude Southworth (judesouthworth@pm.me)"]
license = "AGPL-3.0 only" license = "AGPL-3.0 only"
[dependencies] [dependencies]
axum = { version = "0.6.20", features = ["json"] } axum = { version = "0.7", features = ["json"] }
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
sqlx = { version = "0.7.1", features = ["runtime-tokio", "postgres", "uuid"] } sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid"] }
reqwest = { version = "0.11.18", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0.183", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
navidrome = { path = "navidrome" } uuid = { version = "1.7", features = ["v4", "serde"] }
uuid = { version = "1.4.1", features = ["v4", "serde"] } md5 = "0.7.0"
getrandom = "0.2.12"
thiserror = "1.0.58"
[package.metadata.deb] [package.metadata.deb]
depends = "$auto" depends = "$auto"

View File

@ -1,9 +1,10 @@
CREATE TABLE "tracked_playlist" ( CREATE TABLE "tracked_playlist"
(
rule_id UUID NOT NULL PRIMARY KEY, -- Internal ID rule_id UUID NOT NULL PRIMARY KEY, -- Internal ID
playlist_id UUID, -- UUID of the playlist in navidrome playlist_id UUID, -- UUID of the playlist in subsonic
playlist_name VARCHAR(250), -- Name of the playlist in navidrome playlist_name VARCHAR(250), -- Name of the playlist in subsonic
playlist_size INT NOT NULL, -- Max number of tracks to populate playlist_size INT NOT NULL, -- Max number of tracks to populate
tracking_user VARCHAR(250), -- User to track stats for tracking_user VARCHAR(250) NOT NULL, -- User to track stats for
tracking_type VARCHAR(14), -- Matches time period params from listenbrainz 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 reduce_duplication_on VARCHAR(250) -- Allow e.g preferring songs from different albums or artists
); );

1144
navidrome/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -1,6 +0,0 @@
use async_trait::async_trait;
#[async_trait]
pub trait Auth {
fn login(&mut self) -> Result<(), reqwest::Error>;
}

View File

@ -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)?,
});
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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!()
}
}

View File

@ -1,2 +0,0 @@
pub mod models;
pub mod client;

View File

@ -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>,
}

View File

@ -1,2 +0,0 @@
pub mod generic;
pub mod navidrome;

View File

@ -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>,
}

View File

@ -1,5 +1,3 @@
pub mod update_playlists; pub mod update_playlists;
pub mod update_tracks;
pub use update_playlists::update_playlists_daemon; pub use update_playlists::update_playlists_daemon;
pub use update_tracks::update_tracks_daemon;

View File

@ -1,17 +1,30 @@
use crate::listenbrainz;
use crate::listenbrainz::StatsRange;
use crate::models::TrackedPlaylist; use crate::models::TrackedPlaylist;
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;
use thiserror::Error;
use tokio::time::{interval, MissedTickBehavior}; 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..."); println!("Playlist daemon starting...");
let database = PgPool::connect(&env::var("DATABASE_URL").unwrap()) let database = PgPool::connect(&env::var("DATABASE_URL").unwrap())
.await .await
.unwrap(); .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); ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop { loop {
@ -22,7 +35,7 @@ pub async fn update_playlists_daemon() -> Result<(), Box<dyn std::error::Error>>
match records_res { match records_res {
Ok(records) => { Ok(records) => {
for record in 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
}

View File

@ -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;
}
}

View File

@ -47,6 +47,21 @@ impl StatsRange {
StatsRange::All => "all_time", 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)] #[derive(Deserialize)]
@ -70,6 +85,8 @@ pub struct RecordingsPayload {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct RecordingsEntry { pub struct RecordingsEntry {
pub track_name: String, pub track_name: String,
pub release_name: String,
pub artist_name: String,
pub recording_mbid: String, pub recording_mbid: String,
pub release_mbid: String, pub release_mbid: String,
} }

View File

@ -1,11 +1,12 @@
mod daemon; mod daemon;
mod listenbrainz; mod listenbrainz;
mod models; mod models;
mod subsonic;
mod track;
use navidrome::{client::builder::NavidromeBuilder, models::navidrome::NavidromeTrack}; use crate::daemon::update_playlists_daemon;
use crate::daemon::{update_playlists_daemon, update_tracks_daemon};
use crate::models::{PartialTrackedPlaylist, TrackedPlaylist}; use crate::models::{PartialTrackedPlaylist, TrackedPlaylist};
use crate::subsonic::SubsonicBuilder;
use axum::extract::State; use axum::extract::State;
use axum::routing::{get, put}; use axum::routing::{get, put};
use axum::{extract, Json, Router}; use axum::{extract, Json, Router};
@ -14,13 +15,12 @@ use sqlx::postgres::PgPool;
use sqlx::Error; use sqlx::Error;
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
#[derive(Clone)] #[derive(Clone)]
struct TrackState { struct TrackState {
tracks: Arc<RwLock<HashMap<String, NavidromeTrack>>>, tracks: Arc<RwLock<HashMap<String, String>>>,
} }
#[tokio::main] #[tokio::main]
@ -28,37 +28,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database = PgPool::connect(&env::var("DATABASE_URL")?).await?; let database = PgPool::connect(&env::var("DATABASE_URL")?).await?;
sqlx::migrate!().run(&database).await?; sqlx::migrate!().run(&database).await?;
let media_client = NavidromeBuilder::new() let media_client = SubsonicBuilder::new()
.base(env::var("NAVIDROME_BASE")?) .base(env::var("SUBSONIC_BASE")?)
.token(env::var("NAVIDROME_TOKEN")?) .username(env::var("SUBSONIC_USERNAME")?)
.password(env::var("SUBSONIC_PASSWORD")?)
.build()?; .build()?;
let mut tracks = Arc::new(RwLock::new(HashMap::new()));
let state = TrackState {
tracks: tracks.clone(),
};
tokio::spawn(async move { tokio::spawn(async move {
update_playlists_daemon().await.unwrap(); update_playlists_daemon(&media_client).await.unwrap();
});
tokio::spawn(async move {
update_tracks_daemon(&media_client, &mut tracks)
.await
.unwrap();
}); });
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_playlist))
.with_state(database) .with_state(database);
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); let listener = tokio::net::TcpListener::bind("localhost:3000").await?;
axum::Server::bind(&addr) axum::serve(listener, app).await?;
.serve(app.into_make_service())
.await?;
Ok(()) Ok(())
} }

View File

@ -44,8 +44,8 @@ pub struct TrackedPlaylist {
pub playlist_id: Option<UuidType>, pub playlist_id: Option<UuidType>,
pub playlist_name: Option<String>, pub playlist_name: Option<String>,
pub playlist_size: i32, pub playlist_size: i32,
pub tracking_user: Option<String>, pub tracking_user: String,
pub tracking_type: Option<String>, pub tracking_type: String,
pub reduce_duplication_on: Option<String>, pub reduce_duplication_on: Option<String>,
} }

141
src/subsonic.rs Normal file
View 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
View 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,
}