This commit is contained in:
Generated
+117
-1
@@ -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
@@ -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
@@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod cover_art;
|
||||
pub mod cover_variants;
|
||||
pub mod dto;
|
||||
pub mod metadata;
|
||||
pub mod mover;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user