Add interface package. Start adding auth stuff for refreshing tokens.

This commit is contained in:
jude 2023-09-12 19:35:49 +01:00
parent 5ff56d8396
commit 08cc752932
20 changed files with 4934 additions and 67 deletions

View File

@ -0,0 +1,16 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="JSUnresolvedFunction" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="JSUnresolvedVariable" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E402" />
</list>
</option>
</inspection_tool>
<inspection_tool class="SqlResolveInspection" enabled="false" level="ERROR" enabled_by_default="false" />
</profile>
</component>

View File

@ -13,10 +13,17 @@
<cargoProject FILE="$PROJECT_DIR$/navidrome/Cargo.toml" />
</component>
<component name="ChangeListManager">
<list default="true" id="52900e09-9584-4b6c-95ff-fbd4ed5d8b2c" name="Changes" comment="Track media server songs list">
<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" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/listenbrainz.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/listenbrainz.rs" 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/src/client/builder.rs" beforeDir="false" afterPath="$PROJECT_DIR$/navidrome/src/client/builder.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$/src/daemon/update_playlists.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/daemon/update_playlists.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>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -42,39 +49,43 @@
<component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 2
}</component>
<component name="ProjectId" id="2TwLYTFaOXHKmGf9lC3ltOC00Kl" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"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",
"last_opened_file_path": "/home/jude/Documents/navidrome-playlists/src/daemon",
"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">{
&quot;keyToString&quot;: {
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.cidr.known.project.marker&quot;: &quot;true&quot;,
&quot;WebServerToolWindowFactoryState&quot;: &quot;false&quot;,
&quot;cf.first.check.clang-format&quot;: &quot;false&quot;,
&quot;cidr.known.project.marker&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/jude/Documents/navidrome-playlists&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;org.rust.cargo.project.model.PROJECT_DISCOVERY&quot;: &quot;true&quot;,
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/mod.rs&quot;: &quot;true&quot;,
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/client/playlists.rs&quot;: &quot;true&quot;,
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/lib.rs&quot;: &quot;true&quot;,
&quot;org.rust.disableDetachedFileInspection/home/jude/navidrome-playlists/navidrome/src/models.rs&quot;: &quot;true&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;language.rust.cargo.check&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
},
"keyToStringList": {
"DatabaseDriversLRU": [
"postgresql"
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;postgresql&quot;
]
}
}]]></component>
}</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/daemon" />
@ -128,7 +139,11 @@
<workItem from="1692003763542" duration="5103000" />
<workItem from="1692042976149" duration="10055000" />
<workItem from="1694271920428" duration="9295000" />
<workItem from="1694285959491" duration="2258000" />
<workItem from="1694285959491" duration="2278000" />
<workItem from="1694290396939" duration="1743000" />
<workItem from="1694333344521" duration="1478000" />
<workItem from="1694450589184" duration="2108000" />
<workItem from="1694534880857" duration="6011000" />
</task>
<task id="LOCAL-00001" summary="Structure">
<created>1692008860369</created>
@ -145,7 +160,15 @@
<option name="project" value="LOCAL" />
<updated>1694286183476</updated>
</task>
<option name="localTasksCounter" value="3" />
<task id="LOCAL-00003" summary="Move listenbrainz user to env var">
<option name="closed" value="true" />
<created>1694288928392</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1694288928392</updated>
</task>
<option name="localTasksCounter" value="4" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -154,7 +177,8 @@
<component name="VcsManagerConfiguration">
<MESSAGE value="Structure" />
<MESSAGE value="Track media server songs list" />
<option name="LAST_COMMIT_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" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

5
Cargo.lock generated
View File

@ -928,6 +928,7 @@ dependencies = [
"serde",
"sqlx",
"tokio",
"uuid",
]
[[package]]
@ -1994,6 +1995,10 @@ name = "uuid"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
dependencies = [
"getrandom",
"serde",
]
[[package]]
name = "vcpkg"

View File

@ -6,12 +6,13 @@ authors = ["Jude Southworth (judesouthworth@pm.me)"]
license = "AGPL-3.0 only"
[dependencies]
axum = "0.6.20"
axum = { version = "0.6.20", 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"] }
[package.metadata.deb]
depends = "$auto"

24
app/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
app/.prettierrc.toml Normal file
View File

@ -0,0 +1 @@
tabWidth = 4

13
app/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Playlist</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

4534
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
app/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "example",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.13.1",
"wouter-preact": "^2.11.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"eslint": "^8.49.0",
"eslint-config-preact": "^1.3.0",
"prettier": "^3.0.3",
"sass": "^1.66.1",
"typescript": "^5.2.2",
"vite": "^4.3.2"
}
}

24
app/src/app.scss Normal file
View File

@ -0,0 +1,24 @@
:root {
font-family: sans-serif;
}
.app-wrapper {
width: 100vw;
justify-content: center;
display: flex;
flex-direction: row;
}
.playlist-list {
}
.playlist {
font-size: 1.125rem;
margin: 8px 0;
padding: 4px;
border-style: solid;
border-width: 1px;
border-color: black;
border-radius: 8px;
}

75
app/src/index.tsx Normal file
View File

@ -0,0 +1,75 @@
import { render } from "preact";
import { Route } from "wouter-preact";
import "./app.scss";
export const App = () => {
return (
<section class={"app-wrapper"}>
<div class={"app"}>
<h1>Playlists</h1>
<Route path={"/playlists"} component={ActivePlaylists} />
<Route path={"/playlists/:id"} component={ViewPlaylist} />
</div>
</section>
);
};
type TrackedPlaylist = {
rule_id: number;
playlist_id: string | null;
playlist_name: string | null;
playlist_size: number;
tracking_user: string | null;
tracking_type: string | null;
reduce_duplication_on: string | null;
};
const ActivePlaylists = () => {
const playlists: TrackedPlaylist[] = [
{
rule_id: 1,
playlist_id: "aaaaaaaa-bbbbbbbbbbbbbbbb-cccccccc",
playlist_name: "Playlist 1",
playlist_size: 25,
tracking_user: "jellywx",
tracking_type: "week",
reduce_duplication_on: null,
},
{
rule_id: 2,
playlist_id: "aaaaaaaa-bbbbbbbbbbbbbbbb-cccccccc",
playlist_name: "Playlist 2",
playlist_size: 25,
tracking_user: "jellywx",
tracking_type: "week",
reduce_duplication_on: null,
},
{
rule_id: 3,
playlist_id: "aaaaaaaa-bbbbbbbbbbbbbbbb-cccccccc",
playlist_name: "Playlist 3",
playlist_size: 25,
tracking_user: "jellywx",
tracking_type: "week",
reduce_duplication_on: null,
},
];
return (
<div class={"playlist-list"}>
{playlists.map((p) => (
<div class={"playlist"}>{p.playlist_name}</div>
))}
</div>
);
};
const ViewPlaylist = ({ params }) => {
return (
<>
<div>Subpage {params.id}</div>
</>
);
};
render(<App />, document.getElementById("app"));

13
app/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"allowJs": true,
"checkJs": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["node_modules/vite/client.d.ts", "**/*"]
}

7
app/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
});

View File

@ -1,9 +1,9 @@
CREATE TABLE "tracked_playlist" (
rule_id BIGSERIAL NOT NULL PRIMARY KEY,
playlist_id UUID,
playlist_name VARCHAR(250),
playlist_size INT NOT NULL,
tracking_user VARCHAR(250),
tracking_type VARCHAR(14),
reduce_duplication_on VARCHAR(250)
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_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
reduce_duplication_on VARCHAR(250) -- Allow e.g preferring songs from different albums or artists
);

View File

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

View File

@ -5,6 +5,8 @@ use std::fmt::{Debug, Display, Formatter};
pub struct NavidromeBuilder {
base: Option<String>,
token: Option<String>,
username: Option<String>,
password: Option<String>,
}
#[derive(Debug)]
@ -26,6 +28,8 @@ impl NavidromeBuilder {
return NavidromeBuilder {
base: None,
token: None,
username: None,
password: None,
};
}
@ -39,6 +43,16 @@ impl NavidromeBuilder {
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()
@ -50,10 +64,9 @@ impl NavidromeBuilder {
.clone()
.ok_or(NavidromeBuilderError::MissingParam)?,
http: client,
token: self
.token
.clone()
.ok_or(NavidromeBuilderError::MissingParam)?,
token: self.token.clone(),
username: self.username.ok_or(NavidromeBuilderError::MissingParam)?,
password: self.password.ok_or(NavidromeBuilderError::MissingParam)?,
});
}
}

View File

@ -13,7 +13,9 @@ pub type MediaResult<U> = Result<U, reqwest::Error>;
pub struct Navidrome {
base: String,
http: reqwest::Client,
token: String,
token: Option<String>,
username: String,
password: String,
}
impl QualifyPath for Navidrome {

View File

@ -35,4 +35,4 @@ pub async fn update_playlists_daemon() -> Result<(), Box<dyn std::error::Error>>
}
}
async fn update_playlist(playlist: TrackedPlaylist) {}
async fn update_playlist(_playlist: TrackedPlaylist) {}

View File

@ -2,17 +2,16 @@ mod daemon;
mod listenbrainz;
mod models;
use navidrome::{
client::{builder::NavidromeBuilder, library::Library},
models::navidrome::NavidromeTrack,
};
use navidrome::{client::builder::NavidromeBuilder, models::navidrome::NavidromeTrack};
use crate::daemon::{update_playlists_daemon, update_tracks_daemon};
use crate::listenbrainz::StatsRange;
use crate::models::{PartialTrackedPlaylist, TrackedPlaylist};
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use axum::routing::{get, put};
use axum::{extract, Json, Router};
use serde::Serialize;
use sqlx::postgres::PgPool;
use sqlx::Error;
use std::collections::HashMap;
use std::env;
use std::net::SocketAddr;
@ -50,7 +49,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap();
});
let app = Router::new().route("/", get(index)).with_state(database);
let app = Router::new()
.route("/playlists", get(all_playlists))
.route("/playlists", put(create_playlist))
.with_state(database)
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
axum::Server::bind(&addr)
@ -60,15 +63,38 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn index(State(pool): State<PgPool>) -> String {
let response = listenbrainz::recordings(env::var("LISTENBRAINZ_USER")?, StatsRange::Week)
.await
.unwrap();
response
.recordings
.iter()
.map(|a| a.track_name.clone())
.collect::<Vec<String>>()
.join(", ")
#[derive(Serialize)]
struct JsonError {
error: String,
}
impl From<Error> for JsonError {
fn from(value: Error) -> Self {
Self {
error: value.to_string(),
}
}
}
#[derive(Serialize)]
enum Response<T> {
Success(T),
Error(JsonError),
}
async fn create_playlist(
State(pool): State<PgPool>,
extract::Json(partial): extract::Json<PartialTrackedPlaylist>,
) -> Json<Response<TrackedPlaylist>> {
match partial.record(&pool).await {
Ok(playlist) => Json(Response::Success(playlist)),
Err(e) => Json(Response::Error(e.into())),
}
}
async fn all_playlists(State(pool): State<PgPool>) -> Json<Vec<TrackedPlaylist>> {
match TrackedPlaylist::all(&pool).await {
Ok(playlists) => Json(playlists),
Err(_) => Json(vec![]),
}
}

View File

@ -1,11 +1,71 @@
use sqlx::types::Uuid;
use serde::{Deserialize, Serialize};
use sqlx::error::Error;
use sqlx::types::Uuid as UuidType;
use sqlx::{Executor, Postgres};
use uuid::Uuid;
pub struct TrackedPlaylist {
pub rule_id: i64,
pub playlist_id: Option<Uuid>,
#[derive(Deserialize)]
pub struct PartialTrackedPlaylist {
pub playlist_name: Option<String>,
pub playlist_size: i32,
pub tracking_user: Option<String>,
pub tracking_type: Option<String>,
pub reduce_duplication_on: Option<String>,
}
impl PartialTrackedPlaylist {
pub async fn record(
&self,
pool: impl Executor<'_, Database = Postgres> + Copy,
) -> Result<TrackedPlaylist, Error> {
let uuid = Uuid::new_v4();
sqlx::query!(
r#"INSERT INTO "tracked_playlist"
(rule_id, playlist_name, playlist_size, tracking_user, tracking_type, reduce_duplication_on)
VALUES ($1, $2, $3, $4, $5, $6)"#,
uuid,
self.playlist_name,
self.playlist_size,
self.tracking_user,
self.tracking_type,
self.reduce_duplication_on,
)
.execute(pool)
.await?;
TrackedPlaylist::rule(pool, uuid).await
}
}
#[derive(Serialize)]
pub struct TrackedPlaylist {
pub rule_id: Uuid,
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 reduce_duplication_on: Option<String>,
}
impl TrackedPlaylist {
pub async fn all(pool: impl Executor<'_, Database = Postgres>) -> Result<Vec<Self>, Error> {
sqlx::query_as!(Self, r#"SELECT * FROM "tracked_playlist""#)
.fetch_all(pool)
.await
}
pub async fn rule(
pool: impl Executor<'_, Database = Postgres>,
uuid: Uuid,
) -> Result<Self, Error> {
sqlx::query_as!(
Self,
r#"SELECT * FROM "tracked_playlist" WHERE rule_id = $1"#,
uuid
)
.fetch_one(pool)
.await
}
}