Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65da460c0c |
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.17"
|
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.18"
|
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,24 +1947,29 @@ 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 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 context_str = review.context_json_str().to_owned();
|
||||||
let input_path = review.input_path_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();
|
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
|
// Load live config from DB so admin-set values are used
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
{% 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;">
|
<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;">
|
<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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user