added image resizer
Build and Publish / Build and Publish Docker Image (push) Successful in 3m19s

This commit is contained in:
2026-05-27 00:28:39 +03:00
parent c0342ed987
commit 04c30bc4b8
13 changed files with 547 additions and 46 deletions
Generated
+117 -1
View File
@@ -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",
]
+2 -1
View File
@@ -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"
+2 -2
View File
@@ -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"),
});
}
}
+25
View File
@@ -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())
}
+102
View File
@@ -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<CoverVariant> {
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<CoverVariant> {
COVER_VARIANTS
.iter()
.copied()
.filter(|variant| !variant_path(original_path, *variant).exists())
.collect()
}
pub async fn ensure_cover_variants(original_path: &Path) -> anyhow::Result<usize> {
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<usize> {
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)
}
+1
View File
@@ -1,4 +1,5 @@
pub mod cover_art;
pub mod cover_variants;
pub mod dto;
pub mod metadata;
pub mod mover;
+96
View File
@@ -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)
}
}
+1
View File
@@ -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;
+1
View File
@@ -52,6 +52,7 @@ fn build_registry() -> Arc<JobRegistry> {
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)
+5 -4
View File
@@ -1,15 +1,16 @@
use crate::player::dto::UploaderSummary;
use crate::player::rows::ReleaseUploaderRow;
pub(super) fn cover_url(file_id: Option<i64>) -> Option<String> {
file_id.map(|id| format!("/api/player/cover/{id}"))
pub(super) fn cover_variant_url(file_id: Option<i64>, variant: &str) -> Option<String> {
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<i64>,
release_cover: Option<i64>,
variant: &str,
) -> Option<String> {
cover_url(track_cover.or(release_cover))
cover_variant_url(track_cover.or(release_cover), variant)
}
pub(super) async fn load_release_uploaders(
+104 -17
View File
@@ -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<PathMediaFileId>,
) -> cot::Result<cot::http::Response<Body>> {
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<PathMediaFileVariant>,
) -> cot::Result<cot::http::Response<Body>> {
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<cot::http::Response<Body>> {
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<PathMediaFileVariant>| {
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}",
{
+6
View File
@@ -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,
}
+85 -21
View File
@@ -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() {