Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65da460c0c | |||
| 538a6f6abf |
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.16"
|
version = "0.1.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.17"
|
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"
|
||||||
|
|
||||||
|
|||||||
+34
-4
@@ -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
@@ -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()) {
|
||||||
|
|||||||
+111
-9
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 }}",
|
||||||
@@ -853,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';
|
||||||
@@ -893,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}`);
|
||||||
|
|||||||
+40
-10
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user