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"?>
|
<?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[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"Cargo.Run.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"RunOnceActivity.cidr.known.project.marker": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"WebServerToolWindowFactoryState": "false",
|
"RunOnceActivity.cidr.known.project.marker": "true",
|
||||||
"cf.first.check.clang-format": "false",
|
"RunOnceActivity.rust.reset.selective.auto.import": "true",
|
||||||
"cidr.known.project.marker": "true",
|
"WebServerToolWindowFactoryState": "false",
|
||||||
"git-widget-placeholder": "master",
|
"cf.first.check.clang-format": "false",
|
||||||
"last_opened_file_path": "/home/jude/Documents/navidrome-playlists",
|
"cidr.known.project.marker": "true",
|
||||||
"node.js.detected.package.eslint": "true",
|
"git-widget-placeholder": "master",
|
||||||
"node.js.detected.package.tslint": "true",
|
"last_opened_file_path": "/home/jude/Documents/navidrome-playlists",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.detected.package.tslint": "true",
|
||||||
"nodejs_package_manager_path": "npm",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"org.rust.cargo.project.model.PROJECT_DISCOVERY": "true",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/mod.rs": "true",
|
"nodejs_package_manager_path": "npm",
|
||||||
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/playlists.rs": "true",
|
"org.rust.cargo.project.model.PROJECT_DISCOVERY": "true",
|
||||||
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/lib.rs": "true",
|
"org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "",
|
||||||
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/models.rs": "true",
|
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/mod.rs": "true",
|
||||||
"settings.editor.selected.configurable": "language.rust.cargo.check",
|
"org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/playlists.rs": "true",
|
||||||
"vue.rearranger.settings.migration": "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": {
|
"keyToStringList": {
|
||||||
"DatabaseDriversLRU": [
|
"DatabaseDriversLRU": [
|
||||||
"postgresql"
|
"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
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"
|
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"
|
||||||
|
@ -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
|
rule_id UUID NOT NULL PRIMARY KEY, -- Internal ID
|
||||||
playlist_name VARCHAR(250), -- Name of the playlist in navidrome
|
playlist_id UUID, -- UUID of the playlist in subsonic
|
||||||
playlist_size INT NOT NULL, -- Max number of tracks to populate
|
playlist_name VARCHAR(250), -- Name of the playlist in subsonic
|
||||||
tracking_user VARCHAR(250), -- User to track stats for
|
playlist_size INT NOT NULL, -- Max number of tracks to populate
|
||||||
tracking_type VARCHAR(14), -- Matches time period params from listenbrainz
|
tracking_user VARCHAR(250) NOT NULL, -- User to track stats for
|
||||||
reduce_duplication_on VARCHAR(250) -- Allow e.g preferring songs from different albums or artists
|
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_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;
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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",
|
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,
|
||||||
}
|
}
|
||||||
|
40
src/main.rs
40
src/main.rs
@ -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(())
|
||||||
}
|
}
|
||||||
|
@ -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
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