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
+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,
}