From c43ee02b00662c61de3eea409136281975cab986 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 27 May 2026 18:52:17 +0300 Subject: [PATCH] CORE: Improve media paths and player reliability --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/agent/cover_art.rs | 15 +- src/config.rs | 68 +++----- src/i18n/phrases.rs | 2 + src/jobs/artwork_backfill.rs | 186 ++++++++++---------- src/jobs/inbox_process.rs | 25 ++- src/jobs/metadata_backfill.rs | 24 +-- src/main.rs | 1 + src/media_paths.rs | 310 ++++++++++++++++++++++++++++++++++ src/player/mod.rs | 24 +-- src/scheduler/mod.rs | 11 ++ src/torrents.rs | 2 +- templates/player/scripts.html | 98 +++++++++++ templates/player/shell.html | 14 ++ templates/player/styles.html | 40 +++++ 16 files changed, 639 insertions(+), 185 deletions(-) create mode 100644 src/media_paths.rs diff --git a/Cargo.lock b/Cargo.lock index b06c77a..a91f345 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.21" +version = "0.1.22" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 41da3de..578600d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.22" +version = "0.2.0" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/agent/cover_art.rs b/src/agent/cover_art.rs index b61fe2a..c0617bc 100644 --- a/src/agent/cover_art.rs +++ b/src/agent/cover_art.rs @@ -338,12 +338,7 @@ pub async fn save_cover_to_storage( .fetch_optional(pool) .await? { - let path = PathBuf::from(&file_path); - let path = if path.is_absolute() { - path - } else { - Path::new(storage_dir).join(path) - }; + let path = crate::media_paths::resolve_media_file_path(storage_dir, &file_path); if let Err(err) = crate::agent::cover_variants::ensure_cover_variants(&path).await { tracing::warn!(media_file_id = id, error = %err, "Failed to generate cover variants"); } @@ -365,7 +360,13 @@ pub async fn save_cover_to_storage( // Write image data tokio::fs::write(&dest_path, &cover.data).await?; - let relative_path = dest_path.to_string_lossy().to_string(); + let relative_path = crate::media_paths::media_file_path_for_storage(storage_dir, &dest_path) + .ok_or_else(|| { + anyhow::anyhow!( + "cover destination is outside agent_storage_dir: {}", + dest_path.display() + ) + })?; let file_size = cover.data.len() as i64; let media_file = crate::music::MediaFile::create( diff --git a/src/config.rs b/src/config.rs index dc2ddff..aa1e6c6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -329,8 +329,8 @@ impl_env_overrides!( impl AppConfig { fn normalize_host_paths(&mut self) { - self.agent_inbox_dir = normalize_host_path(&self.agent_inbox_dir); - self.agent_storage_dir = normalize_host_path(&self.agent_storage_dir); + self.agent_inbox_dir = crate::media_paths::resolve_config_path(&self.agent_inbox_dir); + self.agent_storage_dir = crate::media_paths::resolve_config_path(&self.agent_storage_dir); } /// Build config: start from defaults, then overlay env vars. @@ -413,44 +413,6 @@ impl AppConfig { } } -fn normalize_host_path(value: &str) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - return String::new(); - } - - normalize_windows_user_path(trimmed).unwrap_or_else(|| trimmed.to_owned()) -} - -#[cfg(not(windows))] -fn normalize_windows_user_path(value: &str) -> Option { - let normalized = value.replace('\\', "/"); - let mut parts = normalized.split('/').filter(|part| !part.is_empty()); - let drive = parts.next()?; - if drive.len() != 2 || !drive.ends_with(':') { - return None; - } - if !parts.next()?.eq_ignore_ascii_case("Users") { - return None; - } - let user = parts.next()?; - if user.is_empty() { - return None; - } - - let mut out = format!("/Users/{user}"); - for part in parts { - out.push('/'); - out.push_str(part); - } - Some(out) -} - -#[cfg(windows)] -fn normalize_windows_user_path(_value: &str) -> Option { - None -} - #[cfg(test)] mod tests { use super::*; @@ -462,21 +424,31 @@ mod tests { assert_eq!(cfg.log_level, "info"); } - #[cfg(not(windows))] #[test] - fn normalizes_windows_user_path_on_unix() { + fn resolves_relative_media_paths_from_working_dir() { + let expected = std::env::current_dir() + .unwrap() + .join("media") + .join("uploads") + .to_string_lossy() + .to_string(); assert_eq!( - normalize_host_path(r"C:\Users\ab\repos\furumusic\media\uploads"), - "/Users/ab/repos/furumusic/media/uploads" + crate::media_paths::resolve_config_path("media/uploads"), + expected ); } - #[cfg(not(windows))] #[test] - fn leaves_unix_path_unchanged() { + fn maps_foreign_windows_media_paths_to_working_dir() { + let expected = std::env::current_dir() + .unwrap() + .join("media") + .join("uploads") + .to_string_lossy() + .to_string(); assert_eq!( - normalize_host_path("/Users/ab/repos/furumusic/media/uploads"), - "/Users/ab/repos/furumusic/media/uploads" + crate::media_paths::resolve_config_path(r"C:\Users\ab\repos\furumusic\media\uploads"), + expected ); } diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 144a747..a8e25b3 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -296,6 +296,8 @@ translations! { player_likes_playlist: "Likes" , "Лайки"; player_listened: "listened" , "прослушано"; player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков..."; + player_connection_lost: "Server connection lost" , "Нет соединения с сервером"; + player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю..."; player_no_results: "No results found" , "Ничего не найдено"; player_new_playlist: "New Playlist" , "Новый плейлист"; player_rename_playlist: "Rename Playlist" , "Переименовать плейлист"; diff --git a/src/jobs/artwork_backfill.rs b/src/jobs/artwork_backfill.rs index 7b31ea3..fa23504 100644 --- a/src/jobs/artwork_backfill.rs +++ b/src/jobs/artwork_backfill.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use reqwest::Client; use serde::Deserialize; @@ -40,34 +40,6 @@ struct LastfmArtistResponse { message: Option, } -#[derive(Debug, Deserialize)] -struct LastfmTopAlbumsResponse { - topalbums: Option, - error: Option, - message: Option, -} - -#[derive(Debug, Deserialize)] -struct LastfmTopAlbums { - album: Option>, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum OneOrMany { - One(T), - Many(Vec), -} - -impl OneOrMany { - fn into_vec(self) -> Vec { - match self { - Self::One(value) => vec![value], - Self::Many(values) => values, - } - } -} - #[derive(Debug, Deserialize)] struct LastfmImageContainer { image: Option>, @@ -88,6 +60,7 @@ struct ArtworkStats { release_skipped_no_audio: u64, artist_lastfm_assigned: u64, artist_lastfm_not_found: u64, + artist_album_fallback_assigned: u64, variants_created: usize, variants_unchanged: usize, variants_missing_original: usize, @@ -125,6 +98,16 @@ impl Job for ArtworkBackfillJob { .build()?; let mut stats = ArtworkStats::default(); + let normalized_paths = + crate::media_paths::normalize_media_file_paths(&ctx.pool, storage_dir).await?; + if normalized_paths > 0 { + log.info(&format!( + "Media path normalization pass: rewrote {normalized_paths} media file path(s) to relative storage paths" + )); + } else { + log.info("Media path normalization pass: all media file paths are already relative"); + } + backfill_release_local(ctx, log, storage_dir, &mut stats).await?; let api_key = ctx.config.lastfm_api_key.trim(); @@ -138,13 +121,14 @@ impl Job for ArtworkBackfillJob { repair_cover_variants(ctx, log, storage_dir, &mut stats).await?; log.info(&format!( - "Artwork backfill complete: release_local_assigned={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}", + "Artwork backfill complete: release_local_assigned={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, artist_album_fallback_assigned={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}", stats.release_local_assigned, stats.release_lastfm_assigned, stats.release_lastfm_not_found, stats.release_skipped_no_audio, stats.artist_lastfm_assigned, stats.artist_lastfm_not_found, + stats.artist_album_fallback_assigned, stats.variants_created, stats.variants_unchanged, stats.variants_missing_original, @@ -221,7 +205,7 @@ async fn backfill_release_local( let audio_files: Vec = audio_paths .iter() - .map(|path| resolve_media_path(storage_dir, path)) + .map(|path| crate::media_paths::resolve_media_file_path(storage_dir, path)) .collect(); let Some(folder) = audio_files.first().and_then(|path| path.parent()) else { stats.failed += 1; @@ -605,13 +589,35 @@ async fn backfill_artist_lastfm( } }, Ok(None) => { - stats.artist_lastfm_not_found += 1; record_lookup_state(&ctx.pool, "artist", artist.id, "not_found", None, None) .await?; log.info(&format!( "Artist {} \"{}\": Last.fm did not return artwork", artist.id, artist.name )); + stats.artist_lastfm_not_found += 1; + match assign_artist_album_fallback(ctx, artist.id).await { + Ok(Some(media_file_id)) => { + stats.artist_album_fallback_assigned += 1; + log.info(&format!( + "Artist {} \"{}\": assigned random local album cover (media_file_id={media_file_id})", + artist.id, artist.name + )); + } + Ok(None) => { + log.info(&format!( + "Artist {} \"{}\": no local album cover available for fallback", + artist.id, artist.name + )); + } + Err(err) => { + stats.failed += 1; + log.warn(&format!( + "Artist {} \"{}\": failed to assign album fallback artwork: {err}", + artist.id, artist.name + )); + } + } } Err(err) if err.to_string().contains("rate limit") => { stats.failed += 1; @@ -675,7 +681,7 @@ async fn repair_cover_variants( )); for (media_file_id, file_path) in rows { - let path = resolve_media_path(storage_dir, &file_path); + let path = crate::media_paths::resolve_media_file_path(storage_dir, &file_path); if !path.exists() { stats.variants_missing_original += 1; log.warn(&format!( @@ -705,6 +711,59 @@ async fn repair_cover_variants( Ok(()) } +async fn assign_artist_album_fallback( + ctx: &JobContext, + artist_id: i64, +) -> anyhow::Result> { + let media_file_id: Option = sqlx::query_scalar( + r#"SELECT media_file_id + FROM ( + SELECT DISTINCT r.cover_file_id AS media_file_id + FROM furumusic__release r + JOIN furumusic__release_artist ra ON ra.release_id = r.id + WHERE ra.artist_id = $1 + AND r.cover_file_id IS NOT NULL + AND r.is_hidden = false + UNION + SELECT DISTINCT r.cover_file_id AS media_file_id + FROM furumusic__release r + JOIN furumusic__track t ON t.release_id = r.id + JOIN furumusic__track_artist ta ON ta.track_id = t.id + WHERE ta.artist_id = $1 + AND r.cover_file_id IS NOT NULL + AND r.is_hidden = false + ) covers + ORDER BY random() + LIMIT 1"#, + ) + .bind(artist_id) + .fetch_optional(&ctx.pool) + .await?; + + let Some(media_file_id) = media_file_id else { + return Ok(None); + }; + + let result = sqlx::query( + r#"UPDATE furumusic__artist + SET image_file_id = $1, + updated_at = $3 + WHERE id = $2 + AND image_file_id IS NULL"#, + ) + .bind(media_file_id) + .bind(artist_id) + .bind(now_iso()) + .execute(&ctx.pool) + .await?; + + if result.rows_affected() == 0 { + Ok(None) + } else { + Ok(Some(media_file_id)) + } +} + async fn fetch_lastfm_album_image( client: &Client, api_key: &str, @@ -772,57 +831,9 @@ async fn fetch_lastfm_artist_image( parsed.message.unwrap_or_default() ); } - if let Some(url) = parsed + Ok(parsed .artist - .and_then(|artist| choose_best_image(artist.image)) - { - return Ok(Some(url)); - } - - fetch_lastfm_artist_top_album_image(client, api_key, artist).await -} - -async fn fetch_lastfm_artist_top_album_image( - client: &Client, - api_key: &str, - artist: &str, -) -> anyhow::Result> { - let response = client - .get("https://ws.audioscrobbler.com/2.0/") - .query(&[ - ("method", "artist.getTopAlbums"), - ("api_key", api_key), - ("artist", artist), - ("autocorrect", "1"), - ("limit", "10"), - ("format", "json"), - ]) - .send() - .await?; - let body = response.text().await?; - let parsed: LastfmTopAlbumsResponse = serde_json::from_str(&body)?; - if let Some(code) = parsed.error { - if code == 6 || code == 7 { - return Ok(None); - } - if code == 29 { - anyhow::bail!("Last.fm rate limit exceeded"); - } - anyhow::bail!( - "Last.fm API error {code}: {}", - parsed.message.unwrap_or_default() - ); - } - - let albums = parsed - .topalbums - .and_then(|topalbums| topalbums.album) - .map(OneOrMany::into_vec) - .unwrap_or_default(); - Ok(albums - .into_iter() - .filter_map(|album| choose_best_image(album.image)) - .next()) + .and_then(|artist| choose_best_image(artist.image))) } fn choose_best_image(images: Option>) -> Option { @@ -943,15 +954,6 @@ fn cover_source_description(source: &CoverSource) -> String { } } -fn resolve_media_path(storage_dir: &str, file_path: &str) -> PathBuf { - let path = PathBuf::from(file_path); - if path.is_absolute() { - path - } else { - Path::new(storage_dir).join(path) - } -} - fn cutoff_iso(days: i64) -> String { (chrono::Utc::now() - chrono::Duration::days(days)) .format("%Y-%m-%dT%H:%M:%SZ") diff --git a/src/jobs/inbox_process.rs b/src/jobs/inbox_process.rs index 03c1623..16af129 100644 --- a/src/jobs/inbox_process.rs +++ b/src/jobs/inbox_process.rs @@ -798,16 +798,29 @@ pub async fn finalize_approved( ) .await? { - mover::MoveOutcome::Moved(p) => p.to_string_lossy().to_string(), - mover::MoveOutcome::Merged(p) => p.to_string_lossy().to_string(), + mover::MoveOutcome::Moved(p) | mover::MoveOutcome::Merged(p) => { + crate::media_paths::media_file_path_for_storage(storage_dir_str, &p).ok_or_else( + || { + anyhow::anyhow!( + "storage destination is outside agent_storage_dir: {}", + p.display() + ) + }, + )? + } } } else { - storage_dir + let expected_path = storage_dir .join(sanitize_filename(artist_name)) .join(sanitize_filename(release_title)) - .join(&dest_filename) - .to_string_lossy() - .to_string() + .join(&dest_filename); + crate::media_paths::media_file_path_for_storage(storage_dir_str, &expected_path) + .ok_or_else(|| { + anyhow::anyhow!( + "storage destination is outside agent_storage_dir: {}", + expected_path.display() + ) + })? }; let media_file = MediaFile::create( diff --git a/src/jobs/metadata_backfill.rs b/src/jobs/metadata_backfill.rs index d936dc2..93c7cbe 100644 --- a/src/jobs/metadata_backfill.rs +++ b/src/jobs/metadata_backfill.rs @@ -1,5 +1,3 @@ -use std::path::{Path, PathBuf}; - use crate::scheduler::{Job, JobContext, JobLog}; #[derive(Debug, Clone, Copy)] @@ -104,11 +102,15 @@ pub async fn run_with_options( for row in rows { scanned += 1; - let Some(path) = resolve_media_path(&row.file_path, &ctx.config.agent_storage_dir) else { + let path = crate::media_paths::resolve_media_file_path( + &ctx.config.agent_storage_dir, + &row.file_path, + ); + if !path.exists() { missing += 1; log.warn(&format!("missing file: {}", row.file_path)); continue; - }; + } let extract_path = path.clone(); let raw_meta = match tokio::task::spawn_blocking(move || { @@ -218,17 +220,3 @@ fn should_update(current: Option, overwrite: bool) -> bool { fn should_update_duration(current: Option, overwrite: bool) -> bool { overwrite || current.unwrap_or(0.0) <= 0.0 } - -fn resolve_media_path(file_path: &str, storage_dir: &str) -> Option { - let path = Path::new(file_path); - if path.exists() { - return Some(path.to_path_buf()); - } - if path.is_relative() && !storage_dir.is_empty() { - let joined = Path::new(storage_dir).join(path); - if joined.exists() { - return Some(joined); - } - } - None -} diff --git a/src/main.rs b/src/main.rs index 02a5cf5..8455a9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod config; mod i18n; mod jobs; mod lastfm; +mod media_paths; mod music; mod oidc; mod player; diff --git a/src/media_paths.rs b/src/media_paths.rs new file mode 100644 index 0000000..581b6d4 --- /dev/null +++ b/src/media_paths.rs @@ -0,0 +1,310 @@ +use std::path::{Component, Path, PathBuf}; + +const KNOWN_MEDIA_ROOTS: &[&str] = &["media/library", "media/uploads"]; + +pub fn resolve_config_path(value: &str) -> String { + let path = resolve_config_path_buf(value); + if path.as_os_str().is_empty() { + String::new() + } else { + path.to_string_lossy().to_string() + } +} + +pub fn resolve_config_path_buf(value: &str) -> PathBuf { + let trimmed = value.trim(); + if trimmed.is_empty() { + return PathBuf::new(); + } + + let normalized = normalize_slashes(trimmed); + if is_host_absolute(&normalized) { + return PathBuf::from(normalized); + } + + if looks_like_windows_absolute(&normalized) { + if let Some(relative) = extract_known_media_root(&normalized) { + return app_root().join(slash_path(&relative)); + } + return PathBuf::from(normalized); + } + + app_root().join(slash_path(&normalized)) +} + +pub fn resolve_media_file_path(storage_dir: &str, file_path: &str) -> PathBuf { + let storage_root = resolve_config_path_buf(storage_dir); + if let Some(relative) = normalize_stored_media_file_path(storage_dir, file_path) { + return storage_root.join(slash_path(&relative)); + } + + let normalized = normalize_slashes(file_path.trim()); + let path = PathBuf::from(&normalized); + if path.is_absolute() { + path + } else { + storage_root.join(path) + } +} + +pub fn media_file_path_for_storage(storage_dir: &str, path: &Path) -> Option { + let storage_root = resolve_config_path_buf(storage_dir); + if let Ok(relative) = path.strip_prefix(&storage_root) { + return relative_path_string(relative); + } + + let normalized = normalize_slashes(&path.to_string_lossy()); + relative_after_storage_marker(&storage_root, &normalized).or_else(|| { + if !is_host_absolute(&normalized) && !looks_like_windows_absolute(&normalized) { + normalize_relative_path(&normalized) + } else { + None + } + }) +} + +pub fn normalize_stored_media_file_path(storage_dir: &str, file_path: &str) -> Option { + let trimmed = file_path.trim(); + if trimmed.is_empty() { + return None; + } + + let storage_root = resolve_config_path_buf(storage_dir); + let normalized = normalize_slashes(trimmed); + let path = PathBuf::from(&normalized); + if path.is_absolute() { + if let Ok(relative) = path.strip_prefix(&storage_root) { + return relative_path_string(relative); + } + return relative_after_storage_marker(&storage_root, &normalized); + } + + if looks_like_windows_absolute(&normalized) { + return relative_after_storage_marker(&storage_root, &normalized); + } + + if let Some(relative) = relative_after_storage_marker_prefix(&storage_root, &normalized) { + return Some(relative); + } + + normalize_relative_path(&normalized) +} + +pub async fn normalize_media_file_paths( + pool: &sqlx::PgPool, + storage_dir: &str, +) -> anyhow::Result { + let rows: Vec<(i64, String)> = + sqlx::query_as("SELECT id, file_path FROM furumusic__media_file ORDER BY id") + .fetch_all(pool) + .await?; + + let mut updated = 0; + for (id, file_path) in rows { + let Some(relative) = normalize_stored_media_file_path(storage_dir, &file_path) else { + continue; + }; + if relative == file_path { + continue; + } + sqlx::query("UPDATE furumusic__media_file SET file_path = $1 WHERE id = $2") + .bind(&relative) + .bind(id) + .execute(pool) + .await?; + updated += 1; + } + + Ok(updated) +} + +fn app_root() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn normalize_slashes(value: &str) -> String { + value.trim().replace('\\', "/") +} + +fn is_host_absolute(value: &str) -> bool { + Path::new(value).is_absolute() +} + +fn looks_like_windows_absolute(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/' && bytes[0].is_ascii_alphabetic() +} + +fn slash_path(value: &str) -> PathBuf { + value + .split('/') + .filter(|part| !part.is_empty() && *part != ".") + .fold(PathBuf::new(), |mut path, part| { + path.push(part); + path + }) +} + +fn normalize_relative_path(value: &str) -> Option { + let parts: Vec<&str> = value + .split('/') + .filter(|part| !part.is_empty() && *part != ".") + .collect(); + if parts.is_empty() || parts.iter().any(|part| *part == "..") { + return None; + } + Some(parts.join("/")) +} + +fn relative_path_string(path: &Path) -> Option { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Normal(value) => parts.push(value.to_string_lossy().to_string()), + Component::CurDir => {} + _ => return None, + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + +fn extract_known_media_root(value: &str) -> Option { + KNOWN_MEDIA_ROOTS + .iter() + .filter_map(|marker| relative_from_marker(value, marker, true)) + .next() +} + +fn relative_after_storage_marker(storage_root: &Path, value: &str) -> Option { + let marker = storage_marker(storage_root)?; + relative_from_marker(value, &marker, false) +} + +fn relative_after_storage_marker_prefix(storage_root: &Path, value: &str) -> Option { + let marker = storage_marker(storage_root)?; + let normalized = normalize_slashes(value); + let normalized_lower = normalized.to_ascii_lowercase(); + let marker_lower = marker.to_ascii_lowercase(); + if normalized_lower == marker_lower { + return None; + } + normalized_lower + .strip_prefix(&(marker_lower + "/")) + .and_then(|_| normalize_relative_path(&normalized[marker.len() + 1..])) +} + +fn storage_marker(storage_root: &Path) -> Option { + let parts: Vec = storage_root + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().to_string()), + _ => None, + }) + .collect(); + + if parts.len() >= 2 { + Some(format!( + "{}/{}", + parts[parts.len() - 2], + parts[parts.len() - 1] + )) + } else { + parts.last().cloned() + } +} + +fn relative_from_marker(value: &str, marker: &str, include_marker: bool) -> Option { + let normalized = normalize_slashes(value); + let haystack = format!("/{}", normalized.trim_matches('/')); + let marker = marker.trim_matches('/'); + let needle = format!("/{marker}"); + let haystack_lower = haystack.to_ascii_lowercase(); + let needle_lower = needle.to_ascii_lowercase(); + let index = haystack_lower.rfind(&needle_lower)?; + let after_marker = index + needle.len(); + if after_marker < haystack.len() && haystack.as_bytes().get(after_marker) != Some(&b'/') { + return None; + } + let tail = haystack[after_marker..].trim_matches('/'); + if include_marker { + if tail.is_empty() { + Some(marker.to_string()) + } else { + Some(format!("{marker}/{tail}")) + } + } else { + normalize_relative_path(tail) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_relative_config_path_from_app_root() { + let expected = app_root().join("media").join("library"); + assert_eq!(resolve_config_path_buf("media/library"), expected); + } + + #[test] + fn maps_foreign_windows_config_media_root_to_app_root() { + let expected = app_root().join("media").join("uploads"); + assert_eq!( + resolve_config_path_buf(r"C:\Users\ab\repos\furumusic\media\uploads"), + expected + ); + } + + #[test] + fn stores_path_relative_to_storage_root() { + let storage = app_root().join("media").join("library"); + let path = storage.join("Artist").join("Album").join("track.flac"); + assert_eq!( + media_file_path_for_storage(&storage.to_string_lossy(), &path).as_deref(), + Some("Artist/Album/track.flac") + ); + } + + #[test] + fn normalizes_legacy_windows_media_file_path() { + let storage = app_root().join("media").join("library"); + assert_eq!( + normalize_stored_media_file_path( + &storage.to_string_lossy(), + r"C:\Users\ab\repos\furumusic\media\library\Buckethead\Pike\cover.jpg", + ) + .as_deref(), + Some("Buckethead/Pike/cover.jpg") + ); + } + + #[test] + fn strips_accidental_relative_storage_root_prefix() { + let storage = app_root().join("media").join("library"); + assert_eq!( + normalize_stored_media_file_path( + &storage.to_string_lossy(), + "media/library/Buckethead/Pike/cover.jpg", + ) + .as_deref(), + Some("Buckethead/Pike/cover.jpg") + ); + } + + #[test] + fn resolves_legacy_windows_media_file_path_to_current_storage() { + let storage = app_root().join("media").join("library"); + assert_eq!( + resolve_media_file_path( + &storage.to_string_lossy(), + r"C:\Users\ab\repos\furumusic\media\library\Buckethead\Pike\cover.jpg", + ), + storage.join("Buckethead").join("Pike").join("cover.jpg") + ); + } +} diff --git a/src/player/mod.rs b/src/player/mod.rs index 6cd99de..a73f566 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -217,10 +217,10 @@ async fn lastfm_connect_handler( }; let (config, _) = AppConfig::load_with_db(&db).await; let Some(credentials) = LastfmCredentials::from_config(&config) else { - return Ok(redirect_response("/?lastfm=not_configured")); + return Ok(redirect_response("/")); }; let Some(origin) = request_origin(&request) else { - return Ok(redirect_response("/?lastfm=bad_origin")); + return Ok(redirect_response("/")); }; let state = uuid::Uuid::new_v4().simple().to_string(); @@ -270,7 +270,7 @@ async fn lastfm_callback_handler( .map(str::trim) .filter(|v| !v.is_empty()) else { - return Ok(redirect_response("/?lastfm=missing_token")); + return Ok(redirect_response("/")); }; let Some(state) = query .0 @@ -279,7 +279,7 @@ async fn lastfm_callback_handler( .map(str::trim) .filter(|v| !v.is_empty()) else { - return Ok(redirect_response("/?lastfm=missing_state")); + return Ok(redirect_response("/")); }; let state_user_id = sqlx::query_scalar::<_, i64>( @@ -290,7 +290,7 @@ async fn lastfm_callback_handler( .await .map_err(|e| cot::Error::internal(e.to_string()))?; if state_user_id != Some(user.id) { - return Ok(redirect_response("/?lastfm=bad_state")); + return Ok(redirect_response("/")); } sqlx::query("DELETE FROM furumusic__lastfm_auth_state WHERE state = $1") .bind(state) @@ -300,7 +300,7 @@ async fn lastfm_callback_handler( let (config, _) = AppConfig::load_with_db(&db).await; let Some(credentials) = LastfmCredentials::from_config(&config) else { - return Ok(redirect_response("/?lastfm=not_configured")); + return Ok(redirect_response("/")); }; let client = LastfmClient::new(credentials).map_err(|e| cot::Error::internal(e.to_string()))?; match client.get_session(token).await { @@ -324,11 +324,11 @@ async fn lastfm_callback_handler( .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; - Ok(redirect_response("/?lastfm=connected")) + Ok(redirect_response("/")) } Err(err) => { tracing::warn!("Last.fm auth failed for user {}: {err}", user.id); - Ok(redirect_response("/?lastfm=auth_failed")) + Ok(redirect_response("/")) } } } @@ -1452,7 +1452,8 @@ async fn stream_handler( return Ok(json_error(StatusCode::NOT_FOUND, "track not found")); }; - let full_path = std::path::Path::new(&config.agent_storage_dir).join(&media.file_path); + let full_path = + crate::media_paths::resolve_media_file_path(&config.agent_storage_dir, &media.file_path); if !full_path.exists() { return Ok(json_error( @@ -1521,7 +1522,7 @@ async fn local_upload_handler( "agent_inbox_dir is not configured", )); } - let inbox_root = std::path::PathBuf::from(inbox_dir); + let inbox_root = crate::media_paths::resolve_config_path_buf(inbox_dir); if !inbox_root.is_absolute() { return Ok(json_error( StatusCode::BAD_REQUEST, @@ -1744,7 +1745,8 @@ async fn cover_response( return Ok(json_error(StatusCode::NOT_FOUND, "media file not found")); }; - let full_path = std::path::Path::new(&config.agent_storage_dir).join(&media.file_path); + let full_path = + crate::media_paths::resolve_media_file_path(&config.agent_storage_dir, &media.file_path); if !full_path.exists() { return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk")); diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 3aee4f5..cfeb348 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -1471,6 +1471,17 @@ pub async fn start_scheduler( Err(e) => tracing::error!("Failed to recover stale reviews: {e}"), } + let (live_config, _) = AppConfig::load_with_db(&db).await; + if !live_config.agent_storage_dir.trim().is_empty() { + match crate::media_paths::normalize_media_file_paths(&pool, &live_config.agent_storage_dir) + .await + { + Ok(0) => {} + Ok(n) => tracing::info!("Normalized {n} media file path(s) to relative storage paths"), + Err(e) => tracing::warn!("Failed to normalize media file paths: {e:#}"), + } + } + // Upsert ScheduledJob rows for job in registry.all_jobs() { ScheduledJob::upsert(&db, job.name(), job.description(), job.default_cron()) diff --git a/src/torrents.rs b/src/torrents.rs index d822a05..63b2d21 100644 --- a/src/torrents.rs +++ b/src/torrents.rs @@ -1242,7 +1242,7 @@ fn validate_selection(files: &[TorrentFileDto], selected_files: &[usize]) -> any fn validate_inbox_dir(inbox_dir: &str) -> anyhow::Result { let trimmed = inbox_dir.trim(); - let path = PathBuf::from(trimmed); + let path = crate::media_paths::resolve_config_path_buf(trimmed); if !path.is_absolute() { bail!( "agent_inbox_dir must be an absolute path for this host, got `{}`", diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 21fb263..3c43afc 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -38,6 +38,8 @@ const T = { lastfmDisconnectConfirm: "{{ t.player_lastfm_disconnect_confirm }}", lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}", lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}", + connectionLost: "{{ t.player_connection_lost }}", + connectionLostDetail: "{{ t.player_connection_lost_detail }}", trackWord: "{{ t.player_tracks_count }}", clientIdle: "{{ t.player_client_idle }}", active: "{{ t.player_active }}", @@ -115,6 +117,42 @@ function coverVariantUrl(url, variant) { } document.addEventListener('alpine:init', () => { + // ----------------------------------------------------------------------- + // Connection monitor + // ----------------------------------------------------------------------- + Alpine.store('connection', { + failureCount: 0, + disconnected: false, + threshold: 2, + + init() { + if (navigator.onLine === false) { + this.failureCount = this.threshold; + this.disconnected = true; + } + window.addEventListener('online', () => this.recordSuccess()); + window.addEventListener('offline', () => this.recordFailure()); + }, + + message() { + return T.connectionLostDetail; + }, + + recordSuccess() { + this.failureCount = 0; + this.disconnected = false; + }, + + recordFailure() { + this.failureCount += 1; + if (this.failureCount >= this.threshold) { + this.disconnected = true; + } + }, + }); + + installConnectionFetchMonitor(); + // ----------------------------------------------------------------------- // Audio element // ----------------------------------------------------------------------- @@ -174,10 +212,19 @@ document.addEventListener('alpine:init', () => { lastfmBusy: false, init() { + this.cleanLastfmQuery(); this.load(); this.loadLastfm(); }, + cleanLastfmQuery() { + const url = new URL(window.location.href); + if (!url.searchParams.has('lastfm')) return; + url.searchParams.delete('lastfm'); + const clean = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState({}, document.title, clean || '/'); + }, + async load() { try { const res = await fetch('/api/player/me'); @@ -447,9 +494,13 @@ document.addEventListener('alpine:init', () => { const queue = Alpine.store('queue'); if (queue.tracks.length === 0) return; + this._recordHistoryIfListenThresholdReached(); + let nextIdx; if (this.repeatMode === 'one') { this.seek(0); + this._historyRecorded = false; + this._resetPlaybackTracking(); this.resume(); return; } else if (this.shuffle) { @@ -655,6 +706,18 @@ document.addEventListener('alpine:init', () => { }).catch(() => {}); }, + _recordHistoryIfListenThresholdReached() { + if (this._historyRecorded || !this.currentTrack) return false; + this._trackListenedDelta(); + const duration = this._trackDuration(); + if (duration <= 0) return false; + const listened = Math.floor(Number(this._listenedSeconds || 0)); + const threshold = Math.ceil(duration / 2); + if (threshold <= 0 || listened < threshold) return false; + this._recordHistory(true); + return true; + }, + _resetPlaybackTracking() { this._nowPlayingSent = false; this._playbackStartedAt = null; @@ -2294,4 +2357,39 @@ document.addEventListener('alpine:init', () => { }, }); }); + +function installConnectionFetchMonitor() { + if (window.__furumusicConnectionMonitorInstalled || !window.fetch) return; + window.__furumusicConnectionMonitorInstalled = true; + const nativeFetch = window.fetch.bind(window); + + window.fetch = async (...args) => { + const tracked = isTrackedPlayerRequest(args[0]); + try { + const response = await nativeFetch(...args); + if (tracked) { + if (response.status >= 500) { + Alpine.store('connection')?.recordFailure(); + } else { + Alpine.store('connection')?.recordSuccess(); + } + } + return response; + } catch (error) { + if (tracked) Alpine.store('connection')?.recordFailure(); + throw error; + } + }; +} + +function isTrackedPlayerRequest(input) { + const rawUrl = typeof input === 'string' ? input : input?.url; + if (!rawUrl) return false; + try { + const url = new URL(rawUrl, window.location.href); + return url.origin === window.location.origin && url.pathname.startsWith('/api/player/'); + } catch { + return false; + } +} diff --git a/templates/player/shell.html b/templates/player/shell.html index 623b041..39738b8 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -275,6 +275,20 @@ +
+ + + + + + + {{ t.player_connection_lost }} +