Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65da460c0c | |||
| 538a6f6abf |
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.1.16"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.1.17"
|
||||
version = "0.1.19"
|
||||
edition = "2024"
|
||||
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::user::User;
|
||||
use views::{
|
||||
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewsBulkForm,
|
||||
SetImageBody, SetupForm, UploadImageBody, UserForm,
|
||||
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewApproveForm,
|
||||
ReviewsBulkForm, SetImageBody, SetupForm, UploadImageBody, UserForm,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -227,6 +227,35 @@ impl App for AdminApp {
|
||||
},
|
||||
"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(
|
||||
"/v2/api/jobs",
|
||||
{
|
||||
@@ -1048,7 +1077,8 @@ impl App for AdminApp {
|
||||
let config = Arc::clone(&self.config);
|
||||
let pool = Arc::clone(&pool);
|
||||
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 pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
@@ -1064,7 +1094,7 @@ impl App for AdminApp {
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).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>,
|
||||
tags: Vec<TagDto>,
|
||||
error_message: Option<String>,
|
||||
normalized: ReviewEditDto,
|
||||
created_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)]
|
||||
struct JobDto {
|
||||
name: String,
|
||||
@@ -533,6 +547,65 @@ pub async fn bulk_reviews(
|
||||
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(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1209,6 +1282,11 @@ fn review_dto(
|
||||
.as_deref()
|
||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||
.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 {
|
||||
id: row.id,
|
||||
@@ -1224,11 +1302,72 @@ fn review_dto(
|
||||
token_count: stat.map(|s| s.prompt_tokens + s.completion_tokens),
|
||||
tags,
|
||||
error_message: row.error_message,
|
||||
normalized,
|
||||
created_at: row.created_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> {
|
||||
let mut tags = Vec::new();
|
||||
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_role: String,
|
||||
review: PendingReview,
|
||||
edit: ReviewEditFields,
|
||||
release_types: &'static [(&'static str, &'static str, &'static str)],
|
||||
lang_code: &'static str,
|
||||
context_pretty: String,
|
||||
result_pretty: String,
|
||||
error_message: String,
|
||||
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(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
@@ -1830,12 +1922,17 @@ pub async fn review_detail(
|
||||
let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let normalized = normalized_from_result_json(review.result_json_str());
|
||||
let edit = edit_fields_from_normalized(&normalized);
|
||||
|
||||
let template = ReviewDetailTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
review,
|
||||
edit,
|
||||
release_types: RELEASE_TYPES,
|
||||
lang_code: i18n.t.lang.code(),
|
||||
context_pretty,
|
||||
result_pretty,
|
||||
error_message,
|
||||
@@ -1850,24 +1947,29 @@ pub async fn review_approve(
|
||||
db: &Database,
|
||||
pool: &sqlx::PgPool,
|
||||
review_id: i64,
|
||||
form: RequestForm<ReviewApproveForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let mut review = PendingReview::get_by_id(db, review_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("review not found"))?;
|
||||
|
||||
let result_str = review.result_json_str().to_owned();
|
||||
let RequestForm(form_result) = form;
|
||||
let normalized = match form_result {
|
||||
FormResult::Ok(data) => normalized_from_review_form(&data),
|
||||
FormResult::ValidationError(_) => {
|
||||
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
|
||||
}
|
||||
};
|
||||
let result_str = serde_json::to_string(&normalized)
|
||||
.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();
|
||||
|
||||
if result_str.is_empty() {
|
||||
let _ = review.set_rejected(db).await;
|
||||
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
|
||||
}
|
||||
|
||||
let normalized: crate::agent::dto::NormalizedFields = serde_json::from_str(&result_str)
|
||||
.map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?;
|
||||
|
||||
let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default();
|
||||
|
||||
// Load live config from DB so admin-set values are used
|
||||
|
||||
@@ -460,6 +460,16 @@ impl PendingReview {
|
||||
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<()> {
|
||||
self.status = LimitedString::new("failed").unwrap();
|
||||
self.error_message = Some(error.to_owned());
|
||||
|
||||
@@ -25,28 +25,76 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if review.status_str() == "pending" %}
|
||||
<h2>{{ t.reviews_result }}</h2>
|
||||
<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>
|
||||
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
|
||||
{% if review.status_str() == "pending" %}
|
||||
<form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="display:inline;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button>
|
||||
</form>
|
||||
<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>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
|
||||
{% 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 }}');">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if !context_pretty.is_empty() %}
|
||||
<h2>{{ t.reviews_context }}</h2>
|
||||
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if !result_pretty.is_empty() %}
|
||||
{% if !result_pretty.is_empty() && review.status_str() != "pending" %}
|
||||
<h2>{{ t.reviews_result }}</h2>
|
||||
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre>
|
||||
{% endif %}
|
||||
|
||||
+94
-1
@@ -851,7 +851,8 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 8px 10px;
|
||||
@@ -1673,7 +1674,60 @@ tbody tr:hover {
|
||||
<label>Error</label>
|
||||
<textarea readonly x-text="activeReview?.error_message || ''"></textarea>
|
||||
</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">
|
||||
<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)">
|
||||
<i data-lucide="rotate-ccw"></i>
|
||||
Requeue
|
||||
@@ -1751,6 +1805,17 @@ function adminV2() {
|
||||
selectedReviewIds: {},
|
||||
reviewSelectionScope: 'ids',
|
||||
activeReview: null,
|
||||
reviewDraft: {
|
||||
title: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
year: '',
|
||||
track_number: '',
|
||||
genre: '',
|
||||
featured_artists: '',
|
||||
release_type: 'album',
|
||||
notes: ''
|
||||
},
|
||||
reviewModalOpen: false,
|
||||
reviewStatuses: [
|
||||
{ value: null, label: 'All' },
|
||||
@@ -2072,6 +2137,17 @@ function adminV2() {
|
||||
|
||||
openReview(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.reviewModalOpen = true;
|
||||
},
|
||||
@@ -2165,6 +2241,23 @@ function adminV2() {
|
||||
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) {
|
||||
job.launching = true;
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const T = {
|
||||
info: "{{ t.player_info }}",
|
||||
noDetails: "{{ t.player_no_details }}",
|
||||
trackInfoTitle: "{{ t.player_track_info }}",
|
||||
loadingHistory: "{{ t.player_loading_history }}",
|
||||
failedLoadHistory: "{{ t.player_failed_load_history }}",
|
||||
totalPlays: "{{ t.player_total_plays }}",
|
||||
@@ -853,6 +854,47 @@ document.addEventListener('alpine:init', () => {
|
||||
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) {
|
||||
const rows = uploaders || [];
|
||||
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) {
|
||||
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.lastfmPlaycount}: ${new Intl.NumberFormat().format(track.lastfm_playcount || 0)}`);
|
||||
if (track.lastfm_updated_at) lines.push(`${T.lastfmUpdated}: ${track.lastfm_updated_at}`);
|
||||
|
||||
+40
-10
@@ -434,8 +434,14 @@
|
||||
</div>
|
||||
<span></span>
|
||||
<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 }}">
|
||||
<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>
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
: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 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>
|
||||
@@ -608,8 +614,14 @@
|
||||
</div>
|
||||
<span></span>
|
||||
<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 }}">
|
||||
<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>
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
: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 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>
|
||||
@@ -725,8 +737,14 @@
|
||||
</div>
|
||||
<span></span>
|
||||
<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 }}">
|
||||
<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>
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
: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 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>
|
||||
@@ -795,8 +813,14 @@
|
||||
</div>
|
||||
<span></span>
|
||||
<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 }}">
|
||||
<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>
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
: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 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>
|
||||
@@ -871,8 +895,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 }}">
|
||||
<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>
|
||||
<button class="queue-track-remove info-btn popularity-info-btn"
|
||||
: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 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>
|
||||
|
||||
@@ -661,11 +661,20 @@ button.user-stat:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
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 {
|
||||
background: none;
|
||||
@@ -692,6 +701,43 @@ button.user-stat:hover {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -926,12 +972,21 @@ button.user-stat:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s;
|
||||
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 {
|
||||
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.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 */
|
||||
.queue-drag-handle {
|
||||
cursor: grab;
|
||||
@@ -2807,6 +2876,24 @@ button.user-stat:hover {
|
||||
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,
|
||||
.like-btn svg {
|
||||
width: 17px;
|
||||
|
||||
Reference in New Issue
Block a user