Compare commits
12 Commits
421ec3199b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dba620c9b | ||
|
|
75f3b4f704 | ||
|
|
621355e352 | ||
|
|
abe321a317 | ||
|
|
ea6a6410f3 | ||
|
|
9d6c0e31c1 | ||
|
|
eaaff5f98f | ||
|
|
fed86c9e85 | ||
|
|
c8e78606b1 | ||
|
|
ee60076f70 | ||
|
|
51bcf26482 | ||
|
|
f6b363c40f |
@@ -8,12 +8,14 @@ repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/web.git"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shanty-config = { path = "../shanty-config" }
|
shanty-config = { path = "../shanty-config" }
|
||||||
|
shanty-data = { path = "../shanty-data" }
|
||||||
shanty-db = { path = "../shanty-db" }
|
shanty-db = { path = "../shanty-db" }
|
||||||
shanty-index = { path = "../shanty-index" }
|
shanty-index = { path = "../shanty-index" }
|
||||||
shanty-tag = { path = "../shanty-tag" }
|
shanty-tag = { path = "../shanty-tag" }
|
||||||
shanty-org = { path = "../shanty-org" }
|
shanty-org = { path = "../shanty-org" }
|
||||||
shanty-watch = { path = "../shanty-watch" }
|
shanty-watch = { path = "../shanty-watch" }
|
||||||
shanty-dl = { path = "../shanty-dl" }
|
shanty-dl = { path = "../shanty-dl" }
|
||||||
|
shanty-playlist = { path = "../shanty-playlist" }
|
||||||
shanty-search = { path = "../shanty-search" }
|
shanty-search = { path = "../shanty-search" }
|
||||||
sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] }
|
sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] }
|
||||||
actix-web = "4"
|
actix-web = "4"
|
||||||
@@ -21,6 +23,10 @@ actix-cors = "0.7"
|
|||||||
actix-files = "0.6"
|
actix-files = "0.6"
|
||||||
actix-session = { version = "0.10", features = ["cookie-session"] }
|
actix-session = { version = "0.10", features = ["cookie-session"] }
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
md-5 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||||
|
serde_urlencoded = "0.7"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -30,6 +36,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
tracing-actix-web = "0.7"
|
tracing-actix-web = "0.7"
|
||||||
|
|||||||
1
frontend/Cargo.lock
generated
1
frontend/Cargo.lock
generated
@@ -862,6 +862,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gloo-net 0.6.0",
|
"gloo-net 0.6.0",
|
||||||
"gloo-timers 0.3.0",
|
"gloo-timers 0.3.0",
|
||||||
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window"] }
|
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window", "DragEvent", "DataTransfer"] }
|
||||||
|
js-sys = "0.3"
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ async fn post_empty<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
|
|||||||
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn put_json<T: DeserializeOwned>(url: &str, body: &str) -> Result<T, ApiError> {
|
||||||
|
let resp = Request::put(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body)
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete(url: &str) -> Result<(), ApiError> {
|
async fn delete(url: &str) -> Result<(), ApiError> {
|
||||||
let resp = Request::delete(url)
|
let resp = Request::delete(url)
|
||||||
.send()
|
.send()
|
||||||
@@ -85,17 +99,9 @@ pub async fn get_me() -> Result<UserInfo, ApiError> {
|
|||||||
get_json(&format!("{BASE}/auth/me")).await
|
get_json(&format!("{BASE}/auth/me")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_users() -> Result<Vec<UserInfo>, ApiError> {
|
// --- Lyrics ---
|
||||||
get_json(&format!("{BASE}/auth/users")).await
|
pub async fn get_lyrics(artist: &str, title: &str) -> Result<LyricsResult, ApiError> {
|
||||||
}
|
get_json(&format!("{BASE}/lyrics?artist={artist}&title={title}")).await
|
||||||
|
|
||||||
pub async fn create_user(username: &str, password: &str) -> Result<UserInfo, ApiError> {
|
|
||||||
let body = serde_json::json!({"username": username, "password": password}).to_string();
|
|
||||||
post_json(&format!("{BASE}/auth/users"), &body).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_user(id: i32) -> Result<(), ApiError> {
|
|
||||||
delete(&format!("{BASE}/auth/users/{id}")).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Status ---
|
// --- Status ---
|
||||||
@@ -108,7 +114,11 @@ pub async fn search_artist(query: &str, limit: u32) -> Result<Vec<ArtistResult>,
|
|||||||
get_json(&format!("{BASE}/search/artist?q={query}&limit={limit}")).await
|
get_json(&format!("{BASE}/search/artist?q={query}&limit={limit}")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_album(query: &str, artist: Option<&str>, limit: u32) -> Result<Vec<AlbumResult>, ApiError> {
|
pub async fn search_album(
|
||||||
|
query: &str,
|
||||||
|
artist: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<AlbumResult>, ApiError> {
|
||||||
let mut url = format!("{BASE}/search/album?q={query}&limit={limit}");
|
let mut url = format!("{BASE}/search/album?q={query}&limit={limit}");
|
||||||
if let Some(a) = artist {
|
if let Some(a) = artist {
|
||||||
url.push_str(&format!("&artist={a}"));
|
url.push_str(&format!("&artist={a}"));
|
||||||
@@ -116,7 +126,11 @@ pub async fn search_album(query: &str, artist: Option<&str>, limit: u32) -> Resu
|
|||||||
get_json(&url).await
|
get_json(&url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_track(query: &str, artist: Option<&str>, limit: u32) -> Result<Vec<TrackResult>, ApiError> {
|
pub async fn search_track(
|
||||||
|
query: &str,
|
||||||
|
artist: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<TrackResult>, ApiError> {
|
||||||
let mut url = format!("{BASE}/search/track?q={query}&limit={limit}");
|
let mut url = format!("{BASE}/search/track?q={query}&limit={limit}");
|
||||||
if let Some(a) = artist {
|
if let Some(a) = artist {
|
||||||
url.push_str(&format!("&artist={a}"));
|
url.push_str(&format!("&artist={a}"));
|
||||||
@@ -129,10 +143,6 @@ pub async fn list_artists(limit: u64, offset: u64) -> Result<Vec<ArtistListItem>
|
|||||||
get_json(&format!("{BASE}/artists?limit={limit}&offset={offset}")).await
|
get_json(&format!("{BASE}/artists?limit={limit}&offset={offset}")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_artist(id: i32) -> Result<ArtistDetail, ApiError> {
|
|
||||||
get_json(&format!("{BASE}/artists/{id}")).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_artist_full(id: &str) -> Result<FullArtistDetail, ApiError> {
|
pub async fn get_artist_full(id: &str) -> Result<FullArtistDetail, ApiError> {
|
||||||
get_json(&format!("{BASE}/artists/{id}/full")).await
|
get_json(&format!("{BASE}/artists/{id}/full")).await
|
||||||
}
|
}
|
||||||
@@ -145,10 +155,6 @@ pub async fn get_album(mbid: &str) -> Result<MbAlbumDetail, ApiError> {
|
|||||||
get_json(&format!("{BASE}/albums/{mbid}")).await
|
get_json(&format!("{BASE}/albums/{mbid}")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_tracks(limit: u64, offset: u64) -> Result<Vec<Track>, ApiError> {
|
|
||||||
get_json(&format!("{BASE}/tracks?limit={limit}&offset={offset}")).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Watchlist ---
|
// --- Watchlist ---
|
||||||
pub async fn add_artist(name: &str, mbid: Option<&str>) -> Result<AddSummary, ApiError> {
|
pub async fn add_artist(name: &str, mbid: Option<&str>) -> Result<AddSummary, ApiError> {
|
||||||
let body = match mbid {
|
let body = match mbid {
|
||||||
@@ -158,7 +164,11 @@ pub async fn add_artist(name: &str, mbid: Option<&str>) -> Result<AddSummary, Ap
|
|||||||
post_json(&format!("{BASE}/artists"), &body).await
|
post_json(&format!("{BASE}/artists"), &body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_album(artist: &str, album: &str, mbid: Option<&str>) -> Result<AddSummary, ApiError> {
|
pub async fn add_album(
|
||||||
|
artist: &str,
|
||||||
|
album: &str,
|
||||||
|
mbid: Option<&str>,
|
||||||
|
) -> Result<AddSummary, ApiError> {
|
||||||
let body = match mbid {
|
let body = match mbid {
|
||||||
Some(m) => format!(r#"{{"artist":"{artist}","album":"{album}","mbid":"{m}"}}"#),
|
Some(m) => format!(r#"{{"artist":"{artist}","album":"{album}","mbid":"{m}"}}"#),
|
||||||
None => format!(r#"{{"artist":"{artist}","album":"{album}"}}"#),
|
None => format!(r#"{{"artist":"{artist}","album":"{album}"}}"#),
|
||||||
@@ -166,14 +176,6 @@ pub async fn add_album(artist: &str, album: &str, mbid: Option<&str>) -> Result<
|
|||||||
post_json(&format!("{BASE}/albums"), &body).await
|
post_json(&format!("{BASE}/albums"), &body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_watchlist() -> Result<Vec<WatchListEntry>, ApiError> {
|
|
||||||
get_json(&format!("{BASE}/watchlist")).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove_watchlist(id: i32) -> Result<(), ApiError> {
|
|
||||||
delete(&format!("{BASE}/watchlist/{id}")).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Downloads ---
|
// --- Downloads ---
|
||||||
pub async fn get_downloads(status: Option<&str>) -> Result<Vec<DownloadItem>, ApiError> {
|
pub async fn get_downloads(status: Option<&str>) -> Result<Vec<DownloadItem>, ApiError> {
|
||||||
let mut url = format!("{BASE}/downloads/queue");
|
let mut url = format!("{BASE}/downloads/queue");
|
||||||
@@ -219,6 +221,35 @@ pub async fn trigger_pipeline() -> Result<PipelineRef, ApiError> {
|
|||||||
post_empty(&format!("{BASE}/pipeline")).await
|
post_empty(&format!("{BASE}/pipeline")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Monitor ---
|
||||||
|
pub async fn set_artist_monitored(id: i32, monitored: bool) -> Result<serde_json::Value, ApiError> {
|
||||||
|
if monitored {
|
||||||
|
post_empty(&format!("{BASE}/artists/{id}/monitor")).await
|
||||||
|
} else {
|
||||||
|
let resp = Request::delete(&format!("{BASE}/artists/{id}/monitor"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_monitor_check() -> Result<TaskRef, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/monitor/check")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scheduler ---
|
||||||
|
pub async fn skip_scheduled_pipeline() -> Result<serde_json::Value, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/scheduler/skip-pipeline")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn skip_scheduled_monitor() -> Result<serde_json::Value, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/scheduler/skip-monitor")).await
|
||||||
|
}
|
||||||
|
|
||||||
// --- System ---
|
// --- System ---
|
||||||
pub async fn trigger_index() -> Result<TaskRef, ApiError> {
|
pub async fn trigger_index() -> Result<TaskRef, ApiError> {
|
||||||
post_empty(&format!("{BASE}/index")).await
|
post_empty(&format!("{BASE}/index")).await
|
||||||
@@ -232,10 +263,6 @@ pub async fn trigger_organize() -> Result<TaskRef, ApiError> {
|
|||||||
post_empty(&format!("{BASE}/organize")).await
|
post_empty(&format!("{BASE}/organize")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_task(id: &str) -> Result<TaskInfo, ApiError> {
|
|
||||||
get_json(&format!("{BASE}/tasks/{id}")).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_config() -> Result<AppConfig, ApiError> {
|
pub async fn get_config() -> Result<AppConfig, ApiError> {
|
||||||
get_json(&format!("{BASE}/config")).await
|
get_json(&format!("{BASE}/config")).await
|
||||||
}
|
}
|
||||||
@@ -254,3 +281,106 @@ pub async fn save_config(config: &AppConfig) -> Result<AppConfig, ApiError> {
|
|||||||
}
|
}
|
||||||
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Playlists ---
|
||||||
|
pub async fn generate_playlist(req: &GenerateRequest) -> Result<GeneratedPlaylist, ApiError> {
|
||||||
|
let body = serde_json::to_string(req).map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
post_json(&format!("{BASE}/playlists/generate"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_playlist(
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
track_ids: &[i32],
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"track_ids": track_ids,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
post_json(&format!("{BASE}/playlists"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_playlists() -> Result<Vec<PlaylistSummary>, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/playlists")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_playlist(id: i32) -> Result<PlaylistDetail, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/playlists/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_playlist(id: i32) -> Result<(), ApiError> {
|
||||||
|
delete(&format!("{BASE}/playlists/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_m3u_url(id: i32) -> String {
|
||||||
|
format!("{BASE}/playlists/{id}/m3u")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_track_to_playlist(
|
||||||
|
playlist_id: i32,
|
||||||
|
track_id: i32,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let body = serde_json::json!({"track_id": track_id}).to_string();
|
||||||
|
post_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_track_from_playlist(playlist_id: i32, track_id: i32) -> Result<(), ApiError> {
|
||||||
|
delete(&format!("{BASE}/playlists/{playlist_id}/tracks/{track_id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reorder_playlist_tracks(
|
||||||
|
playlist_id: i32,
|
||||||
|
track_ids: &[i32],
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let body = serde_json::json!({"track_ids": track_ids}).to_string();
|
||||||
|
put_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_tracks(query: &str) -> Result<Vec<Track>, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subsonic ---
|
||||||
|
|
||||||
|
pub async fn get_subsonic_password_status() -> Result<SubsonicPasswordStatus, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/auth/subsonic-password-status")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_subsonic_password(password: &str) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let body = serde_json::json!({"password": password}).to_string();
|
||||||
|
put_json(&format!("{BASE}/auth/subsonic-password"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- YouTube Auth ---
|
||||||
|
|
||||||
|
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/ytauth/status")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ytauth_login_start() -> Result<serde_json::Value, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/ytauth/login-start")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ytauth_login_stop() -> Result<serde_json::Value, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/ytauth/login-stop")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ytauth_refresh() -> Result<serde_json::Value, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/ytauth/refresh")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ytauth_clear_cookies() -> Result<(), ApiError> {
|
||||||
|
delete(&format!("{BASE}/ytauth/cookies")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MusicBrainz Local DB ---
|
||||||
|
|
||||||
|
pub async fn get_mb_status() -> Result<MbStatus, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/mb-status")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trigger_mb_import() -> Result<TaskRef, ApiError> {
|
||||||
|
post_empty(&format!("{BASE}/mb-import")).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
pub mod navbar;
|
pub mod navbar;
|
||||||
pub mod status_badge;
|
pub mod status_badge;
|
||||||
pub mod watch_indicator;
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub fn navbar(props: &Props) -> Html {
|
|||||||
{ link(Route::Dashboard, "Dashboard") }
|
{ link(Route::Dashboard, "Dashboard") }
|
||||||
{ link(Route::Search, "Search") }
|
{ link(Route::Search, "Search") }
|
||||||
{ link(Route::Library, "Library") }
|
{ link(Route::Library, "Library") }
|
||||||
|
{ link(Route::Playlists, "Playlists") }
|
||||||
{ link(Route::Downloads, "Downloads") }
|
{ link(Route::Downloads, "Downloads") }
|
||||||
if props.role == "admin" {
|
if props.role == "admin" {
|
||||||
{ link(Route::Settings, "Settings") }
|
{ link(Route::Settings, "Settings") }
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct Props {
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(WatchIndicator)]
|
|
||||||
pub fn watch_indicator(props: &Props) -> Html {
|
|
||||||
let (icon, color, title) = match props.status.as_str() {
|
|
||||||
"owned" => ("●", "var(--success)", "Owned"),
|
|
||||||
"partial" => ("◐", "var(--warning)", "Partial"),
|
|
||||||
"wanted" => ("○", "var(--accent)", "Wanted"),
|
|
||||||
"downloading" => ("↓", "var(--accent)", "Downloading"),
|
|
||||||
"fully_watched" => ("●", "var(--accent)", "Fully watched"),
|
|
||||||
"unwatched" => ("○", "var(--text-muted)", "Not watched"),
|
|
||||||
_ => ("○", "var(--text-muted)", "Unknown"),
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<span style={format!("color: {color}; font-size: 1.1em; cursor: help;")} title={title}>
|
|
||||||
{ icon }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::status_badge::StatusBadge;
|
use crate::components::status_badge::StatusBadge;
|
||||||
use crate::types::MbAlbumDetail;
|
use crate::types::{LyricsResult, MbAlbumDetail};
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
@@ -14,6 +15,8 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
let detail = use_state(|| None::<MbAlbumDetail>);
|
let detail = use_state(|| None::<MbAlbumDetail>);
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
let mbid = props.mbid.clone();
|
let mbid = props.mbid.clone();
|
||||||
|
let lyrics_cache: UseStateHandle<HashMap<String, LyricsResult>> = use_state(HashMap::new);
|
||||||
|
let active_lyrics = use_state(|| None::<String>);
|
||||||
|
|
||||||
{
|
{
|
||||||
let detail = detail.clone();
|
let detail = detail.clone();
|
||||||
@@ -37,7 +40,6 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
return html! { <p class="loading">{ "Loading album from MusicBrainz..." }</p> };
|
return html! { <p class="loading">{ "Loading album from MusicBrainz..." }</p> };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format duration from ms
|
|
||||||
let fmt_duration = |ms: u64| -> String {
|
let fmt_duration = |ms: u64| -> String {
|
||||||
let secs = ms / 1000;
|
let secs = ms / 1000;
|
||||||
let mins = secs / 60;
|
let mins = secs / 60;
|
||||||
@@ -45,11 +47,22 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
format!("{mins}:{remaining:02}")
|
format!("{mins}:{remaining:02}")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cover_url = format!("https://coverartarchive.org/release/{}/front-250", d.mbid);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="album-header">
|
||||||
<h2>{ format!("Album") }</h2>
|
<img class="album-art-lg" src={cover_url}
|
||||||
<p class="text-muted text-sm">{ format!("MBID: {}", d.mbid) }</p>
|
loading="lazy"
|
||||||
|
onerror={Callback::from(|e: web_sys::Event| {
|
||||||
|
if let Some(el) = e.target_dyn_into::<web_sys::HtmlElement>() {
|
||||||
|
el.set_attribute("style", "display:none").ok();
|
||||||
|
}
|
||||||
|
})} />
|
||||||
|
<div>
|
||||||
|
<h2>{ "Album" }</h2>
|
||||||
|
<p class="text-muted text-sm">{ format!("MBID: {}", d.mbid) }</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-1">{ format!("Tracks ({})", d.tracks.len()) }</h3>
|
<h3 class="mb-1">{ format!("Tracks ({})", d.tracks.len()) }</h3>
|
||||||
@@ -63,15 +76,49 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
<th>{ "Title" }</th>
|
<th>{ "Title" }</th>
|
||||||
<th>{ "Duration" }</th>
|
<th>{ "Duration" }</th>
|
||||||
<th>{ "Status" }</th>
|
<th>{ "Status" }</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ for d.tracks.iter().map(|t| {
|
{ for d.tracks.iter().map(|t| {
|
||||||
let duration = t.duration_ms
|
let duration = t.duration_ms
|
||||||
.map(|ms| fmt_duration(ms))
|
.map(&fmt_duration)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let track_key = t.recording_mbid.clone();
|
||||||
|
let is_active = active_lyrics.as_ref() == Some(&track_key);
|
||||||
|
let cached = lyrics_cache.get(&track_key).cloned();
|
||||||
|
|
||||||
|
let on_lyrics_click = {
|
||||||
|
let active = active_lyrics.clone();
|
||||||
|
let cache = lyrics_cache.clone();
|
||||||
|
let title = t.title.clone();
|
||||||
|
let key = track_key.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let active = active.clone();
|
||||||
|
let cache = cache.clone();
|
||||||
|
let title = title.clone();
|
||||||
|
let key = key.clone();
|
||||||
|
if active.as_ref() == Some(&key) {
|
||||||
|
active.set(None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
active.set(Some(key.clone()));
|
||||||
|
if cache.contains_key(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(result) = api::get_lyrics("", &title).await {
|
||||||
|
let mut map = (*cache).clone();
|
||||||
|
map.insert(key, result);
|
||||||
|
cache.set(map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
|
<>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ t.track_number.map(|n| n.to_string()).unwrap_or_default() }</td>
|
<td>{ t.track_number.map(|n| n.to_string()).unwrap_or_default() }</td>
|
||||||
<td>{ &t.title }</td>
|
<td>{ &t.title }</td>
|
||||||
@@ -80,10 +127,34 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
if let Some(ref status) = t.status {
|
if let Some(ref status) = t.status {
|
||||||
<StatusBadge status={status.clone()} />
|
<StatusBadge status={status.clone()} />
|
||||||
} else {
|
} else {
|
||||||
<span class="text-muted text-sm">{ "—" }</span>
|
<span class="text-muted text-sm">{ "\u{2014}" }</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
onclick={on_lyrics_click}>
|
||||||
|
{ if is_active { "Hide" } else { "Lyrics" } }
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
if is_active {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
{ match cached {
|
||||||
|
Some(ref lr) if lr.found => html! {
|
||||||
|
<pre class="lyrics">{ lr.synced_lyrics.as_deref().or(lr.lyrics.as_deref()).unwrap_or("") }</pre>
|
||||||
|
},
|
||||||
|
Some(_) => html! {
|
||||||
|
<p class="text-muted text-sm">{ "No lyrics found." }</p>
|
||||||
|
},
|
||||||
|
None => html! {
|
||||||
|
<p class="text-sm loading">{ "Loading lyrics..." }</p>
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::rc::Rc;
|
|
||||||
use std::cell::Cell;
|
|
||||||
use gloo_timers::callback::Interval;
|
use gloo_timers::callback::Interval;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
@@ -58,14 +58,11 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
|
|
||||||
// Phase 2: if not enriched, fetch full data in background
|
// Phase 2: if not enriched, fetch full data in background
|
||||||
if needs_enrich && !user_acted.get() {
|
if needs_enrich && !user_acted.get() {
|
||||||
match api::get_artist_full(&id).await {
|
if let Ok(full) = api::get_artist_full(&id).await {
|
||||||
Ok(full) => {
|
// Only apply if user hasn't triggered a refresh
|
||||||
// Only apply if user hasn't triggered a refresh
|
if !user_acted.get() {
|
||||||
if !user_acted.get() {
|
detail.set(Some(full));
|
||||||
detail.set(Some(full));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(_) => {} // quick data is still showing, don't overwrite with error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,13 +145,115 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let monitor_btn = {
|
||||||
|
// Only show monitor toggle for artists that have a local DB ID (> 0)
|
||||||
|
let artist_id_num = d.artist.id;
|
||||||
|
let is_monitored = d.monitored;
|
||||||
|
if artist_id_num > 0 {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch.clone();
|
||||||
|
let artist_id = id.clone();
|
||||||
|
let label = if is_monitored { "Unmonitor" } else { "Monitor" };
|
||||||
|
let btn_class = if is_monitored {
|
||||||
|
"btn btn-sm btn-secondary"
|
||||||
|
} else {
|
||||||
|
"btn btn-sm btn-primary"
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<button class={btn_class}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
let new_state = !is_monitored;
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch.clone();
|
||||||
|
let artist_id = artist_id.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::set_artist_monitored(artist_id_num, new_state).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let msg = if new_state {
|
||||||
|
"Monitoring enabled -- new releases will be auto-watched"
|
||||||
|
} else {
|
||||||
|
"Monitoring disabled"
|
||||||
|
};
|
||||||
|
message.set(Some(msg.to_string()));
|
||||||
|
fetch.emit(artist_id);
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
{ label }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
|
if let Some(ref banner) = d.artist_banner {
|
||||||
|
<div class="artist-banner" style={format!("background-image: url('{banner}')")}>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2>{ &d.artist.name }</h2>
|
<div class="flex gap-2 items-center">
|
||||||
{ watch_all_btn }
|
if let Some(ref photo) = d.artist_photo {
|
||||||
|
<img class="artist-photo" src={photo.clone()} loading="lazy" />
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
{ &d.artist.name }
|
||||||
|
if d.monitored {
|
||||||
|
<span class="badge badge-success" style="margin-left: 0.5rem; font-size: 0.7em; vertical-align: middle;">{ "Monitored" }</span>
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
|
if let Some(ref info) = d.artist_info {
|
||||||
|
<div class="artist-meta">
|
||||||
|
if let Some(ref country) = info.country {
|
||||||
|
<span class="tag">{ country }</span>
|
||||||
|
}
|
||||||
|
if let Some(ref atype) = info.artist_type {
|
||||||
|
<span class="tag">{ atype }</span>
|
||||||
|
}
|
||||||
|
if let Some(ref year) = info.begin_year {
|
||||||
|
<span class="text-sm text-muted">{ format!("est. {year}") }</span>
|
||||||
|
}
|
||||||
|
if let Some(ref dis) = info.disambiguation {
|
||||||
|
<span class="text-sm text-muted">{ format!("({dis})") }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if !info.urls.is_empty() {
|
||||||
|
<div class="artist-links">
|
||||||
|
{ for info.urls.iter().filter(|u|
|
||||||
|
["wikipedia", "official homepage", "discogs", "wikidata"].contains(&u.link_type.as_str())
|
||||||
|
).map(|u| {
|
||||||
|
let label = match u.link_type.as_str() {
|
||||||
|
"wikipedia" => "Wikipedia",
|
||||||
|
"official homepage" => "Official Site",
|
||||||
|
"discogs" => "Discogs",
|
||||||
|
"wikidata" => "Wikidata",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<a href={u.url.clone()} target="_blank" rel="noopener">{ label }</a>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{ watch_all_btn }
|
||||||
|
{ monitor_btn }
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
if let Some(ref bio) = d.artist_bio {
|
||||||
|
<p class="artist-bio text-sm">{ bio }</p>
|
||||||
|
}
|
||||||
if d.enriched {
|
if d.enriched {
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
<span style="color: var(--accent);">
|
<span style="color: var(--accent);">
|
||||||
@@ -194,6 +293,7 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 60px;"></th>
|
||||||
<th>{ "Title" }</th>
|
<th>{ "Title" }</th>
|
||||||
<th>{ "Date" }</th>
|
<th>{ "Date" }</th>
|
||||||
<th>{ "Owned" }</th>
|
<th>{ "Owned" }</th>
|
||||||
@@ -206,6 +306,7 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
let is_unwatched = album.status == "unwatched";
|
let is_unwatched = album.status == "unwatched";
|
||||||
let row_style = if is_unwatched { "opacity: 0.6;" } else { "" };
|
let row_style = if is_unwatched { "opacity: 0.6;" } else { "" };
|
||||||
|
|
||||||
|
let cover_url = format!("https://coverartarchive.org/release/{}/front-250", album.mbid);
|
||||||
let album_link = html! {
|
let album_link = html! {
|
||||||
<Link<Route> to={Route::Album { mbid: album.mbid.clone() }}>
|
<Link<Route> to={Route::Album { mbid: album.mbid.clone() }}>
|
||||||
{ &album.title }
|
{ &album.title }
|
||||||
@@ -255,6 +356,15 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
|
|
||||||
html! {
|
html! {
|
||||||
<tr style={row_style}>
|
<tr style={row_style}>
|
||||||
|
<td>
|
||||||
|
<img class="album-art" src={cover_url}
|
||||||
|
loading="lazy"
|
||||||
|
onerror={Callback::from(|e: web_sys::Event| {
|
||||||
|
if let Some(el) = e.target_dyn_into::<web_sys::HtmlElement>() {
|
||||||
|
el.set_attribute("style", "display:none").ok();
|
||||||
|
}
|
||||||
|
})} />
|
||||||
|
</td>
|
||||||
<td>{ album_link }</td>
|
<td>{ album_link }</td>
|
||||||
<td class="text-muted">{ album.date.as_deref().unwrap_or("") }</td>
|
<td class="text-muted">{ album.date.as_deref().unwrap_or("") }</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -271,8 +381,7 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
<td>
|
<td>
|
||||||
if tc > 0 {
|
if tc > 0 {
|
||||||
<span class="text-sm" style={
|
<span class="text-sm" style={
|
||||||
if album.watched_tracks >= tc { "color: var(--accent);" }
|
if album.watched_tracks > 0 { "color: var(--accent);" }
|
||||||
else if album.watched_tracks > 0 { "color: var(--accent);" }
|
|
||||||
else { "color: var(--text-muted);" }
|
else { "color: var(--text-muted);" }
|
||||||
}>
|
}>
|
||||||
{ format!("{}/{}", album.watched_tracks, tc) }
|
{ format!("{}/{}", album.watched_tracks, tc) }
|
||||||
|
|||||||
@@ -5,6 +5,30 @@ use crate::api;
|
|||||||
use crate::components::status_badge::StatusBadge;
|
use crate::components::status_badge::StatusBadge;
|
||||||
use crate::types::Status;
|
use crate::types::Status;
|
||||||
|
|
||||||
|
/// Format a UTC datetime string as a relative time like "in 2h 15m".
|
||||||
|
fn format_next_run(datetime_str: &str) -> String {
|
||||||
|
let now_ms = js_sys::Date::new_0().get_time();
|
||||||
|
let parsed = js_sys::Date::parse(datetime_str);
|
||||||
|
|
||||||
|
// NaN means parse failed
|
||||||
|
if parsed.is_nan() {
|
||||||
|
return datetime_str.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let delta_ms = parsed - now_ms;
|
||||||
|
if delta_ms <= 0.0 {
|
||||||
|
return "soon".to_string();
|
||||||
|
}
|
||||||
|
let total_mins = (delta_ms / 60_000.0) as u64;
|
||||||
|
let hours = total_mins / 60;
|
||||||
|
let mins = total_mins % 60;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("in {hours}h {mins}m")
|
||||||
|
} else {
|
||||||
|
format!("in {mins}m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[function_component(DashboardPage)]
|
#[function_component(DashboardPage)]
|
||||||
pub fn dashboard() -> Html {
|
pub fn dashboard() -> Html {
|
||||||
let status = use_state(|| None::<Status>);
|
let status = use_state(|| None::<Status>);
|
||||||
@@ -62,7 +86,10 @@ pub fn dashboard() -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
match api::sync_downloads().await {
|
match api::sync_downloads().await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
message.set(Some(format!("Synced: {} enqueued, {} skipped", s.enqueued, s.skipped)));
|
message.set(Some(format!(
|
||||||
|
"Synced: {} enqueued, {} skipped",
|
||||||
|
s.enqueued, s.skipped
|
||||||
|
)));
|
||||||
fetch.emit(());
|
fetch.emit(());
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
@@ -82,7 +109,10 @@ pub fn dashboard() -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
match api::process_downloads().await {
|
match api::process_downloads().await {
|
||||||
Ok(t) => {
|
Ok(t) => {
|
||||||
message.set(Some(format!("Downloads started (task: {})", &t.task_id[..8])));
|
message.set(Some(format!(
|
||||||
|
"Downloads started (task: {})",
|
||||||
|
&t.task_id[..8]
|
||||||
|
)));
|
||||||
fetch.emit(());
|
fetch.emit(());
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
@@ -101,7 +131,10 @@ pub fn dashboard() -> Html {
|
|||||||
let fetch = fetch.clone();
|
let fetch = fetch.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
match api::trigger_index().await {
|
match api::trigger_index().await {
|
||||||
Ok(t) => { message.set(Some(format!("Indexing started ({})", &t.task_id[..8]))); fetch.emit(()); }
|
Ok(t) => {
|
||||||
|
message.set(Some(format!("Indexing started ({})", &t.task_id[..8])));
|
||||||
|
fetch.emit(());
|
||||||
|
}
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -118,7 +151,10 @@ pub fn dashboard() -> Html {
|
|||||||
let fetch = fetch.clone();
|
let fetch = fetch.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
match api::trigger_tag().await {
|
match api::trigger_tag().await {
|
||||||
Ok(t) => { message.set(Some(format!("Tagging started ({})", &t.task_id[..8]))); fetch.emit(()); }
|
Ok(t) => {
|
||||||
|
message.set(Some(format!("Tagging started ({})", &t.task_id[..8])));
|
||||||
|
fetch.emit(());
|
||||||
|
}
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -135,7 +171,33 @@ pub fn dashboard() -> Html {
|
|||||||
let fetch = fetch.clone();
|
let fetch = fetch.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
match api::trigger_organize().await {
|
match api::trigger_organize().await {
|
||||||
Ok(t) => { message.set(Some(format!("Organizing started ({})", &t.task_id[..8]))); fetch.emit(()); }
|
Ok(t) => {
|
||||||
|
message.set(Some(format!("Organizing started ({})", &t.task_id[..8])));
|
||||||
|
fetch.emit(());
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_monitor_check = {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch_status.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::trigger_monitor_check().await {
|
||||||
|
Ok(t) => {
|
||||||
|
message.set(Some(format!(
|
||||||
|
"Monitor check started (task: {})",
|
||||||
|
&t.task_id[..8]
|
||||||
|
)));
|
||||||
|
fetch.emit(());
|
||||||
|
}
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -153,7 +215,10 @@ pub fn dashboard() -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
match api::trigger_pipeline().await {
|
match api::trigger_pipeline().await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
message.set(Some(format!("Pipeline started — {} tasks queued", p.task_ids.len())));
|
message.set(Some(format!(
|
||||||
|
"Pipeline started — {} tasks queued",
|
||||||
|
p.task_ids.len()
|
||||||
|
)));
|
||||||
fetch.emit(());
|
fetch.emit(());
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
@@ -170,7 +235,77 @@ pub fn dashboard() -> Html {
|
|||||||
return html! { <p class="loading">{ "Loading..." }</p> };
|
return html! { <p class="loading">{ "Loading..." }</p> };
|
||||||
};
|
};
|
||||||
|
|
||||||
let pipeline_active = s.tasks.iter().any(|t| t.status == "Pending" || t.status == "Running");
|
let pipeline_active = s
|
||||||
|
.tasks
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.status == "Pending" || t.status == "Running");
|
||||||
|
|
||||||
|
// Pre-compute scheduled task rows
|
||||||
|
let scheduled_rows = {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
if let Some(ref sched) = s.scheduled {
|
||||||
|
if let Some(ref next) = sched.next_pipeline {
|
||||||
|
let on_skip = {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch_status.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::skip_scheduled_pipeline().await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some("Next pipeline run skipped".into()));
|
||||||
|
fetch.emit(());
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
rows.push(html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ "Auto Pipeline" }</td>
|
||||||
|
<td><span class="badge badge-pending">{ "Scheduled" }</span></td>
|
||||||
|
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</td>
|
||||||
|
<td><button class="btn btn-sm btn-danger" onclick={on_skip}>{ "Skip" }</button></td>
|
||||||
|
</tr>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(ref next) = sched.next_monitor {
|
||||||
|
let on_skip = {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch_status.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let fetch = fetch.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::skip_scheduled_monitor().await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some("Next monitor check skipped".into()));
|
||||||
|
fetch.emit(());
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
rows.push(html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ "Monitor Check" }</td>
|
||||||
|
<td><span class="badge badge-pending">{ "Scheduled" }</span></td>
|
||||||
|
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</td>
|
||||||
|
<td><button class="btn btn-sm btn-danger" onclick={on_skip}>{ "Skip" }</button></td>
|
||||||
|
</tr>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
let has_scheduled = !scheduled_rows.is_empty();
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
@@ -226,11 +361,12 @@ pub fn dashboard() -> Html {
|
|||||||
<button class="btn btn-secondary" onclick={on_index}>{ "Re-index" }</button>
|
<button class="btn btn-secondary" onclick={on_index}>{ "Re-index" }</button>
|
||||||
<button class="btn btn-secondary" onclick={on_tag}>{ "Auto-tag" }</button>
|
<button class="btn btn-secondary" onclick={on_tag}>{ "Auto-tag" }</button>
|
||||||
<button class="btn btn-secondary" onclick={on_organize}>{ "Organize" }</button>
|
<button class="btn btn-secondary" onclick={on_organize}>{ "Organize" }</button>
|
||||||
|
<button class="btn btn-secondary" onclick={on_monitor_check}>{ "Check Monitored Artists" }</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Background Tasks
|
// Background Tasks (always show if there are tasks or scheduled items)
|
||||||
if !s.tasks.is_empty() {
|
if !s.tasks.is_empty() || has_scheduled {
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>{ "Background Tasks" }</h3>
|
<h3>{ "Background Tasks" }</h3>
|
||||||
<table class="tasks-table">
|
<table class="tasks-table">
|
||||||
@@ -238,6 +374,7 @@ pub fn dashboard() -> Html {
|
|||||||
<tr><th>{ "Type" }</th><th>{ "Status" }</th><th>{ "Progress" }</th><th>{ "Result" }</th></tr>
|
<tr><th>{ "Type" }</th><th>{ "Status" }</th><th>{ "Progress" }</th><th>{ "Result" }</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{ for scheduled_rows.into_iter() }
|
||||||
{ for s.tasks.iter().map(|t| {
|
{ for s.tasks.iter().map(|t| {
|
||||||
let progress_html = if let Some(ref p) = t.progress {
|
let progress_html = if let Some(ref p) = t.progress {
|
||||||
if p.total > 0 {
|
if p.total > 0 {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub fn downloads_page() -> Html {
|
|||||||
let items = use_state(|| None::<Vec<DownloadItem>>);
|
let items = use_state(|| None::<Vec<DownloadItem>>);
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
let message = use_state(|| None::<String>);
|
let message = use_state(|| None::<String>);
|
||||||
let dl_query = use_state(|| String::new());
|
let dl_query = use_state(String::new);
|
||||||
|
|
||||||
let refresh = {
|
let refresh = {
|
||||||
let items = items.clone();
|
let items = items.clone();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use yew::prelude::*;
|
|||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::watch_indicator::WatchIndicator;
|
|
||||||
use crate::pages::Route;
|
use crate::pages::Route;
|
||||||
use crate::types::ArtistListItem;
|
use crate::types::ArtistListItem;
|
||||||
|
|
||||||
@@ -46,6 +45,7 @@ pub fn library_page() -> Html {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{ "Name" }</th>
|
<th>{ "Name" }</th>
|
||||||
|
<th>{ "Monitored" }</th>
|
||||||
<th>{ "Owned" }</th>
|
<th>{ "Owned" }</th>
|
||||||
<th>{ "Watched" }</th>
|
<th>{ "Watched" }</th>
|
||||||
<th>{ "Tracks" }</th>
|
<th>{ "Tracks" }</th>
|
||||||
@@ -59,6 +59,11 @@ pub fn library_page() -> Html {
|
|||||||
{ &a.name }
|
{ &a.name }
|
||||||
</Link<Route>>
|
</Link<Route>>
|
||||||
</td>
|
</td>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
if a.monitored {
|
||||||
|
<span style="color: var(--success);" title="Monitored">{ "\u{2713}" }</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
if a.total_items > 0 {
|
if a.total_items > 0 {
|
||||||
<span class="text-sm" style={
|
<span class="text-sm" style={
|
||||||
@@ -73,8 +78,7 @@ pub fn library_page() -> Html {
|
|||||||
<td>
|
<td>
|
||||||
if a.total_items > 0 {
|
if a.total_items > 0 {
|
||||||
<span class="text-sm" style={
|
<span class="text-sm" style={
|
||||||
if a.total_watched >= a.total_items { "color: var(--accent);" }
|
if a.total_watched > 0 { "color: var(--accent);" }
|
||||||
else if a.total_watched > 0 { "color: var(--accent);" }
|
|
||||||
else { "color: var(--text-muted);" }
|
else { "color: var(--text-muted);" }
|
||||||
}>
|
}>
|
||||||
{ format!("{}/{}", a.total_watched, a.total_items) }
|
{ format!("{}/{}", a.total_watched, a.total_items) }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod dashboard;
|
|||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod playlists;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
@@ -23,6 +24,8 @@ pub enum Route {
|
|||||||
Artist { id: String },
|
Artist { id: String },
|
||||||
#[at("/albums/:mbid")]
|
#[at("/albums/:mbid")]
|
||||||
Album { mbid: String },
|
Album { mbid: String },
|
||||||
|
#[at("/playlists")]
|
||||||
|
Playlists,
|
||||||
#[at("/downloads")]
|
#[at("/downloads")]
|
||||||
Downloads,
|
Downloads,
|
||||||
#[at("/settings")]
|
#[at("/settings")]
|
||||||
@@ -39,6 +42,7 @@ pub fn switch(route: Route) -> Html {
|
|||||||
Route::Library => html! { <library::LibraryPage /> },
|
Route::Library => html! { <library::LibraryPage /> },
|
||||||
Route::Artist { id } => html! { <artist::ArtistPage {id} /> },
|
Route::Artist { id } => html! { <artist::ArtistPage {id} /> },
|
||||||
Route::Album { mbid } => html! { <album::AlbumPage {mbid} /> },
|
Route::Album { mbid } => html! { <album::AlbumPage {mbid} /> },
|
||||||
|
Route::Playlists => html! { <playlists::PlaylistsPage /> },
|
||||||
Route::Downloads => html! { <downloads::DownloadsPage /> },
|
Route::Downloads => html! { <downloads::DownloadsPage /> },
|
||||||
Route::Settings => html! { <settings::SettingsPage /> },
|
Route::Settings => html! { <settings::SettingsPage /> },
|
||||||
Route::NotFound => html! { <h2>{ "404 — Not Found" }</h2> },
|
Route::NotFound => html! { <h2>{ "404 — Not Found" }</h2> },
|
||||||
|
|||||||
851
frontend/src/pages/playlists.rs
Normal file
851
frontend/src/pages/playlists.rs
Normal file
@@ -0,0 +1,851 @@
|
|||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::types::*;
|
||||||
|
|
||||||
|
#[function_component(PlaylistsPage)]
|
||||||
|
pub fn playlists_page() -> Html {
|
||||||
|
// Tab state: "saved" | "generate" | "edit"
|
||||||
|
let active_tab = use_state(|| "saved".to_string());
|
||||||
|
let editing_playlist = use_state(|| None::<PlaylistDetail>);
|
||||||
|
|
||||||
|
// All local artists (for seed picker)
|
||||||
|
let all_artists = use_state(Vec::<ArtistListItem>::new);
|
||||||
|
{
|
||||||
|
let all_artists = all_artists.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(artists) = api::list_artists(500, 0).await {
|
||||||
|
all_artists.set(artists);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// All tracks (for add-track search in edit tab)
|
||||||
|
let all_tracks = use_state(Vec::<Track>::new);
|
||||||
|
{
|
||||||
|
let all_tracks = all_tracks.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(tracks) = api::search_tracks("").await {
|
||||||
|
all_tracks.set(tracks);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate form state
|
||||||
|
let seed_input = use_state(String::new);
|
||||||
|
let seed_focused = use_state(|| false);
|
||||||
|
let seeds = use_state(Vec::<String>::new);
|
||||||
|
let count = use_state(|| 50usize);
|
||||||
|
let popularity_bias = use_state(|| 5u8);
|
||||||
|
let ordering = use_state(|| "interleave".to_string());
|
||||||
|
|
||||||
|
// Results
|
||||||
|
let generated = use_state(|| None::<GeneratedPlaylist>);
|
||||||
|
let saved_playlists = use_state(|| None::<Vec<PlaylistSummary>>);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let message = use_state(|| None::<String>);
|
||||||
|
let loading = use_state(|| false);
|
||||||
|
let save_name = use_state(String::new);
|
||||||
|
|
||||||
|
// Edit tab state
|
||||||
|
let edit_name = use_state(String::new);
|
||||||
|
let track_search_input = use_state(String::new);
|
||||||
|
let track_search_focused = use_state(|| false);
|
||||||
|
let dragging_index = use_state(|| None::<usize>);
|
||||||
|
|
||||||
|
// Load saved playlists on mount
|
||||||
|
let refresh_playlists = {
|
||||||
|
let saved_playlists = saved_playlists.clone();
|
||||||
|
Callback::from(move |_: ()| {
|
||||||
|
let saved_playlists = saved_playlists.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(list) = api::list_playlists().await {
|
||||||
|
saved_playlists.set(Some(list));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
refresh.emit(());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate handler
|
||||||
|
let on_generate = {
|
||||||
|
let seeds = seeds.clone();
|
||||||
|
let count = count.clone();
|
||||||
|
let popularity_bias = popularity_bias.clone();
|
||||||
|
let ordering = ordering.clone();
|
||||||
|
let generated = generated.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let seeds = (*seeds).clone();
|
||||||
|
let count = *count;
|
||||||
|
let popularity_bias = *popularity_bias;
|
||||||
|
let ordering_val = (*ordering).clone();
|
||||||
|
let generated = generated.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
loading.set(true);
|
||||||
|
error.set(None);
|
||||||
|
message.set(None);
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let req = GenerateRequest {
|
||||||
|
strategy: "similar".to_string(),
|
||||||
|
seed_artists: seeds,
|
||||||
|
genres: vec![],
|
||||||
|
count,
|
||||||
|
popularity_bias,
|
||||||
|
ordering: ordering_val,
|
||||||
|
rules: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match api::generate_playlist(&req).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let n = result.tracks.len();
|
||||||
|
message.set(Some(format!("Generated {n} tracks")));
|
||||||
|
generated.set(Some(result));
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save handler
|
||||||
|
let on_save = {
|
||||||
|
let generated = generated.clone();
|
||||||
|
let save_name = save_name.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let generated = generated.clone();
|
||||||
|
let name = (*save_name).clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
if name.is_empty() {
|
||||||
|
error.set(Some("Enter a playlist name".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Some(ref gen) = *generated {
|
||||||
|
let track_ids: Vec<i32> = gen.tracks.iter().map(|t| t.track_id).collect();
|
||||||
|
match api::save_playlist(&name, None, &track_ids).await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some(format!("Saved playlist: {name}")));
|
||||||
|
refresh.emit(());
|
||||||
|
active_tab.set("saved".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tab bar ---
|
||||||
|
let tab_bar = {
|
||||||
|
let active = (*active_tab).clone();
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
html! {
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button
|
||||||
|
class={classes!("tab-btn", (active == "saved").then_some("active"))}
|
||||||
|
onclick={{
|
||||||
|
let tab = tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("saved".to_string()))
|
||||||
|
}}
|
||||||
|
>{ "Saved Playlists" }</button>
|
||||||
|
<button
|
||||||
|
class={classes!("tab-btn", (active == "generate").then_some("active"))}
|
||||||
|
onclick={{
|
||||||
|
let tab = tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("generate".to_string()))
|
||||||
|
}}
|
||||||
|
>{ "Generate" }</button>
|
||||||
|
if let Some(ref detail) = *editing {
|
||||||
|
<button
|
||||||
|
class={classes!("tab-btn", (active == "edit").then_some("active"))}
|
||||||
|
onclick={{
|
||||||
|
let tab = tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("edit".to_string()))
|
||||||
|
}}
|
||||||
|
>{ format!("Edit: {}", detail.playlist.name) }</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tab 1: Saved Playlists ---
|
||||||
|
let saved_tab = {
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
let editing_playlist = editing_playlist.clone();
|
||||||
|
let edit_name = edit_name.clone();
|
||||||
|
let saved_playlists = saved_playlists.clone();
|
||||||
|
let refresh_playlists = refresh_playlists.clone();
|
||||||
|
|
||||||
|
let saved_list = if let Some(ref playlists) = *saved_playlists {
|
||||||
|
if playlists.is_empty() {
|
||||||
|
html! { <p class="text-muted">{ "No saved playlists." }</p> }
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{ "Name" }</th>
|
||||||
|
<th>{ "Tracks" }</th>
|
||||||
|
<th>{ "Created" }</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for playlists.iter().map(|p| {
|
||||||
|
let id = p.id;
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
let en = edit_name.clone();
|
||||||
|
html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ &p.name }</td>
|
||||||
|
<td>{ p.track_count }</td>
|
||||||
|
<td class="text-sm text-muted">{ &p.created_at }</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick={{
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
let en = en.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
let en = en.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(detail) = api::get_playlist(id).await {
|
||||||
|
en.set(detail.playlist.name.clone());
|
||||||
|
editing.set(Some(detail));
|
||||||
|
tab.set("edit".to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "Edit" }</button>
|
||||||
|
<a href={api::export_m3u_url(id)}
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
download="true">{ "M3U" }</a>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick={{
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::delete_playlist(id).await;
|
||||||
|
// If we're editing this playlist, clear it
|
||||||
|
if let Some(ref d) = *editing {
|
||||||
|
if d.playlist.id == id {
|
||||||
|
editing.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh.emit(());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "Delete" }</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <p class="loading">{ "Loading..." }</p> }
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<button class="btn btn-primary" onclick={{
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("generate".to_string()))
|
||||||
|
}}>{ "Generate" }</button>
|
||||||
|
<button class="btn btn-secondary" onclick={{
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
let edit_name = edit_name.clone();
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let edit_name = edit_name.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::save_playlist("New Playlist", None, &[]).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
// Extract the new playlist id and open it for editing
|
||||||
|
if let Some(id) = resp.get("id").and_then(|v| v.as_i64()) {
|
||||||
|
let id = id as i32;
|
||||||
|
edit_name.set("New Playlist".to_string());
|
||||||
|
if let Ok(detail) = api::get_playlist(id).await {
|
||||||
|
editing.set(Some(detail));
|
||||||
|
tab.set("edit".to_string());
|
||||||
|
}
|
||||||
|
refresh.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "New" }</button>
|
||||||
|
</div>
|
||||||
|
{ saved_list }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tab 2: Generate ---
|
||||||
|
let generate_tab = {
|
||||||
|
let strategy_inputs = {
|
||||||
|
let seed_input_c = seed_input.clone();
|
||||||
|
let seeds_c = seeds.clone();
|
||||||
|
let query = (*seed_input_c).to_lowercase();
|
||||||
|
let filtered: Vec<_> = (*all_artists)
|
||||||
|
.iter()
|
||||||
|
.filter(|a| {
|
||||||
|
if query.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let name_lower = a.name.to_lowercase();
|
||||||
|
let mut chars = query.chars();
|
||||||
|
let mut current = chars.next();
|
||||||
|
for c in name_lower.chars() {
|
||||||
|
if current == Some(c) {
|
||||||
|
current = chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.is_none()
|
||||||
|
})
|
||||||
|
.filter(|a| !seeds_c.contains(&a.name))
|
||||||
|
.take(15)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
html! {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Seed Artists" }</label>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search your library..."
|
||||||
|
value={(*seed_input_c).clone()}
|
||||||
|
oninput={{
|
||||||
|
let si = seed_input.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
si.set(input.value());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onfocus={{
|
||||||
|
let f = seed_focused.clone();
|
||||||
|
Callback::from(move |_: FocusEvent| f.set(true))
|
||||||
|
}}
|
||||||
|
onblur={{
|
||||||
|
let f = seed_focused.clone();
|
||||||
|
Callback::from(move |_: FocusEvent| {
|
||||||
|
let f = f.clone();
|
||||||
|
gloo_timers::callback::Timeout::new(200, move || f.set(false))
|
||||||
|
.forget();
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
if *seed_focused && !filtered.is_empty() {
|
||||||
|
<div class="autocomplete-dropdown">
|
||||||
|
{ for filtered.iter().map(|a| {
|
||||||
|
let name = a.name.clone();
|
||||||
|
let seeds = seeds.clone();
|
||||||
|
let si = seed_input.clone();
|
||||||
|
let track_count = a.total_items;
|
||||||
|
html! {
|
||||||
|
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
let mut v = (*seeds).clone();
|
||||||
|
v.push(name.clone());
|
||||||
|
seeds.set(v);
|
||||||
|
si.set(String::new());
|
||||||
|
})}>
|
||||||
|
<span>{ &a.name }</span>
|
||||||
|
<span class="text-muted text-sm">{ format!("{} tracks", track_count) }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="tag-list">
|
||||||
|
{ for seeds_c.iter().enumerate().map(|(i, s)| {
|
||||||
|
let seeds = seeds_c.clone();
|
||||||
|
html! {
|
||||||
|
<span class="tag" onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
let mut v = (*seeds).clone();
|
||||||
|
v.remove(i);
|
||||||
|
seeds.set(v);
|
||||||
|
})}>{s}{ " \u{00d7}" }</span>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label>{ format!("Popularity Bias: {}", *popularity_bias) }</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0" max="10"
|
||||||
|
value={popularity_bias.to_string()}
|
||||||
|
oninput={{
|
||||||
|
let pb = popularity_bias.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse() {
|
||||||
|
pb.set(v);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Track Order" }</label>
|
||||||
|
<select onchange={{
|
||||||
|
let ord = ordering.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
ord.set(select.value());
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option>
|
||||||
|
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option>
|
||||||
|
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_list = if let Some(ref gen) = *generated {
|
||||||
|
html! {
|
||||||
|
<div class="card" style="margin-top: 1rem;">
|
||||||
|
<h3>{ format!("Generated Tracks ({})", gen.tracks.len()) }</h3>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Playlist name..."
|
||||||
|
value={(*save_name).clone()}
|
||||||
|
oninput={{
|
||||||
|
let sn = save_name.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
sn.set(input.value());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" onclick={on_save.clone()}>{ "Save Playlist" }</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{ "#" }</th>
|
||||||
|
<th>{ "Title" }</th>
|
||||||
|
<th>{ "Artist" }</th>
|
||||||
|
<th>{ "Album" }</th>
|
||||||
|
<th>{ "Score" }</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for gen.tracks.iter().enumerate().map(|(i, t)| html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ i + 1 }</td>
|
||||||
|
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
||||||
|
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
|
||||||
|
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
|
||||||
|
<td class="text-sm text-muted">{ format!("{:.3}", t.score) }</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Generate Playlist" }</h3>
|
||||||
|
<p class="text-muted text-sm">{ "Build a playlist from similar artists in your library using Last.fm data." }</p>
|
||||||
|
|
||||||
|
{ strategy_inputs }
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ format!("Count: {}", *count) }</label>
|
||||||
|
<input type="range" min="10" max="200" step="10"
|
||||||
|
value={count.to_string()}
|
||||||
|
oninput={{
|
||||||
|
let count = count.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse() {
|
||||||
|
count.set(v);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={on_generate}
|
||||||
|
disabled={*loading}
|
||||||
|
>
|
||||||
|
{ if *loading { "Generating..." } else { "Generate" } }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ track_list }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tab 3: Edit ---
|
||||||
|
let edit_tab = {
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
let active_tab_c = active_tab.clone();
|
||||||
|
let edit_name_c = edit_name.clone();
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
let error_c = error.clone();
|
||||||
|
let message_c = message.clone();
|
||||||
|
let track_search_input_c = track_search_input.clone();
|
||||||
|
let track_search_focused_c = track_search_focused.clone();
|
||||||
|
let all_tracks_c = all_tracks.clone();
|
||||||
|
let dragging_index_c = dragging_index.clone();
|
||||||
|
|
||||||
|
if let Some(ref detail) = *editing {
|
||||||
|
let playlist_id = detail.playlist.id;
|
||||||
|
let tracks = detail.tracks.clone();
|
||||||
|
|
||||||
|
// Filter tracks for add-track search
|
||||||
|
let search_query = (*track_search_input_c).to_lowercase();
|
||||||
|
let existing_ids: Vec<i32> = tracks.iter().map(|t| t.id).collect();
|
||||||
|
let filtered_tracks: Vec<_> = if search_query.is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
(*all_tracks_c)
|
||||||
|
.iter()
|
||||||
|
.filter(|t| {
|
||||||
|
if existing_ids.contains(&t.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let title_lower = t.title.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let artist_lower = t.artist.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
// Subsequence match on title or artist
|
||||||
|
let matches_field = |field: &str| {
|
||||||
|
let mut chars = search_query.chars();
|
||||||
|
let mut current = chars.next();
|
||||||
|
for c in field.chars() {
|
||||||
|
if current == Some(c) {
|
||||||
|
current = chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.is_none()
|
||||||
|
};
|
||||||
|
matches_field(&title_lower) || matches_field(&artist_lower)
|
||||||
|
})
|
||||||
|
.take(15)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-secondary" style="margin-bottom: 1rem;" onclick={{
|
||||||
|
let tab = active_tab_c.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
editing.set(None);
|
||||||
|
tab.set("saved".to_string());
|
||||||
|
})
|
||||||
|
}}>{ "Back to Playlists" }</button>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Playlist Name" }</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(*edit_name_c).clone()}
|
||||||
|
oninput={{
|
||||||
|
let en = edit_name_c.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
en.set(input.value());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" onclick={{
|
||||||
|
let en = edit_name_c.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let message = message_c.clone();
|
||||||
|
let error = error_c.clone();
|
||||||
|
let tab = active_tab_c.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let name = (*en).clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
if name.is_empty() {
|
||||||
|
error.set(Some("Name cannot be empty".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_playlist(playlist_id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let body = serde_json::json!({"name": name}).to_string();
|
||||||
|
let resp = gloo_net::http::Request::put(
|
||||||
|
&format!("/api/playlists/{playlist_id}"),
|
||||||
|
)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(&body)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match resp {
|
||||||
|
Ok(r) if r.ok() => {
|
||||||
|
message.set(Some("Playlist saved".to_string()));
|
||||||
|
refresh.emit(());
|
||||||
|
editing.set(None);
|
||||||
|
tab.set("saved".to_string());
|
||||||
|
}
|
||||||
|
Ok(r) => error.set(Some(format!("HTTP {}", r.status()))),
|
||||||
|
Err(e) => error.set(Some(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "Save" }</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ format!("Tracks ({})", tracks.len()) }</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 2rem;"></th>
|
||||||
|
<th style="width: 2rem;">{ "#" }</th>
|
||||||
|
<th>{ "Title" }</th>
|
||||||
|
<th>{ "Artist" }</th>
|
||||||
|
<th>{ "Album" }</th>
|
||||||
|
<th style="width: 3rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for tracks.iter().enumerate().map(|(i, t)| {
|
||||||
|
let track_id = t.id;
|
||||||
|
let editing = editing.clone();
|
||||||
|
let dragging = dragging_index_c.clone();
|
||||||
|
let idx = i;
|
||||||
|
|
||||||
|
let ondragstart = {
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
Callback::from(move |e: DragEvent| {
|
||||||
|
dragging.set(Some(idx));
|
||||||
|
if let Some(dt) = e.data_transfer() {
|
||||||
|
let _ = dt.set_data("text/plain", &idx.to_string());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let ondragover = Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
});
|
||||||
|
|
||||||
|
let ondrop = {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
let target_idx = idx;
|
||||||
|
if let Some(source_idx) = *dragging {
|
||||||
|
if source_idx != target_idx {
|
||||||
|
let editing_inner = editing.clone();
|
||||||
|
let dragging_inner = dragging.clone();
|
||||||
|
// Reorder locally
|
||||||
|
if let Some(ref detail) = *editing {
|
||||||
|
let mut new_tracks = detail.tracks.clone();
|
||||||
|
let item = new_tracks.remove(source_idx);
|
||||||
|
new_tracks.insert(target_idx, item);
|
||||||
|
let track_ids: Vec<i32> = new_tracks.iter().map(|t| t.id).collect();
|
||||||
|
let new_detail = PlaylistDetail {
|
||||||
|
playlist: detail.playlist.clone(),
|
||||||
|
tracks: new_tracks,
|
||||||
|
};
|
||||||
|
editing_inner.set(Some(new_detail));
|
||||||
|
dragging_inner.set(None);
|
||||||
|
// Call reorder API
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::reorder_playlist_tracks(playlist_id, &track_ids).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragging.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_remove = {
|
||||||
|
let editing = editing.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::remove_track_from_playlist(playlist_id, track_id).await;
|
||||||
|
// Refresh the playlist
|
||||||
|
if let Ok(detail) = api::get_playlist(playlist_id).await {
|
||||||
|
editing.set(Some(detail));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<tr draggable="true"
|
||||||
|
{ondragstart}
|
||||||
|
{ondragover}
|
||||||
|
{ondrop}
|
||||||
|
>
|
||||||
|
<td><span class="drag-handle">{ "\u{2261}" }</span></td>
|
||||||
|
<td>{ i + 1 }</td>
|
||||||
|
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
||||||
|
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
|
||||||
|
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick={on_remove}>
|
||||||
|
{ "\u{00d7}" }
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Add track section
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Add Track" }</h3>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tracks in your library..."
|
||||||
|
value={(*track_search_input_c).clone()}
|
||||||
|
oninput={{
|
||||||
|
let tsi = track_search_input_c.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
tsi.set(input.value());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onfocus={{
|
||||||
|
let f = track_search_focused_c.clone();
|
||||||
|
Callback::from(move |_: FocusEvent| f.set(true))
|
||||||
|
}}
|
||||||
|
onblur={{
|
||||||
|
let f = track_search_focused_c.clone();
|
||||||
|
Callback::from(move |_: FocusEvent| {
|
||||||
|
let f = f.clone();
|
||||||
|
gloo_timers::callback::Timeout::new(200, move || f.set(false)).forget();
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
if *track_search_focused_c && !filtered_tracks.is_empty() {
|
||||||
|
<div class="autocomplete-dropdown">
|
||||||
|
{ for filtered_tracks.iter().map(|t| {
|
||||||
|
let tid = t.id;
|
||||||
|
let title = t.title.clone().unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let artist = t.artist.clone().unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tsi = track_search_input_c.clone();
|
||||||
|
html! {
|
||||||
|
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tsi = tsi.clone();
|
||||||
|
tsi.set(String::new());
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::add_track_to_playlist(playlist_id, tid).await;
|
||||||
|
if let Ok(detail) = api::get_playlist(playlist_id).await {
|
||||||
|
editing.set(Some(detail));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
<span>{ format!("{} - {}", title, artist) }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <p class="text-muted">{ "No playlist selected for editing." }</p> }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ "Playlists" }</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if let Some(ref msg) = *message {
|
||||||
|
<div class="card" style="border-color: var(--success);">{ msg }</div>
|
||||||
|
}
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
<div class="card error">{ err }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{ tab_bar }
|
||||||
|
|
||||||
|
if *active_tab == "saved" {
|
||||||
|
{ saved_tab }
|
||||||
|
}
|
||||||
|
if *active_tab == "generate" {
|
||||||
|
{ generate_tab }
|
||||||
|
}
|
||||||
|
if *active_tab == "edit" {
|
||||||
|
{ edit_tab }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ enum SearchResults {
|
|||||||
|
|
||||||
#[function_component(SearchPage)]
|
#[function_component(SearchPage)]
|
||||||
pub fn search_page() -> Html {
|
pub fn search_page() -> Html {
|
||||||
let query = use_state(|| String::new());
|
let query = use_state(String::new);
|
||||||
let search_type = use_state(|| "artist".to_string());
|
let search_type = use_state(|| "artist".to_string());
|
||||||
let results = use_state(|| SearchResults::None);
|
let results = use_state(|| SearchResults::None);
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
|
|||||||
@@ -3,17 +3,27 @@ use web_sys::HtmlSelectElement;
|
|||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::types::AppConfig;
|
use crate::types::{AppConfig, MbStatus, SubsonicPasswordStatus, YtAuthStatus};
|
||||||
|
|
||||||
#[function_component(SettingsPage)]
|
#[function_component(SettingsPage)]
|
||||||
pub fn settings_page() -> Html {
|
pub fn settings_page() -> Html {
|
||||||
let config = use_state(|| None::<AppConfig>);
|
let config = use_state(|| None::<AppConfig>);
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
let message = use_state(|| None::<String>);
|
let message = use_state(|| None::<String>);
|
||||||
|
let ytauth = use_state(|| None::<YtAuthStatus>);
|
||||||
|
let ytauth_loading = use_state(|| false);
|
||||||
|
let subsonic_status = use_state(|| None::<SubsonicPasswordStatus>);
|
||||||
|
let subsonic_password = use_state(String::new);
|
||||||
|
let subsonic_saving = use_state(|| false);
|
||||||
|
let mb_status = use_state(|| None::<MbStatus>);
|
||||||
|
let mb_importing = use_state(|| false);
|
||||||
|
|
||||||
{
|
{
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let subsonic_status = subsonic_status.clone();
|
||||||
|
let mb_status = mb_status.clone();
|
||||||
use_effect_with((), move |_| {
|
use_effect_with((), move |_| {
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
match api::get_config().await {
|
match api::get_config().await {
|
||||||
@@ -21,6 +31,21 @@ pub fn settings_page() -> Html {
|
|||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(status) = api::get_ytauth_status().await {
|
||||||
|
ytauth.set(Some(status));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(status) = api::get_subsonic_password_status().await {
|
||||||
|
subsonic_status.set(Some(status));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(status) = api::get_mb_status().await {
|
||||||
|
mb_status.set(Some(status));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +93,254 @@ pub fn settings_page() -> Html {
|
|||||||
return html! { <p class="loading">{ "Loading configuration..." }</p> };
|
return html! { <p class="loading">{ "Loading configuration..." }</p> };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build YouTube auth card HTML outside the main html! macro
|
||||||
|
let ytauth_html = {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let ytauth_loading = ytauth_loading.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
if let Some(ref status) = *ytauth {
|
||||||
|
if status.login_session_active {
|
||||||
|
let vnc_url = status.vnc_url.clone().unwrap_or_default();
|
||||||
|
let on_done = {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let ytauth_loading = ytauth_loading.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let ytauth_loading = ytauth_loading.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
ytauth_loading.set(true);
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::ytauth_login_stop().await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some(
|
||||||
|
"YouTube login complete! Cookies exported.".into(),
|
||||||
|
));
|
||||||
|
if let Ok(s) = api::get_ytauth_status().await {
|
||||||
|
ytauth.set(Some(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
ytauth_loading.set(false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<p class="text-sm">{ "Log into YouTube in the browser below, then click Done." }</p>
|
||||||
|
if !vnc_url.is_empty() {
|
||||||
|
<p class="text-sm"><a href={vnc_url.clone()} target="_blank">{ "Open login window" }</a></p>
|
||||||
|
<iframe src={vnc_url} style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);margin:0.5rem 0;" />
|
||||||
|
}
|
||||||
|
<button class="btn btn-primary" onclick={on_done} disabled={*ytauth_loading}>
|
||||||
|
{ if *ytauth_loading { "Finishing..." } else { "Done \u{2014} I've logged in" } }
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else if status.authenticated {
|
||||||
|
let age_text = status
|
||||||
|
.cookie_age_hours
|
||||||
|
.map(|h| format!("cookies {h:.0}h old"))
|
||||||
|
.unwrap_or_else(|| "authenticated".into());
|
||||||
|
let on_refresh = {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let ytauth_loading = ytauth_loading.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let ytauth_loading = ytauth_loading.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
ytauth_loading.set(true);
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::ytauth_refresh().await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some("Cookies refreshed".into()));
|
||||||
|
if let Ok(s) = api::get_ytauth_status().await {
|
||||||
|
ytauth.set(Some(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
ytauth_loading.set(false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let on_clear = {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::ytauth_clear_cookies().await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some("YouTube auth cleared".into()));
|
||||||
|
if let Ok(s) = api::get_ytauth_status().await {
|
||||||
|
ytauth.set(Some(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<span class="badge badge-success">{ "Authenticated" }</span>
|
||||||
|
<span class="text-muted text-sm" style="margin-left: 0.5rem;">{ age_text }</span>
|
||||||
|
</p>
|
||||||
|
if status.refresh_enabled {
|
||||||
|
<p class="text-sm text-muted">{ "Auto-refresh is enabled" }</p>
|
||||||
|
}
|
||||||
|
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick={on_refresh} disabled={*ytauth_loading}>
|
||||||
|
{ if *ytauth_loading { "Refreshing..." } else { "Refresh Now" } }
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick={on_clear}>
|
||||||
|
{ "Clear Authentication" }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let on_start = {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let ytauth_loading = ytauth_loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let ytauth = ytauth.clone();
|
||||||
|
let ytauth_loading = ytauth_loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
ytauth_loading.set(true);
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::ytauth_login_start().await {
|
||||||
|
Ok(_) => {
|
||||||
|
if let Ok(s) = api::get_ytauth_status().await {
|
||||||
|
ytauth.set(Some(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
ytauth_loading.set(false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
{ "Authenticate with YouTube for higher download rate limits (~2000/hr vs ~300/hr) and access to age-restricted content. " }
|
||||||
|
{ "This launches a browser where you log into your Google account." }
|
||||||
|
</p>
|
||||||
|
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
|
||||||
|
<p class="text-sm" style="margin:0;">
|
||||||
|
<strong style="color: var(--warning);">{ "Warning: " }</strong>
|
||||||
|
{ "YouTube may permanently suspend accounts that are used with third-party download tools. " }
|
||||||
|
<strong>{ "Use a throwaway Google account" }</strong>
|
||||||
|
{ " \u{2014} do not log in with your primary account." }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick={on_start} disabled={*ytauth_loading}>
|
||||||
|
{ if *ytauth_loading { "Starting..." } else { "Authenticate YouTube" } }
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <p class="text-muted text-sm">{ "Loading..." }</p> }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ytdlp_version_html = if let Some(ref status) = *ytauth {
|
||||||
|
let version = status
|
||||||
|
.ytdlp_version
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "not found".into());
|
||||||
|
if status.ytdlp_update_available {
|
||||||
|
let latest = status.ytdlp_latest.clone().unwrap_or_default();
|
||||||
|
html! {
|
||||||
|
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0 0 0.75rem 0; padding: 0.5rem 0.75rem;">
|
||||||
|
<p class="text-sm" style="margin:0;">
|
||||||
|
<strong style="color: var(--warning);">{ "yt-dlp update available: " }</strong>
|
||||||
|
{ format!("{version} \u{2192} {latest}") }
|
||||||
|
<span class="text-muted">{ " \u{2014} run " }</span>
|
||||||
|
<code>{ "pip install -U yt-dlp" }</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<p class="text-muted text-sm" style="margin: 0 0 0.75rem 0;">
|
||||||
|
{ format!("yt-dlp {version}") }
|
||||||
|
<span style="color: var(--success); margin-left: 0.3rem;">{ "\u{2713} up to date" }</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastfm_key_html = {
|
||||||
|
let key_set = ytauth
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.lastfm_api_key_set)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if key_set {
|
||||||
|
html! {
|
||||||
|
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
|
||||||
|
<span style="color: var(--success);">{ "\u{2713}" }</span>
|
||||||
|
{ " API key configured via " }
|
||||||
|
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
|
||||||
|
{ "Set " }
|
||||||
|
<code>{ "SHANTY_LASTFM_API_KEY" }</code>
|
||||||
|
{ " environment variable. Get a key at " }
|
||||||
|
<a href="https://www.last.fm/api/account/create" target="_blank">{ "last.fm/api/account/create" }</a>
|
||||||
|
{ " (use any name, leave callback URL blank)." }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let fanart_key_html = {
|
||||||
|
let key_set = ytauth
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.fanart_api_key_set)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if key_set {
|
||||||
|
html! {
|
||||||
|
<p class="text-sm" style="margin: 0.25rem 0 0 0;">
|
||||||
|
<span style="color: var(--success);">{ "\u{2713}" }</span>
|
||||||
|
{ " API key configured via " }
|
||||||
|
<code>{ "SHANTY_FANART_API_KEY" }</code>
|
||||||
|
{ ". Provides artist thumbnails and HD banners." }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<p class="text-sm" style="margin: 0.25rem 0 0 0; color: var(--warning);">
|
||||||
|
{ "Set " }
|
||||||
|
<code>{ "SHANTY_FANART_API_KEY" }</code>
|
||||||
|
{ " environment variable. Get a key at " }
|
||||||
|
<a href="https://fanart.tv/get-an-api-key/" target="_blank">{ "fanart.tv" }</a>
|
||||||
|
{ "." }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -211,18 +484,6 @@ pub fn settings_page() -> Html {
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>{ "Cookies Path (optional)" }</label>
|
|
||||||
<input type="text" value={c.download.cookies_path.clone().unwrap_or_default()}
|
|
||||||
placeholder="~/.config/shanty/cookies.txt"
|
|
||||||
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
|
||||||
let input: HtmlInputElement = e.target_unchecked_into();
|
|
||||||
let mut cfg = (*config).clone().unwrap();
|
|
||||||
let v = input.value();
|
|
||||||
cfg.download.cookies_path = if v.is_empty() { None } else { Some(v) };
|
|
||||||
config.set(Some(cfg));
|
|
||||||
})} />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{ "Rate Limit (requests/hour, guest)" }</label>
|
<label>{ "Rate Limit (requests/hour, guest)" }</label>
|
||||||
<input type="number" value={c.download.rate_limit.to_string()}
|
<input type="number" value={c.download.rate_limit.to_string()}
|
||||||
@@ -249,6 +510,316 @@ pub fn settings_page() -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// YouTube Authentication
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "YouTube Authentication" }</h3>
|
||||||
|
{ ytdlp_version_html }
|
||||||
|
{ ytauth_html }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Subsonic API
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Subsonic API" }</h3>
|
||||||
|
<p class="text-sm text-muted mb-1">
|
||||||
|
{ "Connect Subsonic-compatible apps (DSub, Symfonium, Feishin) to stream your library. " }
|
||||||
|
{ "This is a minimal Subsonic implementation for basic browsing and playback. " }
|
||||||
|
{ "For a full-featured Subsonic server, consider " }
|
||||||
|
<a href="https://www.navidrome.org" target="_blank">{ "Navidrome" }</a>
|
||||||
|
{ " pointed at the same library." }
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Server URL (most clients add /rest automatically)" }</label>
|
||||||
|
<input type="text" readonly=true value={
|
||||||
|
format!("http://{}:{}",
|
||||||
|
c.web.bind.clone(),
|
||||||
|
c.web.port)
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
if let Some(ref status) = *subsonic_status {
|
||||||
|
if status.set {
|
||||||
|
html! {
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="badge badge-success">{ "Password set" }</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<p class="text-sm text-muted">{ "No Subsonic password set. Set one below to enable access." }</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <p class="text-sm text-muted">{ "Loading..." }</p> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
|
||||||
|
<p class="text-sm" style="margin:0;">
|
||||||
|
<strong style="color: var(--warning);">{ "Warning: " }</strong>
|
||||||
|
{ "This password is stored in plaintext per the Subsonic protocol. Do " }
|
||||||
|
<strong>{ "not" }</strong>
|
||||||
|
{ " reuse a password from another account." }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Subsonic Password" }</label>
|
||||||
|
<input type="password" placeholder="Enter Subsonic password"
|
||||||
|
value={(*subsonic_password).clone()}
|
||||||
|
oninput={let subsonic_password = subsonic_password.clone(); Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
subsonic_password.set(input.value());
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary"
|
||||||
|
disabled={*subsonic_saving || subsonic_password.is_empty()}
|
||||||
|
onclick={{
|
||||||
|
let subsonic_password = subsonic_password.clone();
|
||||||
|
let subsonic_saving = subsonic_saving.clone();
|
||||||
|
let subsonic_status = subsonic_status.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let pw = (*subsonic_password).clone();
|
||||||
|
let subsonic_saving = subsonic_saving.clone();
|
||||||
|
let subsonic_status = subsonic_status.clone();
|
||||||
|
let subsonic_password = subsonic_password.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
subsonic_saving.set(true);
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::set_subsonic_password(&pw).await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some("Subsonic password saved".into()));
|
||||||
|
subsonic_password.set(String::new());
|
||||||
|
if let Ok(s) = api::get_subsonic_password_status().await {
|
||||||
|
subsonic_status.set(Some(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
subsonic_saving.set(false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{ if *subsonic_saving { "Saving..." } else { "Save Subsonic Password" } }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// MusicBrainz Local Database
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "MusicBrainz Database" }</h3>
|
||||||
|
<p class="text-sm text-muted mb-1">
|
||||||
|
{ "Import the MusicBrainz database locally for instant artist/album lookups instead of rate-limited API calls. " }
|
||||||
|
{ "Makes browsing and watching artists dramatically faster." }
|
||||||
|
</p>
|
||||||
|
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.5rem 0;">
|
||||||
|
<p class="text-sm" style="margin:0;">
|
||||||
|
<strong style="color: var(--warning);">{ "Heads up: " }</strong>
|
||||||
|
{ "This downloads ~24 GB of data and builds a ~10 GB local database. " }
|
||||||
|
{ "The initial import can take 3-6 hours depending on your hardware. " }
|
||||||
|
{ "Total disk usage: ~35 GB (downloads + database). " }
|
||||||
|
{ "After the initial import, the database is automatically refreshed weekly to stay current." }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
if let Some(ref status) = *mb_status {
|
||||||
|
if status.has_local_db {
|
||||||
|
if let Some(ref stats) = status.stats {
|
||||||
|
let import_date = stats.last_import_date.clone().unwrap_or_else(|| "unknown".into());
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<span class="badge badge-success">{ "Loaded" }</span>
|
||||||
|
<span class="text-muted text-sm" style="margin-left: 0.5rem;">
|
||||||
|
{ format!("imported {}", import_date) }
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
{ format!("{} artists, {} release groups, {} releases, {} recordings",
|
||||||
|
stats.artists, stats.release_groups, stats.releases, stats.recordings) }
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<p><span class="badge badge-success">{ "Loaded" }</span></p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
{ "Not configured. Import data to enable instant lookups." }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <p class="text-sm text-muted">{ "Loading..." }</p> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button type="button" class="btn btn-primary"
|
||||||
|
disabled={*mb_importing}
|
||||||
|
onclick={{
|
||||||
|
let mb_importing = mb_importing.clone();
|
||||||
|
let mb_status = mb_status.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let mb_importing = mb_importing.clone();
|
||||||
|
let mb_status = mb_status.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
mb_importing.set(true);
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::trigger_mb_import().await {
|
||||||
|
Ok(task_ref) => {
|
||||||
|
message.set(Some(format!(
|
||||||
|
"MusicBrainz import started (task {}). This will take a while.",
|
||||||
|
task_ref.task_id
|
||||||
|
)));
|
||||||
|
// Refresh status after a short delay
|
||||||
|
if let Ok(s) = api::get_mb_status().await {
|
||||||
|
mb_status.set(Some(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
mb_importing.set(false);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{ if *mb_importing { "Starting import..." } else { "Import MusicBrainz Data" } }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Metadata Providers
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Metadata Providers" }</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Music Database" }</label>
|
||||||
|
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.metadata.metadata_source = select.value();
|
||||||
|
config.set(Some(cfg));
|
||||||
|
})}>
|
||||||
|
{ for [("musicbrainz", "MusicBrainz")].iter().map(|(v, label)| html! {
|
||||||
|
<option value={*v} selected={c.metadata.metadata_source == *v}>{ label }</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Artist Images" }</label>
|
||||||
|
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.metadata.artist_image_source = select.value();
|
||||||
|
config.set(Some(cfg));
|
||||||
|
})}>
|
||||||
|
{ for [("wikipedia", "Wikipedia"), ("fanarttv", "fanart.tv")].iter().map(|(v, label)| html! {
|
||||||
|
<option value={*v} selected={c.metadata.artist_image_source == *v}>{ label }</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
if c.metadata.artist_image_source == "fanarttv" {
|
||||||
|
{ fanart_key_html.clone() }
|
||||||
|
}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Artist Bios" }</label>
|
||||||
|
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.metadata.artist_bio_source = select.value();
|
||||||
|
config.set(Some(cfg));
|
||||||
|
})}>
|
||||||
|
{ for [("wikipedia", "Wikipedia"), ("lastfm", "Last.fm")].iter().map(|(v, label)| html! {
|
||||||
|
<option value={*v} selected={c.metadata.artist_bio_source == *v}>{ label }</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
if c.metadata.artist_bio_source == "lastfm" {
|
||||||
|
{ lastfm_key_html.clone() }
|
||||||
|
}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Lyrics" }</label>
|
||||||
|
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.metadata.lyrics_source = select.value();
|
||||||
|
config.set(Some(cfg));
|
||||||
|
})}>
|
||||||
|
{ for [("lrclib", "LRCLIB")].iter().map(|(v, label)| html! {
|
||||||
|
<option value={*v} selected={c.metadata.lyrics_source == *v}>{ label }</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Cover Art" }</label>
|
||||||
|
<select onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.metadata.cover_art_source = select.value();
|
||||||
|
config.set(Some(cfg));
|
||||||
|
})}>
|
||||||
|
{ for [("coverartarchive", "Cover Art Archive")].iter().map(|(v, label)| html! {
|
||||||
|
<option value={*v} selected={c.metadata.cover_art_source == *v}>{ label }</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Scheduling
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Scheduling" }</h3>
|
||||||
|
<p class="text-sm text-muted mb-1">{ "Automate pipeline runs and new release monitoring" }</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={c.scheduling.pipeline_enabled}
|
||||||
|
onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.scheduling.pipeline_enabled = input.checked();
|
||||||
|
config.set(Some(cfg));
|
||||||
|
})} />
|
||||||
|
{ " Automatically run full pipeline (sync, download, index, tag, organize)" }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Pipeline Interval (hours)" }</label>
|
||||||
|
<input type="number" min="1" max="168" value={c.scheduling.pipeline_interval_hours.to_string()}
|
||||||
|
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse() {
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.scheduling.pipeline_interval_hours = v;
|
||||||
|
config.set(Some(cfg));
|
||||||
|
}
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" checked={c.scheduling.monitor_enabled}
|
||||||
|
onchange={let config = config.clone(); Callback::from(move |e: Event| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.scheduling.monitor_enabled = input.checked();
|
||||||
|
config.set(Some(cfg));
|
||||||
|
})} />
|
||||||
|
{ " Automatically check monitored artists for new releases" }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Monitor Interval (hours)" }</label>
|
||||||
|
<input type="number" min="1" max="168" value={c.scheduling.monitor_interval_hours.to_string()}
|
||||||
|
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse() {
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.scheduling.monitor_interval_hours = v;
|
||||||
|
config.set(Some(cfg));
|
||||||
|
}
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Indexing
|
// Indexing
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>{ "Indexing" }</h3>
|
<h3>{ "Indexing" }</h3>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub struct ArtistListItem {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub musicbrainz_id: Option<String>,
|
pub musicbrainz_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub monitored: bool,
|
||||||
pub total_watched: usize,
|
pub total_watched: usize,
|
||||||
pub total_owned: usize,
|
pub total_owned: usize,
|
||||||
pub total_items: usize,
|
pub total_items: usize,
|
||||||
@@ -61,16 +63,44 @@ pub struct FullArtistDetail {
|
|||||||
pub total_owned_tracks: u32,
|
pub total_owned_tracks: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enriched: bool,
|
pub enriched: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub monitored: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist_info: Option<ArtistInfoFe>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist_photo: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist_bio: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist_banner: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
pub struct Album {
|
pub struct ArtistInfoFe {
|
||||||
pub id: i32,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub album_artist: String,
|
#[serde(default)]
|
||||||
pub year: Option<i32>,
|
pub disambiguation: Option<String>,
|
||||||
pub genre: Option<String>,
|
#[serde(default)]
|
||||||
pub musicbrainz_id: Option<String>,
|
pub country: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub begin_year: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub urls: Vec<ArtistUrlFe>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct ArtistUrlFe {
|
||||||
|
pub url: String,
|
||||||
|
pub link_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct LyricsResult {
|
||||||
|
pub found: bool,
|
||||||
|
pub lyrics: Option<String>,
|
||||||
|
pub synced_lyrics: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
@@ -86,18 +116,6 @@ pub struct Track {
|
|||||||
pub codec: Option<String>,
|
pub codec: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
|
||||||
pub struct ArtistDetail {
|
|
||||||
pub artist: Artist,
|
|
||||||
pub albums: Vec<Album>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
|
||||||
pub struct AlbumDetail {
|
|
||||||
pub album: Album,
|
|
||||||
pub tracks: Vec<Track>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Album detail from MusicBrainz (the primary album view).
|
/// Album detail from MusicBrainz (the primary album view).
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
pub struct MbAlbumDetail {
|
pub struct MbAlbumDetail {
|
||||||
@@ -148,15 +166,26 @@ pub struct TrackResult {
|
|||||||
pub score: u8,
|
pub score: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Watchlist ---
|
// --- YouTube Auth ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
pub struct WatchListEntry {
|
pub struct YtAuthStatus {
|
||||||
pub id: i32,
|
pub authenticated: bool,
|
||||||
pub item_type: String,
|
pub cookie_age_hours: Option<f64>,
|
||||||
pub name: String,
|
pub cookie_count: Option<i64>,
|
||||||
pub artist_name: Option<String>,
|
pub refresh_enabled: bool,
|
||||||
pub status: String,
|
pub login_session_active: bool,
|
||||||
|
pub vnc_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ytdlp_version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ytdlp_latest: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ytdlp_update_available: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lastfm_api_key_set: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fanart_api_key_set: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Downloads ---
|
// --- Downloads ---
|
||||||
@@ -215,6 +244,14 @@ pub struct Status {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tagging: Option<TaggingStatus>,
|
pub tagging: Option<TaggingStatus>,
|
||||||
pub tasks: Vec<TaskInfo>,
|
pub tasks: Vec<TaskInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scheduled: Option<ScheduledTasks>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct ScheduledTasks {
|
||||||
|
pub next_pipeline: Option<String>,
|
||||||
|
pub next_monitor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
@@ -252,6 +289,95 @@ pub struct SyncStats {
|
|||||||
pub skipped: u64,
|
pub skipped: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Playlists ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GenerateRequest {
|
||||||
|
pub strategy: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seed_artists: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
#[serde(default = "default_playlist_count")]
|
||||||
|
pub count: usize,
|
||||||
|
#[serde(default = "default_popularity_bias")]
|
||||||
|
pub popularity_bias: u8,
|
||||||
|
#[serde(default = "default_ordering")]
|
||||||
|
pub ordering: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rules: Option<SmartRulesInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_playlist_count() -> usize {
|
||||||
|
50
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_popularity_bias() -> u8 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ordering() -> String {
|
||||||
|
"interleave".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SmartRulesInput {
|
||||||
|
#[serde(default)]
|
||||||
|
pub genres: Vec<String>,
|
||||||
|
pub added_within_days: Option<u32>,
|
||||||
|
pub year_range: Option<(i32, i32)>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artists: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct GeneratedPlaylist {
|
||||||
|
pub tracks: Vec<GeneratedTrack>,
|
||||||
|
pub strategy: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resolved_seeds: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct GeneratedTrack {
|
||||||
|
pub track_id: i32,
|
||||||
|
pub file_path: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub score: f64,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct PlaylistSummary {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub track_count: u64,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct PlaylistDetail {
|
||||||
|
pub playlist: SavedPlaylist,
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct SavedPlaylist {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subsonic ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct SubsonicPasswordStatus {
|
||||||
|
pub set: bool,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Config ---
|
// --- Config ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -270,6 +396,12 @@ pub struct AppConfig {
|
|||||||
pub download: DownloadConfigFe,
|
pub download: DownloadConfigFe,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub indexing: IndexingConfigFe,
|
pub indexing: IndexingConfigFe,
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: MetadataConfigFe,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scheduling: SchedulingConfigFe,
|
||||||
|
#[serde(default)]
|
||||||
|
pub musicbrainz: MusicBrainzConfigFe,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
@@ -302,6 +434,19 @@ pub struct DownloadConfigFe {
|
|||||||
pub rate_limit: u32,
|
pub rate_limit: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rate_limit_auth: u32,
|
pub rate_limit_auth: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cookie_refresh_enabled: bool,
|
||||||
|
#[serde(default = "default_cookie_refresh_hours")]
|
||||||
|
pub cookie_refresh_hours: u32,
|
||||||
|
#[serde(default = "default_vnc_port")]
|
||||||
|
pub vnc_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cookie_refresh_hours() -> u32 {
|
||||||
|
6
|
||||||
|
}
|
||||||
|
fn default_vnc_port() -> u16 {
|
||||||
|
6080
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
@@ -309,3 +454,97 @@ pub struct IndexingConfigFe {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub concurrency: usize,
|
pub concurrency: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MetadataConfigFe {
|
||||||
|
#[serde(default = "default_metadata_source")]
|
||||||
|
pub metadata_source: String,
|
||||||
|
#[serde(default = "default_artist_image_source")]
|
||||||
|
pub artist_image_source: String,
|
||||||
|
#[serde(default = "default_artist_bio_source")]
|
||||||
|
pub artist_bio_source: String,
|
||||||
|
#[serde(default = "default_lyrics_source")]
|
||||||
|
pub lyrics_source: String,
|
||||||
|
#[serde(default = "default_cover_art_source")]
|
||||||
|
pub cover_art_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MetadataConfigFe {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
metadata_source: default_metadata_source(),
|
||||||
|
artist_image_source: default_artist_image_source(),
|
||||||
|
artist_bio_source: default_artist_bio_source(),
|
||||||
|
lyrics_source: default_lyrics_source(),
|
||||||
|
cover_art_source: default_cover_art_source(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SchedulingConfigFe {
|
||||||
|
#[serde(default)]
|
||||||
|
pub pipeline_enabled: bool,
|
||||||
|
#[serde(default = "default_pipeline_interval_hours")]
|
||||||
|
pub pipeline_interval_hours: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub monitor_enabled: bool,
|
||||||
|
#[serde(default = "default_monitor_interval_hours")]
|
||||||
|
pub monitor_interval_hours: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_pipeline_interval_hours() -> u32 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
fn default_monitor_interval_hours() -> u32 {
|
||||||
|
12
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_metadata_source() -> String {
|
||||||
|
"musicbrainz".into()
|
||||||
|
}
|
||||||
|
fn default_artist_image_source() -> String {
|
||||||
|
"wikipedia".into()
|
||||||
|
}
|
||||||
|
fn default_artist_bio_source() -> String {
|
||||||
|
"wikipedia".into()
|
||||||
|
}
|
||||||
|
fn default_lyrics_source() -> String {
|
||||||
|
"lrclib".into()
|
||||||
|
}
|
||||||
|
fn default_cover_art_source() -> String {
|
||||||
|
"coverartarchive".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MusicBrainz local DB ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct MusicBrainzConfigFe {
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_db_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_update: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct MbStatus {
|
||||||
|
pub has_local_db: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stats: Option<MbLocalStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct MbLocalStats {
|
||||||
|
#[serde(default)]
|
||||||
|
pub artists: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub release_groups: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub releases: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recordings: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tracks: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_import_date: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,129 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.stat-card .value { font-size: 2rem; font-weight: bold; color: var(--accent); }
|
.stat-card .value { font-size: 2rem; font-weight: bold; color: var(--accent); }
|
||||||
.stat-card .label { font-size: 0.85rem; color: var(--text-secondary); }
|
.stat-card .label { font-size: 0.85rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Album art */
|
||||||
|
.album-art {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-card);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.album-art-lg {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.album-art-placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.album-art-placeholder-lg {
|
||||||
|
display: block;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.album-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.artist-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.artist-meta .tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.artist-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.artist-links a {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.artist-links a:hover { color: var(--accent); }
|
||||||
|
.artist-banner {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.artist-photo {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.artist-bio {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 800px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
z-index: 10;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.autocomplete-item {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.autocomplete-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.lyrics {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
|
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||||
@@ -151,6 +274,7 @@ input:focus, select:focus { outline: none; border-color: var(--accent); }
|
|||||||
.badge-pending { background: var(--text-muted); color: white; }
|
.badge-pending { background: var(--text-muted); color: white; }
|
||||||
.badge-failed { background: var(--danger); color: white; }
|
.badge-failed { background: var(--danger); color: white; }
|
||||||
.badge-completed { background: var(--success); color: white; }
|
.badge-completed { background: var(--success); color: white; }
|
||||||
|
.badge-success { background: var(--success); color: white; }
|
||||||
|
|
||||||
/* Task table fixed column widths */
|
/* Task table fixed column widths */
|
||||||
table.tasks-table { table-layout: fixed; }
|
table.tasks-table { table-layout: fixed; }
|
||||||
@@ -199,6 +323,18 @@ table.tasks-table td { overflow: hidden; text-overflow: ellipsis; }
|
|||||||
.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; }
|
.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; }
|
||||||
.auth-card p { margin-bottom: 1.5rem; }
|
.auth-card p { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
/* Tab bar */
|
||||||
|
.tab-bar { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid var(--border); }
|
||||||
|
.tab-btn { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; font-size: 0.9rem; }
|
||||||
|
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
.tab-btn:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Drag and drop */
|
||||||
|
.drag-handle { cursor: grab; user-select: none; color: var(--text-muted); padding-right: 0.5rem; font-size: 1.2rem; }
|
||||||
|
tr.drag-over { background: rgba(59, 130, 246, 0.1); }
|
||||||
|
tr[draggable="true"] { cursor: grab; }
|
||||||
|
tr[draggable="true"]:active { cursor: grabbing; }
|
||||||
|
|
||||||
/* Sidebar user section */
|
/* Sidebar user section */
|
||||||
.sidebar-user {
|
.sidebar-user {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ pub fn get_session_user(session: &Session) -> Option<(i32, String, String)> {
|
|||||||
|
|
||||||
/// Require authentication. Returns (user_id, username, role) or 401.
|
/// Require authentication. Returns (user_id, username, role) or 401.
|
||||||
pub fn require_auth(session: &Session) -> Result<(i32, String, String), ApiError> {
|
pub fn require_auth(session: &Session) -> Result<(i32, String, String), ApiError> {
|
||||||
get_session_user(session)
|
get_session_user(session).ok_or_else(|| ApiError::Unauthorized("not logged in".into()))
|
||||||
.ok_or_else(|| ApiError::Unauthorized("not logged in".into()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Require admin role. Returns (user_id, username, role) or 403.
|
/// Require admin role. Returns (user_id, username, role) or 403.
|
||||||
|
|||||||
107
src/cookie_refresh.rs
Normal file
107
src/cookie_refresh.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//! Background task that periodically refreshes YouTube cookies via headless Firefox.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
|
/// Spawn the cookie refresh background loop.
|
||||||
|
///
|
||||||
|
/// This task runs forever, sleeping for `cookie_refresh_hours` between refreshes.
|
||||||
|
/// It reads the current config on each iteration so changes take effect without restart.
|
||||||
|
pub fn spawn(config: Arc<RwLock<AppConfig>>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (enabled, hours) = {
|
||||||
|
let cfg = config.read().await;
|
||||||
|
(
|
||||||
|
cfg.download.cookie_refresh_enabled,
|
||||||
|
cfg.download.cookie_refresh_hours.max(1),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sleep for the configured interval
|
||||||
|
tokio::time::sleep(Duration::from_secs(u64::from(hours) * 3600)).await;
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile_dir = shanty_config::data_dir().join("firefox-profile");
|
||||||
|
let cookies_path = shanty_config::data_dir().join("cookies.txt");
|
||||||
|
|
||||||
|
if !profile_dir.exists() {
|
||||||
|
tracing::warn!(
|
||||||
|
"cookie refresh skipped: no Firefox profile at {}",
|
||||||
|
profile_dir.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("starting cookie refresh");
|
||||||
|
|
||||||
|
match run_refresh(&profile_dir, &cookies_path).await {
|
||||||
|
Ok(msg) => tracing::info!("cookie refresh complete: {msg}"),
|
||||||
|
Err(e) => tracing::error!("cookie refresh failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_refresh(profile_dir: &Path, cookies_path: &Path) -> Result<String, String> {
|
||||||
|
let script = find_script()?;
|
||||||
|
|
||||||
|
let output = Command::new("python3")
|
||||||
|
.arg(&script)
|
||||||
|
.args([
|
||||||
|
"refresh",
|
||||||
|
&profile_dir.to_string_lossy(),
|
||||||
|
&cookies_path.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to run cookie_manager.py: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("cookie_manager.py failed: {stderr}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
|
||||||
|
// Check for error in JSON response
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&stdout)
|
||||||
|
&& v.get("status").and_then(|s| s.as_str()) == Some("error")
|
||||||
|
{
|
||||||
|
let err = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown");
|
||||||
|
return Err(err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_script() -> Result<PathBuf, String> {
|
||||||
|
let candidates = [
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.join("cookie_manager.py"))),
|
||||||
|
Some(PathBuf::from("/usr/share/shanty/cookie_manager.py")),
|
||||||
|
Some(PathBuf::from("/usr/local/share/shanty/cookie_manager.py")),
|
||||||
|
Some(PathBuf::from("shanty-dl/scripts/cookie_manager.py")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for candidate in candidates.into_iter().flatten() {
|
||||||
|
if candidate.exists() {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("cookie_manager.py not found".into())
|
||||||
|
}
|
||||||
@@ -97,6 +97,12 @@ impl From<shanty_tag::TagError> for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<shanty_data::DataError> for ApiError {
|
||||||
|
fn from(e: shanty_data::DataError) -> Self {
|
||||||
|
ApiError::Internal(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<shanty_org::OrgError> for ApiError {
|
impl From<shanty_org::OrgError> for ApiError {
|
||||||
fn from(e: shanty_org::OrgError) -> Self {
|
fn from(e: shanty_org::OrgError) -> Self {
|
||||||
ApiError::Internal(e.to_string())
|
ApiError::Internal(e.to_string())
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
//! Web interface backend for Shanty.
|
//! Web interface backend for Shanty.
|
||||||
//!
|
//!
|
||||||
//! An Actix-web server that ties all Shanty components together, exposing a REST
|
//! An Actix-web server that ties all Shanty components together, exposing a REST
|
||||||
//! API consumed by the Elm frontend. Handles background tasks, configuration,
|
//! API consumed by the Yew (WASM) frontend. Handles background tasks, configuration,
|
||||||
//! and orchestration of indexing, tagging, downloading, and more.
|
//! and orchestration of indexing, tagging, downloading, and more.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod cookie_refresh;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod mb_update;
|
||||||
|
pub mod monitor;
|
||||||
|
pub mod pipeline;
|
||||||
|
pub mod pipeline_scheduler;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
|
|||||||
94
src/main.rs
94
src/main.rs
@@ -5,9 +5,10 @@ use clap::Parser;
|
|||||||
use tracing_actix_web::TracingLogger;
|
use tracing_actix_web::TracingLogger;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use shanty_data::WikipediaFetcher;
|
||||||
|
use shanty_data::{HybridMusicBrainzFetcher, LocalMusicBrainzFetcher, MusicBrainzFetcher};
|
||||||
use shanty_db::Database;
|
use shanty_db::Database;
|
||||||
use shanty_search::MusicBrainzSearch;
|
use shanty_search::MusicBrainzSearch;
|
||||||
use shanty_tag::MusicBrainzClient;
|
|
||||||
|
|
||||||
use shanty_web::config::AppConfig;
|
use shanty_web::config::AppConfig;
|
||||||
use shanty_web::routes;
|
use shanty_web::routes;
|
||||||
@@ -53,8 +54,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!(url = %config.database_url, "connecting to database");
|
tracing::info!(url = %config.database_url, "connecting to database");
|
||||||
let db = Database::new(&config.database_url).await?;
|
let db = Database::new(&config.database_url).await?;
|
||||||
|
|
||||||
let mb_client = MusicBrainzClient::new()?;
|
let mb_remote = MusicBrainzFetcher::new()?;
|
||||||
let search = MusicBrainzSearch::new()?;
|
let search = MusicBrainzSearch::with_limiter(mb_remote.limiter())?;
|
||||||
|
|
||||||
|
// Set up local MB database if configured
|
||||||
|
let local_mb = create_local_mb_fetcher(&config);
|
||||||
|
let mb_client = HybridMusicBrainzFetcher::new(local_mb, mb_remote);
|
||||||
|
|
||||||
|
if mb_client.has_local_db()
|
||||||
|
&& let Some(stats) = mb_client.local_stats()
|
||||||
|
{
|
||||||
|
tracing::info!(
|
||||||
|
artists = stats.artists,
|
||||||
|
release_groups = stats.release_groups,
|
||||||
|
"local MusicBrainz database loaded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let wiki_fetcher = WikipediaFetcher::new()?;
|
||||||
|
|
||||||
let bind = format!("{}:{}", config.web.bind, config.web.port);
|
let bind = format!("{}:{}", config.web.bind, config.web.port);
|
||||||
tracing::info!(bind = %bind, "starting server");
|
tracing::info!(bind = %bind, "starting server");
|
||||||
@@ -64,11 +81,27 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
db,
|
db,
|
||||||
mb_client,
|
mb_client,
|
||||||
search,
|
search,
|
||||||
|
wiki_fetcher,
|
||||||
config: std::sync::Arc::new(tokio::sync::RwLock::new(config)),
|
config: std::sync::Arc::new(tokio::sync::RwLock::new(config)),
|
||||||
config_path,
|
config_path,
|
||||||
tasks: TaskManager::new(),
|
tasks: TaskManager::new(),
|
||||||
|
firefox_login: tokio::sync::Mutex::new(None),
|
||||||
|
scheduler: tokio::sync::Mutex::new(shanty_web::state::SchedulerInfo {
|
||||||
|
next_pipeline: None,
|
||||||
|
next_monitor: None,
|
||||||
|
skip_pipeline: false,
|
||||||
|
skip_monitor: false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start background cookie refresh task
|
||||||
|
shanty_web::cookie_refresh::spawn(state.config.clone());
|
||||||
|
|
||||||
|
// Start pipeline and monitor schedulers
|
||||||
|
shanty_web::pipeline_scheduler::spawn(state.clone());
|
||||||
|
shanty_web::monitor::spawn(state.clone());
|
||||||
|
shanty_web::mb_update::spawn(state.clone());
|
||||||
|
|
||||||
// Resolve static files directory relative to the binary location
|
// Resolve static files directory relative to the binary location
|
||||||
let static_dir = std::env::current_exe()
|
let static_dir = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -98,25 +131,35 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(SessionMiddleware::builder(
|
.wrap(
|
||||||
CookieSessionStore::default(),
|
SessionMiddleware::builder(CookieSessionStore::default(), session_key.clone())
|
||||||
session_key.clone(),
|
.cookie_secure(false)
|
||||||
).cookie_secure(false).build())
|
.build(),
|
||||||
|
)
|
||||||
.wrap(TracingLogger::default())
|
.wrap(TracingLogger::default())
|
||||||
.app_data(state.clone())
|
.app_data(state.clone())
|
||||||
.configure(routes::configure)
|
.configure(routes::configure)
|
||||||
.service(
|
.service(
|
||||||
actix_files::Files::new("/", static_dir.clone())
|
actix_files::Files::new("/", static_dir.clone())
|
||||||
.index_file("index.html")
|
.index_file("index.html")
|
||||||
.prefer_utf8(true),
|
.prefer_utf8(true)
|
||||||
|
.guard(actix_web::guard::fn_guard(|ctx| {
|
||||||
|
!ctx.head().uri.path().starts_with("/rest")
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
// SPA fallback: serve index.html for any route not matched
|
// SPA fallback: serve index.html for any route not matched
|
||||||
// by API or static files, so client-side routing works on refresh
|
// by API or static files, so client-side routing works on refresh.
|
||||||
|
// /rest/* paths get a Subsonic error instead of index.html.
|
||||||
.default_service(web::to({
|
.default_service(web::to({
|
||||||
let index_path = static_dir.join("index.html");
|
let index_path = static_dir.join("index.html");
|
||||||
move |req: actix_web::HttpRequest| {
|
move |req: actix_web::HttpRequest| {
|
||||||
let index_path = index_path.clone();
|
let index_path = index_path.clone();
|
||||||
async move {
|
async move {
|
||||||
|
if req.path().starts_with("/rest") {
|
||||||
|
return Ok(actix_web::HttpResponse::NotFound()
|
||||||
|
.content_type("application/json")
|
||||||
|
.body(r#"{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":0,"message":"Unknown endpoint"}}}"#));
|
||||||
|
}
|
||||||
actix_files::NamedFile::open_async(index_path)
|
actix_files::NamedFile::open_async(index_path)
|
||||||
.await
|
.await
|
||||||
.map(|f| f.into_response(&req))
|
.map(|f| f.into_response(&req))
|
||||||
@@ -130,3 +173,36 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a LocalMusicBrainzFetcher from config if available.
|
||||||
|
fn create_local_mb_fetcher(config: &AppConfig) -> Option<LocalMusicBrainzFetcher> {
|
||||||
|
let db_path = config
|
||||||
|
.musicbrainz
|
||||||
|
.local_db_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
let default_path = shanty_config::data_dir().join("shanty-mb.db");
|
||||||
|
if default_path.exists() {
|
||||||
|
Some(default_path.to_string_lossy().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match LocalMusicBrainzFetcher::new(&db_path) {
|
||||||
|
Ok(fetcher) => {
|
||||||
|
if fetcher.is_available() {
|
||||||
|
tracing::info!(path = %db_path, "opened local MusicBrainz database");
|
||||||
|
Some(fetcher)
|
||||||
|
} else {
|
||||||
|
tracing::debug!(path = %db_path, "local MB database exists but has no data");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %db_path, error = %e, "failed to open local MB database");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
120
src/mb_update.rs
Normal file
120
src/mb_update.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
//! Background task that periodically re-imports the MusicBrainz database.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use actix_web::web;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Spawn the weekly MB database update loop.
|
||||||
|
///
|
||||||
|
/// Only runs if a local MB database exists (meaning the user has done an initial import).
|
||||||
|
/// Downloads fresh dumps and re-imports weekly.
|
||||||
|
pub fn spawn(state: web::Data<AppState>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Wait 1 hour after startup before first check
|
||||||
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Check if local DB exists and auto-update is desired
|
||||||
|
let has_local = state.mb_client.has_local_db();
|
||||||
|
if !has_local {
|
||||||
|
// No local DB — sleep a day and check again
|
||||||
|
tokio::time::sleep(Duration::from_secs(86400)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check how old the import is
|
||||||
|
let needs_update = state
|
||||||
|
.mb_client
|
||||||
|
.local_stats()
|
||||||
|
.and_then(|s| s.last_import_date)
|
||||||
|
.map(|date| {
|
||||||
|
// Parse the date and check if it's older than 7 days
|
||||||
|
chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")
|
||||||
|
.map(|d| {
|
||||||
|
let age = chrono::Utc::now().naive_utc().date() - d;
|
||||||
|
age.num_days() >= 7
|
||||||
|
})
|
||||||
|
.unwrap_or(true) // If we can't parse the date, update
|
||||||
|
})
|
||||||
|
.unwrap_or(false); // No stats = no local DB = skip
|
||||||
|
|
||||||
|
if !needs_update {
|
||||||
|
// Check again in 24 hours
|
||||||
|
tokio::time::sleep(Duration::from_secs(86400)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("starting weekly MusicBrainz database update");
|
||||||
|
|
||||||
|
let data_dir = shanty_config::data_dir().join("mb-dumps");
|
||||||
|
let db_path = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.musicbrainz
|
||||||
|
.local_db_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| shanty_config::data_dir().join("shanty-mb.db"));
|
||||||
|
|
||||||
|
// Download fresh dumps
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||||
|
tracing::error!(error = %e, "failed to create dump dir for MB update");
|
||||||
|
tokio::time::sleep(Duration::from_secs(86400)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = match shanty_data::mb_import::discover_latest_dump_folder().await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "failed to discover latest MB dump");
|
||||||
|
tokio::time::sleep(Duration::from_secs(86400)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut download_failed = false;
|
||||||
|
for filename in shanty_data::mb_import::DUMP_FILES {
|
||||||
|
if let Err(e) =
|
||||||
|
shanty_data::mb_import::download_dump(filename, ×tamp, &data_dir, |msg| {
|
||||||
|
tracing::info!("{msg}");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(file = filename, error = %e, "MB dump download failed");
|
||||||
|
download_failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if download_failed {
|
||||||
|
tokio::time::sleep(Duration::from_secs(86400)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run import in blocking task
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
shanty_data::mb_import::run_import_at_path(&db_path, &data_dir, |msg| {
|
||||||
|
tracing::info!("{msg}");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(stats)) => {
|
||||||
|
tracing::info!(%stats, "weekly MusicBrainz update complete");
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::error!(error = %e, "weekly MusicBrainz import failed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "weekly MusicBrainz import task panicked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep 7 days before next check
|
||||||
|
tokio::time::sleep(Duration::from_secs(7 * 86400)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
264
src/monitor.rs
Normal file
264
src/monitor.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//! Artist monitoring — periodically checks for new releases from monitored artists
|
||||||
|
//! and automatically adds them to the watchlist.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use actix_web::web;
|
||||||
|
use serde::Serialize;
|
||||||
|
use shanty_data::MetadataFetcher;
|
||||||
|
use shanty_db::queries;
|
||||||
|
use shanty_search::SearchProvider;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Stats returned from a monitor check run.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MonitorStats {
|
||||||
|
pub artists_checked: u32,
|
||||||
|
pub new_releases_found: u32,
|
||||||
|
pub tracks_added: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check all monitored artists for new releases and add them to the watchlist.
|
||||||
|
pub async fn check_monitored_artists(
|
||||||
|
state: &web::Data<AppState>,
|
||||||
|
) -> Result<MonitorStats, ApiError> {
|
||||||
|
let monitored = queries::artists::list_monitored(state.db.conn()).await?;
|
||||||
|
|
||||||
|
let mut stats = MonitorStats {
|
||||||
|
artists_checked: 0,
|
||||||
|
new_releases_found: 0,
|
||||||
|
tracks_added: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let allowed = state.config.read().await.allowed_secondary_types.clone();
|
||||||
|
|
||||||
|
for artist in &monitored {
|
||||||
|
let mbid = match &artist.musicbrainz_id {
|
||||||
|
Some(m) => m.clone(),
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
artist_id = artist.id,
|
||||||
|
name = %artist.name,
|
||||||
|
"monitored artist has no MBID, skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.artists_checked += 1;
|
||||||
|
|
||||||
|
// Fetch release groups from MusicBrainz
|
||||||
|
let all_release_groups = match state.search.get_release_groups(&mbid).await {
|
||||||
|
Ok(rgs) => rgs,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
artist = %artist.name,
|
||||||
|
error = %e,
|
||||||
|
"failed to fetch release groups for monitored artist"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by allowed secondary types (same logic as enrich_artist)
|
||||||
|
let release_groups: Vec<_> = all_release_groups
|
||||||
|
.into_iter()
|
||||||
|
.filter(|rg| {
|
||||||
|
if rg.secondary_types.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
rg.secondary_types.iter().all(|st| allowed.contains(st))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Get all existing wanted items for this artist
|
||||||
|
let artist_wanted = queries::wanted::list(state.db.conn(), None, None).await?;
|
||||||
|
let wanted_mbids: HashSet<String> = artist_wanted
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.artist_id == Some(artist.id))
|
||||||
|
.filter_map(|w| w.musicbrainz_id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Check each release group's tracks to find new ones
|
||||||
|
for rg in &release_groups {
|
||||||
|
// Check if we already have any tracks from this release group cached
|
||||||
|
let cache_key = format!("artist_rg_tracks:{}", rg.id);
|
||||||
|
let cached_tracks: Option<Vec<String>> =
|
||||||
|
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
|
||||||
|
// Parse the cached data to get recording MBIDs
|
||||||
|
if let Ok(cached) = serde_json::from_str::<serde_json::Value>(&json) {
|
||||||
|
cached
|
||||||
|
.get("tracks")
|
||||||
|
.and_then(|t| t.as_array())
|
||||||
|
.map(|tracks| {
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| {
|
||||||
|
t.get("recording_mbid")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_mbids = if let Some(mbids) = cached_tracks {
|
||||||
|
mbids
|
||||||
|
} else {
|
||||||
|
// Not cached — resolve release and fetch tracks (rate limited by shared MB client)
|
||||||
|
let release_mbid = if let Some(ref rid) = rg.first_release_id {
|
||||||
|
rid.clone()
|
||||||
|
} else {
|
||||||
|
// Resolve from release group (goes through shared rate limiter)
|
||||||
|
match state.mb_client.resolve_release_from_group(&rg.id).await {
|
||||||
|
Ok(rid) => rid,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(rg_id = %rg.id, error = %e, "skipping release group");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.mb_client.get_release_tracks(&release_mbid).await {
|
||||||
|
Ok(tracks) => tracks.into_iter().map(|t| t.recording_mbid).collect(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
release = %release_mbid,
|
||||||
|
error = %e,
|
||||||
|
"failed to fetch tracks"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any of these tracks are NOT in the wanted items
|
||||||
|
let new_mbids: Vec<&String> = track_mbids
|
||||||
|
.iter()
|
||||||
|
.filter(|mbid| !wanted_mbids.contains(*mbid))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if new_mbids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found a release group with new tracks — add the whole album via shanty_watch
|
||||||
|
tracing::info!(
|
||||||
|
artist = %artist.name,
|
||||||
|
album = %rg.title,
|
||||||
|
new_tracks = new_mbids.len(),
|
||||||
|
"new release detected for monitored artist"
|
||||||
|
);
|
||||||
|
|
||||||
|
stats.new_releases_found += 1;
|
||||||
|
|
||||||
|
match shanty_watch::add_album(
|
||||||
|
state.db.conn(),
|
||||||
|
Some(&artist.name),
|
||||||
|
Some(&rg.title),
|
||||||
|
rg.first_release_id.as_deref(),
|
||||||
|
&state.mb_client,
|
||||||
|
None, // system-initiated, no user_id
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(summary) => {
|
||||||
|
stats.tracks_added += summary.tracks_added as u32;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
artist = %artist.name,
|
||||||
|
album = %rg.title,
|
||||||
|
error = %e,
|
||||||
|
"failed to add album for monitored artist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_checked_at
|
||||||
|
if let Err(e) = queries::artists::update_last_checked(state.db.conn(), artist.id).await {
|
||||||
|
tracing::warn!(
|
||||||
|
artist_id = artist.id,
|
||||||
|
error = %e,
|
||||||
|
"failed to update last_checked_at"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the monitor scheduler background loop.
|
||||||
|
///
|
||||||
|
/// Sleeps for the configured interval, then checks monitored artists if enabled.
|
||||||
|
/// Reads config each iteration so changes take effect without restart.
|
||||||
|
pub fn spawn(state: web::Data<AppState>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (enabled, hours) = {
|
||||||
|
let cfg = state.config.read().await;
|
||||||
|
(
|
||||||
|
cfg.scheduling.monitor_enabled,
|
||||||
|
cfg.scheduling.monitor_interval_hours.max(1),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let sleep_secs = u64::from(hours) * 3600;
|
||||||
|
|
||||||
|
// Update next-run time
|
||||||
|
{
|
||||||
|
let mut sched = state.scheduler.lock().await;
|
||||||
|
sched.next_monitor = if enabled {
|
||||||
|
Some(
|
||||||
|
(chrono::Utc::now() + chrono::Duration::seconds(sleep_secs as i64))
|
||||||
|
.naive_utc(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(sleep_secs)).await;
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this run was skipped
|
||||||
|
{
|
||||||
|
let mut sched = state.scheduler.lock().await;
|
||||||
|
sched.next_monitor = None;
|
||||||
|
if sched.skip_monitor {
|
||||||
|
sched.skip_monitor = false;
|
||||||
|
tracing::info!("scheduled monitor check skipped (user cancelled)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("scheduled monitor check starting");
|
||||||
|
match check_monitored_artists(&state).await {
|
||||||
|
Ok(stats) => {
|
||||||
|
tracing::info!(
|
||||||
|
artists_checked = stats.artists_checked,
|
||||||
|
new_releases = stats.new_releases_found,
|
||||||
|
tracks_added = stats.tracks_added,
|
||||||
|
"scheduled monitor check complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "scheduled monitor check failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
218
src/pipeline.rs
Normal file
218
src/pipeline.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//! Shared pipeline logic used by both the API endpoint and the scheduler.
|
||||||
|
|
||||||
|
use actix_web::web;
|
||||||
|
use shanty_db::queries;
|
||||||
|
|
||||||
|
use crate::routes::artists::enrich_all_watched_artists;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Register and spawn the full 6-step pipeline. Returns the task IDs immediately.
|
||||||
|
///
|
||||||
|
/// Steps: sync, download, index, tag, organize, enrich.
|
||||||
|
pub fn spawn_pipeline(state: &web::Data<AppState>) -> Vec<String> {
|
||||||
|
let sync_id = state.tasks.register_pending("sync");
|
||||||
|
let download_id = state.tasks.register_pending("download");
|
||||||
|
let index_id = state.tasks.register_pending("index");
|
||||||
|
let tag_id = state.tasks.register_pending("tag");
|
||||||
|
let organize_id = state.tasks.register_pending("organize");
|
||||||
|
let enrich_id = state.tasks.register_pending("enrich");
|
||||||
|
|
||||||
|
let task_ids = vec![
|
||||||
|
sync_id.clone(),
|
||||||
|
download_id.clone(),
|
||||||
|
index_id.clone(),
|
||||||
|
tag_id.clone(),
|
||||||
|
organize_id.clone(),
|
||||||
|
enrich_id.clone(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_pipeline_inner(
|
||||||
|
&state,
|
||||||
|
&sync_id,
|
||||||
|
&download_id,
|
||||||
|
&index_id,
|
||||||
|
&tag_id,
|
||||||
|
&organize_id,
|
||||||
|
&enrich_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
task_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the pipeline without registering tasks (for the scheduler, which logs instead).
|
||||||
|
pub async fn run_pipeline(state: &web::Data<AppState>) -> Vec<String> {
|
||||||
|
let sync_id = state.tasks.register_pending("sync");
|
||||||
|
let download_id = state.tasks.register_pending("download");
|
||||||
|
let index_id = state.tasks.register_pending("index");
|
||||||
|
let tag_id = state.tasks.register_pending("tag");
|
||||||
|
let organize_id = state.tasks.register_pending("organize");
|
||||||
|
let enrich_id = state.tasks.register_pending("enrich");
|
||||||
|
|
||||||
|
let task_ids = vec![
|
||||||
|
sync_id.clone(),
|
||||||
|
download_id.clone(),
|
||||||
|
index_id.clone(),
|
||||||
|
tag_id.clone(),
|
||||||
|
organize_id.clone(),
|
||||||
|
enrich_id.clone(),
|
||||||
|
];
|
||||||
|
|
||||||
|
run_pipeline_inner(
|
||||||
|
state,
|
||||||
|
&sync_id,
|
||||||
|
&download_id,
|
||||||
|
&index_id,
|
||||||
|
&tag_id,
|
||||||
|
&organize_id,
|
||||||
|
&enrich_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
task_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_pipeline_inner(
|
||||||
|
state: &web::Data<AppState>,
|
||||||
|
sync_id: &str,
|
||||||
|
download_id: &str,
|
||||||
|
index_id: &str,
|
||||||
|
tag_id: &str,
|
||||||
|
organize_id: &str,
|
||||||
|
enrich_id: &str,
|
||||||
|
) {
|
||||||
|
let cfg = state.config.read().await.clone();
|
||||||
|
|
||||||
|
// Step 1: Sync
|
||||||
|
state.tasks.start(sync_id);
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(sync_id, 0, 0, "Syncing watchlist to download queue...");
|
||||||
|
match shanty_dl::sync_wanted_to_queue(state.db.conn(), false).await {
|
||||||
|
Ok(stats) => state.tasks.complete(sync_id, format!("{stats}")),
|
||||||
|
Err(e) => state.tasks.fail(sync_id, e.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Download
|
||||||
|
state.tasks.start(download_id);
|
||||||
|
let cookies = cfg.download.cookies_path.clone();
|
||||||
|
let format: shanty_dl::AudioFormat = cfg
|
||||||
|
.download
|
||||||
|
.format
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(shanty_dl::AudioFormat::Opus);
|
||||||
|
let source: shanty_dl::SearchSource = cfg
|
||||||
|
.download
|
||||||
|
.search_source
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(shanty_dl::SearchSource::YouTubeMusic);
|
||||||
|
let rate = if cookies.is_some() {
|
||||||
|
cfg.download.rate_limit_auth
|
||||||
|
} else {
|
||||||
|
cfg.download.rate_limit
|
||||||
|
};
|
||||||
|
let backend = shanty_dl::YtDlpBackend::new(rate, source, cookies.clone());
|
||||||
|
let backend_config = shanty_dl::BackendConfig {
|
||||||
|
output_dir: cfg.download_path.clone(),
|
||||||
|
format,
|
||||||
|
cookies_path: cookies,
|
||||||
|
};
|
||||||
|
let task_state = state.clone();
|
||||||
|
let progress_tid = download_id.to_string();
|
||||||
|
let on_progress: shanty_dl::ProgressFn = Box::new(move |current, total, msg| {
|
||||||
|
task_state
|
||||||
|
.tasks
|
||||||
|
.update_progress(&progress_tid, current, total, msg);
|
||||||
|
});
|
||||||
|
match shanty_dl::run_queue_with_progress(
|
||||||
|
state.db.conn(),
|
||||||
|
&backend,
|
||||||
|
&backend_config,
|
||||||
|
false,
|
||||||
|
Some(on_progress),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stats) => {
|
||||||
|
let _ = queries::cache::purge_prefix(state.db.conn(), "artist_totals:").await;
|
||||||
|
state.tasks.complete(download_id, format!("{stats}"));
|
||||||
|
}
|
||||||
|
Err(e) => state.tasks.fail(download_id, e.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Index
|
||||||
|
state.tasks.start(index_id);
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(index_id, 0, 0, "Scanning library...");
|
||||||
|
let scan_config = shanty_index::ScanConfig {
|
||||||
|
root: cfg.library_path.clone(),
|
||||||
|
dry_run: false,
|
||||||
|
concurrency: cfg.indexing.concurrency,
|
||||||
|
};
|
||||||
|
match shanty_index::run_scan(state.db.conn(), &scan_config).await {
|
||||||
|
Ok(stats) => state.tasks.complete(index_id, format!("{stats}")),
|
||||||
|
Err(e) => state.tasks.fail(index_id, e.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Tag
|
||||||
|
state.tasks.start(tag_id);
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(tag_id, 0, 0, "Tagging tracks...");
|
||||||
|
match shanty_tag::MusicBrainzClient::new() {
|
||||||
|
Ok(mb) => {
|
||||||
|
let tag_config = shanty_tag::TagConfig {
|
||||||
|
dry_run: false,
|
||||||
|
write_tags: cfg.tagging.write_tags,
|
||||||
|
confidence: cfg.tagging.confidence,
|
||||||
|
};
|
||||||
|
match shanty_tag::run_tagging(state.db.conn(), &mb, &tag_config, None).await {
|
||||||
|
Ok(stats) => state.tasks.complete(tag_id, format!("{stats}")),
|
||||||
|
Err(e) => state.tasks.fail(tag_id, e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => state.tasks.fail(tag_id, e.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Organize
|
||||||
|
state.tasks.start(organize_id);
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(organize_id, 0, 0, "Organizing files...");
|
||||||
|
let org_config = shanty_org::OrgConfig {
|
||||||
|
target_dir: cfg.library_path.clone(),
|
||||||
|
format: cfg.organization_format.clone(),
|
||||||
|
dry_run: false,
|
||||||
|
copy: false,
|
||||||
|
};
|
||||||
|
match shanty_org::organize_from_db(state.db.conn(), &org_config).await {
|
||||||
|
Ok(stats) => {
|
||||||
|
let promoted = queries::wanted::promote_downloaded_to_owned(state.db.conn())
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
let msg = if promoted > 0 {
|
||||||
|
format!("{stats} — {promoted} items marked as owned")
|
||||||
|
} else {
|
||||||
|
format!("{stats}")
|
||||||
|
};
|
||||||
|
state.tasks.complete(organize_id, msg);
|
||||||
|
}
|
||||||
|
Err(e) => state.tasks.fail(organize_id, e.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Enrich
|
||||||
|
state.tasks.start(enrich_id);
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(enrich_id, 0, 0, "Refreshing artist data...");
|
||||||
|
match enrich_all_watched_artists(state).await {
|
||||||
|
Ok(count) => state
|
||||||
|
.tasks
|
||||||
|
.complete(enrich_id, format!("{count} artists refreshed")),
|
||||||
|
Err(e) => state.tasks.fail(enrich_id, e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/pipeline_scheduler.rs
Normal file
59
src/pipeline_scheduler.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//! Background task that runs the full pipeline on a configurable interval.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use actix_web::web;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Spawn the pipeline scheduler background loop.
|
||||||
|
///
|
||||||
|
/// Sleeps for the configured interval, then runs the full pipeline if enabled.
|
||||||
|
/// Reads config each iteration so changes take effect without restart.
|
||||||
|
pub fn spawn(state: web::Data<AppState>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (enabled, hours) = {
|
||||||
|
let cfg = state.config.read().await;
|
||||||
|
(
|
||||||
|
cfg.scheduling.pipeline_enabled,
|
||||||
|
cfg.scheduling.pipeline_interval_hours.max(1),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let sleep_secs = u64::from(hours) * 3600;
|
||||||
|
|
||||||
|
// Update next-run time
|
||||||
|
{
|
||||||
|
let mut sched = state.scheduler.lock().await;
|
||||||
|
sched.next_pipeline = if enabled {
|
||||||
|
Some((Utc::now() + chrono::Duration::seconds(sleep_secs as i64)).naive_utc())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(sleep_secs)).await;
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this run was skipped
|
||||||
|
{
|
||||||
|
let mut sched = state.scheduler.lock().await;
|
||||||
|
sched.next_pipeline = None;
|
||||||
|
if sched.skip_pipeline {
|
||||||
|
sched.skip_pipeline = false;
|
||||||
|
tracing::info!("scheduled pipeline skipped (user cancelled)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("scheduled pipeline starting");
|
||||||
|
let task_ids = crate::pipeline::run_pipeline(&state).await;
|
||||||
|
tracing::info!(?task_ids, "scheduled pipeline complete");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ use actix_session::Session;
|
|||||||
use actix_web::{HttpResponse, web};
|
use actix_web::{HttpResponse, web};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use shanty_data::MetadataFetcher;
|
||||||
use shanty_db::entities::wanted_item::WantedStatus;
|
use shanty_db::entities::wanted_item::WantedStatus;
|
||||||
use shanty_db::queries;
|
use shanty_db::queries;
|
||||||
use shanty_tag::provider::MetadataProvider;
|
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
@@ -116,51 +116,18 @@ async fn get_album(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Given a release-group MBID, find the first release MBID via the MB API.
|
/// Given a release-group MBID, find the first release MBID via the shared MB client.
|
||||||
async fn resolve_release_from_group(
|
async fn resolve_release_from_group(
|
||||||
_state: &web::Data<AppState>,
|
state: &web::Data<AppState>,
|
||||||
release_group_mbid: &str,
|
release_group_mbid: &str,
|
||||||
) -> Result<String, ApiError> {
|
) -> Result<String, ApiError> {
|
||||||
// Use the MB client's get_json (it's private, so we go through search)
|
state
|
||||||
// The approach: search for releases by this release group
|
.mb_client
|
||||||
// MB API: /ws/2/release?release-group={mbid}&fmt=json&limit=1
|
.resolve_release_from_group(release_group_mbid)
|
||||||
// Since we can't call get_json directly, use the artist_releases approach
|
|
||||||
// to find a release that matches this group.
|
|
||||||
//
|
|
||||||
// Actually, the simplest: the MetadataProvider trait has get_artist_releases
|
|
||||||
// which returns releases, but we need releases for a release GROUP.
|
|
||||||
// Let's add a direct HTTP call here via reqwest.
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Respect rate limiting by going through a small delay
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("Shanty/0.1.0 (shanty-music-app)")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let resp: serde_json::Value = client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
.map_err(|e| {
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
resp.get("releases")
|
|
||||||
.and_then(|r| r.as_array())
|
|
||||||
.and_then(|arr| arr.first())
|
|
||||||
.and_then(|r| r.get("id"))
|
|
||||||
.and_then(|id| id.as_str())
|
|
||||||
.map(String::from)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::NotFound(format!(
|
ApiError::NotFound(format!(
|
||||||
"no releases found for release group {release_group_mbid}"
|
"no releases found for release group {release_group_mbid}: {e}"
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use actix_session::Session;
|
|||||||
use actix_web::{HttpResponse, web};
|
use actix_web::{HttpResponse, web};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use shanty_data::{ArtistBioFetcher, ArtistImageFetcher, MetadataFetcher};
|
||||||
use shanty_db::entities::wanted_item::WantedStatus;
|
use shanty_db::entities::wanted_item::WantedStatus;
|
||||||
use shanty_db::queries;
|
use shanty_db::queries;
|
||||||
use shanty_search::SearchProvider;
|
use shanty_search::SearchProvider;
|
||||||
use shanty_tag::provider::MetadataProvider;
|
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
@@ -33,6 +33,7 @@ struct ArtistListItem {
|
|||||||
id: i32,
|
id: i32,
|
||||||
name: String,
|
name: String,
|
||||||
musicbrainz_id: Option<String>,
|
musicbrainz_id: Option<String>,
|
||||||
|
monitored: bool,
|
||||||
total_watched: usize,
|
total_watched: usize,
|
||||||
total_owned: usize,
|
total_owned: usize,
|
||||||
total_items: usize,
|
total_items: usize,
|
||||||
@@ -72,6 +73,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::post().to(add_artist)),
|
.route(web::post().to(add_artist)),
|
||||||
)
|
)
|
||||||
.service(web::resource("/artists/{id}/full").route(web::get().to(get_artist_full)))
|
.service(web::resource("/artists/{id}/full").route(web::get().to(get_artist_full)))
|
||||||
|
.service(
|
||||||
|
web::resource("/artists/{id}/monitor")
|
||||||
|
.route(web::post().to(set_monitored))
|
||||||
|
.route(web::delete().to(unset_monitored)),
|
||||||
|
)
|
||||||
.service(
|
.service(
|
||||||
web::resource("/artists/{id}")
|
web::resource("/artists/{id}")
|
||||||
.route(web::get().to(get_artist))
|
.route(web::get().to(get_artist))
|
||||||
@@ -121,6 +127,7 @@ async fn list_artists(
|
|||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
musicbrainz_id: a.musicbrainz_id.clone(),
|
musicbrainz_id: a.musicbrainz_id.clone(),
|
||||||
|
monitored: a.monitored,
|
||||||
total_watched,
|
total_watched,
|
||||||
total_owned,
|
total_owned,
|
||||||
total_items,
|
total_items,
|
||||||
@@ -186,8 +193,29 @@ async fn get_cached_album_tracks(
|
|||||||
let release_mbid = if let Some(rid) = first_release_id {
|
let release_mbid = if let Some(rid) = first_release_id {
|
||||||
rid.to_string()
|
rid.to_string()
|
||||||
} else {
|
} else {
|
||||||
// Browse releases for this release group
|
// Check DB cache for previously resolved release MBID
|
||||||
resolve_release_from_group(rg_id).await?
|
let resolve_cache_key = format!("release_for_rg:{rg_id}");
|
||||||
|
if let Ok(Some(cached_rid)) = queries::cache::get(state.db.conn(), &resolve_cache_key).await
|
||||||
|
{
|
||||||
|
cached_rid
|
||||||
|
} else {
|
||||||
|
// Browse releases for this release group (through shared rate limiter)
|
||||||
|
let rid = state
|
||||||
|
.mb_client
|
||||||
|
.resolve_release_from_group(rg_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("MB error for group {rg_id}: {e}")))?;
|
||||||
|
// Cache the resolved release MBID for 365 days — it never changes
|
||||||
|
let _ = queries::cache::set(
|
||||||
|
state.db.conn(),
|
||||||
|
&resolve_cache_key,
|
||||||
|
"musicbrainz",
|
||||||
|
&rid,
|
||||||
|
365 * 86400,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
rid
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mb_tracks = state
|
let mb_tracks = state
|
||||||
@@ -221,37 +249,6 @@ async fn get_cached_album_tracks(
|
|||||||
Ok(cached)
|
Ok(cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Given a release-group MBID, find the first release MBID.
|
|
||||||
async fn resolve_release_from_group(release_group_mbid: &str) -> Result<String, ApiError> {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("Shanty/0.1.0 (shanty-music-app)")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1"
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp: serde_json::Value = client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
resp.get("releases")
|
|
||||||
.and_then(|r| r.as_array())
|
|
||||||
.and_then(|arr| arr.first())
|
|
||||||
.and_then(|r| r.get("id"))
|
|
||||||
.and_then(|id| id.as_str())
|
|
||||||
.map(String::from)
|
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("no releases for group {release_group_mbid}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ArtistFullParams {
|
pub struct ArtistFullParams {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -279,7 +276,8 @@ pub async fn enrich_artist(
|
|||||||
quick_mode: bool,
|
quick_mode: bool,
|
||||||
) -> Result<serde_json::Value, ApiError> {
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
// Resolve artist: local ID or MBID
|
// Resolve artist: local ID or MBID
|
||||||
let (artist, id, mbid) = if let Ok(local_id) = id_or_mbid.parse() {
|
// Track whether we already fetched artist info during resolution to avoid a duplicate API call
|
||||||
|
let (artist, id, mbid, prefetched_info) = if let Ok(local_id) = id_or_mbid.parse() {
|
||||||
let artist = queries::artists::get_by_id(state.db.conn(), local_id).await?;
|
let artist = queries::artists::get_by_id(state.db.conn(), local_id).await?;
|
||||||
let mbid = match &artist.musicbrainz_id {
|
let mbid = match &artist.musicbrainz_id {
|
||||||
Some(m) => m.clone(),
|
Some(m) => m.clone(),
|
||||||
@@ -294,7 +292,7 @@ pub async fn enrich_artist(
|
|||||||
})?
|
})?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(artist, Some(local_id), mbid)
|
(artist, Some(local_id), mbid, None)
|
||||||
} else {
|
} else {
|
||||||
let mbid = id_or_mbid.to_string();
|
let mbid = id_or_mbid.to_string();
|
||||||
|
|
||||||
@@ -308,28 +306,76 @@ pub async fn enrich_artist(
|
|||||||
|
|
||||||
if let Some(a) = local {
|
if let Some(a) = local {
|
||||||
let local_id = a.id;
|
let local_id = a.id;
|
||||||
(a, Some(local_id), mbid)
|
(a, Some(local_id), mbid, None)
|
||||||
} else {
|
} else {
|
||||||
// Look up artist name from MusicBrainz by MBID — don't create a local record
|
// Look up artist info from MusicBrainz by MBID — don't create a local record
|
||||||
let (name, _disambiguation) = state
|
// This fetches url-rels too, so we reuse it below instead of calling get_artist_info() again
|
||||||
.mb_client
|
let info =
|
||||||
.get_artist_by_mbid(&mbid)
|
state.mb_client.get_artist_info(&mbid).await.map_err(|e| {
|
||||||
.await
|
ApiError::NotFound(format!("artist MBID {mbid} not found: {e}"))
|
||||||
.map_err(|e| ApiError::NotFound(format!("artist MBID {mbid} not found: {e}")))?;
|
})?;
|
||||||
|
|
||||||
// Create a synthetic artist object for display only (not saved to DB)
|
// Create a synthetic artist object for display only (not saved to DB)
|
||||||
let synthetic = shanty_db::entities::artist::Model {
|
let synthetic = shanty_db::entities::artist::Model {
|
||||||
id: 0,
|
id: 0,
|
||||||
name,
|
name: info.name.clone(),
|
||||||
musicbrainz_id: Some(mbid.clone()),
|
musicbrainz_id: Some(mbid.clone()),
|
||||||
added_at: chrono::Utc::now().naive_utc(),
|
added_at: chrono::Utc::now().naive_utc(),
|
||||||
top_songs: "[]".to_string(),
|
top_songs: "[]".to_string(),
|
||||||
similar_artists: "[]".to_string(),
|
similar_artists: "[]".to_string(),
|
||||||
|
monitored: false,
|
||||||
|
last_checked_at: None,
|
||||||
};
|
};
|
||||||
(synthetic, None, mbid)
|
(synthetic, None, mbid, Some(info))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch detailed artist info (country, type, URLs) — reuse if already fetched during resolution
|
||||||
|
let artist_info = if let Some(info) = prefetched_info {
|
||||||
|
tracing::debug!(
|
||||||
|
mbid = %mbid,
|
||||||
|
urls = info.urls.len(),
|
||||||
|
country = ?info.country,
|
||||||
|
"reusing prefetched artist info"
|
||||||
|
);
|
||||||
|
Some(info)
|
||||||
|
} else {
|
||||||
|
match state.mb_client.get_artist_info(&mbid).await {
|
||||||
|
Ok(info) => {
|
||||||
|
tracing::debug!(
|
||||||
|
mbid = %mbid,
|
||||||
|
urls = info.urls.len(),
|
||||||
|
country = ?info.country,
|
||||||
|
"fetched artist info"
|
||||||
|
);
|
||||||
|
Some(info)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(mbid = %mbid, error = %e, "failed to fetch artist info");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch artist photo + bio + banner (cached, provider-aware)
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let image_source = config.metadata.artist_image_source.clone();
|
||||||
|
let bio_source = config.metadata.artist_bio_source.clone();
|
||||||
|
let lastfm_api_key = config.metadata.lastfm_api_key.clone();
|
||||||
|
let fanart_api_key = config.metadata.fanart_api_key.clone();
|
||||||
|
drop(config);
|
||||||
|
let (artist_photo, artist_bio, artist_banner) = fetch_artist_enrichment(
|
||||||
|
state,
|
||||||
|
&mbid,
|
||||||
|
&artist_info,
|
||||||
|
&image_source,
|
||||||
|
&bio_source,
|
||||||
|
lastfm_api_key.as_deref(),
|
||||||
|
fanart_api_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tracing::debug!(mbid = %mbid, has_photo = artist_photo.is_some(), has_bio = artist_bio.is_some(), has_banner = artist_banner.is_some(), "artist enrichment data");
|
||||||
|
|
||||||
// Fetch release groups and filter by allowed secondary types
|
// Fetch release groups and filter by allowed secondary types
|
||||||
let all_release_groups = state
|
let all_release_groups = state
|
||||||
.search
|
.search
|
||||||
@@ -568,6 +614,11 @@ pub async fn enrich_artist(
|
|||||||
"total_watched_tracks": total_artist_watched,
|
"total_watched_tracks": total_artist_watched,
|
||||||
"total_owned_tracks": total_artist_owned,
|
"total_owned_tracks": total_artist_owned,
|
||||||
"enriched": !skip_track_fetch,
|
"enriched": !skip_track_fetch,
|
||||||
|
"monitored": artist.monitored,
|
||||||
|
"artist_info": artist_info,
|
||||||
|
"artist_photo": artist_photo,
|
||||||
|
"artist_bio": artist_bio,
|
||||||
|
"artist_banner": artist_banner,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,6 +642,132 @@ pub async fn enrich_all_watched_artists(state: &AppState) -> Result<u32, ApiErro
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch artist photo, bio, and banner using configured providers, with per-source caching.
|
||||||
|
async fn fetch_artist_enrichment(
|
||||||
|
state: &AppState,
|
||||||
|
mbid: &str,
|
||||||
|
artist_info: &Option<shanty_data::ArtistInfo>,
|
||||||
|
image_source: &str,
|
||||||
|
bio_source: &str,
|
||||||
|
lastfm_api_key: Option<&str>,
|
||||||
|
fanart_api_key: Option<&str>,
|
||||||
|
) -> (Option<String>, Option<String>, Option<String>) {
|
||||||
|
let Some(info) = artist_info.as_ref() else {
|
||||||
|
tracing::debug!(mbid = mbid, "no artist info for enrichment");
|
||||||
|
return (None, None, None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build fanart.tv fetcher once if needed (used for both image and banner)
|
||||||
|
let fanart_fetcher = if image_source == "fanarttv" {
|
||||||
|
fanart_api_key.and_then(|key| shanty_data::FanartTvFetcher::new(key.to_string()).ok())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch image (cached per source — only cache hits, not misses)
|
||||||
|
let image_cache_key = format!("artist_image:{image_source}:{mbid}");
|
||||||
|
let photo_url = if let Ok(Some(cached)) =
|
||||||
|
queries::cache::get(state.db.conn(), &image_cache_key).await
|
||||||
|
&& !cached.is_empty()
|
||||||
|
{
|
||||||
|
Some(cached)
|
||||||
|
} else {
|
||||||
|
let url = match image_source {
|
||||||
|
"wikipedia" => state
|
||||||
|
.wiki_fetcher
|
||||||
|
.get_artist_image(info)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None),
|
||||||
|
"fanarttv" => match &fanart_fetcher {
|
||||||
|
Some(f) => f.get_artist_image(info).await.unwrap_or(None),
|
||||||
|
None => {
|
||||||
|
tracing::warn!("fanart.tv selected but SHANTY_FANART_API_KEY not set");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(ref val) = url {
|
||||||
|
let _ = queries::cache::set(
|
||||||
|
state.db.conn(),
|
||||||
|
&image_cache_key,
|
||||||
|
image_source,
|
||||||
|
val,
|
||||||
|
30 * 86400,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
url
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch banner (cached per source — only for providers that support banners)
|
||||||
|
let banner_cache_key = format!("artist_banner:{image_source}:{mbid}");
|
||||||
|
let banner = if let Ok(Some(cached)) =
|
||||||
|
queries::cache::get(state.db.conn(), &banner_cache_key).await
|
||||||
|
&& !cached.is_empty()
|
||||||
|
{
|
||||||
|
Some(cached)
|
||||||
|
} else {
|
||||||
|
let url = match image_source {
|
||||||
|
"fanarttv" => match &fanart_fetcher {
|
||||||
|
Some(f) => f.get_artist_banner(info).await.unwrap_or(None),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(ref val) = url {
|
||||||
|
let _ = queries::cache::set(
|
||||||
|
state.db.conn(),
|
||||||
|
&banner_cache_key,
|
||||||
|
image_source,
|
||||||
|
val,
|
||||||
|
30 * 86400,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
url
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch bio (cached per source — only cache hits, not misses)
|
||||||
|
let bio_cache_key = format!("artist_bio:{bio_source}:{mbid}");
|
||||||
|
let bio = if let Ok(Some(cached)) = queries::cache::get(state.db.conn(), &bio_cache_key).await
|
||||||
|
&& !cached.is_empty()
|
||||||
|
{
|
||||||
|
Some(cached)
|
||||||
|
} else {
|
||||||
|
let text = match bio_source {
|
||||||
|
"wikipedia" => state
|
||||||
|
.wiki_fetcher
|
||||||
|
.get_artist_bio(info)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None),
|
||||||
|
"lastfm" => {
|
||||||
|
if let Some(key) = lastfm_api_key {
|
||||||
|
match shanty_data::LastFmBioFetcher::new(key.to_string()) {
|
||||||
|
Ok(fetcher) => fetcher.get_artist_bio(info).await.unwrap_or(None),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "failed to create Last.fm fetcher");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Last.fm bio source selected but SHANTY_LASTFM_API_KEY not set");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(ref val) = text {
|
||||||
|
let _ =
|
||||||
|
queries::cache::set(state.db.conn(), &bio_cache_key, bio_source, val, 30 * 86400)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
text
|
||||||
|
};
|
||||||
|
|
||||||
|
(photo_url, bio, banner)
|
||||||
|
}
|
||||||
|
|
||||||
async fn add_artist(
|
async fn add_artist(
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -635,3 +812,33 @@ async fn delete_artist(
|
|||||||
queries::artists::delete(state.db.conn(), id).await?;
|
queries::artists::delete(state.db.conn(), id).await?;
|
||||||
Ok(HttpResponse::NoContent().finish())
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_monitored(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let artist = queries::artists::set_monitored(state.db.conn(), id, true).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"id": artist.id,
|
||||||
|
"name": artist.name,
|
||||||
|
"monitored": artist.monitored,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unset_monitored(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let artist = queries::artists::set_monitored(state.db.conn(), id, false).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"id": artist.id,
|
||||||
|
"name": artist.name,
|
||||||
|
"monitored": artist.monitored,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{HttpResponse, web};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use shanty_db::entities::user::UserRole;
|
use shanty_db::entities::user::UserRole;
|
||||||
@@ -20,7 +20,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::get().to(list_users))
|
.route(web::get().to(list_users))
|
||||||
.route(web::post().to(create_user)),
|
.route(web::post().to(create_user)),
|
||||||
)
|
)
|
||||||
.service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user)));
|
.service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user)))
|
||||||
|
.service(
|
||||||
|
web::resource("/auth/subsonic-password").route(web::put().to(set_subsonic_password)),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/auth/subsonic-password-status")
|
||||||
|
.route(web::get().to(subsonic_password_status)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -41,6 +48,11 @@ struct CreateUserRequest {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SubsonicPasswordRequest {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if initial setup is required (no users in database).
|
/// Check if initial setup is required (no users in database).
|
||||||
async fn setup_required(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
async fn setup_required(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
|
||||||
let count = queries::users::count(state.db.conn()).await?;
|
let count = queries::users::count(state.db.conn()).await?;
|
||||||
@@ -76,7 +88,11 @@ async fn setup(
|
|||||||
// Adopt any orphaned wanted items from before auth was added
|
// Adopt any orphaned wanted items from before auth was added
|
||||||
let adopted = queries::users::adopt_orphaned_wanted_items(state.db.conn(), user.id).await?;
|
let adopted = queries::users::adopt_orphaned_wanted_items(state.db.conn(), user.id).await?;
|
||||||
if adopted > 0 {
|
if adopted > 0 {
|
||||||
tracing::info!(count = adopted, user_id = user.id, "adopted orphaned wanted items");
|
tracing::info!(
|
||||||
|
count = adopted,
|
||||||
|
user_id = user.id,
|
||||||
|
"adopted orphaned wanted items"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
auth::set_session(&session, user.id, &user.username, "admin");
|
auth::set_session(&session, user.id, &user.username, "admin");
|
||||||
@@ -201,3 +217,34 @@ async fn delete_user(
|
|||||||
tracing::info!(user_id = user_id, "user deleted by admin");
|
tracing::info!(user_id = user_id, "user deleted by admin");
|
||||||
Ok(HttpResponse::NoContent().finish())
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the Subsonic password for the current user.
|
||||||
|
async fn set_subsonic_password(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
body: web::Json<SubsonicPasswordRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let (user_id, _, _) = auth::require_auth(&session)?;
|
||||||
|
|
||||||
|
if body.password.len() < 4 {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"password must be at least 4 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
queries::users::set_subsonic_password(state.db.conn(), user_id, &body.password).await?;
|
||||||
|
tracing::info!(user_id = user_id, "subsonic password set");
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "ok" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the current user has a Subsonic password set.
|
||||||
|
async fn subsonic_password_status(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let (user_id, _, _) = auth::require_auth(&session)?;
|
||||||
|
let user = queries::users::get_by_id(state.db.conn(), user_id).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"set": user.subsonic_password.is_some(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ async fn enqueue_download(
|
|||||||
Ok(HttpResponse::Ok().json(item))
|
Ok(HttpResponse::Ok().json(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_downloads(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn sync_downloads(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let stats = shanty_dl::sync_wanted_to_queue(state.db.conn(), false).await?;
|
let stats = shanty_dl::sync_wanted_to_queue(state.db.conn(), false).await?;
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
@@ -67,7 +70,10 @@ async fn sync_downloads(state: web::Data<AppState>, session: Session) -> Result<
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trigger_process(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn trigger_process(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let task_id = state.tasks.register("download");
|
let task_id = state.tasks.register("download");
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
|
|||||||
68
src/routes/lyrics.rs
Normal file
68
src/routes/lyrics.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use actix_session::Session;
|
||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use shanty_data::LyricsFetcher;
|
||||||
|
use shanty_db::queries;
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LyricsQuery {
|
||||||
|
artist: String,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(web::resource("/lyrics").route(web::get().to(get_lyrics)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_lyrics(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
query: web::Query<LyricsQuery>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
|
||||||
|
let artist = &query.artist;
|
||||||
|
let title = &query.title;
|
||||||
|
|
||||||
|
// Normalize cache key
|
||||||
|
let cache_key = format!("lyrics:{}:{}", artist.to_lowercase(), title.to_lowercase());
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
|
||||||
|
return Ok(HttpResponse::Ok()
|
||||||
|
.content_type("application/json")
|
||||||
|
.body(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use LrclibFetcher from shanty-data
|
||||||
|
let fetcher = shanty_data::LrclibFetcher::new()
|
||||||
|
.map_err(|e| ApiError::Internal(format!("failed to create lyrics fetcher: {e}")))?;
|
||||||
|
|
||||||
|
let lyrics_result = fetcher
|
||||||
|
.get_lyrics(artist, title)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("lyrics fetch failed: {e}")))?;
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"found": lyrics_result.found,
|
||||||
|
"lyrics": lyrics_result.lyrics,
|
||||||
|
"synced_lyrics": lyrics_result.synced_lyrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache for 30 days
|
||||||
|
let _ = queries::cache::set(
|
||||||
|
state.db.conn(),
|
||||||
|
&cache_key,
|
||||||
|
"lrclib",
|
||||||
|
&result.to_string(),
|
||||||
|
30 * 86400,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(result))
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ pub mod albums;
|
|||||||
pub mod artists;
|
pub mod artists;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
|
pub mod lyrics;
|
||||||
|
pub mod playlists;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod subsonic;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
pub mod tracks;
|
pub mod tracks;
|
||||||
|
pub mod ytauth;
|
||||||
|
|
||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
|
|
||||||
@@ -17,6 +21,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.configure(tracks::configure)
|
.configure(tracks::configure)
|
||||||
.configure(search::configure)
|
.configure(search::configure)
|
||||||
.configure(downloads::configure)
|
.configure(downloads::configure)
|
||||||
.configure(system::configure),
|
.configure(lyrics::configure)
|
||||||
|
.configure(system::configure)
|
||||||
|
.configure(ytauth::configure)
|
||||||
|
.configure(playlists::configure),
|
||||||
);
|
);
|
||||||
|
// Subsonic API at /rest/*
|
||||||
|
subsonic::configure(cfg);
|
||||||
}
|
}
|
||||||
|
|||||||
291
src/routes/playlists.rs
Normal file
291
src/routes/playlists.rs
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
use actix_session::Session;
|
||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use shanty_db::queries;
|
||||||
|
use shanty_playlist::{self, PlaylistRequest};
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(web::resource("/playlists/generate").route(web::post().to(generate_playlist)))
|
||||||
|
.service(
|
||||||
|
web::resource("/playlists")
|
||||||
|
.route(web::get().to(list_playlists))
|
||||||
|
.route(web::post().to(save_playlist)),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/playlists/{id}")
|
||||||
|
.route(web::get().to(get_playlist))
|
||||||
|
.route(web::put().to(update_playlist))
|
||||||
|
.route(web::delete().to(delete_playlist)),
|
||||||
|
)
|
||||||
|
.service(web::resource("/playlists/{id}/m3u").route(web::get().to(export_m3u)))
|
||||||
|
.service(
|
||||||
|
web::resource("/playlists/{id}/tracks")
|
||||||
|
.route(web::post().to(add_track))
|
||||||
|
.route(web::put().to(reorder_tracks)),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/playlists/{id}/tracks/{track_id}")
|
||||||
|
.route(web::delete().to(remove_track)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/playlists/generate — generate a playlist without saving.
|
||||||
|
async fn generate_playlist(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
body: web::Json<PlaylistRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let req = body.into_inner();
|
||||||
|
let conn = state.db.conn();
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let lastfm_key = config.metadata.lastfm_api_key.clone();
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let result = match req.strategy.as_str() {
|
||||||
|
"similar" => {
|
||||||
|
let api_key = lastfm_key.unwrap_or_default();
|
||||||
|
if api_key.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"SHANTY_LASTFM_API_KEY is required for similar-artist playlists".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fetcher = shanty_data::LastFmSimilarFetcher::new(api_key)
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
shanty_playlist::similar_artists(
|
||||||
|
conn,
|
||||||
|
&fetcher,
|
||||||
|
req.seed_artists,
|
||||||
|
req.count,
|
||||||
|
req.popularity_bias,
|
||||||
|
&req.ordering,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||||
|
}
|
||||||
|
"genre" => shanty_playlist::genre_based(conn, req.genres, req.count, &req.ordering)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?,
|
||||||
|
"random" => shanty_playlist::random(conn, req.count, req.ordering != "random")
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?,
|
||||||
|
"smart" => {
|
||||||
|
let rules = req.rules.unwrap_or_default();
|
||||||
|
shanty_playlist::smart(conn, rules, req.count)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(ApiError::BadRequest(format!("unknown strategy: {other}")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SavePlaylistRequest {
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
track_ids: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/playlists — save a generated playlist.
|
||||||
|
async fn save_playlist(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
body: web::Json<SavePlaylistRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let (user_id, _, _) = auth::require_auth(&session)?;
|
||||||
|
let req = body.into_inner();
|
||||||
|
let playlist = queries::playlists::create(
|
||||||
|
state.db.conn(),
|
||||||
|
&req.name,
|
||||||
|
req.description.as_deref(),
|
||||||
|
Some(user_id),
|
||||||
|
&req.track_ids,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Created().json(playlist))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/playlists — list saved playlists.
|
||||||
|
async fn list_playlists(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let (user_id, _, _) = auth::require_auth(&session)?;
|
||||||
|
let playlists = queries::playlists::list(state.db.conn(), Some(user_id)).await?;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PlaylistSummary {
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
track_count: u64,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut summaries = Vec::new();
|
||||||
|
for p in playlists {
|
||||||
|
let count = queries::playlists::get_track_count(state.db.conn(), p.id).await?;
|
||||||
|
summaries.push(PlaylistSummary {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
track_count: count,
|
||||||
|
created_at: p.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(summaries))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/playlists/{id} — get playlist with tracks.
|
||||||
|
async fn get_playlist(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let playlist = queries::playlists::get_by_id(state.db.conn(), id).await?;
|
||||||
|
let tracks = queries::playlists::get_tracks(state.db.conn(), id).await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"playlist": playlist,
|
||||||
|
"tracks": tracks,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdatePlaylistRequest {
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/playlists/{id} — update playlist name/description.
|
||||||
|
async fn update_playlist(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
body: web::Json<UpdatePlaylistRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let req = body.into_inner();
|
||||||
|
let playlist = queries::playlists::update(
|
||||||
|
state.db.conn(),
|
||||||
|
id,
|
||||||
|
req.name.as_deref(),
|
||||||
|
req.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(playlist))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/playlists/{id} — delete a playlist.
|
||||||
|
async fn delete_playlist(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
queries::playlists::delete(state.db.conn(), id).await?;
|
||||||
|
Ok(HttpResponse::NoContent().finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AddTrackRequest {
|
||||||
|
track_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/playlists/{id}/tracks — add a track to playlist.
|
||||||
|
async fn add_track(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
body: web::Json<AddTrackRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let req = body.into_inner();
|
||||||
|
queries::playlists::add_track(state.db.conn(), id, req.track_id).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReorderTracksRequest {
|
||||||
|
track_ids: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/playlists/{id}/tracks — reorder tracks in playlist.
|
||||||
|
async fn reorder_tracks(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
body: web::Json<ReorderTracksRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let req = body.into_inner();
|
||||||
|
queries::playlists::reorder_tracks(state.db.conn(), id, req.track_ids).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/playlists/{id}/tracks/{track_id} — remove a track from playlist.
|
||||||
|
async fn remove_track(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(i32, i32)>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let (id, track_id) = path.into_inner();
|
||||||
|
queries::playlists::remove_track(state.db.conn(), id, track_id).await?;
|
||||||
|
Ok(HttpResponse::NoContent().finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/playlists/{id}/m3u — export as M3U file.
|
||||||
|
async fn export_m3u(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let playlist = queries::playlists::get_by_id(state.db.conn(), id).await?;
|
||||||
|
let tracks = queries::playlists::get_tracks(state.db.conn(), id).await?;
|
||||||
|
|
||||||
|
// Convert DB tracks to PlaylistTracks for M3U generation
|
||||||
|
let playlist_tracks: Vec<shanty_playlist::PlaylistTrack> = tracks
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| shanty_playlist::PlaylistTrack {
|
||||||
|
track_id: t.id,
|
||||||
|
file_path: t.file_path,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
album: t.album,
|
||||||
|
score: 0.0,
|
||||||
|
duration: t.duration,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let m3u = shanty_playlist::to_m3u(&playlist_tracks);
|
||||||
|
let filename = format!("{}.m3u", playlist.name.replace(' ', "_"));
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.insert_header(("Content-Type", "audio/x-mpegurl"))
|
||||||
|
.insert_header((
|
||||||
|
"Content-Disposition",
|
||||||
|
format!("attachment; filename=\"{filename}\""),
|
||||||
|
))
|
||||||
|
.body(m3u))
|
||||||
|
}
|
||||||
42
src/routes/subsonic/annotation.rs
Normal file
42
src/routes/subsonic/annotation.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||||
|
use super::response;
|
||||||
|
|
||||||
|
/// GET /rest/scrobble[.view]
|
||||||
|
pub async fn scrobble(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid id");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log the scrobble for now; full play tracking can be added later
|
||||||
|
tracing::info!(
|
||||||
|
user = %user.username,
|
||||||
|
id_type = prefix,
|
||||||
|
id = entity_id,
|
||||||
|
"subsonic scrobble"
|
||||||
|
);
|
||||||
|
|
||||||
|
response::ok(¶ms.format, serde_json::json!({}))
|
||||||
|
}
|
||||||
126
src/routes/subsonic/auth.rs
Normal file
126
src/routes/subsonic/auth.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use actix_web::HttpRequest;
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
use shanty_db::entities::user::Model as User;
|
||||||
|
use shanty_db::queries;
|
||||||
|
|
||||||
|
/// Subsonic authentication method.
|
||||||
|
pub enum AuthMethod {
|
||||||
|
/// Modern: token = md5(password + salt)
|
||||||
|
Token { token: String, salt: String },
|
||||||
|
/// Legacy: plaintext password
|
||||||
|
Password(String),
|
||||||
|
/// Legacy: hex-encoded password (p=enc:hexstring)
|
||||||
|
HexPassword(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common Subsonic API parameters extracted from the query string.
|
||||||
|
pub struct SubsonicParams {
|
||||||
|
/// Username
|
||||||
|
pub username: String,
|
||||||
|
/// Authentication method + credentials
|
||||||
|
pub auth: AuthMethod,
|
||||||
|
/// API version requested
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub version: String,
|
||||||
|
/// Client name
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub client: String,
|
||||||
|
/// Response format: "xml" or "json"
|
||||||
|
pub format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum SubsonicAuthError {
|
||||||
|
MissingParam(String),
|
||||||
|
AuthFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubsonicParams {
|
||||||
|
/// Extract Subsonic params from the query string.
|
||||||
|
pub fn from_request(req: &HttpRequest) -> Result<Self, SubsonicAuthError> {
|
||||||
|
let qs = req.query_string();
|
||||||
|
let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
|
||||||
|
|
||||||
|
let get = |name: &str| -> Option<String> {
|
||||||
|
params
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == name)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let username = get("u").ok_or_else(|| SubsonicAuthError::MissingParam("u".into()))?;
|
||||||
|
let version = get("v").unwrap_or_else(|| "1.16.1".into());
|
||||||
|
let client = get("c").unwrap_or_else(|| "unknown".into());
|
||||||
|
let format = get("f").unwrap_or_else(|| "xml".into());
|
||||||
|
|
||||||
|
// Try token auth first (modern), then legacy password
|
||||||
|
let auth = if let (Some(token), Some(salt)) = (get("t"), get("s")) {
|
||||||
|
AuthMethod::Token { token, salt }
|
||||||
|
} else if let Some(p) = get("p") {
|
||||||
|
if let Some(hex_str) = p.strip_prefix("enc:") {
|
||||||
|
AuthMethod::HexPassword(hex_str.to_string())
|
||||||
|
} else {
|
||||||
|
AuthMethod::Password(p)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(SubsonicAuthError::MissingParam(
|
||||||
|
"authentication required (t+s or p)".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
username,
|
||||||
|
auth,
|
||||||
|
version,
|
||||||
|
client,
|
||||||
|
format,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify Subsonic authentication against the stored subsonic_password.
|
||||||
|
pub async fn verify_auth(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
params: &SubsonicParams,
|
||||||
|
) -> Result<User, SubsonicAuthError> {
|
||||||
|
let user = queries::users::find_by_username(db, ¶ms.username)
|
||||||
|
.await
|
||||||
|
.map_err(|_| SubsonicAuthError::AuthFailed)?
|
||||||
|
.ok_or(SubsonicAuthError::AuthFailed)?;
|
||||||
|
|
||||||
|
let subsonic_password = user
|
||||||
|
.subsonic_password
|
||||||
|
.as_deref()
|
||||||
|
.ok_or(SubsonicAuthError::AuthFailed)?;
|
||||||
|
|
||||||
|
match ¶ms.auth {
|
||||||
|
AuthMethod::Token { token, salt } => {
|
||||||
|
// Compute md5(password + salt) and compare
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(subsonic_password.as_bytes());
|
||||||
|
hasher.update(salt.as_bytes());
|
||||||
|
let result = hasher.finalize();
|
||||||
|
let expected = hex::encode(result);
|
||||||
|
if expected != *token {
|
||||||
|
return Err(SubsonicAuthError::AuthFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthMethod::Password(password) => {
|
||||||
|
// Direct plaintext comparison
|
||||||
|
if password != subsonic_password {
|
||||||
|
return Err(SubsonicAuthError::AuthFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthMethod::HexPassword(hex_str) => {
|
||||||
|
// Decode hex to string, compare
|
||||||
|
let decoded = hex::decode(hex_str).map_err(|_| SubsonicAuthError::AuthFailed)?;
|
||||||
|
let password = String::from_utf8(decoded).map_err(|_| SubsonicAuthError::AuthFailed)?;
|
||||||
|
if password != subsonic_password {
|
||||||
|
return Err(SubsonicAuthError::AuthFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
589
src/routes/subsonic/browsing.rs
Normal file
589
src/routes/subsonic/browsing.rs
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use shanty_db::queries;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||||
|
use super::response::{self, SubsonicChild};
|
||||||
|
|
||||||
|
/// GET /rest/getMusicFolders[.view]
|
||||||
|
pub async fn get_music_folders(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"musicFolders": {
|
||||||
|
"musicFolder": [
|
||||||
|
{ "id": 1, "name": "Music" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getArtists[.view]
|
||||||
|
pub async fn get_artists(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let artists = match queries::artists::list(state.db.conn(), 10000, 0).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_GENERIC,
|
||||||
|
&format!("database error: {e}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group artists by first letter for the index
|
||||||
|
let mut index_map: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
|
||||||
|
for artist in &artists {
|
||||||
|
let first_char = artist
|
||||||
|
.name
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.unwrap_or('#')
|
||||||
|
.to_uppercase()
|
||||||
|
.next()
|
||||||
|
.unwrap_or('#');
|
||||||
|
let key = if first_char.is_alphabetic() {
|
||||||
|
first_char.to_string()
|
||||||
|
} else {
|
||||||
|
"#".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count albums for this artist
|
||||||
|
let album_count = queries::albums::get_by_artist(state.db.conn(), artist.id)
|
||||||
|
.await
|
||||||
|
.map(|a| a.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
index_map.entry(key).or_default().push(serde_json::json!({
|
||||||
|
"id": format!("ar-{}", artist.id),
|
||||||
|
"name": artist.name,
|
||||||
|
"albumCount": album_count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let indices: Vec<serde_json::Value> = index_map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, artists)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"artist": artists,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"artists": {
|
||||||
|
"ignoredArticles": "The El La Los Las Le Les",
|
||||||
|
"index": indices,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getArtist[.view]
|
||||||
|
pub async fn get_artist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, artist_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"invalid artist id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let artist = match queries::artists::get_by_id(state.db.conn(), artist_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"artist not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let albums = queries::albums::get_by_artist(state.db.conn(), artist_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut album_list: Vec<serde_json::Value> = Vec::new();
|
||||||
|
for album in &albums {
|
||||||
|
let track_count = queries::tracks::get_by_album(state.db.conn(), album.id)
|
||||||
|
.await
|
||||||
|
.map(|t| t.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let duration: i32 = queries::tracks::get_by_album(state.db.conn(), album.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||||
|
.sum();
|
||||||
|
let mut album_json = serde_json::json!({
|
||||||
|
"id": format!("al-{}", album.id),
|
||||||
|
"name": album.name,
|
||||||
|
"title": album.name,
|
||||||
|
"artist": if album.album_artist.is_empty() { &artist.name } else { &album.album_artist },
|
||||||
|
"artistId": format!("ar-{}", artist.id),
|
||||||
|
"coverArt": format!("al-{}", album.id),
|
||||||
|
"songCount": track_count,
|
||||||
|
"duration": duration,
|
||||||
|
"created": "2024-01-01T00:00:00",
|
||||||
|
});
|
||||||
|
// Only include year/genre if present (avoid nulls)
|
||||||
|
if let Some(year) = album.year {
|
||||||
|
album_json["year"] = serde_json::json!(year);
|
||||||
|
}
|
||||||
|
if let Some(ref genre) = album.genre {
|
||||||
|
album_json["genre"] = serde_json::json!(genre);
|
||||||
|
}
|
||||||
|
album_list.push(album_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"artist": {
|
||||||
|
"id": format!("ar-{}", artist.id),
|
||||||
|
"name": artist.name,
|
||||||
|
"albumCount": albums.len(),
|
||||||
|
"album": album_list,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getAlbum[.view]
|
||||||
|
pub async fn get_album(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, album_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"invalid album id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let album = match queries::albums::get_by_id(state.db.conn(), album_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "album not found");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tracks = queries::tracks::get_by_album(state.db.conn(), album_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let total_duration: i32 = tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
let song_list: Vec<serde_json::Value> = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut album_json = serde_json::json!({
|
||||||
|
"id": format!("al-{}", album.id),
|
||||||
|
"name": album.name,
|
||||||
|
"title": album.name,
|
||||||
|
"artist": album.album_artist,
|
||||||
|
"artistId": album.artist_id.map(|id| format!("ar-{id}")),
|
||||||
|
"coverArt": format!("al-{}", album.id),
|
||||||
|
"songCount": tracks.len(),
|
||||||
|
"duration": total_duration,
|
||||||
|
"created": "2024-01-01T00:00:00",
|
||||||
|
"song": song_list,
|
||||||
|
});
|
||||||
|
if let Some(year) = album.year {
|
||||||
|
album_json["year"] = serde_json::json!(year);
|
||||||
|
}
|
||||||
|
if let Some(ref genre) = album.genre {
|
||||||
|
album_json["genre"] = serde_json::json!(genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"album": album_json,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getSong[.view]
|
||||||
|
pub async fn get_song(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let child = SubsonicChild::from_track(&track);
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"song": serde_json::to_value(child).unwrap_or_default(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getGenres[.view]
|
||||||
|
pub async fn get_genres(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all tracks and extract unique genres
|
||||||
|
let tracks = queries::tracks::list(state.db.conn(), 100_000, 0)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut genre_counts: BTreeMap<String, (u64, u64)> = BTreeMap::new();
|
||||||
|
for track in &tracks {
|
||||||
|
if let Some(ref genre) = track.genre {
|
||||||
|
let entry = genre_counts.entry(genre.clone()).or_insert((0, 0));
|
||||||
|
entry.0 += 1; // song count
|
||||||
|
// album count is approximated - we count unique album_ids per genre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also count album_ids per genre
|
||||||
|
let mut genre_albums: BTreeMap<String, std::collections::HashSet<i32>> = BTreeMap::new();
|
||||||
|
for track in &tracks {
|
||||||
|
if let Some(ref genre) = track.genre
|
||||||
|
&& let Some(album_id) = track.album_id
|
||||||
|
{
|
||||||
|
genre_albums
|
||||||
|
.entry(genre.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(album_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let genre_list: Vec<serde_json::Value> = genre_counts
|
||||||
|
.iter()
|
||||||
|
.map(|(name, (song_count, _))| {
|
||||||
|
let album_count = genre_albums.get(name).map(|s| s.len()).unwrap_or(0);
|
||||||
|
serde_json::json!({
|
||||||
|
"songCount": song_count,
|
||||||
|
"albumCount": album_count,
|
||||||
|
"value": name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"genres": {
|
||||||
|
"genre": genre_list,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getIndexes[.view] — folder-based browsing (same data as getArtists).
|
||||||
|
pub async fn get_indexes(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let artists = match queries::artists::list(state.db.conn(), 10000, 0).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_GENERIC,
|
||||||
|
&format!("database error: {e}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut index_map: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
|
||||||
|
for artist in &artists {
|
||||||
|
let first_char = artist
|
||||||
|
.name
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.unwrap_or('#')
|
||||||
|
.to_uppercase()
|
||||||
|
.next()
|
||||||
|
.unwrap_or('#');
|
||||||
|
let key = if first_char.is_alphabetic() {
|
||||||
|
first_char.to_string()
|
||||||
|
} else {
|
||||||
|
"#".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
index_map.entry(key).or_default().push(serde_json::json!({
|
||||||
|
"id": format!("ar-{}", artist.id),
|
||||||
|
"name": artist.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let indices: Vec<serde_json::Value> = index_map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, artists)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"artist": artists,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"indexes": {
|
||||||
|
"ignoredArticles": "The El La Los Las Le Les",
|
||||||
|
"index": indices,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getMusicDirectory[.view] — returns children of a directory.
|
||||||
|
/// For artist IDs (ar-N): returns albums as children.
|
||||||
|
/// For album IDs (al-N): returns tracks as children.
|
||||||
|
pub async fn get_music_directory(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (prefix, db_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid id");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match prefix {
|
||||||
|
"ar" => {
|
||||||
|
// Artist directory → list albums
|
||||||
|
let artist = match queries::artists::get_by_id(state.db.conn(), db_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"artist not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let albums = queries::albums::get_by_artist(state.db.conn(), db_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let children: Vec<serde_json::Value> = albums
|
||||||
|
.iter()
|
||||||
|
.map(|album| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": format!("al-{}", album.id),
|
||||||
|
"parent": format!("ar-{}", artist.id),
|
||||||
|
"isDir": true,
|
||||||
|
"title": album.name,
|
||||||
|
"artist": album.album_artist,
|
||||||
|
"coverArt": format!("al-{}", album.id),
|
||||||
|
"year": album.year,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"directory": {
|
||||||
|
"id": id_str,
|
||||||
|
"name": artist.name,
|
||||||
|
"child": children,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"al" => {
|
||||||
|
// Album directory → list tracks
|
||||||
|
let album = match queries::albums::get_by_id(state.db.conn(), db_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"album not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tracks = queries::tracks::get_by_album(state.db.conn(), db_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let children: Vec<serde_json::Value> = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"directory": {
|
||||||
|
"id": id_str,
|
||||||
|
"name": album.name,
|
||||||
|
"parent": album.artist_id.map(|id| format!("ar-{id}")),
|
||||||
|
"child": children,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"unknown" => {
|
||||||
|
// Plain numeric ID — try artist first, then album
|
||||||
|
if let Ok(artist) = queries::artists::get_by_id(state.db.conn(), db_id).await {
|
||||||
|
let albums = queries::albums::get_by_artist(state.db.conn(), db_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let children: Vec<serde_json::Value> = albums
|
||||||
|
.iter()
|
||||||
|
.map(|album| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": format!("al-{}", album.id),
|
||||||
|
"parent": format!("ar-{}", artist.id),
|
||||||
|
"isDir": true,
|
||||||
|
"title": album.name,
|
||||||
|
"artist": album.album_artist,
|
||||||
|
"coverArt": format!("al-{}", album.id),
|
||||||
|
"year": album.year,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"directory": {
|
||||||
|
"id": id_str,
|
||||||
|
"name": artist.name,
|
||||||
|
"child": children,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else if let Ok(album) = queries::albums::get_by_id(state.db.conn(), db_id).await {
|
||||||
|
let tracks = queries::tracks::get_by_album(state.db.conn(), db_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let children: Vec<serde_json::Value> = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"directory": {
|
||||||
|
"id": id_str,
|
||||||
|
"name": album.name,
|
||||||
|
"parent": album.artist_id.map(|id| format!("ar-{id}")),
|
||||||
|
"child": children,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
response::error(¶ms.format, response::ERROR_NOT_FOUND, "not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => response::error(¶ms.format, response::ERROR_NOT_FOUND, "unknown id type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/routes/subsonic/helpers.rs
Normal file
70
src/routes/subsonic/helpers.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
|
use shanty_db::entities::user::Model as User;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::auth::{SubsonicAuthError, SubsonicParams, verify_auth};
|
||||||
|
use super::response;
|
||||||
|
|
||||||
|
/// Extract and authenticate subsonic params, returning an error HttpResponse on failure.
|
||||||
|
pub async fn authenticate(
|
||||||
|
req: &HttpRequest,
|
||||||
|
state: &web::Data<AppState>,
|
||||||
|
) -> Result<(SubsonicParams, User), HttpResponse> {
|
||||||
|
tracing::debug!(
|
||||||
|
path = req.path(),
|
||||||
|
query = req.query_string(),
|
||||||
|
"subsonic request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let params = SubsonicParams::from_request(req).map_err(|e| match e {
|
||||||
|
SubsonicAuthError::MissingParam(name) => response::error(
|
||||||
|
"xml",
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
&format!("missing required parameter: {name}"),
|
||||||
|
),
|
||||||
|
SubsonicAuthError::AuthFailed => response::error(
|
||||||
|
"xml",
|
||||||
|
response::ERROR_NOT_AUTHENTICATED,
|
||||||
|
"wrong username or password",
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let user = verify_auth(state.db.conn(), ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
SubsonicAuthError::AuthFailed => response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_AUTHENTICATED,
|
||||||
|
"wrong username or password",
|
||||||
|
),
|
||||||
|
SubsonicAuthError::MissingParam(name) => response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
&format!("missing required parameter: {name}"),
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((params, user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Subsonic ID like "ar-123" into (prefix, id).
|
||||||
|
/// Also accepts plain numbers (e.g., "123") — returns prefix "unknown".
|
||||||
|
pub fn parse_subsonic_id(id: &str) -> Option<(&str, i32)> {
|
||||||
|
if let Some((prefix, num_str)) = id.split_once('-') {
|
||||||
|
let num = num_str.parse().ok()?;
|
||||||
|
Some((prefix, num))
|
||||||
|
} else {
|
||||||
|
// Plain number — no prefix
|
||||||
|
let num = id.parse().ok()?;
|
||||||
|
Some(("unknown", num))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a query parameter by name from the request.
|
||||||
|
pub fn get_query_param(req: &HttpRequest, name: &str) -> Option<String> {
|
||||||
|
let qs = req.query_string();
|
||||||
|
let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
|
||||||
|
params.into_iter().find(|(k, _)| k == name).map(|(_, v)| v)
|
||||||
|
}
|
||||||
312
src/routes/subsonic/media.rs
Normal file
312
src/routes/subsonic/media.rs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
use actix_files::NamedFile;
|
||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use shanty_db::queries;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||||
|
use super::response;
|
||||||
|
|
||||||
|
/// GET /rest/stream[.view]
|
||||||
|
pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_ext = std::path::Path::new(&track.file_path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
let requested_format = get_query_param(&req, "format");
|
||||||
|
let max_bit_rate = get_query_param(&req, "maxBitRate")
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Determine if transcoding is needed:
|
||||||
|
// - Client explicitly requests a different format
|
||||||
|
// - File is opus/ogg (many mobile clients can't play these natively)
|
||||||
|
// - Client requests a specific bitrate
|
||||||
|
let needs_transcode = match requested_format.as_deref() {
|
||||||
|
Some("raw") => false, // Explicitly asked for no transcoding
|
||||||
|
Some(fmt) if fmt != file_ext => true, // Different format requested
|
||||||
|
_ => {
|
||||||
|
// Auto-transcode opus/ogg to mp3 since many clients don't support them
|
||||||
|
matches!(file_ext.as_str(), "opus" | "ogg") || (max_bit_rate > 0 && max_bit_rate < 320)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check file exists before doing anything
|
||||||
|
if !std::path::Path::new(&track.file_path).exists() {
|
||||||
|
tracing::error!(path = %track.file_path, "track file not found on disk");
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
&format!("file not found: {}", track.file_path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_transcode {
|
||||||
|
let target_format = requested_format
|
||||||
|
.as_deref()
|
||||||
|
.filter(|f| *f != "raw")
|
||||||
|
.unwrap_or("mp3");
|
||||||
|
let bitrate = if max_bit_rate > 0 {
|
||||||
|
max_bit_rate
|
||||||
|
} else {
|
||||||
|
192 // Default transcoding bitrate
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_type = match target_format {
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"opus" => "audio/ogg",
|
||||||
|
"ogg" => "audio/ogg",
|
||||||
|
"aac" | "m4a" => "audio/mp4",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
_ => "audio/mpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
track_id = track_id,
|
||||||
|
from = %file_ext,
|
||||||
|
to = target_format,
|
||||||
|
bitrate = bitrate,
|
||||||
|
"transcoding stream"
|
||||||
|
);
|
||||||
|
|
||||||
|
match Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
"-i",
|
||||||
|
&track.file_path,
|
||||||
|
"-map",
|
||||||
|
"0:a",
|
||||||
|
"-b:a",
|
||||||
|
&format!("{bitrate}k"),
|
||||||
|
"-v",
|
||||||
|
"0",
|
||||||
|
"-f",
|
||||||
|
target_format,
|
||||||
|
"-",
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(output) => {
|
||||||
|
if output.status.success() && !output.stdout.is_empty() {
|
||||||
|
tracing::debug!(
|
||||||
|
track_id = track_id,
|
||||||
|
bytes = output.stdout.len(),
|
||||||
|
"transcoding complete"
|
||||||
|
);
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(content_type)
|
||||||
|
.body(output.stdout)
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
tracing::error!(
|
||||||
|
status = ?output.status,
|
||||||
|
stderr = %stderr,
|
||||||
|
path = %track.file_path,
|
||||||
|
"ffmpeg transcoding failed"
|
||||||
|
);
|
||||||
|
match NamedFile::open_async(&track.file_path).await {
|
||||||
|
Ok(file) => file.into_response(&req),
|
||||||
|
Err(_) => response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"transcoding failed",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "failed to start ffmpeg");
|
||||||
|
match NamedFile::open_async(&track.file_path).await {
|
||||||
|
Ok(file) => file.into_response(&req),
|
||||||
|
Err(_) => {
|
||||||
|
response::error(¶ms.format, response::ERROR_NOT_FOUND, "file not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Serve the file directly with Range request support
|
||||||
|
match NamedFile::open_async(&track.file_path).await {
|
||||||
|
Ok(file) => file.into_response(&req),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for streaming");
|
||||||
|
response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"file not found on disk",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/download[.view]
|
||||||
|
pub async fn download(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match NamedFile::open_async(&track.file_path).await {
|
||||||
|
Ok(file) => {
|
||||||
|
let file = file.set_content_disposition(actix_web::http::header::ContentDisposition {
|
||||||
|
disposition: actix_web::http::header::DispositionType::Attachment,
|
||||||
|
parameters: vec![actix_web::http::header::DispositionParam::Filename(
|
||||||
|
std::path::Path::new(&track.file_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("track")
|
||||||
|
.to_string(),
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
file.into_response(&req)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for download");
|
||||||
|
response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"file not found on disk",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getCoverArt[.view]
|
||||||
|
pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cover art IDs can be album IDs (al-N) or artist IDs (ar-N)
|
||||||
|
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"invalid cover art id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match prefix {
|
||||||
|
"al" => {
|
||||||
|
let album = match queries::albums::get_by_id(state.db.conn(), entity_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"album not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref cover_art_path) = album.cover_art_path {
|
||||||
|
// If it's a URL, redirect to it
|
||||||
|
if cover_art_path.starts_with("http://") || cover_art_path.starts_with("https://") {
|
||||||
|
return HttpResponse::TemporaryRedirect()
|
||||||
|
.append_header(("Location", cover_art_path.as_str()))
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise try to serve as a local file
|
||||||
|
match NamedFile::open_async(cover_art_path).await {
|
||||||
|
Ok(file) => return file.into_response(&req),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %cover_art_path, error = %e, "cover art file not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If album has a MusicBrainz ID, redirect to Cover Art Archive
|
||||||
|
if let Some(ref mbid) = album.musicbrainz_id {
|
||||||
|
let url = format!("https://coverartarchive.org/release/{mbid}/front-250");
|
||||||
|
return HttpResponse::TemporaryRedirect()
|
||||||
|
.append_header(("Location", url.as_str()))
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cover art available
|
||||||
|
HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// For other types, no cover art
|
||||||
|
HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/routes/subsonic/mod.rs
Normal file
86
src/routes/subsonic/mod.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
mod annotation;
|
||||||
|
mod auth;
|
||||||
|
mod browsing;
|
||||||
|
mod helpers;
|
||||||
|
mod media;
|
||||||
|
mod playlists;
|
||||||
|
mod response;
|
||||||
|
mod search;
|
||||||
|
mod system;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
use actix_web::web;
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/rest")
|
||||||
|
// System
|
||||||
|
.route("/ping", web::get().to(system::ping))
|
||||||
|
.route("/ping.view", web::get().to(system::ping))
|
||||||
|
.route("/getLicense", web::get().to(system::get_license))
|
||||||
|
.route("/getLicense.view", web::get().to(system::get_license))
|
||||||
|
// Browsing
|
||||||
|
.route(
|
||||||
|
"/getMusicFolders",
|
||||||
|
web::get().to(browsing::get_music_folders),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/getMusicFolders.view",
|
||||||
|
web::get().to(browsing::get_music_folders),
|
||||||
|
)
|
||||||
|
.route("/getIndexes", web::get().to(browsing::get_indexes))
|
||||||
|
.route("/getIndexes.view", web::get().to(browsing::get_indexes))
|
||||||
|
.route(
|
||||||
|
"/getMusicDirectory",
|
||||||
|
web::get().to(browsing::get_music_directory),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/getMusicDirectory.view",
|
||||||
|
web::get().to(browsing::get_music_directory),
|
||||||
|
)
|
||||||
|
.route("/getArtists", web::get().to(browsing::get_artists))
|
||||||
|
.route("/getArtists.view", web::get().to(browsing::get_artists))
|
||||||
|
.route("/getArtist", web::get().to(browsing::get_artist))
|
||||||
|
.route("/getArtist.view", web::get().to(browsing::get_artist))
|
||||||
|
.route("/getAlbum", web::get().to(browsing::get_album))
|
||||||
|
.route("/getAlbum.view", web::get().to(browsing::get_album))
|
||||||
|
.route("/getSong", web::get().to(browsing::get_song))
|
||||||
|
.route("/getSong.view", web::get().to(browsing::get_song))
|
||||||
|
.route("/getGenres", web::get().to(browsing::get_genres))
|
||||||
|
.route("/getGenres.view", web::get().to(browsing::get_genres))
|
||||||
|
// Search
|
||||||
|
.route("/search3", web::get().to(search::search3))
|
||||||
|
.route("/search3.view", web::get().to(search::search3))
|
||||||
|
// Media
|
||||||
|
.route("/stream", web::get().to(media::stream))
|
||||||
|
.route("/stream.view", web::get().to(media::stream))
|
||||||
|
.route("/download", web::get().to(media::download))
|
||||||
|
.route("/download.view", web::get().to(media::download))
|
||||||
|
.route("/getCoverArt", web::get().to(media::get_cover_art))
|
||||||
|
.route("/getCoverArt.view", web::get().to(media::get_cover_art))
|
||||||
|
// Playlists
|
||||||
|
.route("/getPlaylists", web::get().to(playlists::get_playlists))
|
||||||
|
.route(
|
||||||
|
"/getPlaylists.view",
|
||||||
|
web::get().to(playlists::get_playlists),
|
||||||
|
)
|
||||||
|
.route("/getPlaylist", web::get().to(playlists::get_playlist))
|
||||||
|
.route("/getPlaylist.view", web::get().to(playlists::get_playlist))
|
||||||
|
.route("/createPlaylist", web::get().to(playlists::create_playlist))
|
||||||
|
.route(
|
||||||
|
"/createPlaylist.view",
|
||||||
|
web::get().to(playlists::create_playlist),
|
||||||
|
)
|
||||||
|
.route("/deletePlaylist", web::get().to(playlists::delete_playlist))
|
||||||
|
.route(
|
||||||
|
"/deletePlaylist.view",
|
||||||
|
web::get().to(playlists::delete_playlist),
|
||||||
|
)
|
||||||
|
// Annotation
|
||||||
|
.route("/scrobble", web::get().to(annotation::scrobble))
|
||||||
|
.route("/scrobble.view", web::get().to(annotation::scrobble))
|
||||||
|
// User
|
||||||
|
.route("/getUser", web::get().to(user::get_user))
|
||||||
|
.route("/getUser.view", web::get().to(user::get_user)),
|
||||||
|
);
|
||||||
|
}
|
||||||
250
src/routes/subsonic/playlists.rs
Normal file
250
src/routes/subsonic/playlists.rs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
|
use shanty_db::queries;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||||
|
use super::response::{self, SubsonicChild};
|
||||||
|
|
||||||
|
/// GET /rest/getPlaylists[.view]
|
||||||
|
pub async fn get_playlists(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let playlists = queries::playlists::list(state.db.conn(), Some(user.id))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut playlist_list: Vec<serde_json::Value> = Vec::new();
|
||||||
|
for pl in &playlists {
|
||||||
|
let track_count = queries::playlists::get_track_count(state.db.conn(), pl.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Calculate total duration
|
||||||
|
let tracks = queries::playlists::get_tracks(state.db.conn(), pl.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let duration: i32 = tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
let mut pl_json = serde_json::json!({
|
||||||
|
"id": format!("pl-{}", pl.id),
|
||||||
|
"name": pl.name,
|
||||||
|
"owner": user.username,
|
||||||
|
"public": false,
|
||||||
|
"songCount": track_count,
|
||||||
|
"duration": duration,
|
||||||
|
"created": pl.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"changed": pl.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
});
|
||||||
|
if let Some(ref desc) = pl.description {
|
||||||
|
pl_json["comment"] = serde_json::json!(desc);
|
||||||
|
}
|
||||||
|
playlist_list.push(pl_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"playlists": {
|
||||||
|
"playlist": playlist_list,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getPlaylist[.view]
|
||||||
|
pub async fn get_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"invalid playlist id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let playlist = match queries::playlists::get_by_id(state.db.conn(), playlist_id).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"playlist not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tracks = queries::playlists::get_tracks(state.db.conn(), playlist_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let duration: i32 = tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
let entry_list: Vec<serde_json::Value> = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut pl_json = serde_json::json!({
|
||||||
|
"id": format!("pl-{}", playlist.id),
|
||||||
|
"name": playlist.name,
|
||||||
|
"owner": user.username,
|
||||||
|
"public": false,
|
||||||
|
"songCount": tracks.len(),
|
||||||
|
"duration": duration,
|
||||||
|
"created": playlist.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"changed": playlist.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"entry": entry_list,
|
||||||
|
});
|
||||||
|
if let Some(ref desc) = playlist.description {
|
||||||
|
pl_json["comment"] = serde_json::json!(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"playlist": pl_json,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/createPlaylist[.view]
|
||||||
|
pub async fn create_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = match get_query_param(&req, "name") {
|
||||||
|
Some(n) => n,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: name",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect songId params (can be repeated)
|
||||||
|
let qs = req.query_string();
|
||||||
|
let query_params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
|
||||||
|
let track_ids: Vec<i32> = query_params
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| k == "songId")
|
||||||
|
.filter_map(|(_, v)| parse_subsonic_id(v).map(|(_, id)| id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match queries::playlists::create(state.db.conn(), &name, None, Some(user.id), &track_ids).await
|
||||||
|
{
|
||||||
|
Ok(playlist) => {
|
||||||
|
let tracks = queries::playlists::get_tracks(state.db.conn(), playlist.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let duration: i32 = tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.duration.map(|d| d as i32))
|
||||||
|
.sum();
|
||||||
|
let entry_list: Vec<serde_json::Value> = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| {
|
||||||
|
serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut pl_json = serde_json::json!({
|
||||||
|
"id": format!("pl-{}", playlist.id),
|
||||||
|
"name": playlist.name,
|
||||||
|
"owner": user.username,
|
||||||
|
"public": false,
|
||||||
|
"songCount": tracks.len(),
|
||||||
|
"duration": duration,
|
||||||
|
"created": playlist.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"changed": playlist.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"entry": entry_list,
|
||||||
|
});
|
||||||
|
if let Some(ref desc) = playlist.description {
|
||||||
|
pl_json["comment"] = serde_json::json!(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"playlist": pl_json,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_GENERIC,
|
||||||
|
&format!("failed to create playlist: {e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/deletePlaylist[.view]
|
||||||
|
pub async fn delete_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"invalid playlist id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match queries::playlists::delete(state.db.conn(), playlist_id).await {
|
||||||
|
Ok(()) => response::ok(¶ms.format, serde_json::json!({})),
|
||||||
|
Err(e) => response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_GENERIC,
|
||||||
|
&format!("failed to delete playlist: {e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/routes/subsonic/response.rs
Normal file
249
src/routes/subsonic/response.rs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
use actix_web::HttpResponse;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
const SUBSONIC_VERSION: &str = "1.16.1";
|
||||||
|
const XMLNS: &str = "http://subsonic.org/restapi";
|
||||||
|
|
||||||
|
/// Build a successful Subsonic response in the requested format.
|
||||||
|
pub fn ok(format: &str, body: serde_json::Value) -> HttpResponse {
|
||||||
|
format_response(format, "ok", body, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Subsonic error response.
|
||||||
|
pub fn error(format: &str, code: u32, message: &str) -> HttpResponse {
|
||||||
|
let err = serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format_response(format, "failed", err, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subsonic error codes.
|
||||||
|
pub const ERROR_GENERIC: u32 = 0;
|
||||||
|
pub const ERROR_MISSING_PARAM: u32 = 10;
|
||||||
|
pub const ERROR_NOT_AUTHENTICATED: u32 = 40;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const ERROR_NOT_AUTHORIZED: u32 = 50;
|
||||||
|
pub const ERROR_NOT_FOUND: u32 = 70;
|
||||||
|
|
||||||
|
fn format_response(
|
||||||
|
format: &str,
|
||||||
|
status: &str,
|
||||||
|
body: serde_json::Value,
|
||||||
|
_type_attr: Option<&str>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
match format {
|
||||||
|
"json" => format_json(status, body),
|
||||||
|
_ => format_xml(status, body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_json(status: &str, body: serde_json::Value) -> HttpResponse {
|
||||||
|
let mut response = serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"version": SUBSONIC_VERSION,
|
||||||
|
"type": "shanty",
|
||||||
|
"serverVersion": "0.1.0",
|
||||||
|
"openSubsonic": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge body into response
|
||||||
|
if let serde_json::Value::Object(map) = body
|
||||||
|
&& let serde_json::Value::Object(ref mut resp_map) = response
|
||||||
|
{
|
||||||
|
for (k, v) in map {
|
||||||
|
resp_map.insert(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapper = serde_json::json!({
|
||||||
|
"subsonic-response": response,
|
||||||
|
});
|
||||||
|
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_xml(status: &str, body: serde_json::Value) -> HttpResponse {
|
||||||
|
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
|
xml.push_str(&format!(
|
||||||
|
"<subsonic-response xmlns=\"{XMLNS}\" status=\"{status}\" version=\"{SUBSONIC_VERSION}\" type=\"shanty\" serverVersion=\"0.1.0\" openSubsonic=\"true\">"
|
||||||
|
));
|
||||||
|
|
||||||
|
if let serde_json::Value::Object(map) = &body {
|
||||||
|
for (key, value) in map {
|
||||||
|
json_to_xml(&mut xml, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xml.push_str("</subsonic-response>");
|
||||||
|
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type("application/xml; charset=UTF-8")
|
||||||
|
.body(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a JSON value into XML elements. The Subsonic XML format uses:
|
||||||
|
/// - Object keys become element names
|
||||||
|
/// - Primitive values in objects become attributes
|
||||||
|
/// - Arrays become repeated elements
|
||||||
|
/// - Nested objects become child elements
|
||||||
|
fn json_to_xml(xml: &mut String, tag: &str, value: &serde_json::Value) {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
for item in arr {
|
||||||
|
json_to_xml(xml, tag, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
xml.push_str(&format!("<{tag}"));
|
||||||
|
|
||||||
|
let mut children = Vec::new();
|
||||||
|
for (k, v) in map {
|
||||||
|
match v {
|
||||||
|
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
|
||||||
|
children.push((k, v));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let val_str = match v {
|
||||||
|
serde_json::Value::String(s) => xml_escape(s),
|
||||||
|
serde_json::Value::Bool(b) => b.to_string(),
|
||||||
|
serde_json::Value::Number(n) => n.to_string(),
|
||||||
|
serde_json::Value::Null => String::new(),
|
||||||
|
_ => v.to_string(),
|
||||||
|
};
|
||||||
|
xml.push_str(&format!(" {k}=\"{val_str}\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if children.is_empty() {
|
||||||
|
xml.push_str("/>");
|
||||||
|
} else {
|
||||||
|
xml.push('>');
|
||||||
|
for (k, v) in children {
|
||||||
|
json_to_xml(xml, k, v);
|
||||||
|
}
|
||||||
|
xml.push_str(&format!("</{tag}>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
xml.push_str(&format!("<{tag}>{}</{tag}>", xml_escape(s)));
|
||||||
|
}
|
||||||
|
serde_json::Value::Number(n) => {
|
||||||
|
xml.push_str(&format!("<{tag}>{n}</{tag}>"));
|
||||||
|
}
|
||||||
|
serde_json::Value::Bool(b) => {
|
||||||
|
xml.push_str(&format!("<{tag}>{b}</{tag}>"));
|
||||||
|
}
|
||||||
|
serde_json::Value::Null => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to build a "child" (track) JSON for Subsonic responses.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubsonicChild {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent: Option<String>,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub album: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub track: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub genre: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub suffix: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bit_rate: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist_id: Option<String>,
|
||||||
|
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub media_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubsonicChild {
|
||||||
|
pub fn from_track(track: &shanty_db::entities::track::Model) -> Self {
|
||||||
|
let suffix = std::path::Path::new(&track.file_path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let content_type = suffix.as_deref().map(|s| {
|
||||||
|
match s {
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"ogg" | "opus" => "audio/ogg",
|
||||||
|
"m4a" | "aac" => "audio/mp4",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"wma" => "audio/x-ms-wma",
|
||||||
|
_ => "audio/mpeg",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let path_display = format!(
|
||||||
|
"{}/{}",
|
||||||
|
track.artist.as_deref().unwrap_or("Unknown Artist"),
|
||||||
|
std::path::Path::new(&track.file_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
);
|
||||||
|
|
||||||
|
SubsonicChild {
|
||||||
|
id: format!("tr-{}", track.id),
|
||||||
|
parent: track.album_id.map(|id| format!("al-{id}")),
|
||||||
|
is_dir: false,
|
||||||
|
title: track.title.clone().unwrap_or_else(|| "Unknown".to_string()),
|
||||||
|
album: track.album.clone(),
|
||||||
|
artist: track.artist.clone(),
|
||||||
|
track: track.track_number,
|
||||||
|
year: track.year,
|
||||||
|
genre: track.genre.clone(),
|
||||||
|
cover_art: track.album_id.map(|id| format!("al-{id}")),
|
||||||
|
size: Some(track.file_size),
|
||||||
|
content_type,
|
||||||
|
suffix,
|
||||||
|
duration: track.duration.map(|d| d as i32),
|
||||||
|
bit_rate: track.bitrate,
|
||||||
|
path: Some(path_display),
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
album_id: track.album_id.map(|id| format!("al-{id}")),
|
||||||
|
artist_id: track.artist_id.map(|id| format!("ar-{id}")),
|
||||||
|
media_type: Some("music".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/routes/subsonic/search.rs
Normal file
124
src/routes/subsonic/search.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
|
use shanty_db::queries;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::helpers::{authenticate, get_query_param};
|
||||||
|
use super::response::{self, SubsonicChild};
|
||||||
|
|
||||||
|
/// GET /rest/search3[.view]
|
||||||
|
pub async fn search3(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = match get_query_param(&req, "query") {
|
||||||
|
Some(q) => q,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: query",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let artist_count: u64 = get_query_param(&req, "artistCount")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(20);
|
||||||
|
let album_count: u64 = get_query_param(&req, "albumCount")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(20);
|
||||||
|
let song_count: u64 = get_query_param(&req, "songCount")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(20);
|
||||||
|
|
||||||
|
// Search tracks (which gives us artists and albums too)
|
||||||
|
let tracks = queries::tracks::search(state.db.conn(), &query)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Collect unique artists from tracks
|
||||||
|
let mut seen_artists = std::collections::HashSet::new();
|
||||||
|
let mut artist_results: Vec<serde_json::Value> = Vec::new();
|
||||||
|
for track in &tracks {
|
||||||
|
if let Some(artist_id) = track.artist_id
|
||||||
|
&& seen_artists.insert(artist_id)
|
||||||
|
&& artist_results.len() < artist_count as usize
|
||||||
|
&& let Ok(artist) = queries::artists::get_by_id(state.db.conn(), artist_id).await
|
||||||
|
{
|
||||||
|
let album_ct = queries::albums::get_by_artist(state.db.conn(), artist_id)
|
||||||
|
.await
|
||||||
|
.map(|a| a.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
artist_results.push(serde_json::json!({
|
||||||
|
"id": format!("ar-{}", artist.id),
|
||||||
|
"name": artist.name,
|
||||||
|
"albumCount": album_ct,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also search artists by name directly
|
||||||
|
let all_artists = queries::artists::list(state.db.conn(), 10000, 0)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
for artist in &all_artists {
|
||||||
|
if artist.name.to_lowercase().contains(&query_lower)
|
||||||
|
&& seen_artists.insert(artist.id)
|
||||||
|
&& artist_results.len() < artist_count as usize
|
||||||
|
{
|
||||||
|
let album_ct = queries::albums::get_by_artist(state.db.conn(), artist.id)
|
||||||
|
.await
|
||||||
|
.map(|a| a.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
artist_results.push(serde_json::json!({
|
||||||
|
"id": format!("ar-{}", artist.id),
|
||||||
|
"name": artist.name,
|
||||||
|
"albumCount": album_ct,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique albums from tracks
|
||||||
|
let mut seen_albums = std::collections::HashSet::new();
|
||||||
|
let mut album_results: Vec<serde_json::Value> = Vec::new();
|
||||||
|
for track in &tracks {
|
||||||
|
if let Some(aid) = track.album_id
|
||||||
|
&& seen_albums.insert(aid)
|
||||||
|
&& album_results.len() < album_count as usize
|
||||||
|
&& let Ok(album) = queries::albums::get_by_id(state.db.conn(), aid).await
|
||||||
|
{
|
||||||
|
album_results.push(serde_json::json!({
|
||||||
|
"id": format!("al-{}", album.id),
|
||||||
|
"name": album.name,
|
||||||
|
"artist": album.album_artist,
|
||||||
|
"artistId": album.artist_id.map(|id| format!("ar-{id}")),
|
||||||
|
"coverArt": format!("al-{}", album.id),
|
||||||
|
"year": album.year,
|
||||||
|
"genre": album.genre,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Song results
|
||||||
|
let song_results: Vec<serde_json::Value> = tracks
|
||||||
|
.iter()
|
||||||
|
.take(song_count as usize)
|
||||||
|
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"searchResult3": {
|
||||||
|
"artist": artist_results,
|
||||||
|
"album": album_results,
|
||||||
|
"song": song_results,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/routes/subsonic/system.rs
Normal file
35
src/routes/subsonic/system.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::helpers::authenticate;
|
||||||
|
use super::response;
|
||||||
|
|
||||||
|
/// GET /rest/ping[.view]
|
||||||
|
pub async fn ping(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
response::ok(¶ms.format, serde_json::json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getLicense[.view]
|
||||||
|
pub async fn get_license(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"license": {
|
||||||
|
"valid": true,
|
||||||
|
"email": "shanty@localhost",
|
||||||
|
"licenseExpires": "2099-12-31T23:59:59",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/routes/subsonic/user.rs
Normal file
42
src/routes/subsonic/user.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
|
use shanty_db::entities::user::UserRole;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::helpers::authenticate;
|
||||||
|
use super::response;
|
||||||
|
|
||||||
|
/// GET /rest/getUser[.view]
|
||||||
|
pub async fn get_user(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_admin = user.role == UserRole::Admin;
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"user": {
|
||||||
|
"username": user.username,
|
||||||
|
"email": "",
|
||||||
|
"scrobblingEnabled": false,
|
||||||
|
"adminRole": is_admin,
|
||||||
|
"settingsRole": is_admin,
|
||||||
|
"downloadRole": true,
|
||||||
|
"uploadRole": false,
|
||||||
|
"playlistRole": true,
|
||||||
|
"coverArtRole": false,
|
||||||
|
"commentRole": false,
|
||||||
|
"podcastRole": false,
|
||||||
|
"streamRole": true,
|
||||||
|
"jukeboxRole": false,
|
||||||
|
"shareRole": false,
|
||||||
|
"videoConversionRole": false,
|
||||||
|
"folder": [1],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(web::resource("/tasks/{id}").route(web::get().to(get_task)))
|
.service(web::resource("/tasks/{id}").route(web::get().to(get_task)))
|
||||||
.service(web::resource("/watchlist").route(web::get().to(list_watchlist)))
|
.service(web::resource("/watchlist").route(web::get().to(list_watchlist)))
|
||||||
.service(web::resource("/watchlist/{id}").route(web::delete().to(remove_watchlist)))
|
.service(web::resource("/watchlist/{id}").route(web::delete().to(remove_watchlist)))
|
||||||
|
.service(web::resource("/monitor/check").route(web::post().to(trigger_monitor_check)))
|
||||||
|
.service(web::resource("/monitor/status").route(web::get().to(get_monitor_status)))
|
||||||
|
.service(web::resource("/scheduler/skip-pipeline").route(web::post().to(skip_pipeline)))
|
||||||
|
.service(web::resource("/scheduler/skip-monitor").route(web::post().to(skip_monitor)))
|
||||||
|
.service(web::resource("/mb-status").route(web::get().to(get_mb_status)))
|
||||||
|
.service(web::resource("/mb-import").route(web::post().to(trigger_mb_import)))
|
||||||
.service(
|
.service(
|
||||||
web::resource("/config")
|
web::resource("/config")
|
||||||
.route(web::get().to(get_config))
|
.route(web::get().to(get_config))
|
||||||
@@ -27,7 +33,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_status(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn get_status(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let summary = shanty_watch::library_summary(state.db.conn()).await?;
|
let summary = shanty_watch::library_summary(state.db.conn()).await?;
|
||||||
let pending_items =
|
let pending_items =
|
||||||
@@ -45,6 +54,14 @@ async fn get_status(state: web::Data<AppState>, session: Session) -> Result<Http
|
|||||||
|
|
||||||
let needs_tagging = queries::tracks::get_needing_metadata(state.db.conn()).await?;
|
let needs_tagging = queries::tracks::get_needing_metadata(state.db.conn()).await?;
|
||||||
|
|
||||||
|
// Scheduled task info
|
||||||
|
let sched = state.scheduler.lock().await;
|
||||||
|
let scheduled_tasks = serde_json::json!({
|
||||||
|
"next_pipeline": sched.next_pipeline,
|
||||||
|
"next_monitor": sched.next_monitor,
|
||||||
|
});
|
||||||
|
drop(sched);
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
"library": summary,
|
"library": summary,
|
||||||
"queue": {
|
"queue": {
|
||||||
@@ -58,10 +75,14 @@ async fn get_status(state: web::Data<AppState>, session: Session) -> Result<Http
|
|||||||
"items": needs_tagging.iter().take(20).collect::<Vec<_>>(),
|
"items": needs_tagging.iter().take(20).collect::<Vec<_>>(),
|
||||||
},
|
},
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
|
"scheduled": scheduled_tasks,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trigger_index(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn trigger_index(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let task_id = state.tasks.register("index");
|
let task_id = state.tasks.register("index");
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
@@ -86,7 +107,10 @@ async fn trigger_index(state: web::Data<AppState>, session: Session) -> Result<H
|
|||||||
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trigger_tag(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn trigger_tag(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let task_id = state.tasks.register("tag");
|
let task_id = state.tasks.register("tag");
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
@@ -119,7 +143,10 @@ async fn trigger_tag(state: web::Data<AppState>, session: Session) -> Result<Htt
|
|||||||
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trigger_organize(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn trigger_organize(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let task_id = state.tasks.register("organize");
|
let task_id = state.tasks.register("organize");
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
@@ -156,160 +183,12 @@ async fn trigger_organize(state: web::Data<AppState>, session: Session) -> Resul
|
|||||||
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn trigger_pipeline(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn trigger_pipeline(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let sync_id = state.tasks.register_pending("sync");
|
let task_ids = crate::pipeline::spawn_pipeline(&state);
|
||||||
let download_id = state.tasks.register_pending("download");
|
|
||||||
let index_id = state.tasks.register_pending("index");
|
|
||||||
let tag_id = state.tasks.register_pending("tag");
|
|
||||||
let organize_id = state.tasks.register_pending("organize");
|
|
||||||
let enrich_id = state.tasks.register_pending("enrich");
|
|
||||||
|
|
||||||
let task_ids = vec![
|
|
||||||
sync_id.clone(),
|
|
||||||
download_id.clone(),
|
|
||||||
index_id.clone(),
|
|
||||||
tag_id.clone(),
|
|
||||||
organize_id.clone(),
|
|
||||||
enrich_id.clone(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let state = state.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let cfg = state.config.read().await.clone();
|
|
||||||
|
|
||||||
// Step 1: Sync
|
|
||||||
state.tasks.start(&sync_id);
|
|
||||||
state
|
|
||||||
.tasks
|
|
||||||
.update_progress(&sync_id, 0, 0, "Syncing watchlist to download queue...");
|
|
||||||
match shanty_dl::sync_wanted_to_queue(state.db.conn(), false).await {
|
|
||||||
Ok(stats) => state.tasks.complete(&sync_id, format!("{stats}")),
|
|
||||||
Err(e) => state.tasks.fail(&sync_id, e.to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Download
|
|
||||||
state.tasks.start(&download_id);
|
|
||||||
let cookies = cfg.download.cookies_path.clone();
|
|
||||||
let format: shanty_dl::AudioFormat = cfg
|
|
||||||
.download
|
|
||||||
.format
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(shanty_dl::AudioFormat::Opus);
|
|
||||||
let source: shanty_dl::SearchSource = cfg
|
|
||||||
.download
|
|
||||||
.search_source
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(shanty_dl::SearchSource::YouTubeMusic);
|
|
||||||
let rate = if cookies.is_some() {
|
|
||||||
cfg.download.rate_limit_auth
|
|
||||||
} else {
|
|
||||||
cfg.download.rate_limit
|
|
||||||
};
|
|
||||||
let backend = shanty_dl::YtDlpBackend::new(rate, source, cookies.clone());
|
|
||||||
let backend_config = shanty_dl::BackendConfig {
|
|
||||||
output_dir: cfg.download_path.clone(),
|
|
||||||
format,
|
|
||||||
cookies_path: cookies,
|
|
||||||
};
|
|
||||||
let task_state = state.clone();
|
|
||||||
let progress_tid = download_id.clone();
|
|
||||||
let on_progress: shanty_dl::ProgressFn = Box::new(move |current, total, msg| {
|
|
||||||
task_state
|
|
||||||
.tasks
|
|
||||||
.update_progress(&progress_tid, current, total, msg);
|
|
||||||
});
|
|
||||||
match shanty_dl::run_queue_with_progress(
|
|
||||||
state.db.conn(),
|
|
||||||
&backend,
|
|
||||||
&backend_config,
|
|
||||||
false,
|
|
||||||
Some(on_progress),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(stats) => {
|
|
||||||
let _ = queries::cache::purge_prefix(state.db.conn(), "artist_totals:").await;
|
|
||||||
state.tasks.complete(&download_id, format!("{stats}"));
|
|
||||||
}
|
|
||||||
Err(e) => state.tasks.fail(&download_id, e.to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Index
|
|
||||||
state.tasks.start(&index_id);
|
|
||||||
state
|
|
||||||
.tasks
|
|
||||||
.update_progress(&index_id, 0, 0, "Scanning library...");
|
|
||||||
let scan_config = shanty_index::ScanConfig {
|
|
||||||
root: cfg.library_path.clone(),
|
|
||||||
dry_run: false,
|
|
||||||
concurrency: cfg.indexing.concurrency,
|
|
||||||
};
|
|
||||||
match shanty_index::run_scan(state.db.conn(), &scan_config).await {
|
|
||||||
Ok(stats) => state.tasks.complete(&index_id, format!("{stats}")),
|
|
||||||
Err(e) => state.tasks.fail(&index_id, e.to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Tag
|
|
||||||
state.tasks.start(&tag_id);
|
|
||||||
state
|
|
||||||
.tasks
|
|
||||||
.update_progress(&tag_id, 0, 0, "Tagging tracks...");
|
|
||||||
match shanty_tag::MusicBrainzClient::new() {
|
|
||||||
Ok(mb) => {
|
|
||||||
let tag_config = shanty_tag::TagConfig {
|
|
||||||
dry_run: false,
|
|
||||||
write_tags: cfg.tagging.write_tags,
|
|
||||||
confidence: cfg.tagging.confidence,
|
|
||||||
};
|
|
||||||
match shanty_tag::run_tagging(state.db.conn(), &mb, &tag_config, None).await {
|
|
||||||
Ok(stats) => state.tasks.complete(&tag_id, format!("{stats}")),
|
|
||||||
Err(e) => state.tasks.fail(&tag_id, e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => state.tasks.fail(&tag_id, e.to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Organize
|
|
||||||
state.tasks.start(&organize_id);
|
|
||||||
state
|
|
||||||
.tasks
|
|
||||||
.update_progress(&organize_id, 0, 0, "Organizing files...");
|
|
||||||
let org_config = shanty_org::OrgConfig {
|
|
||||||
target_dir: cfg.library_path.clone(),
|
|
||||||
format: cfg.organization_format.clone(),
|
|
||||||
dry_run: false,
|
|
||||||
copy: false,
|
|
||||||
};
|
|
||||||
match shanty_org::organize_from_db(state.db.conn(), &org_config).await {
|
|
||||||
Ok(stats) => {
|
|
||||||
let promoted = queries::wanted::promote_downloaded_to_owned(state.db.conn())
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
let msg = if promoted > 0 {
|
|
||||||
format!("{stats} — {promoted} items marked as owned")
|
|
||||||
} else {
|
|
||||||
format!("{stats}")
|
|
||||||
};
|
|
||||||
state.tasks.complete(&organize_id, msg);
|
|
||||||
}
|
|
||||||
Err(e) => state.tasks.fail(&organize_id, e.to_string()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Enrich
|
|
||||||
state.tasks.start(&enrich_id);
|
|
||||||
state
|
|
||||||
.tasks
|
|
||||||
.update_progress(&enrich_id, 0, 0, "Refreshing artist data...");
|
|
||||||
match enrich_all_watched_artists(&state).await {
|
|
||||||
Ok(count) => state
|
|
||||||
.tasks
|
|
||||||
.complete(&enrich_id, format!("{count} artists refreshed")),
|
|
||||||
Err(e) => state.tasks.fail(&enrich_id, e.to_string()),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_ids": task_ids })))
|
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_ids": task_ids })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +205,10 @@ async fn get_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_watchlist(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn list_watchlist(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let (user_id, _, _) = auth::require_auth(&session)?;
|
let (user_id, _, _) = auth::require_auth(&session)?;
|
||||||
let items = shanty_watch::list_items(state.db.conn(), None, None, Some(user_id)).await?;
|
let items = shanty_watch::list_items(state.db.conn(), None, None, Some(user_id)).await?;
|
||||||
Ok(HttpResponse::Ok().json(items))
|
Ok(HttpResponse::Ok().json(items))
|
||||||
@@ -343,7 +225,57 @@ async fn remove_watchlist(
|
|||||||
Ok(HttpResponse::NoContent().finish())
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_config(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
async fn trigger_monitor_check(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
let state = state.clone();
|
||||||
|
let task_id = state.tasks.register("monitor_check");
|
||||||
|
let tid = task_id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(&tid, 0, 0, "Checking monitored artists...");
|
||||||
|
match crate::monitor::check_monitored_artists(&state).await {
|
||||||
|
Ok(stats) => state.tasks.complete(
|
||||||
|
&tid,
|
||||||
|
format!(
|
||||||
|
"{} artists checked, {} new releases, {} tracks added",
|
||||||
|
stats.artists_checked, stats.new_releases_found, stats.tracks_added
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Err(e) => state.tasks.fail(&tid, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_monitor_status(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let monitored = queries::artists::list_monitored(state.db.conn()).await?;
|
||||||
|
let items: Vec<serde_json::Value> = monitored
|
||||||
|
.iter()
|
||||||
|
.map(|a| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": a.id,
|
||||||
|
"name": a.name,
|
||||||
|
"musicbrainz_id": a.musicbrainz_id,
|
||||||
|
"monitored": a.monitored,
|
||||||
|
"last_checked_at": a.last_checked_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(HttpResponse::Ok().json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_config(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_auth(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let config = state.config.read().await;
|
let config = state.config.read().await;
|
||||||
Ok(HttpResponse::Ok().json(&*config))
|
Ok(HttpResponse::Ok().json(&*config))
|
||||||
@@ -375,3 +307,128 @@ async fn save_config(
|
|||||||
tracing::info!("config updated via API");
|
tracing::info!("config updated via API");
|
||||||
Ok(HttpResponse::Ok().json(&new_config))
|
Ok(HttpResponse::Ok().json(&new_config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_pipeline(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
let mut sched = state.scheduler.lock().await;
|
||||||
|
sched.skip_pipeline = true;
|
||||||
|
sched.next_pipeline = None;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"status": "skipped"})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_monitor(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
let mut sched = state.scheduler.lock().await;
|
||||||
|
sched.skip_monitor = true;
|
||||||
|
sched.next_monitor = None;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"status": "skipped"})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_mb_status(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let has_local = state.mb_client.has_local_db();
|
||||||
|
let stats = state.mb_client.local_stats();
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"has_local_db": has_local,
|
||||||
|
"stats": stats,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trigger_mb_import(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
let task_id = state.tasks.register("mb_import");
|
||||||
|
let tid = task_id.clone();
|
||||||
|
let config = state.config.read().await.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(&tid, 0, 0, "Starting MusicBrainz import...");
|
||||||
|
|
||||||
|
let data_dir = shanty_config::data_dir().join("mb-dumps");
|
||||||
|
let db_path = config
|
||||||
|
.musicbrainz
|
||||||
|
.local_db_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| shanty_config::data_dir().join("shanty-mb.db"));
|
||||||
|
|
||||||
|
// Download dumps
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(&tid, 0, 4, "Downloading dumps...");
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.fail(&tid, format!("Failed to create data dir: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = match shanty_data::mb_import::discover_latest_dump_folder().await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.fail(&tid, format!("Failed to discover latest dump: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (i, filename) in shanty_data::mb_import::DUMP_FILES.iter().enumerate() {
|
||||||
|
state.tasks.update_progress(
|
||||||
|
&tid,
|
||||||
|
i as u64,
|
||||||
|
4 + 4, // 4 downloads + 4 imports
|
||||||
|
&format!("Downloading {filename}..."),
|
||||||
|
);
|
||||||
|
if let Err(e) =
|
||||||
|
shanty_data::mb_import::download_dump(filename, ×tamp, &data_dir, |_| {}).await
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.fail(&tid, format!("Failed to download {filename}: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run import
|
||||||
|
state
|
||||||
|
.tasks
|
||||||
|
.update_progress(&tid, 4, 8, "Importing into database...");
|
||||||
|
|
||||||
|
let tid_clone = tid.clone();
|
||||||
|
let state_clone = state.clone();
|
||||||
|
// Run import in blocking task since rusqlite is sync
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
shanty_data::mb_import::run_import_at_path(&db_path, &data_dir, |msg| {
|
||||||
|
state_clone.tasks.update_progress(&tid_clone, 4, 8, msg);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(stats)) => {
|
||||||
|
state.tasks.complete(&tid, format!("{stats}"));
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
state.tasks.fail(&tid, format!("Import failed: {e}"));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.tasks.fail(&tid, format!("Import task panicked: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
|
||||||
|
}
|
||||||
|
|||||||
345
src/routes/ytauth.rs
Normal file
345
src/routes/ytauth.rs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use actix_session::Session;
|
||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::auth;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::state::{AppState, FirefoxLoginSession};
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/ytauth")
|
||||||
|
.route("/status", web::get().to(status))
|
||||||
|
.route("/login-start", web::post().to(login_start))
|
||||||
|
.route("/login-stop", web::post().to(login_stop))
|
||||||
|
.route("/refresh", web::post().to(refresh))
|
||||||
|
.route("/cookies", web::delete().to(clear_cookies)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AuthStatus {
|
||||||
|
authenticated: bool,
|
||||||
|
cookie_age_hours: Option<f64>,
|
||||||
|
cookie_count: Option<i64>,
|
||||||
|
refresh_enabled: bool,
|
||||||
|
login_session_active: bool,
|
||||||
|
vnc_url: Option<String>,
|
||||||
|
ytdlp_version: Option<String>,
|
||||||
|
ytdlp_latest: Option<String>,
|
||||||
|
ytdlp_update_available: bool,
|
||||||
|
lastfm_api_key_set: bool,
|
||||||
|
fanart_api_key_set: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/ytauth/status — check YouTube auth state.
|
||||||
|
async fn status(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let profile_dir = shanty_config::data_dir().join("firefox-profile");
|
||||||
|
|
||||||
|
// Check login session
|
||||||
|
let login = state.firefox_login.lock().await;
|
||||||
|
let login_active = login.is_some();
|
||||||
|
let vnc_url = login.as_ref().map(|s| s.vnc_url.clone());
|
||||||
|
drop(login);
|
||||||
|
|
||||||
|
// Run status check via Python script
|
||||||
|
let (authenticated, cookie_age, cookie_count) =
|
||||||
|
if let Ok(result) = run_cookie_manager(&["status", &profile_dir.to_string_lossy()]).await {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&result) {
|
||||||
|
(
|
||||||
|
v.get("authenticated")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
v.get("cookie_age_hours").and_then(|v| v.as_f64()),
|
||||||
|
v.get("cookie_count").and_then(|v| v.as_i64()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(false, None, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(false, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check yt-dlp version (local + latest from PyPI)
|
||||||
|
let (ytdlp_version, ytdlp_latest, ytdlp_update_available) = check_ytdlp_version().await;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(AuthStatus {
|
||||||
|
authenticated,
|
||||||
|
cookie_age_hours: cookie_age,
|
||||||
|
cookie_count,
|
||||||
|
refresh_enabled: config.download.cookie_refresh_enabled,
|
||||||
|
login_session_active: login_active,
|
||||||
|
vnc_url,
|
||||||
|
ytdlp_version,
|
||||||
|
ytdlp_latest,
|
||||||
|
ytdlp_update_available,
|
||||||
|
lastfm_api_key_set: config.metadata.lastfm_api_key.is_some(),
|
||||||
|
fanart_api_key_set: config.metadata.fanart_api_key.is_some(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/ytauth/login-start — launch Firefox + noVNC for interactive login.
|
||||||
|
async fn login_start(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
|
||||||
|
let mut login = state.firefox_login.lock().await;
|
||||||
|
if login.is_some() {
|
||||||
|
return Err(ApiError::BadRequest("login session already active".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let profile_dir = shanty_config::data_dir().join("firefox-profile");
|
||||||
|
let vnc_port = config.download.vnc_port;
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let result = run_cookie_manager(&[
|
||||||
|
"login-start",
|
||||||
|
&profile_dir.to_string_lossy(),
|
||||||
|
&vnc_port.to_string(),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("failed to start login: {e}")))?;
|
||||||
|
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&result)
|
||||||
|
.map_err(|e| ApiError::Internal(format!("bad response from cookie_manager: {e}")))?;
|
||||||
|
|
||||||
|
if v.get("status").and_then(|s| s.as_str()) == Some("error") {
|
||||||
|
let err = v
|
||||||
|
.get("error")
|
||||||
|
.and_then(|e| e.as_str())
|
||||||
|
.unwrap_or("unknown error");
|
||||||
|
return Err(ApiError::Internal(format!("login start failed: {err}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let vnc_url = v
|
||||||
|
.get("vnc_url")
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
*login = Some(FirefoxLoginSession {
|
||||||
|
vnc_url: vnc_url.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"status": "running",
|
||||||
|
"vnc_url": vnc_url,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/ytauth/login-stop — stop Firefox, export cookies, enable refresh.
|
||||||
|
async fn login_stop(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
|
||||||
|
let mut login = state.firefox_login.lock().await;
|
||||||
|
if login.is_none() {
|
||||||
|
return Err(ApiError::BadRequest("no active login session".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile_dir = shanty_config::data_dir().join("firefox-profile");
|
||||||
|
let cookies_path = shanty_config::data_dir().join("cookies.txt");
|
||||||
|
|
||||||
|
let result = run_cookie_manager(&[
|
||||||
|
"login-stop",
|
||||||
|
"--profile-dir",
|
||||||
|
&profile_dir.to_string_lossy(),
|
||||||
|
"--cookies-output",
|
||||||
|
&cookies_path.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("failed to stop login: {e}")))?;
|
||||||
|
|
||||||
|
*login = None;
|
||||||
|
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&result).unwrap_or_default();
|
||||||
|
|
||||||
|
// Update config to use the cookies and enable refresh
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
config.download.cookies_path = Some(cookies_path.clone());
|
||||||
|
config.download.cookie_refresh_enabled = true;
|
||||||
|
config
|
||||||
|
.save(state.config_path.as_deref())
|
||||||
|
.map_err(ApiError::Internal)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"status": "stopped",
|
||||||
|
"cookies_exported": v.get("cookies_count").is_some(),
|
||||||
|
"cookies_path": cookies_path.to_string_lossy(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/ytauth/refresh — trigger immediate headless cookie refresh.
|
||||||
|
async fn refresh(state: web::Data<AppState>, session: Session) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
|
||||||
|
let profile_dir = shanty_config::data_dir().join("firefox-profile");
|
||||||
|
let cookies_path = shanty_config::data_dir().join("cookies.txt");
|
||||||
|
|
||||||
|
if !profile_dir.exists() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"no Firefox profile found — log in first".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = run_cookie_manager(&[
|
||||||
|
"refresh",
|
||||||
|
&profile_dir.to_string_lossy(),
|
||||||
|
&cookies_path.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("refresh failed: {e}")))?;
|
||||||
|
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&result).unwrap_or_default();
|
||||||
|
|
||||||
|
if v.get("status").and_then(|s| s.as_str()) == Some("error") {
|
||||||
|
let err = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown");
|
||||||
|
return Err(ApiError::Internal(format!("refresh failed: {err}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure config points to cookies
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
if config.download.cookies_path.is_none() {
|
||||||
|
config.download.cookies_path = Some(cookies_path);
|
||||||
|
config
|
||||||
|
.save(state.config_path.as_deref())
|
||||||
|
.map_err(ApiError::Internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/ytauth/cookies — clear cookies and Firefox profile.
|
||||||
|
async fn clear_cookies(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_admin(&session)?;
|
||||||
|
|
||||||
|
let profile_dir = shanty_config::data_dir().join("firefox-profile");
|
||||||
|
let cookies_path = shanty_config::data_dir().join("cookies.txt");
|
||||||
|
|
||||||
|
// Remove cookies file
|
||||||
|
if cookies_path.exists() {
|
||||||
|
std::fs::remove_file(&cookies_path).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Firefox profile
|
||||||
|
if profile_dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&profile_dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
let mut config = state.config.write().await;
|
||||||
|
config.download.cookies_path = None;
|
||||||
|
config.download.cookie_refresh_enabled = false;
|
||||||
|
config
|
||||||
|
.save(state.config_path.as_deref())
|
||||||
|
.map_err(ApiError::Internal)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"status": "cleared"})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper ---
|
||||||
|
|
||||||
|
/// Find the cookie_manager.py script (same search logic as ytmusic_search.py).
|
||||||
|
fn find_cookie_manager_script() -> Result<PathBuf, ApiError> {
|
||||||
|
let candidates = [
|
||||||
|
// Next to the binary
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.join("cookie_manager.py"))),
|
||||||
|
// Standard install locations
|
||||||
|
Some(PathBuf::from("/usr/share/shanty/cookie_manager.py")),
|
||||||
|
Some(PathBuf::from("/usr/local/share/shanty/cookie_manager.py")),
|
||||||
|
// Development: crate root
|
||||||
|
Some(PathBuf::from("shanty-dl/scripts/cookie_manager.py")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for candidate in candidates.into_iter().flatten() {
|
||||||
|
if candidate.exists() {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ApiError::Internal("cookie_manager.py not found".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run cookie_manager.py with the given arguments and return stdout.
|
||||||
|
async fn run_cookie_manager(args: &[&str]) -> Result<String, String> {
|
||||||
|
let script = find_cookie_manager_script().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let output = Command::new("python3")
|
||||||
|
.arg(&script)
|
||||||
|
.args(args)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to run cookie_manager.py: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("cookie_manager.py failed: {stderr}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check installed yt-dlp version and compare to latest on PyPI.
|
||||||
|
async fn check_ytdlp_version() -> (Option<String>, Option<String>, bool) {
|
||||||
|
// Get installed version
|
||||||
|
let installed = Command::new("yt-dlp")
|
||||||
|
.arg("--version")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||||
|
|
||||||
|
// Get latest version from PyPI
|
||||||
|
let latest = async {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
let resp: serde_json::Value = client
|
||||||
|
.get("https://pypi.org/pypi/yt-dlp/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
resp.get("info")?.get("version")?.as_str().map(String::from)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Normalize version strings for comparison: "2026.03.17" and "2026.3.17" should match.
|
||||||
|
let normalize = |v: &str| -> String {
|
||||||
|
v.split('.')
|
||||||
|
.map(|part| part.trim_start_matches('0'))
|
||||||
|
.map(|p| if p.is_empty() { "0" } else { p })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(".")
|
||||||
|
};
|
||||||
|
let update_available = match (&installed, &latest) {
|
||||||
|
(Some(i), Some(l)) => normalize(i) != normalize(l),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
(installed, latest, update_available)
|
||||||
|
}
|
||||||
27
src/state.rs
27
src/state.rs
@@ -1,18 +1,39 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
use shanty_data::HybridMusicBrainzFetcher;
|
||||||
|
use shanty_data::WikipediaFetcher;
|
||||||
use shanty_db::Database;
|
use shanty_db::Database;
|
||||||
use shanty_search::MusicBrainzSearch;
|
use shanty_search::MusicBrainzSearch;
|
||||||
use shanty_tag::MusicBrainzClient;
|
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::tasks::TaskManager;
|
use crate::tasks::TaskManager;
|
||||||
|
|
||||||
|
/// Tracks an active Firefox login session for YouTube auth.
|
||||||
|
pub struct FirefoxLoginSession {
|
||||||
|
pub vnc_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks next-run times for scheduled background tasks.
|
||||||
|
pub struct SchedulerInfo {
|
||||||
|
/// When the next pipeline run is scheduled (None if disabled).
|
||||||
|
pub next_pipeline: Option<chrono::NaiveDateTime>,
|
||||||
|
/// When the next monitor check is scheduled (None if disabled).
|
||||||
|
pub next_monitor: Option<chrono::NaiveDateTime>,
|
||||||
|
/// Skip the next pipeline run (one-shot, resets after skip).
|
||||||
|
pub skip_pipeline: bool,
|
||||||
|
/// Skip the next monitor run (one-shot, resets after skip).
|
||||||
|
pub skip_monitor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub mb_client: MusicBrainzClient,
|
pub mb_client: HybridMusicBrainzFetcher,
|
||||||
pub search: MusicBrainzSearch,
|
pub search: MusicBrainzSearch,
|
||||||
|
pub wiki_fetcher: WikipediaFetcher,
|
||||||
pub config: Arc<RwLock<AppConfig>>,
|
pub config: Arc<RwLock<AppConfig>>,
|
||||||
pub config_path: Option<String>,
|
pub config_path: Option<String>,
|
||||||
pub tasks: TaskManager,
|
pub tasks: TaskManager,
|
||||||
|
pub firefox_login: Mutex<Option<FirefoxLoginSession>>,
|
||||||
|
pub scheduler: Mutex<SchedulerInfo>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user