Compare commits

..

3 Commits

Author SHA1 Message Date
Ultradesu 65da460c0c ADMIN: added pending review
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s
2026-05-27 15:03:06 +03:00
Ultradesu 538a6f6abf PLAYER: added simple rating
Build and Publish / Build and Publish Docker Image (push) Successful in 2m55s
2026-05-27 12:55:31 +03:00
ab 04c30bc4b8 added image resizer
Build and Publish / Build and Publish Docker Image (push) Successful in 3m19s
2026-05-27 00:28:39 +03:00
20 changed files with 1163 additions and 81 deletions
Generated
+117 -1
View File
@@ -501,6 +501,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -599,6 +605,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -1304,6 +1316,15 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" 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]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -1397,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.1.15" version = "0.1.18"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1407,6 +1428,7 @@ dependencies = [
"croner", "croner",
"encoding_rs", "encoding_rs",
"id3", "id3",
"image",
"librqbit", "librqbit",
"openidconnect", "openidconnect",
"reqwest", "reqwest",
@@ -1588,6 +1610,16 @@ dependencies = [
"wasip3", "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]] [[package]]
name = "gimli" name = "gimli"
version = "0.32.3" version = "0.32.3"
@@ -2026,6 +2058,34 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2522,6 +2582,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "multer" name = "multer"
version = "3.1.0" version = "3.1.0"
@@ -3000,6 +3070,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" 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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -3067,6 +3150,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]] [[package]]
name = "quanta" name = "quanta"
version = "0.12.6" version = "0.12.6"
@@ -3082,6 +3171,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -5142,6 +5237,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.1" version = "1.6.1"
@@ -5690,3 +5791,18 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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] [package]
name = "furumusic" name = "furumusic"
version = "0.1.16" version = "0.1.19"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" 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" id3 = "1"
encoding_rs = "0.8" encoding_rs = "0.8"
sha2 = "0.10" 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"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
anyhow = "1.0" anyhow = "1.0"
tokio-cron-scheduler = "0.15" tokio-cron-scheduler = "0.15"
+34 -4
View File
@@ -20,8 +20,8 @@ use crate::i18n::I18n;
use crate::scheduler::{JobRegistry, SchedulerHandle}; use crate::scheduler::{JobRegistry, SchedulerHandle};
use crate::user::User; use crate::user::User;
use views::{ use views::{
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewsBulkForm, ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewApproveForm,
SetImageBody, SetupForm, UploadImageBody, UserForm, ReviewsBulkForm, SetImageBody, SetupForm, UploadImageBody, UserForm,
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -227,6 +227,35 @@ impl App for AdminApp {
}, },
"admin_v2_reviews_bulk", "admin_v2_reviews_bulk",
), ),
Route::with_handler_and_name(
"/v2/api/reviews/{id}/approve",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(
move |session: Session,
db: Database,
path: Path<PathId>,
json: Json<v2::ReviewEditDto>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_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("admin pool")
})
.await;
v2::approve_review(session, db, pg_pool, path.0.id, json).await
}
},
)
},
"admin_v2_review_approve",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/v2/api/jobs", "/v2/api/jobs",
{ {
@@ -1048,7 +1077,8 @@ impl App for AdminApp {
let config = Arc::clone(&self.config); let config = Arc::clone(&self.config);
let pool = Arc::clone(&pool); let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config); let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathId>| { move |session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ReviewApproveForm>| {
let config = Arc::clone(&config); let config = Arc::clone(&config);
let pool = Arc::clone(&pool); let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config); let pool_config = Arc::clone(&pool_config);
@@ -1064,7 +1094,7 @@ impl App for AdminApp {
.await .await
.expect("admin pool") .expect("admin pool")
}).await; }).await;
views::review_approve(admin, &config, &db, pg_pool, path.0.id).await views::review_approve(admin, &config, &db, pg_pool, path.0.id, form).await
} }
} }
}), }),
+139
View File
@@ -156,10 +156,24 @@ struct ReviewDto {
token_count: Option<i64>, token_count: Option<i64>,
tags: Vec<TagDto>, tags: Vec<TagDto>,
error_message: Option<String>, error_message: Option<String>,
normalized: ReviewEditDto,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub(super) struct ReviewEditDto {
title: String,
artist: String,
album: String,
year: String,
track_number: String,
genre: String,
featured_artists: String,
release_type: String,
notes: String,
}
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
struct JobDto { struct JobDto {
name: String, name: String,
@@ -533,6 +547,65 @@ pub async fn bulk_reviews(
Json(BulkReviewsResponse { ok: true, affected }).into_response() Json(BulkReviewsResponse { ok: true, affected }).into_response()
} }
pub async fn approve_review(
session: Session,
db: Database,
pool: &PgPool,
review_id: i64,
Json(body): Json<ReviewEditDto>,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let mut review = crate::scheduler::PendingReview::get_by_id(&db, review_id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
.ok_or_else(|| cot::Error::internal("review not found"))?;
let normalized = normalized_from_review_edit(&body);
let result_json = serde_json::to_string(&normalized)
.map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?;
review
.set_result_json(&db, result_json)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let context: serde_json::Value =
serde_json::from_str(review.context_json_str()).unwrap_or_default();
let input_path = review.input_path_str().to_owned();
let (live_config, _) = AppConfig::load_with_db(&db).await;
let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id)
.await
.unwrap_or(None);
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
match crate::jobs::inbox_process::finalize_approved(
&db,
pool,
&live_config,
&input_path,
&normalized,
&context,
&live_config.agent_storage_dir,
model_name.as_deref(),
)
.await
{
Ok(()) => {
let _ = review.set_approved(&db).await;
Json(serde_json::json!({ "ok": true })).into_response()
}
Err(error) => {
tracing::error!(?error, "review approval failed");
let _ = review.set_rejected(&db).await;
Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"review approval failed",
))
}
}
}
pub async fn jobs( pub async fn jobs(
session: Session, session: Session,
db: Database, db: Database,
@@ -1209,6 +1282,11 @@ fn review_dto(
.as_deref() .as_deref()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok()) .and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|value| value.get("confidence").and_then(|v| v.as_f64())); .and_then(|value| value.get("confidence").and_then(|v| v.as_f64()));
let normalized = row
.result_json
.as_deref()
.map(review_edit_dto_from_json)
.unwrap_or_default();
ReviewDto { ReviewDto {
id: row.id, id: row.id,
@@ -1224,11 +1302,72 @@ fn review_dto(
token_count: stat.map(|s| s.prompt_tokens + s.completion_tokens), token_count: stat.map(|s| s.prompt_tokens + s.completion_tokens),
tags, tags,
error_message: row.error_message, error_message: row.error_message,
normalized,
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
} }
} }
fn review_edit_dto_from_json(result_json: &str) -> ReviewEditDto {
let Ok(normalized) = serde_json::from_str::<crate::agent::dto::NormalizedFields>(result_json)
else {
return ReviewEditDto::default();
};
ReviewEditDto {
title: normalized.title.unwrap_or_default(),
artist: normalized.artist.unwrap_or_default(),
album: normalized.album.unwrap_or_default(),
year: normalized.year.map(|v| v.to_string()).unwrap_or_default(),
track_number: normalized
.track_number
.map(|v| v.to_string())
.unwrap_or_default(),
genre: normalized.genre.unwrap_or_default(),
featured_artists: normalized.featured_artists.join(", "),
release_type: normalized
.release_type
.unwrap_or_else(|| "album".to_owned()),
notes: normalized.notes.unwrap_or_default(),
}
}
fn optional_trimmed(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn parse_optional_i32(value: &str) -> Option<i32> {
value.trim().parse::<i32>().ok()
}
fn parse_featured_artists(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_owned)
.collect()
}
fn normalized_from_review_edit(edit: &ReviewEditDto) -> crate::agent::dto::NormalizedFields {
crate::agent::dto::NormalizedFields {
title: optional_trimmed(&edit.title),
artist: optional_trimmed(&edit.artist),
album: optional_trimmed(&edit.album),
year: parse_optional_i32(&edit.year),
track_number: parse_optional_i32(&edit.track_number),
genre: optional_trimmed(&edit.genre),
featured_artists: parse_featured_artists(&edit.featured_artists),
release_type: optional_trimmed(&edit.release_type).or_else(|| Some("album".to_owned())),
confidence: Some(1.0),
notes: optional_trimmed(&edit.notes),
}
}
fn media_tags(row: &ReviewMediaRow) -> Vec<TagDto> { fn media_tags(row: &ReviewMediaRow) -> Vec<TagDto> {
let mut tags = Vec::new(); let mut tags = Vec::new();
if let Some(format) = row.audio_format.as_deref().filter(|s| !s.trim().is_empty()) { if let Some(format) = row.audio_format.as_deref().filter(|s| !s.trim().is_empty()) {
+113 -11
View File
@@ -799,7 +799,7 @@ pub async fn artists_edit(
.await .await
.ok() .ok()
.flatten() .flatten()
.map(|mf| format!("/api/player/cover/{}", mf.id_val())), .map(|mf| format!("/api/player/cover/{}/large", mf.id_val())),
None => None, None => None,
}; };
@@ -879,7 +879,7 @@ pub async fn artists_available_covers(
covers.push(AvailableCover { covers.push(AvailableCover {
media_file_id: cover_fid, media_file_id: cover_fid,
release_title: release.title_str().to_owned(), release_title: release.title_str().to_owned(),
cover_url: format!("/api/player/cover/{cover_fid}"), cover_url: format!("/api/player/cover/{cover_fid}/medium"),
}); });
} }
} }
@@ -1794,12 +1794,104 @@ struct ReviewDetailTemplate {
user_name: String, user_name: String,
user_role: String, user_role: String,
review: PendingReview, review: PendingReview,
edit: ReviewEditFields,
release_types: &'static [(&'static str, &'static str, &'static str)],
lang_code: &'static str,
context_pretty: String, context_pretty: String,
result_pretty: String, result_pretty: String,
error_message: String, error_message: String,
stats: Option<scheduler::ProcessingStats>, stats: Option<scheduler::ProcessingStats>,
} }
#[derive(Debug, Default)]
struct ReviewEditFields {
title: String,
artist: String,
album: String,
year: String,
track_number: String,
genre: String,
featured_artists: String,
release_type: String,
notes: String,
}
#[derive(Debug, Form)]
pub struct ReviewApproveForm {
title: String,
artist: String,
album: String,
year: String,
track_number: String,
genre: String,
featured_artists: String,
release_type: String,
notes: String,
}
fn optional_trimmed(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn parse_optional_i32(value: &str) -> Option<i32> {
value.trim().parse::<i32>().ok()
}
fn parse_featured_artists(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_owned)
.collect()
}
fn edit_fields_from_normalized(
normalized: &crate::agent::dto::NormalizedFields,
) -> ReviewEditFields {
ReviewEditFields {
title: normalized.title.clone().unwrap_or_default(),
artist: normalized.artist.clone().unwrap_or_default(),
album: normalized.album.clone().unwrap_or_default(),
year: normalized.year.map(|v| v.to_string()).unwrap_or_default(),
track_number: normalized
.track_number
.map(|v| v.to_string())
.unwrap_or_default(),
genre: normalized.genre.clone().unwrap_or_default(),
featured_artists: normalized.featured_artists.join(", "),
release_type: normalized
.release_type
.clone()
.unwrap_or_else(|| "album".to_owned()),
notes: normalized.notes.clone().unwrap_or_default(),
}
}
fn normalized_from_result_json(result_json: &str) -> crate::agent::dto::NormalizedFields {
serde_json::from_str(result_json).unwrap_or_default()
}
fn normalized_from_review_form(form: &ReviewApproveForm) -> crate::agent::dto::NormalizedFields {
crate::agent::dto::NormalizedFields {
title: optional_trimmed(&form.title),
artist: optional_trimmed(&form.artist),
album: optional_trimmed(&form.album),
year: parse_optional_i32(&form.year),
track_number: parse_optional_i32(&form.track_number),
genre: optional_trimmed(&form.genre),
featured_artists: parse_featured_artists(&form.featured_artists),
release_type: optional_trimmed(&form.release_type).or_else(|| Some("album".to_owned())),
confidence: Some(1.0),
notes: optional_trimmed(&form.notes),
}
}
pub async fn review_detail( pub async fn review_detail(
admin: AuthenticatedUser, admin: AuthenticatedUser,
i18n: I18n, i18n: I18n,
@@ -1830,12 +1922,17 @@ pub async fn review_detail(
let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id) let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id)
.await .await
.unwrap_or(None); .unwrap_or(None);
let normalized = normalized_from_result_json(review.result_json_str());
let edit = edit_fields_from_normalized(&normalized);
let template = ReviewDetailTemplate { let template = ReviewDetailTemplate {
t: i18n.t, t: i18n.t,
user_name: admin.name, user_name: admin.name,
user_role: admin.role.code().to_owned(), user_role: admin.role.code().to_owned(),
review, review,
edit,
release_types: RELEASE_TYPES,
lang_code: i18n.t.lang.code(),
context_pretty, context_pretty,
result_pretty, result_pretty,
error_message, error_message,
@@ -1850,23 +1947,28 @@ pub async fn review_approve(
db: &Database, db: &Database,
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
review_id: i64, review_id: i64,
form: RequestForm<ReviewApproveForm>,
) -> cot::Result<cot::http::Response<Body>> { ) -> cot::Result<cot::http::Response<Body>> {
let mut review = PendingReview::get_by_id(db, review_id) let mut review = PendingReview::get_by_id(db, review_id)
.await .await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))? .map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("review not found"))?; .ok_or_else(|| cot::Error::internal("review not found"))?;
let result_str = review.result_json_str().to_owned(); let RequestForm(form_result) = form;
let context_str = review.context_json_str().to_owned(); let normalized = match form_result {
let input_path = review.input_path_str().to_owned(); FormResult::Ok(data) => normalized_from_review_form(&data),
FormResult::ValidationError(_) => {
if result_str.is_empty() {
let _ = review.set_rejected(db).await;
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))); return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
} }
};
let normalized: crate::agent::dto::NormalizedFields = serde_json::from_str(&result_str) let result_str = serde_json::to_string(&normalized)
.map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?; .map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?;
review
.set_result_json(db, result_str)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?;
let context_str = review.context_json_str().to_owned();
let input_path = review.input_path_str().to_owned();
let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default(); let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default();
+25
View File
@@ -328,6 +328,23 @@ pub async fn save_cover_to_storage(
.await?; .await?;
if let Some((id,)) = existing { 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); return Ok(id);
} }
@@ -374,6 +391,14 @@ pub async fn save_cover_to_storage(
"Saved cover art" "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()) 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_art;
pub mod cover_variants;
pub mod dto; pub mod dto;
pub mod metadata; pub mod metadata;
pub mod mover; 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_image_backfill;
pub mod artist_track_image_backfill; pub mod artist_track_image_backfill;
pub mod cover_backfill; pub mod cover_backfill;
pub mod cover_variant_backfill;
pub mod inbox_discover; pub mod inbox_discover;
pub mod inbox_process; pub mod inbox_process;
pub mod lastfm_popularity; 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::cover_backfill::CoverBackfillJob);
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob); registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob); registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
registry.register(jobs::cover_variant_backfill::CoverVariantBackfillJob);
registry.register(jobs::metadata_backfill::MetadataBackfillJob); registry.register(jobs::metadata_backfill::MetadataBackfillJob);
registry.register(jobs::lastfm_popularity::LastfmPopularityJob); registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
Arc::new(registry) Arc::new(registry)
+5 -4
View File
@@ -1,15 +1,16 @@
use crate::player::dto::UploaderSummary; use crate::player::dto::UploaderSummary;
use crate::player::rows::ReleaseUploaderRow; use crate::player::rows::ReleaseUploaderRow;
pub(super) fn cover_url(file_id: Option<i64>) -> Option<String> { pub(super) fn cover_variant_url(file_id: Option<i64>, variant: &str) -> Option<String> {
file_id.map(|id| format!("/api/player/cover/{id}")) 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>, track_cover: Option<i64>,
release_cover: Option<i64>, release_cover: Option<i64>,
variant: &str,
) -> Option<String> { ) -> 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( pub(super) async fn load_release_uploaders(
+104 -17
View File
@@ -25,7 +25,7 @@ mod queries;
mod rows; mod rows;
use dto::*; 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 queries::*;
use rows::*; use rows::*;
@@ -203,7 +203,7 @@ async fn artists_handler(
.map(|r| ArtistCard { .map(|r| ArtistCard {
id: r.id, id: r.id,
name: r.name, 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, release_count: r.release_count,
track_count: r.track_count, track_count: r.track_count,
}) })
@@ -279,7 +279,7 @@ async fn artist_detail_handler(
title: r.title, title: r.title,
release_type: r.release_type, release_type: r.release_type,
year: r.year, 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, track_count: r.track_count,
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(), uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
}) })
@@ -386,7 +386,11 @@ async fn artist_detail_handler(
duration_seconds: t.duration_seconds, duration_seconds: t.duration_seconds,
artists: featured_main_artists.remove(&tid).unwrap_or_default(), artists: featured_main_artists.remove(&tid).unwrap_or_default(),
featured_artists: featured_feat_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}"), stream_url: format!("/api/player/stream/{tid}"),
uploader_name: t.uploader_name, uploader_name: t.uploader_name,
audio_format: t.audio_format, audio_format: t.audio_format,
@@ -405,7 +409,7 @@ async fn artist_detail_handler(
Json(ArtistDetail { Json(ArtistDetail {
id: artist.id, id: artist.id,
name: artist.name, name: artist.name,
image_url: cover_url(image_file_id), image_url: cover_variant_url(image_file_id, "large"),
total_track_count, total_track_count,
total_play_count, total_play_count,
releases: release_cards, releases: release_cards,
@@ -539,7 +543,11 @@ async fn release_detail_handler(
artists: track_main_artists.remove(&tid).unwrap_or_default(), artists: track_main_artists.remove(&tid).unwrap_or_default(),
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
release_year: t.release_year, 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}"), stream_url: format!("/api/player/stream/{tid}"),
uploader_name: t.uploader_name, uploader_name: t.uploader_name,
audio_format: t.audio_format, audio_format: t.audio_format,
@@ -565,7 +573,7 @@ async fn release_detail_handler(
title: release.title, title: release.title,
release_type: release.release_type, release_type: release.release_type,
year: release.year, year: release.year,
cover_url: cover_url(release.cover_file_id), cover_url: cover_variant_url(release.cover_file_id, "large"),
artists: release_artists artists: release_artists
.into_iter() .into_iter()
.map(|a| ArtistRef { .map(|a| ArtistRef {
@@ -797,7 +805,11 @@ async fn build_track_items(
artists: track_main_artists.remove(&tid).unwrap_or_default(), artists: track_main_artists.remove(&tid).unwrap_or_default(),
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
release_year: t.release_year, 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}"), stream_url: format!("/api/player/stream/{tid}"),
uploader_name: t.uploader_name, uploader_name: t.uploader_name,
audio_format: t.audio_format, audio_format: t.audio_format,
@@ -1138,13 +1150,40 @@ async fn cover_handler(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
config: &AppConfig, config: &AppConfig,
path: Path<PathMediaFileId>, 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>> { ) -> cot::Result<cot::http::Response<Body>> {
let Some(_user) = auth::get_session_user(&session, &db).await else { let Some(_user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
}; };
let media_file_id = path.0.media_file_id;
let media = sqlx::query_as::<_, MediaFileRow>( 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", "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")); 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 .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|e| cot::Error::internal(e.to_string()))?;
let response = cot::http::Response::builder() let response = cot::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(CONTENT_TYPE, media.mime_type.as_str()) .header(CONTENT_TYPE, content_type)
.header(CONTENT_LENGTH, data.len().to_string()) .header(CONTENT_LENGTH, data.len().to_string())
.header("Cache-Control", "public, max-age=86400") .header("Cache-Control", "public, max-age=86400")
.body(Body::fixed(data)) .body(Body::fixed(data))
@@ -1590,7 +1641,7 @@ async fn search_handler(
.map(|r| ArtistCard { .map(|r| ArtistCard {
id: r.id, id: r.id,
name: r.name, 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, release_count: r.release_count,
track_count: r.track_count, track_count: r.track_count,
}) })
@@ -1608,7 +1659,7 @@ async fn search_handler(
title: r.title, title: r.title,
release_type: r.release_type, release_type: r.release_type,
year: r.year, 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, track_count: r.track_count,
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(), 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(), artists: track_main_artists.remove(&tid).unwrap_or_default(),
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
release_year: t.release_year, 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}"), stream_url: format!("/api/player/stream/{tid}"),
uploader_name: t.uploader_name, uploader_name: t.uploader_name,
audio_format: t.audio_format, audio_format: t.audio_format,
@@ -2097,7 +2152,7 @@ async fn followed_artists_handler(
.map(|r| ArtistCard { .map(|r| ArtistCard {
id: r.id, id: r.id,
name: r.name, 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, release_count: r.release_count,
track_count: r.track_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(), artists: track_main_artists.remove(&tid).unwrap_or_default(),
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
release_year: t.release_year, 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}"), stream_url: format!("/api/player/stream/{tid}"),
uploader_name: t.uploader_name, uploader_name: t.uploader_name,
audio_format: t.audio_format, audio_format: t.audio_format,
@@ -3130,6 +3189,34 @@ impl App for PlayerApp {
"player_stream", "player_stream",
), ),
// -- Cover art -- // -- 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( Route::with_handler_and_name(
"/cover/{media_file_id}", "/cover/{media_file_id}",
{ {
+6
View File
@@ -70,3 +70,9 @@ pub(super) struct PathTrackId {
pub(super) struct PathMediaFileId { pub(super) struct PathMediaFileId {
pub(super) media_file_id: i64, pub(super) media_file_id: i64,
} }
#[derive(Debug, Deserialize)]
pub(super) struct PathMediaFileVariant {
pub(super) media_file_id: i64,
pub(super) variant: String,
}
+10
View File
@@ -460,6 +460,16 @@ impl PendingReview {
self.save(db).await self.save(db).await
} }
pub async fn set_result_json(
&mut self,
db: &Database,
result_json: String,
) -> cot::db::Result<()> {
self.result_json = Some(result_json);
self.updated_at = now_iso();
self.save(db).await
}
pub async fn set_failed(&mut self, db: &Database, error: &str) -> cot::db::Result<()> { pub async fn set_failed(&mut self, db: &Database, error: &str) -> cot::db::Result<()> {
self.status = LimitedString::new("failed").unwrap(); self.status = LimitedString::new("failed").unwrap();
self.error_message = Some(error.to_owned()); self.error_message = Some(error.to_owned());
+53 -5
View File
@@ -25,28 +25,76 @@
</div> </div>
{% endif %} {% endif %}
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
{% if review.status_str() == "pending" %} {% if review.status_str() == "pending" %}
<form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="display:inline;"> <h2>{{ t.reviews_result }}</h2>
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button> <form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="margin: 1rem 0;">
<table>
<tr>
<td><label for="artist">Artist</label></td>
<td><input name="artist" id="artist" value="{{ edit.artist }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="album">Album</label></td>
<td><input name="album" id="album" value="{{ edit.album }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="title">Title</label></td>
<td><input name="title" id="title" value="{{ edit.title }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="year">Year</label></td>
<td><input name="year" id="year" type="number" min="0" max="3000" value="{{ edit.year }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="track_number">Track</label></td>
<td><input name="track_number" id="track_number" type="number" min="0" value="{{ edit.track_number }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="genre">Genre</label></td>
<td><input name="genre" id="genre" value="{{ edit.genre }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="featured_artists">Featured artists</label></td>
<td><input name="featured_artists" id="featured_artists" value="{{ edit.featured_artists }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="release_type">{{ t.releases_type }}</label></td>
<td>
<select name="release_type" id="release_type" style="width:100%; padding:.4rem;">
{% for rt in release_types %}
<option value="{{ rt.0 }}"{% if edit.release_type == rt.0 %} selected{% endif %}>{% if lang_code == "ru" %}{{ rt.2 }}{% else %}{{ rt.1 }}{% endif %} ({{ rt.0 }})</option>
{% endfor %}
</select>
</td>
</tr>
<tr>
<td><label for="notes">Notes</label></td>
<td><textarea name="notes" id="notes" style="width:100%; min-height:4rem;">{{ edit.notes }}</textarea></td>
</tr>
</table>
<button type="submit" style="margin-top:1rem; padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button>
</form> </form>
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
<form method="post" action="/admin/reviews/{{ review.id_val() }}/reject" style="display:inline;"> <form method="post" action="/admin/reviews/{{ review.id_val() }}/reject" style="display:inline;">
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_reject }}</button> <button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_reject }}</button>
</form> </form>
{% endif %} </div>
{% else %}
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
{% if review.status_str() == "failed" || review.status_str() == "processing" %} {% if review.status_str() == "failed" || review.status_str() == "processing" %}
<form method="post" action="/admin/reviews/{{ review.id_val() }}/requeue" style="display:inline;" onsubmit="return confirm('{{ t.reviews_requeue_confirm }}');"> <form method="post" action="/admin/reviews/{{ review.id_val() }}/requeue" style="display:inline;" onsubmit="return confirm('{{ t.reviews_requeue_confirm }}');">
<button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button> <button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% if !context_pretty.is_empty() %} {% if !context_pretty.is_empty() %}
<h2>{{ t.reviews_context }}</h2> <h2>{{ t.reviews_context }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre> <pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre>
{% endif %} {% endif %}
{% if !result_pretty.is_empty() %} {% if !result_pretty.is_empty() && review.status_str() != "pending" %}
<h2>{{ t.reviews_result }}</h2> <h2>{{ t.reviews_result }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre> <pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre>
{% endif %} {% endif %}
+94 -1
View File
@@ -851,7 +851,8 @@ tbody tr:hover {
} }
.field input, .field input,
.field textarea { .field textarea,
.field select {
width: 100%; width: 100%;
min-height: 34px; min-height: 34px;
padding: 8px 10px; padding: 8px 10px;
@@ -1673,7 +1674,60 @@ tbody tr:hover {
<label>Error</label> <label>Error</label>
<textarea readonly x-text="activeReview?.error_message || ''"></textarea> <textarea readonly x-text="activeReview?.error_message || ''"></textarea>
</div> </div>
<div x-show="activeReview?.status === 'pending'">
<div class="field">
<label>Artist</label>
<input x-model="reviewDraft.artist" />
</div>
<div class="field">
<label>Album</label>
<input x-model="reviewDraft.album" />
</div>
<div class="field">
<label>Title</label>
<input x-model="reviewDraft.title" />
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div class="field">
<label>Year</label>
<input type="number" min="0" max="3000" x-model="reviewDraft.year" />
</div>
<div class="field">
<label>Track</label>
<input type="number" min="0" x-model="reviewDraft.track_number" />
</div>
</div>
<div class="field">
<label>Genre</label>
<input x-model="reviewDraft.genre" />
</div>
<div class="field">
<label>Featured artists</label>
<input x-model="reviewDraft.featured_artists" />
</div>
<div class="field">
<label>Release type</label>
<select x-model="reviewDraft.release_type">
<option value="album">Album</option>
<option value="single">Single</option>
<option value="ep">EP</option>
<option value="compilation">Compilation</option>
<option value="soundtrack">Soundtrack</option>
<option value="live">Live</option>
<option value="remix">Remix</option>
<option value="unknown">Unknown</option>
</select>
</div>
<div class="field">
<label>Notes</label>
<textarea x-model="reviewDraft.notes"></textarea>
</div>
</div>
<div class="toolbar"> <div class="toolbar">
<button class="btn primary" x-show="activeReview?.status === 'pending'" @click="approveActiveReview()">
<i data-lucide="check"></i>
Approve
</button>
<button class="btn warn" @click="bulkOneReview('requeue', activeReview)"> <button class="btn warn" @click="bulkOneReview('requeue', activeReview)">
<i data-lucide="rotate-ccw"></i> <i data-lucide="rotate-ccw"></i>
Requeue Requeue
@@ -1751,6 +1805,17 @@ function adminV2() {
selectedReviewIds: {}, selectedReviewIds: {},
reviewSelectionScope: 'ids', reviewSelectionScope: 'ids',
activeReview: null, activeReview: null,
reviewDraft: {
title: '',
artist: '',
album: '',
year: '',
track_number: '',
genre: '',
featured_artists: '',
release_type: 'album',
notes: ''
},
reviewModalOpen: false, reviewModalOpen: false,
reviewStatuses: [ reviewStatuses: [
{ value: null, label: 'All' }, { value: null, label: 'All' },
@@ -2072,6 +2137,17 @@ function adminV2() {
openReview(row) { openReview(row) {
this.activeReview = row; this.activeReview = row;
this.reviewDraft = Object.assign({
title: '',
artist: '',
album: '',
year: '',
track_number: '',
genre: '',
featured_artists: '',
release_type: 'album',
notes: ''
}, row.normalized || {});
this.activeRunDetail = null; this.activeRunDetail = null;
this.reviewModalOpen = true; this.reviewModalOpen = true;
}, },
@@ -2165,6 +2241,23 @@ function adminV2() {
this.reviewModalOpen = false; this.reviewModalOpen = false;
}, },
async approveActiveReview() {
if (!this.activeReview) return;
try {
await this.request(`${this.apiBase}/reviews/${this.activeReview.id}/approve`, {
method: 'POST',
body: JSON.stringify(this.reviewDraft)
});
this.showToast('Review approved');
this.reviewModalOpen = false;
this.activeReview = null;
await this.loadReviews(false);
await this.loadLibrary(false);
} catch (error) {
this.showToast(error.message);
}
},
async runJob(job) { async runJob(job) {
job.launching = true; job.launching = true;
try { try {
+128 -22
View File
@@ -3,6 +3,7 @@
const T = { const T = {
info: "{{ t.player_info }}", info: "{{ t.player_info }}",
noDetails: "{{ t.player_no_details }}", noDetails: "{{ t.player_no_details }}",
trackInfoTitle: "{{ t.player_track_info }}",
loadingHistory: "{{ t.player_loading_history }}", loadingHistory: "{{ t.player_loading_history }}",
failedLoadHistory: "{{ t.player_failed_load_history }}", failedLoadHistory: "{{ t.player_failed_load_history }}",
totalPlays: "{{ t.player_total_plays }}", totalPlays: "{{ t.player_total_plays }}",
@@ -97,6 +98,11 @@ function formatTime(seconds) {
return m + ':' + (sec < 10 ? '0' : '') + sec; 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', () => { document.addEventListener('alpine:init', () => {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Audio element // Audio element
@@ -440,7 +446,7 @@ document.addEventListener('alpine:init', () => {
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: t.title, title: t.title,
artist: t.artists.map(a => a.name).join(', '), 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('play', () => this.resume());
navigator.mediaSession.setActionHandler('pause', () => this.pause()); navigator.mediaSession.setActionHandler('pause', () => this.pause());
@@ -634,6 +640,8 @@ document.addEventListener('alpine:init', () => {
searchResults: null, searchResults: null,
searchLoading: false, searchLoading: false,
_previousView: 'artists', _previousView: 'artists',
_activeHash: location.hash || '#artists',
_scrollPositions: {},
_hashNav: false, // guard against circular hash updates _hashNav: false, // guard against circular hash updates
@@ -643,26 +651,31 @@ document.addEventListener('alpine:init', () => {
// Listen for browser back/forward // Listen for browser back/forward
window.addEventListener('hashchange', () => { 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) // Navigate to initial hash (if any)
this._navigateFromHash(); this._navigateFromHash({ fromHash: true, restoreScroll: true });
}, },
_setHash(hash) { _setHash(hash) {
this._hashNav = true; this._hashNav = true;
this._activeHash = hash;
location.hash = hash; location.hash = hash;
// Reset guard after a tick // Reset guard after a tick
setTimeout(() => { this._hashNav = false; }, 0); setTimeout(() => { this._hashNav = false; }, 0);
}, },
_navigateFromHash() { _navigateFromHash(options = {}) {
if (this._hashNav) return; if (this._hashNav) return;
const hash = location.hash || '#artists'; const hash = location.hash || '#artists';
const match = hash.match(/^#(\w+)(?:\/([-]?\d+))?(?:\?(.*))?$/); const match = hash.match(/^#(\w+)(?:\/([-]?\d+))?(?:\?(.*))?$/);
if (!match) { if (!match) {
this.goArtists(); this.goArtists(options);
return; return;
} }
const view = match[1]; const view = match[1];
@@ -670,26 +683,74 @@ document.addEventListener('alpine:init', () => {
const params = match[3] || ''; const params = match[3] || '';
if (view === 'artists' && !id) { 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) { } else if (view === 'artist' && id) {
this.openArtist(id); this.openArtist(id, options);
} else if (view === 'release' && id) { } else if (view === 'release' && id) {
this.openRelease(id); this.openRelease(id, options);
} else if (view === 'playlist' && id) { } else if (view === 'playlist' && id) {
this.openPlaylist(id); this.openPlaylist(id, options);
} else if (view === 'search') { } else if (view === 'search') {
const qMatch = params.match(/q=([^&]*)/); const qMatch = params.match(/q=([^&]*)/);
if (qMatch) { if (qMatch) {
const q = decodeURIComponent(qMatch[1]); const q = decodeURIComponent(qMatch[1]);
this.searchQuery = q; this.searchQuery = q;
this.search(q); this.search(q, options);
} }
} else { } 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.view = 'artists';
this.currentArtist = null; this.currentArtist = null;
this.currentRelease = null; this.currentRelease = null;
@@ -697,8 +758,8 @@ document.addEventListener('alpine:init', () => {
this.searchQuery = ''; this.searchQuery = '';
this.searchResults = null; this.searchResults = null;
this._previousView = 'artists'; this._previousView = 'artists';
this._setHash('#artists');
this.$nextTick(() => { this._setupScroll(); }); this.$nextTick(() => { this._setupScroll(); });
this._afterNavigation(options);
}, },
async loadArtists(page) { async loadArtists(page) {
@@ -722,17 +783,18 @@ document.addEventListener('alpine:init', () => {
this.loading = false; this.loading = false;
}, },
async openArtist(id) { async openArtist(id, options = {}) {
this._beginNavigation('#artist/' + id, options);
this.searchQuery = ''; this.searchQuery = '';
this.searchResults = null; this.searchResults = null;
this.view = 'artist_detail'; this.view = 'artist_detail';
this.currentArtist = null; this.currentArtist = null;
this._setHash('#artist/' + id);
try { try {
const res = await fetch(`/api/player/artists/${id}`); const res = await fetch(`/api/player/artists/${id}`);
if (!res.ok) throw new Error('failed'); if (!res.ok) throw new Error('failed');
this.currentArtist = await res.json(); this.currentArtist = await res.json();
} catch {} } catch {}
this._afterNavigation(options);
}, },
artistReleaseGroups() { artistReleaseGroups() {
@@ -792,6 +854,47 @@ document.addEventListener('alpine:init', () => {
return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx]; return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx];
}, },
trackPopularityValue(track) {
const value = Number(track?.lastfm_rating);
return Number.isFinite(value) && value > 0 ? value : null;
},
hasPopularity(track) {
return this.trackPopularityValue(track) != null;
},
popularityLabel(track) {
const value = this.trackPopularityValue(track);
if (value == null) return 'i';
if (value >= 10000) return Math.round(value / 1000) + 'k';
if (value >= 1000) return (value / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return Math.round(value).toString();
},
popularityStyle(track) {
const value = this.trackPopularityValue(track);
if (value == null) return '';
const t = Math.max(0, Math.min(1, Math.log1p(value) / Math.log1p(180)));
const hue = 210 - (190 * t);
const saturation = 42 + (46 * t);
const lightness = 30 + (16 * t);
const bg = `hsla(${hue.toFixed(0)}, ${saturation.toFixed(0)}%, ${lightness.toFixed(0)}%, 0.28)`;
const hoverBg = `hsla(${hue.toFixed(0)}, ${saturation.toFixed(0)}%, ${Math.min(54, lightness + 6).toFixed(0)}%, 0.36)`;
const border = `hsla(${hue.toFixed(0)}, ${saturation.toFixed(0)}%, ${Math.min(66, lightness + 16).toFixed(0)}%, 0.52)`;
const fg = `hsl(${hue.toFixed(0)}, ${Math.min(96, saturation + 8).toFixed(0)}%, ${Math.min(86, lightness + 34).toFixed(0)}%)`;
return `--popularity-bg:${bg};--popularity-hover-bg:${hoverBg};--popularity-border:${border};--popularity-fg:${fg}`;
},
trackInfoTitle(track) {
const value = this.trackPopularityValue(track);
if (value == null) return this.trackInfo(track);
return `${T.lastfmRating}: ${Math.round(value)}\n${this.trackInfo(track)}`;
},
openTrackInfo(track) {
Alpine.store('info').open(T.trackInfoTitle, this.trackInfo(track));
},
uploadersInfo(uploaders) { uploadersInfo(uploaders) {
const rows = uploaders || []; const rows = uploaders || [];
if (!rows.length) return 'UFO'; if (!rows.length) return 'UFO';
@@ -832,7 +935,7 @@ document.addEventListener('alpine:init', () => {
]; ];
if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) { if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) {
const rating = Number(track.lastfm_rating || 0); const rating = Number(track.lastfm_rating || 0);
lines.push(`${T.lastfmRating}: ${Number.isFinite(rating) ? rating.toFixed(2) : T.unknown}`); lines.push(`${T.lastfmRating}: ${Number.isFinite(rating) ? Math.round(rating) : T.unknown}`);
lines.push(`${T.lastfmListeners}: ${new Intl.NumberFormat().format(track.lastfm_listeners || 0)}`); lines.push(`${T.lastfmListeners}: ${new Intl.NumberFormat().format(track.lastfm_listeners || 0)}`);
lines.push(`${T.lastfmPlaycount}: ${new Intl.NumberFormat().format(track.lastfm_playcount || 0)}`); lines.push(`${T.lastfmPlaycount}: ${new Intl.NumberFormat().format(track.lastfm_playcount || 0)}`);
if (track.lastfm_updated_at) lines.push(`${T.lastfmUpdated}: ${track.lastfm_updated_at}`); if (track.lastfm_updated_at) lines.push(`${T.lastfmUpdated}: ${track.lastfm_updated_at}`);
@@ -842,28 +945,30 @@ document.addEventListener('alpine:init', () => {
return lines.join('\n'); return lines.join('\n');
}, },
async openRelease(id) { async openRelease(id, options = {}) {
this._beginNavigation('#release/' + id, options);
this.searchQuery = ''; this.searchQuery = '';
this.searchResults = null; this.searchResults = null;
this.view = 'release_detail'; this.view = 'release_detail';
this.currentRelease = null; this.currentRelease = null;
this._setHash('#release/' + id);
try { try {
const res = await fetch(`/api/player/releases/${id}`); const res = await fetch(`/api/player/releases/${id}`);
if (!res.ok) throw new Error('failed'); if (!res.ok) throw new Error('failed');
this.currentRelease = await res.json(); this.currentRelease = await res.json();
} catch {} } catch {}
this._afterNavigation(options);
}, },
async openPlaylist(id) { async openPlaylist(id, options = {}) {
this._beginNavigation('#playlist/' + id, options);
this.view = 'playlist_detail'; this.view = 'playlist_detail';
this.currentPlaylist = null; this.currentPlaylist = null;
this._setHash('#playlist/' + id);
try { try {
const res = await fetch(`/api/player/playlists/${id}`); const res = await fetch(`/api/player/playlists/${id}`);
if (!res.ok) throw new Error('failed'); if (!res.ok) throw new Error('failed');
this.currentPlaylist = await res.json(); this.currentPlaylist = await res.json();
} catch {} } catch {}
this._afterNavigation(options);
}, },
async playRelease(releaseId) { async playRelease(releaseId) {
@@ -899,17 +1004,17 @@ document.addEventListener('alpine:init', () => {
} catch {} } catch {}
}, },
async search(query) { async search(query, options = {}) {
const q = (query || '').trim(); const q = (query || '').trim();
if (!q) { if (!q) {
this.clearSearch(); this.clearSearch();
return; return;
} }
this._beginNavigation('#search?q=' + encodeURIComponent(q), options);
if (this.view !== 'search') { if (this.view !== 'search') {
this._previousView = this.view; this._previousView = this.view;
} }
this.view = 'search'; this.view = 'search';
this._setHash('#search?q=' + encodeURIComponent(q));
this.searchLoading = true; this.searchLoading = true;
try { try {
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`); const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
@@ -919,6 +1024,7 @@ document.addEventListener('alpine:init', () => {
this.searchResults = { artists: [], releases: [], tracks: [] }; this.searchResults = { artists: [], releases: [], tracks: [] };
} }
this.searchLoading = false; this.searchLoading = false;
this._afterNavigation(options);
}, },
clearSearch() { clearSearch() {
+40 -10
View File
@@ -434,8 +434,14 @@
</div> </div>
<span></span> <span></span>
<div class="track-actions"> <div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}"> <button class="track-action-btn info-btn popularity-info-btn"
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> :class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button> </button>
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="{{ t.player_play }}"> <button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
@@ -608,8 +614,14 @@
</div> </div>
<span></span> <span></span>
<div class="track-actions"> <div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}"> <button class="track-action-btn info-btn popularity-info-btn"
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> :class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button> </button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="{{ t.player_play }}"> <button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
@@ -725,8 +737,14 @@
</div> </div>
<span></span> <span></span>
<div class="track-actions"> <div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}"> <button class="track-action-btn info-btn popularity-info-btn"
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> :class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button> </button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="{{ t.player_play }}"> <button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
@@ -795,8 +813,14 @@
</div> </div>
<span></span> <span></span>
<div class="track-actions"> <div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}"> <button class="track-action-btn info-btn popularity-info-btn"
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> :class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button> </button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="{{ t.player_play }}"> <button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
@@ -871,8 +895,14 @@
</div> </div>
</div> </div>
<div class="queue-track-actions"> <div class="queue-track-actions">
<button class="queue-track-remove info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}"> <button class="queue-track-remove info-btn popularity-info-btn"
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> :class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button> </button>
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}"> <button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
+91 -4
View File
@@ -661,11 +661,20 @@ button.user-stat:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
opacity: 0; opacity: 1;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.track-row:hover .track-actions { opacity: 1; } .track-actions > :not(.popularity-info-btn) {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.track-row:hover .track-actions > * {
opacity: 1;
pointer-events: auto;
}
.track-action-btn { .track-action-btn {
background: none; background: none;
@@ -692,6 +701,43 @@ button.user-stat:hover {
color: var(--text-primary); color: var(--text-primary);
} }
.popularity-info-btn {
min-width: 26px;
height: 20px;
padding: 0 3px;
border: 0;
border-radius: 0;
background: transparent;
font-size: 11px;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
.popularity-info-btn.has-popularity {
color: var(--popularity-fg, var(--text-primary));
background: transparent;
}
.popularity-info-btn.has-popularity:hover {
color: var(--popularity-fg, var(--text-primary));
background: transparent;
}
.popularity-info-btn.no-popularity {
min-width: 18px;
width: 18px;
padding: 0;
}
.popularity-info-btn .info-letter {
font-size: 12px;
font-weight: 800;
font-style: normal;
line-height: 1;
}
.card-info-btn { .card-info-btn {
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -926,12 +972,21 @@ button.user-stat:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
opacity: 0; opacity: 1;
transition: opacity 0.15s; transition: opacity 0.15s;
flex-shrink: 0; flex-shrink: 0;
} }
.queue-track:hover .queue-track-actions { opacity: 1; } .queue-track-actions > :not(.popularity-info-btn) {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.queue-track:hover .queue-track-actions > * {
opacity: 1;
pointer-events: auto;
}
.queue-track-remove { .queue-track-remove {
background: none; background: none;
@@ -948,6 +1003,20 @@ button.user-stat:hover {
.queue-track-remove:hover { color: var(--text-primary); background: var(--bg-hover); } .queue-track-remove:hover { color: var(--text-primary); background: var(--bg-hover); }
.queue-track-remove.popularity-info-btn {
min-width: 26px;
width: auto;
height: 20px;
padding: 0 3px;
border-radius: 0;
}
.queue-track-remove.popularity-info-btn.no-popularity {
min-width: 18px;
width: 18px;
padding: 0;
}
/* Drag handle */ /* Drag handle */
.queue-drag-handle { .queue-drag-handle {
cursor: grab; cursor: grab;
@@ -2807,6 +2876,24 @@ button.user-stat:hover {
padding: 6px; padding: 6px;
} }
.popularity-info-btn {
min-width: 28px;
height: 22px;
padding: 0 3px;
}
.popularity-info-btn.no-popularity {
min-width: 20px;
width: 20px;
padding: 0;
}
.track-actions > *,
.queue-track-actions > * {
opacity: 1;
pointer-events: auto;
}
.track-action-btn svg, .track-action-btn svg,
.like-btn svg { .like-btn svg {
width: 17px; width: 17px;