refactor: extract psysonic-integration crate (M5/7)

Moves all outbound external-service bridges out of the top crate into a
new psysonic-integration crate.

  crates/psysonic-integration/
    src/discord.rs                     Rich Presence + iTunes artwork
    src/navidrome/{client,covers,...}  Native REST API admin (5 modules)
    src/remote.rs                      radio-browser, last.fm, ICY meta,
                                       generic CORS proxy (fetch_url_bytes
                                       / fetch_json_url / resolve_stream_url)
    src/bandsintown.rs                 events for an artist
    src/lib.rs                         module declarations + macro re-exports

Top crate keeps `crate::discord` working via
`pub use psysonic_integration::discord;`.

invoke_handler! in lib.rs uses full deepest paths
(`psysonic_integration::navidrome::users::nd_list_users`, etc.) so
Tauri's `__cmd__*` magic macros resolve at the right module — same
pattern audio + syncfs already use.

Cross-crate refs migrated:
  crate::subsonic_wire_user_agent → psysonic_core::user_agent::*
  pub(crate) → pub                  (cross-crate visibility)

Behaviour preserving. Cargo check + clippy --workspace clean.
This commit is contained in:
Psychotoxical
2026-05-09 13:51:28 +02:00
parent 9417d522f3
commit 98d8ea6353
17 changed files with 145 additions and 117 deletions
+17
View File
@@ -3611,6 +3611,7 @@ dependencies = [
"psysonic-analysis", "psysonic-analysis",
"psysonic-audio", "psysonic-audio",
"psysonic-core", "psysonic-core",
"psysonic-integration",
"psysonic-syncfs", "psysonic-syncfs",
"reqwest", "reqwest",
"ringbuf", "ringbuf",
@@ -3695,6 +3696,22 @@ dependencies = [
"tauri", "tauri",
] ]
[[package]]
name = "psysonic-integration"
version = "1.46.0-dev"
dependencies = [
"discord-rich-presence",
"futures-util",
"md5",
"psysonic-core",
"reqwest",
"serde",
"serde_json",
"tauri",
"tokio",
"url",
]
[[package]] [[package]]
name = "psysonic-syncfs" name = "psysonic-syncfs"
version = "1.46.0-dev" version = "1.46.0-dev"
+1
View File
@@ -34,6 +34,7 @@ psysonic-core = { path = "crates/psysonic-core" }
psysonic-analysis = { path = "crates/psysonic-analysis" } psysonic-analysis = { path = "crates/psysonic-analysis" }
psysonic-audio = { path = "crates/psysonic-audio" } psysonic-audio = { path = "crates/psysonic-audio" }
psysonic-syncfs = { path = "crates/psysonic-syncfs" } psysonic-syncfs = { path = "crates/psysonic-syncfs" }
psysonic-integration = { path = "crates/psysonic-integration" }
tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
@@ -0,0 +1,19 @@
[package]
name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking"] }
futures-util = "0.3"
discord-rich-presence = "1.1"
url = "2"
md5 = "0.8"
@@ -2,10 +2,10 @@
// Public REST API: https://rest.bandsintown.com/artists/{name}/events?app_id=… // Public REST API: https://rest.bandsintown.com/artists/{name}/events?app_id=…
// Bandsintown whitelists app IDs — arbitrary strings now return 403 Forbidden. // Bandsintown whitelists app IDs — arbitrary strings now return 403 Forbidden.
// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted. // `js_app_id` is the ID their own embeddable widget uses and is broadly accepted.
pub(crate) const BANDSINTOWN_APP_ID: &str = "js_app_id"; pub const BANDSINTOWN_APP_ID: &str = "js_app_id";
#[derive(serde::Serialize, Default)] #[derive(serde::Serialize, Default)]
pub(crate) struct BandsintownEvent { pub struct BandsintownEvent {
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00") datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
venue_name: String, venue_name: String,
venue_city: String, venue_city: String,
@@ -20,7 +20,7 @@ pub(crate) struct BandsintownEvent {
/// Returns an empty list on any failure (404, network, parse) — the UI /// Returns an empty list on any failure (404, network, parse) — the UI
/// just hides the section in that case. /// just hides the section in that case.
#[tauri::command] #[tauri::command]
pub(crate) async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> { pub async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> {
let trimmed = artist_name.trim(); let trimmed = artist_name.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(vec![]); return Ok(vec![]);
@@ -0,0 +1,17 @@
//! `psysonic-integration` — outbound bridges to external services that the
//! UI surfaces but the WebView can't talk to directly (CORS, custom auth, etc.).
//!
//! Domains:
//! - `discord` — Discord Rich Presence (album artwork via iTunes)
//! - `navidrome` — Navidrome's native REST API (admin: users/playlists/covers/queries)
//! - `remote` — radio-browser, last.fm, ICY-meta probe, generic CORS proxy
//! - `bandsintown` — bandsintown events for an artist
// Re-export the logging facade so submodules keep using
// `crate::app_eprintln!()` and `crate::app_deprintln!()`.
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
pub mod bandsintown;
pub mod discord;
pub mod navidrome;
pub mod remote;
@@ -2,7 +2,7 @@
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls. //! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
/// Authenticate with Navidrome's own REST API and return a Bearer token. /// Authenticate with Navidrome's own REST API and return a Bearer token.
pub(crate) async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> { pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let resp = client let resp = client
.post(format!("{}/auth/login", server_url)) .post(format!("{}/auth/login", server_url))
@@ -19,7 +19,7 @@ pub(crate) async fn navidrome_token(server_url: &str, username: &str, password:
/// Payload returned by Navidrome's `/auth/login`. /// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub(crate) struct NdLoginResult { pub struct NdLoginResult {
pub(super) token: String, pub(super) token: String,
#[serde(rename = "userId")] #[serde(rename = "userId")]
pub(super) user_id: String, pub(super) user_id: String,
@@ -31,7 +31,7 @@ pub(crate) struct NdLoginResult {
/// frontend toasts can show the actual transport cause (connection refused, /// frontend toasts can show the actual transport cause (connection refused,
/// tls handshake fail, cert expired, etc.) instead of reqwest's opaque /// tls handshake fail, cert expired, etc.) instead of reqwest's opaque
/// "error sending request for url (…)" wrapper. /// "error sending request for url (…)" wrapper.
pub(crate) fn nd_err(e: reqwest::Error) -> String { pub fn nd_err(e: reqwest::Error) -> String {
let mut msg = e.to_string(); let mut msg = e.to_string();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e); let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(s) = src { while let Some(s) = src {
@@ -50,7 +50,7 @@ pub(crate) fn nd_err(e: reqwest::Error) -> String {
/// turns a transient glitch into a visible outage. Status-level failures /// turns a transient glitch into a visible outage. Status-level failures
/// (401/403/400 with body) return immediately — we don't retry logic /// (401/403/400 with body) return immediately — we don't retry logic
/// errors. /// errors.
pub(crate) async fn nd_retry<F, Fut>(mut build_and_send: F) -> Result<reqwest::Response, String> pub async fn nd_retry<F, Fut>(mut build_and_send: F) -> Result<reqwest::Response, String>
where where
F: FnMut() -> Fut, F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>, Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
@@ -90,7 +90,7 @@ where
/// keep-alive connections in the pool caused intermittent "tls handshake /// keep-alive connections in the pool caused intermittent "tls handshake
/// eof" errors on the second call to an admin endpoint when a server or /// eof" errors on the second call to an admin endpoint when a server or
/// proxy had already closed the TCP connection between calls. /// proxy had already closed the TCP connection between calls.
pub(crate) fn nd_http_client() -> reqwest::Client { pub fn nd_http_client() -> reqwest::Client {
// TLS 1.2 only: rustls + nginx with TLS-1.3 session resumption caches // TLS 1.2 only: rustls + nginx with TLS-1.3 session resumption caches
// produces intermittent ECONNRESET mid-handshake when the upstream // produces intermittent ECONNRESET mid-handshake when the upstream
// starts churning keep-alive connections. Pinning TLS 1.2 matches what // starts churning keep-alive connections. Pinning TLS 1.2 matches what
@@ -5,7 +5,7 @@
use super::client::navidrome_token; use super::client::navidrome_token;
#[tauri::command] #[tauri::command]
pub(crate) async fn upload_playlist_cover( pub async fn upload_playlist_cover(
server_url: String, server_url: String,
playlist_id: String, playlist_id: String,
username: String, username: String,
@@ -32,7 +32,7 @@ pub(crate) async fn upload_playlist_cover(
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn upload_radio_cover( pub async fn upload_radio_cover(
server_url: String, server_url: String,
radio_id: String, radio_id: String,
username: String, username: String,
@@ -59,7 +59,7 @@ pub(crate) async fn upload_radio_cover(
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn upload_artist_image( pub async fn upload_artist_image(
server_url: String, server_url: String,
artist_id: String, artist_id: String,
username: String, username: String,
@@ -86,7 +86,7 @@ pub(crate) async fn upload_artist_image(
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn delete_radio_cover( pub async fn delete_radio_cover(
server_url: String, server_url: String,
radio_id: String, radio_id: String,
username: String, username: String,
@@ -0,0 +1,11 @@
//! Navidrome native REST API: split into a small client/auth/retry core
//! plus per-domain submodules (covers, users, queries, playlists). Each
//! Tauri command goes through `nd_http_client()` + `nd_retry()` so flaky
//! reverse proxies in front of the server don't surface as user-visible
//! transport errors on a single retry-able blip.
mod client;
pub mod covers;
pub mod playlists;
pub mod queries;
pub mod users;
@@ -6,7 +6,7 @@ use super::client::{nd_err, nd_http_client, nd_retry};
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists. /// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_list_playlists( pub async fn nd_list_playlists(
server_url: String, server_url: String,
token: String, token: String,
smart: Option<bool>, smart: Option<bool>,
@@ -30,7 +30,7 @@ pub(crate) async fn nd_list_playlists(
/// POST `/api/playlist` — create playlist (supports smart rules payload). /// POST `/api/playlist` — create playlist (supports smart rules payload).
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_create_playlist( pub async fn nd_create_playlist(
server_url: String, server_url: String,
token: String, token: String,
body: serde_json::Value, body: serde_json::Value,
@@ -53,7 +53,7 @@ pub(crate) async fn nd_create_playlist(
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). /// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_update_playlist( pub async fn nd_update_playlist(
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
@@ -77,7 +77,7 @@ pub(crate) async fn nd_update_playlist(
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available). /// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_get_playlist( pub async fn nd_get_playlist(
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
@@ -99,7 +99,7 @@ pub(crate) async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist. /// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_delete_playlist( pub async fn nd_delete_playlist(
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
@@ -7,7 +7,7 @@ use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. /// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list.
/// Available to any authenticated user (no admin required). Returns raw JSON array. /// Available to any authenticated user (no admin required). Returns raw JSON array.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_list_songs( pub async fn nd_list_songs(
server_url: String, server_url: String,
token: String, token: String,
sort: String, sort: String,
@@ -53,7 +53,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any /// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any
/// authenticated user. Returns raw JSON array. /// authenticated user. Returns raw JSON array.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_list_artists_by_role( pub async fn nd_list_artists_by_role(
server_url: String, server_url: String,
token: String, token: String,
role: String, role: String,
@@ -93,7 +93,7 @@ pub(crate) async fn nd_list_artists_by_role(
/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome /// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome
/// generates `role_<role>_id` filters dynamically from `model.AllRoles`. /// generates `role_<role>_id` filters dynamically from `model.AllRoles`.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_list_albums_by_artist_role( pub async fn nd_list_albums_by_artist_role(
server_url: String, server_url: String,
token: String, token: String,
artist_id: String, artist_id: String,
@@ -131,7 +131,7 @@ pub(crate) async fn nd_list_albums_by_artist_role(
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. /// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_list_libraries( pub async fn nd_list_libraries(
server_url: String, server_url: String,
token: String, token: String,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
@@ -150,7 +150,7 @@ pub(crate) async fn nd_list_libraries(
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user. /// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400. /// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_set_user_libraries( pub async fn nd_set_user_libraries(
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
@@ -184,7 +184,7 @@ pub(crate) async fn nd_set_user_libraries(
/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit /// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit
/// it for non-admin users on some configurations. /// it for non-admin users on some configurations.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_get_song_path( pub async fn nd_get_song_path(
server_url: String, server_url: String,
username: String, username: String,
password: String, password: String,
@@ -6,7 +6,7 @@ use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult};
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin. /// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command] #[tauri::command]
pub(crate) async fn navidrome_login( pub async fn navidrome_login(
server_url: String, server_url: String,
username: String, username: String,
password: String, password: String,
@@ -30,7 +30,7 @@ pub(crate) async fn navidrome_login(
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields. /// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_list_users( pub async fn nd_list_users(
server_url: String, server_url: String,
token: String, token: String,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
@@ -48,7 +48,7 @@ pub(crate) async fn nd_list_users(
/// POST `/api/user` — create a user. /// POST `/api/user` — create a user.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_create_user( pub async fn nd_create_user(
server_url: String, server_url: String,
token: String, token: String,
user_name: String, user_name: String,
@@ -81,7 +81,7 @@ pub(crate) async fn nd_create_user(
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged. /// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_update_user( pub async fn nd_update_user(
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
@@ -118,7 +118,7 @@ pub(crate) async fn nd_update_user(
/// DELETE `/api/user/{id}`. /// DELETE `/api/user/{id}`.
#[tauri::command] #[tauri::command]
pub(crate) async fn nd_delete_user( pub async fn nd_delete_user(
server_url: String, server_url: String,
token: String, token: String,
id: String, id: String,
@@ -1,10 +1,10 @@
use crate::subsonic_wire_user_agent; use psysonic_core::user_agent::subsonic_wire_user_agent;
pub(crate) const RADIO_PAGE_SIZE: u32 = 25; pub const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView). /// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
#[tauri::command] #[tauri::command]
pub(crate) async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> { pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string(); let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string(); let offset_s = offset.to_string();
@@ -27,7 +27,7 @@ pub(crate) async fn search_radio_browser(query: String, offset: u32) -> Result<V
/// Fetch top-voted stations from radio-browser.info for initial suggestions. /// Fetch top-voted stations from radio-browser.info for initial suggestions.
#[tauri::command] #[tauri::command]
pub(crate) async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> { pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string(); let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string(); let offset_s = offset.to_string();
@@ -46,7 +46,7 @@ pub(crate) async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS. /// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type). /// Returns (bytes, content_type).
#[tauri::command] #[tauri::command]
pub(crate) async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> { pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent()) .user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
@@ -76,7 +76,7 @@ pub(crate) async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), St
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions. /// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
/// Returns the response body as a UTF-8 string for parsing on the JS side. /// Returns the response body as a UTF-8 string for parsing on the JS side.
#[tauri::command] #[tauri::command]
pub(crate) async fn fetch_json_url(url: String) -> Result<String, String> { pub async fn fetch_json_url(url: String) -> Result<String, String> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent()) .user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
@@ -96,7 +96,7 @@ pub(crate) async fn fetch_json_url(url: String) -> Result<String, String> {
/// ICY metadata response returned to the frontend. /// ICY metadata response returned to the frontend.
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub(crate) struct IcyMetadata { pub struct IcyMetadata {
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`). /// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
stream_title: Option<String>, stream_title: Option<String>,
/// Value of the `icy-name` response header. /// Value of the `icy-name` response header.
@@ -110,7 +110,7 @@ pub(crate) struct IcyMetadata {
} }
/// Extract the first `File1=` stream URL from a PLS playlist file. /// Extract the first `File1=` stream URL from a PLS playlist file.
pub(crate) fn parse_pls_stream_url(content: &str) -> Option<String> { pub fn parse_pls_stream_url(content: &str) -> Option<String> {
content.lines() content.lines()
.map(str::trim) .map(str::trim)
.find(|l| l.to_lowercase().starts_with("file1=")) .find(|l| l.to_lowercase().starts_with("file1="))
@@ -122,7 +122,7 @@ pub(crate) fn parse_pls_stream_url(content: &str) -> Option<String> {
} }
/// Extract the first non-comment HTTP URL from an M3U/M3U8 playlist file. /// Extract the first non-comment HTTP URL from an M3U/M3U8 playlist file.
pub(crate) fn parse_m3u_stream_url(content: &str) -> Option<String> { pub fn parse_m3u_stream_url(content: &str) -> Option<String> {
content.lines() content.lines()
.map(str::trim) .map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('#') .find(|l| !l.is_empty() && !l.starts_with('#')
@@ -132,7 +132,7 @@ pub(crate) fn parse_m3u_stream_url(content: &str) -> Option<String> {
/// If `url` points to a PLS or M3U playlist, fetch it and return the first /// If `url` points to a PLS or M3U playlist, fetch it and return the first
/// stream URL it contains. Returns `None` for direct stream URLs. /// stream URL it contains. Returns `None` for direct stream URLs.
pub(crate) async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option<String> { pub async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option<String> {
let path = url.split('?').next().unwrap_or(url).to_lowercase(); let path = url.split('?').next().unwrap_or(url).to_lowercase();
let is_pls = path.ends_with(".pls"); let is_pls = path.ends_with(".pls");
let is_m3u = path.ends_with(".m3u") || path.ends_with(".m3u8"); let is_m3u = path.ends_with(".m3u") || path.ends_with(".m3u8");
@@ -173,7 +173,7 @@ pub(crate) async fn resolve_playlist_url(client: &reqwest::Client, url: &str) ->
/// If `url` is a PLS or M3U playlist file it is resolved to the first direct /// If `url` is a PLS or M3U playlist file it is resolved to the first direct
/// stream URL before the ICY request is made. /// stream URL before the ICY request is made.
#[tauri::command] #[tauri::command]
pub(crate) async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> { pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
use futures_util::StreamExt; use futures_util::StreamExt;
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
@@ -277,7 +277,7 @@ pub(crate) async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, Strin
/// Returns the original URL unchanged if it is not a recognised playlist format /// Returns the original URL unchanged if it is not a recognised playlist format
/// or if the playlist cannot be fetched/parsed. /// or if the playlist cannot be fetched/parsed.
#[tauri::command] #[tauri::command]
pub(crate) async fn resolve_stream_url(url: String) -> String { pub async fn resolve_stream_url(url: String) -> String {
let Ok(client) = reqwest::Client::builder() let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build() .build()
@@ -291,7 +291,7 @@ pub(crate) async fn resolve_stream_url(url: String) -> String {
/// `params` is a list of [key, value] pairs (method must be included). /// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made. /// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
#[tauri::command] #[tauri::command]
pub(crate) async fn lastfm_request( pub async fn lastfm_request(
params: Vec<[String; 2]>, params: Vec<[String; 2]>,
sign: bool, sign: bool,
get: bool, get: bool,
+32 -31
View File
@@ -2,9 +2,10 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
pub mod cli; pub mod cli;
mod discord;
mod lib_commands; mod lib_commands;
pub use psysonic_integration::discord;
pub use psysonic_core::logging; pub use psysonic_core::logging;
pub use psysonic_core::{app_eprintln, app_deprintln}; pub use psysonic_core::{app_eprintln, app_deprintln};
pub use psysonic_core::user_agent::{ pub use psysonic_core::user_agent::{
@@ -391,35 +392,35 @@ pub fn run() {
audio::device_commands::audio_default_output_device_name, audio::device_commands::audio_default_output_device_name,
audio::device_commands::audio_set_device, audio::device_commands::audio_set_device,
audio::commands::audio_chain_preload, audio::commands::audio_chain_preload,
discord::discord_update_presence, psysonic_integration::discord::discord_update_presence,
discord::discord_clear_presence, psysonic_integration::discord::discord_clear_presence,
lastfm_request, psysonic_integration::remote::lastfm_request,
upload_playlist_cover, psysonic_integration::navidrome::covers::upload_playlist_cover,
upload_radio_cover, psysonic_integration::navidrome::covers::upload_radio_cover,
upload_artist_image, psysonic_integration::navidrome::covers::upload_artist_image,
delete_radio_cover, psysonic_integration::navidrome::covers::delete_radio_cover,
navidrome_login, psysonic_integration::navidrome::users::navidrome_login,
nd_list_users, psysonic_integration::navidrome::users::nd_list_users,
nd_create_user, psysonic_integration::navidrome::users::nd_create_user,
nd_update_user, psysonic_integration::navidrome::users::nd_update_user,
nd_delete_user, psysonic_integration::navidrome::users::nd_delete_user,
nd_list_libraries, psysonic_integration::navidrome::queries::nd_list_libraries,
nd_list_songs, psysonic_integration::navidrome::queries::nd_list_songs,
nd_list_artists_by_role, psysonic_integration::navidrome::queries::nd_list_artists_by_role,
nd_list_albums_by_artist_role, psysonic_integration::navidrome::queries::nd_list_albums_by_artist_role,
nd_set_user_libraries, psysonic_integration::navidrome::queries::nd_set_user_libraries,
nd_list_playlists, psysonic_integration::navidrome::playlists::nd_list_playlists,
nd_create_playlist, psysonic_integration::navidrome::playlists::nd_create_playlist,
nd_update_playlist, psysonic_integration::navidrome::playlists::nd_update_playlist,
nd_get_playlist, psysonic_integration::navidrome::playlists::nd_get_playlist,
nd_delete_playlist, psysonic_integration::navidrome::playlists::nd_delete_playlist,
nd_get_song_path, psysonic_integration::navidrome::queries::nd_get_song_path,
search_radio_browser, psysonic_integration::remote::search_radio_browser,
get_top_radio_stations, psysonic_integration::remote::get_top_radio_stations,
fetch_url_bytes, psysonic_integration::remote::fetch_url_bytes,
fetch_json_url, psysonic_integration::remote::fetch_json_url,
fetch_icy_metadata, psysonic_integration::remote::fetch_icy_metadata,
resolve_stream_url, psysonic_integration::remote::resolve_stream_url,
analysis_get_waveform, analysis_get_waveform,
analysis_get_waveform_for_track, analysis_get_waveform_for_track,
analysis_get_loudness_for_track, analysis_get_loudness_for_track,
@@ -457,7 +458,7 @@ pub fn run() {
psysonic_syncfs::cache::downloads::open_folder, psysonic_syncfs::cache::downloads::open_folder,
psysonic_syncfs::cache::downloads::get_embedded_lyrics, psysonic_syncfs::cache::downloads::get_embedded_lyrics,
psysonic_syncfs::cache::downloads::fetch_netease_lyrics, psysonic_syncfs::cache::downloads::fetch_netease_lyrics,
fetch_bandsintown_events, psysonic_integration::bandsintown::fetch_bandsintown_events,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
taskbar_win::update_taskbar_icon, taskbar_win::update_taskbar_icon,
]) ])
+6 -15
View File
@@ -1,11 +1,9 @@
mod analysis;
mod cli_bridge; mod cli_bridge;
mod core; mod core;
mod navidrome; mod integration;
mod perf; mod perf;
mod platform; mod platform;
mod remote;
mod integration;
mod analysis;
// Tauri commands re-exported for the lib.rs invoke_handler. // Tauri commands re-exported for the lib.rs invoke_handler.
pub(crate) use cli_bridge::{ pub(crate) use cli_bridge::{
@@ -16,19 +14,8 @@ pub(crate) use core::{
exit_app, export_runtime_logs, frontend_debug_log, greet, set_logging_mode, exit_app, export_runtime_logs, frontend_debug_log, greet, set_logging_mode,
set_subsonic_wire_user_agent, set_subsonic_wire_user_agent,
}; };
pub(crate) use navidrome::{
delete_radio_cover, navidrome_login, nd_create_playlist, nd_create_user, nd_delete_playlist,
nd_delete_user, nd_get_playlist, nd_get_song_path, nd_list_albums_by_artist_role,
nd_list_artists_by_role, nd_list_libraries, nd_list_playlists, nd_list_songs, nd_list_users,
nd_set_user_libraries, nd_update_playlist, nd_update_user, upload_artist_image,
upload_playlist_cover, upload_radio_cover,
};
pub(crate) use perf::performance_cpu_snapshot; pub(crate) use perf::performance_cpu_snapshot;
pub(crate) use platform::{set_linux_webkit_smooth_scrolling, set_window_decorations}; pub(crate) use platform::{set_linux_webkit_smooth_scrolling, set_window_decorations};
pub(crate) use remote::{
fetch_icy_metadata, fetch_json_url, fetch_url_bytes, get_top_radio_stations, lastfm_request,
resolve_stream_url, search_radio_browser,
};
pub(crate) use integration::{ pub(crate) use integration::{
check_dir_accessible, mpris_set_metadata, mpris_set_playback, register_global_shortcut, check_dir_accessible, mpris_set_metadata, mpris_set_playback, register_global_shortcut,
unregister_global_shortcut, unregister_global_shortcut,
@@ -38,3 +25,7 @@ pub(crate) use analysis::{
analysis_enqueue_seed_from_url, analysis_get_loudness_for_track, analysis_get_waveform, analysis_enqueue_seed_from_url, analysis_get_loudness_for_track, analysis_get_waveform,
analysis_get_waveform_for_track, analysis_prune_pending_to_track_ids, analysis_get_waveform_for_track, analysis_prune_pending_to_track_ids,
}; };
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown
// now live in `psysonic_integration`. invoke_handler! in lib.rs registers
// them with their full paths so Tauri's `__cmd__*` macros resolve correctly.
@@ -1,28 +0,0 @@
//! Navidrome native REST API: split into a small client/auth/retry core
//! plus per-domain submodules (covers, users, queries, playlists). Each
//! Tauri command goes through `nd_http_client()` + `nd_retry()` so flaky
//! reverse proxies in front of the server don't surface as user-visible
//! transport errors on a single retry-able blip.
mod client;
mod covers;
mod users;
mod queries;
mod playlists;
// Re-export only the Tauri commands — `client` items (nd_http_client,
// nd_retry, navidrome_token, NdLoginResult, nd_err) are internal helpers
// used by the other submodules and don't need crate-wide visibility.
pub(crate) use covers::{
delete_radio_cover, upload_artist_image, upload_playlist_cover, upload_radio_cover,
};
pub(crate) use users::{
navidrome_login, nd_create_user, nd_delete_user, nd_list_users, nd_update_user,
};
pub(crate) use queries::{
nd_get_song_path, nd_list_albums_by_artist_role, nd_list_artists_by_role, nd_list_libraries,
nd_list_songs, nd_set_user_libraries,
};
pub(crate) use playlists::{
nd_create_playlist, nd_delete_playlist, nd_get_playlist, nd_list_playlists, nd_update_playlist,
};
+1 -2
View File
@@ -1,9 +1,8 @@
mod mini; mod mini;
mod bandsintown;
pub(crate) use mini::{ pub(crate) use mini::{
close_mini_player, open_mini_player, pause_rendering, persist_mini_pos_throttled, close_mini_player, open_mini_player, pause_rendering, persist_mini_pos_throttled,
preload_mini_player, resize_mini_player, resume_rendering, set_mini_player_always_on_top, preload_mini_player, resize_mini_player, resume_rendering, set_mini_player_always_on_top,
show_main_window, PAUSE_RENDERING_JS, RESUME_RENDERING_JS, show_main_window, PAUSE_RENDERING_JS, RESUME_RENDERING_JS,
}; };
pub(crate) use bandsintown::fetch_bandsintown_events; // Bandsintown moved to psysonic-integration; lib.rs uses the full path.