diff --git a/Cargo.lock b/Cargo.lock index 469cf4f..00f9892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -599,6 +605,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1304,6 +1316,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -1397,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.15" +version = "0.1.16" dependencies = [ "anyhow", "async-trait", @@ -1407,6 +1428,7 @@ dependencies = [ "croner", "encoding_rs", "id3", + "image", "librqbit", "openidconnect", "reqwest", @@ -1588,6 +1610,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2026,6 +2058,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2522,6 +2582,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -3000,6 +3070,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3067,6 +3150,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quanta" version = "0.12.6" @@ -3082,6 +3171,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -5142,6 +5237,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -5690,3 +5791,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 9e04665..695854d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.16" +version = "0.1.17" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" @@ -20,6 +20,7 @@ symphonia = { version = "0.5", default-features = false, features = ["mp3","aac" id3 = "1" encoding_rs = "0.8" sha2 = "0.10" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] } anyhow = "1.0" tokio-cron-scheduler = "0.15" diff --git a/src/admin/views.rs b/src/admin/views.rs index 7ec0e17..d9a5430 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -799,7 +799,7 @@ pub async fn artists_edit( .await .ok() .flatten() - .map(|mf| format!("/api/player/cover/{}", mf.id_val())), + .map(|mf| format!("/api/player/cover/{}/large", mf.id_val())), None => None, }; @@ -879,7 +879,7 @@ pub async fn artists_available_covers( covers.push(AvailableCover { media_file_id: cover_fid, release_title: release.title_str().to_owned(), - cover_url: format!("/api/player/cover/{cover_fid}"), + cover_url: format!("/api/player/cover/{cover_fid}/medium"), }); } } diff --git a/src/agent/cover_art.rs b/src/agent/cover_art.rs index 61c9ec8..e09edda 100644 --- a/src/agent/cover_art.rs +++ b/src/agent/cover_art.rs @@ -328,6 +328,23 @@ pub async fn save_cover_to_storage( .await?; if let Some((id,)) = existing { + if let Some((file_path,)) = sqlx::query_as::<_, (String,)>( + "SELECT file_path FROM furumusic__media_file WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await? + { + let path = PathBuf::from(&file_path); + let path = if path.is_absolute() { + path + } else { + Path::new(storage_dir).join(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"); + } + } return Ok(id); } @@ -374,6 +391,14 @@ pub async fn save_cover_to_storage( "Saved cover art" ); + if let Err(err) = crate::agent::cover_variants::ensure_cover_variants(&dest_path).await { + tracing::warn!( + media_file_id = media_file.id_val(), + error = %err, + "Failed to generate cover variants" + ); + } + Ok(media_file.id_val()) } diff --git a/src/agent/cover_variants.rs b/src/agent/cover_variants.rs new file mode 100644 index 0000000..54a9e67 --- /dev/null +++ b/src/agent/cover_variants.rs @@ -0,0 +1,102 @@ +use std::path::{Path, PathBuf}; + +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; + +#[derive(Debug, Clone, Copy)] +pub struct CoverVariant { + pub name: &'static str, + pub max_edge: u32, + pub quality: u8, +} + +pub const COVER_VARIANTS: &[CoverVariant] = &[ + CoverVariant { + name: "small", + max_edge: 96, + quality: 80, + }, + CoverVariant { + name: "medium", + max_edge: 256, + quality: 82, + }, + CoverVariant { + name: "large", + max_edge: 512, + quality: 85, + }, +]; + +pub fn variant_by_name(name: &str) -> Option { + COVER_VARIANTS + .iter() + .copied() + .find(|variant| variant.name == name) +} + +pub fn variant_path(original_path: &Path, variant: CoverVariant) -> PathBuf { + let stem = original_path + .file_stem() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("cover"); + let filename = format!("{stem}.{}.jpg", variant.name); + original_path.with_file_name(filename) +} + +pub fn missing_variants(original_path: &Path) -> Vec { + COVER_VARIANTS + .iter() + .copied() + .filter(|variant| !variant_path(original_path, *variant).exists()) + .collect() +} + +pub async fn ensure_cover_variants(original_path: &Path) -> anyhow::Result { + let missing = missing_variants(original_path); + if missing.is_empty() { + return Ok(0); + } + + let original_path = original_path.to_path_buf(); + tokio::task::spawn_blocking(move || generate_missing_variants_sync(&original_path, &missing)) + .await + .map_err(|err| anyhow::anyhow!("cover variant task failed: {err}"))? +} + +fn generate_missing_variants_sync( + original_path: &Path, + variants: &[CoverVariant], +) -> anyhow::Result { + let data = std::fs::read(original_path)?; + let image = image::load_from_memory(&data)?; + + let mut created = 0usize; + for variant in variants { + let path = variant_path(original_path, *variant); + if path.exists() { + continue; + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let resized = image + .resize(variant.max_edge, variant.max_edge, FilterType::Lanczos3) + .to_rgb8(); + let mut output = Vec::new(); + let mut encoder = JpegEncoder::new_with_quality(&mut output, variant.quality); + encoder.encode( + &resized, + resized.width(), + resized.height(), + image::ExtendedColorType::Rgb8, + )?; + std::fs::write(path, output)?; + created += 1; + } + + Ok(created) +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 0841064..a1299e4 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,4 +1,5 @@ pub mod cover_art; +pub mod cover_variants; pub mod dto; pub mod metadata; pub mod mover; diff --git a/src/jobs/cover_variant_backfill.rs b/src/jobs/cover_variant_backfill.rs new file mode 100644 index 0000000..93be224 --- /dev/null +++ b/src/jobs/cover_variant_backfill.rs @@ -0,0 +1,96 @@ +use std::path::{Path, PathBuf}; + +use crate::agent::cover_variants; +use crate::scheduler::{Job, JobContext, JobLog}; + +pub struct CoverVariantBackfillJob; + +#[async_trait::async_trait] +impl Job for CoverVariantBackfillJob { + fn name(&self) -> &'static str { + "cover_variant_backfill" + } + + fn description(&self) -> &'static str { + "Generate missing resized cover image variants" + } + + fn default_cron(&self) -> &'static str { + // Once a day after cover extraction and artist image assignment. + "0 45 3 * * *" + } + + async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> { + let storage_dir = &ctx.config.agent_storage_dir; + if storage_dir.is_empty() { + log.warn("agent_storage_dir is not configured, skipping cover variant backfill"); + return Ok(()); + } + + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, file_path FROM furumusic__media_file WHERE file_type = 'cover_art' ORDER BY id", + ) + .fetch_all(&ctx.pool) + .await?; + + if rows.is_empty() { + log.info("No cover art media files found"); + return Ok(()); + } + + log.info(&format!( + "Found {} cover art media file(s), checking variants...", + rows.len() + )); + + let mut created = 0usize; + let mut unchanged = 0usize; + let mut missing_original = 0usize; + let mut failed = 0usize; + + for (media_file_id, file_path) in rows { + let path = resolve_media_path(storage_dir, &file_path); + if !path.exists() { + missing_original += 1; + log.warn(&format!( + "Media file {media_file_id}: original cover not found at {}", + path.display() + )); + continue; + } + + match cover_variants::ensure_cover_variants(&path).await { + Ok(0) => unchanged += 1, + Ok(count) => { + created += count; + log.info(&format!( + "Media file {media_file_id}: created {count} variant(s)" + )); + } + Err(err) => { + failed += 1; + log.warn(&format!( + "Media file {media_file_id}: failed to create variants: {err}" + )); + } + } + } + + log.info(&format!( + "Cover variant backfill complete: {created} variant(s) created, \ + {unchanged} original(s) already complete, {missing_original} missing original(s), \ + {failed} failed original(s)" + )); + + Ok(()) + } +} + +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) + } +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index eb84ce9..84cd0c9 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -1,6 +1,7 @@ pub mod artist_image_backfill; pub mod artist_track_image_backfill; pub mod cover_backfill; +pub mod cover_variant_backfill; pub mod inbox_discover; pub mod inbox_process; pub mod lastfm_popularity; diff --git a/src/main.rs b/src/main.rs index c794613..d02c342 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,7 @@ fn build_registry() -> Arc { registry.register(jobs::cover_backfill::CoverBackfillJob); registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob); registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob); + registry.register(jobs::cover_variant_backfill::CoverVariantBackfillJob); registry.register(jobs::metadata_backfill::MetadataBackfillJob); registry.register(jobs::lastfm_popularity::LastfmPopularityJob); Arc::new(registry) diff --git a/src/player/helpers.rs b/src/player/helpers.rs index 4de91e1..d0e7be5 100644 --- a/src/player/helpers.rs +++ b/src/player/helpers.rs @@ -1,15 +1,16 @@ use crate::player::dto::UploaderSummary; use crate::player::rows::ReleaseUploaderRow; -pub(super) fn cover_url(file_id: Option) -> Option { - file_id.map(|id| format!("/api/player/cover/{id}")) +pub(super) fn cover_variant_url(file_id: Option, variant: &str) -> Option { + file_id.map(|id| format!("/api/player/cover/{id}/{variant}")) } -pub(super) fn track_cover_url( +pub(super) fn track_cover_variant_url( track_cover: Option, release_cover: Option, + variant: &str, ) -> Option { - cover_url(track_cover.or(release_cover)) + cover_variant_url(track_cover.or(release_cover), variant) } pub(super) async fn load_release_uploaders( diff --git a/src/player/mod.rs b/src/player/mod.rs index deba36d..f8e1330 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -25,7 +25,7 @@ mod queries; mod rows; use dto::*; -use helpers::{cover_url, load_release_uploaders, track_cover_url}; +use helpers::{cover_variant_url, load_release_uploaders, track_cover_variant_url}; use queries::*; use rows::*; @@ -203,7 +203,7 @@ async fn artists_handler( .map(|r| ArtistCard { id: r.id, name: r.name, - image_url: cover_url(r.image_file_id), + image_url: cover_variant_url(r.image_file_id, "medium"), release_count: r.release_count, track_count: r.track_count, }) @@ -279,7 +279,7 @@ async fn artist_detail_handler( title: r.title, release_type: r.release_type, year: r.year, - cover_url: cover_url(r.cover_file_id), + cover_url: cover_variant_url(r.cover_file_id, "medium"), track_count: r.track_count, uploaders: release_uploaders.remove(&r.id).unwrap_or_default(), }) @@ -386,7 +386,11 @@ async fn artist_detail_handler( duration_seconds: t.duration_seconds, artists: featured_main_artists.remove(&tid).unwrap_or_default(), featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(), - cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), + cover_url: track_cover_variant_url( + t.cover_file_id, + t.release_cover_file_id, + "medium", + ), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, audio_format: t.audio_format, @@ -405,7 +409,7 @@ async fn artist_detail_handler( Json(ArtistDetail { id: artist.id, name: artist.name, - image_url: cover_url(image_file_id), + image_url: cover_variant_url(image_file_id, "large"), total_track_count, total_play_count, releases: release_cards, @@ -539,7 +543,11 @@ async fn release_detail_handler( artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), release_year: t.release_year, - cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), + cover_url: track_cover_variant_url( + t.cover_file_id, + t.release_cover_file_id, + "medium", + ), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, audio_format: t.audio_format, @@ -565,7 +573,7 @@ async fn release_detail_handler( title: release.title, release_type: release.release_type, year: release.year, - cover_url: cover_url(release.cover_file_id), + cover_url: cover_variant_url(release.cover_file_id, "large"), artists: release_artists .into_iter() .map(|a| ArtistRef { @@ -797,7 +805,11 @@ async fn build_track_items( artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), release_year: t.release_year, - cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), + cover_url: track_cover_variant_url( + t.cover_file_id, + t.release_cover_file_id, + "medium", + ), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, audio_format: t.audio_format, @@ -1138,13 +1150,40 @@ async fn cover_handler( pool: &sqlx::PgPool, config: &AppConfig, path: Path, +) -> cot::Result> { + cover_response(session, db, pool, config, path.0.media_file_id, None).await +} + +async fn cover_variant_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + config: &AppConfig, + path: Path, +) -> cot::Result> { + cover_response( + session, + db, + pool, + config, + path.0.media_file_id, + Some(path.0.variant.as_str()), + ) + .await +} + +async fn cover_response( + session: Session, + db: Database, + pool: &sqlx::PgPool, + config: &AppConfig, + media_file_id: i64, + variant_name: Option<&str>, ) -> cot::Result> { let Some(_user) = auth::get_session_user(&session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; - let media_file_id = path.0.media_file_id; - let media = sqlx::query_as::<_, MediaFileRow>( "SELECT file_path, mime_type::text as mime_type, file_size_bytes FROM furumusic__media_file WHERE id = $1", ) @@ -1163,13 +1202,25 @@ async fn cover_handler( return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk")); } - let data = tokio::fs::read(&full_path) + let (response_path, content_type) = variant_name + .and_then(crate::agent::cover_variants::variant_by_name) + .map(|variant| { + let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant); + if variant_path.exists() { + (variant_path, "image/jpeg") + } else { + (full_path.clone(), media.mime_type.as_str()) + } + }) + .unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str())); + + let data = tokio::fs::read(&response_path) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let response = cot::http::Response::builder() .status(StatusCode::OK) - .header(CONTENT_TYPE, media.mime_type.as_str()) + .header(CONTENT_TYPE, content_type) .header(CONTENT_LENGTH, data.len().to_string()) .header("Cache-Control", "public, max-age=86400") .body(Body::fixed(data)) @@ -1590,7 +1641,7 @@ async fn search_handler( .map(|r| ArtistCard { id: r.id, name: r.name, - image_url: cover_url(r.image_file_id), + image_url: cover_variant_url(r.image_file_id, "medium"), release_count: r.release_count, track_count: r.track_count, }) @@ -1608,7 +1659,7 @@ async fn search_handler( title: r.title, release_type: r.release_type, year: r.year, - cover_url: cover_url(r.cover_file_id), + cover_url: cover_variant_url(r.cover_file_id, "medium"), track_count: r.track_count, uploaders: release_uploaders.remove(&r.id).unwrap_or_default(), }) @@ -1627,7 +1678,11 @@ async fn search_handler( artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), release_year: t.release_year, - cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), + cover_url: track_cover_variant_url( + t.cover_file_id, + t.release_cover_file_id, + "medium", + ), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, audio_format: t.audio_format, @@ -2097,7 +2152,7 @@ async fn followed_artists_handler( .map(|r| ArtistCard { id: r.id, name: r.name, - image_url: cover_url(r.image_file_id), + image_url: cover_variant_url(r.image_file_id, "small"), release_count: r.release_count, track_count: r.track_count, }) @@ -2274,7 +2329,11 @@ async fn tracks_by_ids_handler( artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), release_year: t.release_year, - cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), + cover_url: track_cover_variant_url( + t.cover_file_id, + t.release_cover_file_id, + "medium", + ), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, audio_format: t.audio_format, @@ -3130,6 +3189,34 @@ impl App for PlayerApp { "player_stream", ), // -- Cover art -- + Route::with_handler_and_name( + "/cover/{media_file_id}/{variant}", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let config = Arc::clone(&self.config); + get( + move |session: Session, db: Database, path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let config = Arc::clone(&config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + cover_variant_handler(session, db, pg_pool, &config, path).await + } + }, + ) + }, + "player_cover_variant", + ), Route::with_handler_and_name( "/cover/{media_file_id}", { diff --git a/src/player/queries.rs b/src/player/queries.rs index 07c4f4a..76198ea 100644 --- a/src/player/queries.rs +++ b/src/player/queries.rs @@ -70,3 +70,9 @@ pub(super) struct PathTrackId { pub(super) struct PathMediaFileId { pub(super) media_file_id: i64, } + +#[derive(Debug, Deserialize)] +pub(super) struct PathMediaFileVariant { + pub(super) media_file_id: i64, + pub(super) variant: String, +} diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 9686999..ec87521 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -97,6 +97,11 @@ function formatTime(seconds) { return m + ':' + (sec < 10 ? '0' : '') + sec; } +function coverVariantUrl(url, variant) { + if (!url) return url; + return url.replace(/\/(small|medium|large)$/, '/' + variant); +} + document.addEventListener('alpine:init', () => { // ----------------------------------------------------------------------- // Audio element @@ -440,7 +445,7 @@ document.addEventListener('alpine:init', () => { navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artists.map(a => a.name).join(', '), - artwork: t.cover_url ? [{ src: t.cover_url, sizes: '512x512', type: 'image/jpeg' }] : [], + artwork: t.cover_url ? [{ src: coverVariantUrl(t.cover_url, 'large'), sizes: '512x512', type: 'image/jpeg' }] : [], }); navigator.mediaSession.setActionHandler('play', () => this.resume()); navigator.mediaSession.setActionHandler('pause', () => this.pause()); @@ -634,6 +639,8 @@ document.addEventListener('alpine:init', () => { searchResults: null, searchLoading: false, _previousView: 'artists', + _activeHash: location.hash || '#artists', + _scrollPositions: {}, _hashNav: false, // guard against circular hash updates @@ -643,26 +650,31 @@ document.addEventListener('alpine:init', () => { // Listen for browser back/forward window.addEventListener('hashchange', () => { - this._navigateFromHash(); + if (this._hashNav) return; + const nextHash = location.hash || '#artists'; + this._saveScrollPosition(this._activeHash); + this._activeHash = nextHash; + this._navigateFromHash({ fromHash: true, restoreScroll: true }); }); // Navigate to initial hash (if any) - this._navigateFromHash(); + this._navigateFromHash({ fromHash: true, restoreScroll: true }); }, _setHash(hash) { this._hashNav = true; + this._activeHash = hash; location.hash = hash; // Reset guard after a tick setTimeout(() => { this._hashNav = false; }, 0); }, - _navigateFromHash() { + _navigateFromHash(options = {}) { if (this._hashNav) return; const hash = location.hash || '#artists'; const match = hash.match(/^#(\w+)(?:\/([-]?\d+))?(?:\?(.*))?$/); if (!match) { - this.goArtists(); + this.goArtists(options); return; } const view = match[1]; @@ -670,26 +682,74 @@ document.addEventListener('alpine:init', () => { const params = match[3] || ''; if (view === 'artists' && !id) { - if (this.view !== 'artists') this.goArtists(); + if (this.view !== 'artists') this.goArtists(options); + else if (options.restoreScroll) this._restoreScrollPosition(hash); } else if (view === 'artist' && id) { - this.openArtist(id); + this.openArtist(id, options); } else if (view === 'release' && id) { - this.openRelease(id); + this.openRelease(id, options); } else if (view === 'playlist' && id) { - this.openPlaylist(id); + this.openPlaylist(id, options); } else if (view === 'search') { const qMatch = params.match(/q=([^&]*)/); if (qMatch) { const q = decodeURIComponent(qMatch[1]); this.searchQuery = q; - this.search(q); + this.search(q, options); } } else { - this.goArtists(); + this.goArtists(options); } }, - goArtists() { + _scrollElement() { + return document.getElementById('center-scroll'); + }, + + _saveScrollPosition(hash = this._activeHash) { + const el = this._scrollElement(); + if (!el || !hash) return; + this._scrollPositions[hash] = el.scrollTop; + }, + + _scrollToTop() { + const el = this._scrollElement(); + if (el) el.scrollTop = 0; + }, + + _restoreScrollPosition(hash = this._activeHash) { + const top = this._scrollPositions[hash]; + if (top == null) return; + const restore = () => { + const el = this._scrollElement(); + if (el) el.scrollTop = top; + }; + this.$nextTick(() => { + restore(); + requestAnimationFrame(restore); + setTimeout(restore, 150); + }); + }, + + _afterNavigation(options = {}) { + if (options.restoreScroll) { + this._restoreScrollPosition(this._activeHash); + } else { + this.$nextTick(() => { this._scrollToTop(); }); + } + }, + + _beginNavigation(hash, options = {}) { + if (!options.fromHash) { + this._saveScrollPosition(this._activeHash); + this._setHash(hash); + } else { + this._activeHash = hash; + } + }, + + goArtists(options = {}) { + this._beginNavigation('#artists', options); this.view = 'artists'; this.currentArtist = null; this.currentRelease = null; @@ -697,8 +757,8 @@ document.addEventListener('alpine:init', () => { this.searchQuery = ''; this.searchResults = null; this._previousView = 'artists'; - this._setHash('#artists'); this.$nextTick(() => { this._setupScroll(); }); + this._afterNavigation(options); }, async loadArtists(page) { @@ -722,17 +782,18 @@ document.addEventListener('alpine:init', () => { this.loading = false; }, - async openArtist(id) { + async openArtist(id, options = {}) { + this._beginNavigation('#artist/' + id, options); this.searchQuery = ''; this.searchResults = null; this.view = 'artist_detail'; this.currentArtist = null; - this._setHash('#artist/' + id); try { const res = await fetch(`/api/player/artists/${id}`); if (!res.ok) throw new Error('failed'); this.currentArtist = await res.json(); } catch {} + this._afterNavigation(options); }, artistReleaseGroups() { @@ -842,28 +903,30 @@ document.addEventListener('alpine:init', () => { return lines.join('\n'); }, - async openRelease(id) { + async openRelease(id, options = {}) { + this._beginNavigation('#release/' + id, options); this.searchQuery = ''; this.searchResults = null; this.view = 'release_detail'; this.currentRelease = null; - this._setHash('#release/' + id); try { const res = await fetch(`/api/player/releases/${id}`); if (!res.ok) throw new Error('failed'); this.currentRelease = await res.json(); } catch {} + this._afterNavigation(options); }, - async openPlaylist(id) { + async openPlaylist(id, options = {}) { + this._beginNavigation('#playlist/' + id, options); this.view = 'playlist_detail'; this.currentPlaylist = null; - this._setHash('#playlist/' + id); try { const res = await fetch(`/api/player/playlists/${id}`); if (!res.ok) throw new Error('failed'); this.currentPlaylist = await res.json(); } catch {} + this._afterNavigation(options); }, async playRelease(releaseId) { @@ -899,17 +962,17 @@ document.addEventListener('alpine:init', () => { } catch {} }, - async search(query) { + async search(query, options = {}) { const q = (query || '').trim(); if (!q) { this.clearSearch(); return; } + this._beginNavigation('#search?q=' + encodeURIComponent(q), options); if (this.view !== 'search') { this._previousView = this.view; } this.view = 'search'; - this._setHash('#search?q=' + encodeURIComponent(q)); this.searchLoading = true; try { const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`); @@ -919,6 +982,7 @@ document.addEventListener('alpine:init', () => { this.searchResults = { artists: [], releases: [], tracks: [] }; } this.searchLoading = false; + this._afterNavigation(options); }, clearSearch() {