Compare commits

..
8 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 4141562e33 Added stat page
Build and Publish / Build and Publish Docker Image (push) Successful in 9m49s
2026-11-12 11:52:38 +00:00
Aleksandr Bogomiakov bcb5d378c8 Add bulk approval for selected admin reviews
Build and Publish / Build and Publish Docker Image (push) Successful in 6m10s
2026-10-23 01:12:54 +01:00
Ultradesu 59345a8d22 Added AI chat with metadata
Build and Publish / Build and Publish Docker Image (push) Successful in 4m18s
2026-10-19 13:35:05 +01:00
Ultradesu 0975f3851a Added AI chat with metadata
Build and Publish / Build and Publish Docker Image (push) Successful in 4m1s
2026-10-19 13:13:10 +01:00
Ultradesu bded5a79cc Accept partial agent metadata plans
Build and Publish / Build and Publish Docker Image (push) Successful in 3m49s
2026-10-19 12:28:20 +01:00
Ultradesu 2aede192fe Added AI chat with metadata
Build and Publish / Build and Publish Docker Image (push) Successful in 3m50s
2026-10-19 12:13:14 +01:00
Ultradesu 2199fda240 Avoid stripped proc macros in release builds 2026-10-19 12:10:24 +01:00
Ultradesu 8f7c0d231f Add guided metadata repair tool to admin 2026-10-19 12:05:56 +01:00
54 changed files with 4600 additions and 4819 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@
/federation
/similarity-models
/federation-cache
*.DS_Store
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.11.1"
version = "0.12.2"
dependencies = [
"anyhow",
"async-stream",
+6 -1
View File
@@ -1,9 +1,14 @@
[package]
name = "furumusic"
version = "0.11.1"
version = "0.12.2"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
# macOS 27 rejects some stripped proc-macro dylibs with a misaligned LINKEDIT
# string pool. Build scripts and proc macros are compile-time artifacts only.
[profile.release.build-override]
strip = false
[dependencies]
# default-features off: cot's defaults include the sqlite backend, whose old
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
+64
View File
@@ -96,6 +96,70 @@ The repository also contains a multi-stage `Dockerfile` for building a small
runtime image. A deployment must provide PostgreSQL plus persistent, writable
volumes for the inbox and music library.
## Admin statistics
The only admin interface is `/admin/v2`, also reachable from `/admin/`.
**Statistics** shows listening totals, a timeline, artist/track/release rankings,
and paginated history. Select an artist or track to see its listeners, then a
listener to see their history within the same filters. The **Users** list links
to each user's full statistics.
Periods use UTC. Counts come from the synchronized `listen_event` history: a
natural finish, half of a track, or four minutes qualifies as a play. Interrupted
sessions under five seconds are not retained. Short retained sessions contribute
to listened time but not play counts. The source filter separates this instance
from synchronized devices. Artist rankings use recorded artist names, including
featured credits; the same artist is counted once per event. Existing history
remains available when its local audio file is deleted. Library and service
cards show current instance totals, independently of the listening filters.
Migration `m_0051_add_listen_statistics_indexes` adds time and content indexes.
The old admin pages and `/admin/debug` are removed; first-run setup remains at
`/admin/setup`.
Regression checks:
```bash
node --test tests/*.test.cjs
cargo test admin:: -- --skip postgres_statistics_drilldown_and_pagination
# Use a disposable PostgreSQL; this test creates connection-local temporary tables.
FURU_STATISTICS_TEST_DATABASE_URL=postgresql://... cargo test postgres_statistics_drilldown_and_pagination -- --ignored
```
`tests/browser-statistics.cjs` additionally exercises a fresh disposable instance
at `FURU_TEST_URL` (default `http://127.0.0.1:3018`) and seeds the disposable Docker
container `furumi-statistics-test-pg`. It requires Playwright (or
`PLAYWRIGHT_MODULE`) and Chrome (or `CHROME_PATH`). Never point this test at a
live service.
## Importing into playlists
The player's download manager offers **Add downloads to a playlist** for file
and folder uploads and torrents, as well as YouTube. Choose an existing owned
playlist or create one before starting; leaving the choice empty imports only
into the library. Selected audio tracks are appended after processing or manual
approval, including files resolved to tracks already in the library.
Migration `m_0052_add_import_playlist_destinations` persists the destination and
completion receipts. Retries and restarts retain the choice, avoid duplicates,
and do not re-add tracks subsequently removed by the user. A started batch keeps
its destination for its remaining files.
```bash
node --test tests/import_playlists.test.cjs
# Disposable PostgreSQL; both tests use temporary tables.
FURU_IMPORT_TEST_DATABASE_URL=postgresql://... cargo test import_destinations -- --ignored
FURU_IMPORT_TEST_DATABASE_URL=postgresql://... cargo test torrent_playlist -- --ignored
```
`tests/browser-import-playlists.cjs` checks the file upload flow, database
reconciliation and the torrent picker on desktop and mobile. It requires a fresh
migrated instance with the import agent disabled at `FURU_TEST_URL` (default
`http://127.0.0.1:3019`), the disposable PostgreSQL container
`furumi-import-playlist-test-pg` with database `furumi_import_test`, and Playwright
and Chrome configured as above. It uses a local torrent preview fixture and
simulated imported tracks, without downloading external torrents.
## Configuration
Most settings can be changed from the administration interface. Every setting
+167
View File
@@ -0,0 +1,167 @@
use super::v2::{json_error, require_admin_json};
use crate::{auth::Role, user::User};
use cot::{db::Database, http::StatusCode, json::Json, response::IntoResponse, session::Session};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(super) struct UserRequest {
action: String,
id: Option<i64>,
#[serde(default)]
username: String,
#[serde(default)]
email: String,
#[serde(default)]
display_name: String,
#[serde(default)]
password: String,
#[serde(default)]
role: String,
}
pub(super) async fn manage_user(
session: Session,
db: Database,
Json(body): Json<UserRequest>,
) -> cot::Result<cot::response::Response> {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(r) => return Ok(r),
};
if body.action == "delete" {
let Some(id) = body.id.filter(|id| *id > 0) else {
return Ok(json_error(StatusCode::BAD_REQUEST, "Invalid user"));
};
if id == admin.id {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"You cannot delete your own account",
));
}
User::delete_by_id(&db, id).await?;
} else {
if !["create", "update"].contains(&body.action.as_str())
|| body.username.trim().is_empty()
|| body.username.chars().count() > 255
|| Role::from_code(&body.role).is_none()
|| (body.action == "create" && body.password.is_empty())
{
return Ok(json_error(
StatusCode::BAD_REQUEST,
"Provide a username, valid role, and a password for new accounts",
));
}
let optional = |s: String| if s.trim().is_empty() { None } else { Some(s) };
let email = optional(body.email);
let display_name = optional(body.display_name);
if body.action == "create" {
if User::get_by_username(&db, body.username.trim())
.await?
.is_some()
{
return Ok(json_error(StatusCode::CONFLICT, "Username already exists"));
}
User::create(
&db,
body.username.trim(),
email.as_deref(),
display_name.as_deref(),
&body.password,
&body.role,
)
.await?;
} else {
let Some(id) = body.id.filter(|id| *id > 0) else {
return Ok(json_error(StatusCode::BAD_REQUEST, "Invalid user"));
};
if id == admin.id && body.role != "admin" {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"You cannot remove your own administrator role",
));
}
let Some(mut user) = User::get_by_id(&db, id).await? else {
return Ok(json_error(StatusCode::NOT_FOUND, "User not found"));
};
if User::get_by_username(&db, body.username.trim())
.await?
.is_some_and(|u| u.id_val() != id)
{
return Ok(json_error(StatusCode::CONFLICT, "Username already exists"));
}
user.update_fields(
&db,
body.username.trim(),
email.as_deref(),
display_name.as_deref(),
(!body.password.is_empty()).then_some(body.password.as_str()),
&body.role,
)
.await?;
}
}
Json(serde_json::json!({"ok": true})).into_response()
}
#[derive(Debug, Deserialize)]
pub(super) struct ScheduleRequest {
cron: String,
}
pub(super) async fn schedule(
session: Session,
db: Database,
handle: &std::sync::Arc<
tokio::sync::OnceCell<std::sync::Arc<crate::scheduler::SchedulerHandle>>,
>,
name: &str,
Json(body): Json<ScheduleRequest>,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let cron = body.cron.trim();
if !valid_schedule(cron) {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"Invalid cron schedule: include the seconds field",
));
}
if crate::scheduler::ScheduledJob::get_by_name(&db, name)
.await?
.is_none()
{
return Ok(json_error(StatusCode::NOT_FOUND, "Job not found"));
}
let Some(handle) = handle.get() else {
return Ok(json_error(
StatusCode::SERVICE_UNAVAILABLE,
"Scheduler is not ready",
));
};
handle
.reschedule_job(name, cron)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(serde_json::json!({"ok": true})).into_response()
}
fn valid_schedule(cron: &str) -> bool {
cron.len() <= 100
&& croner::parser::CronParser::builder()
.seconds(croner::parser::Seconds::Required)
.build()
.parse(cron)
.is_ok()
&& crate::scheduler::compute_next_run(cron).is_some()
}
#[cfg(test)]
mod tests {
#[test]
fn schedules_use_the_same_seconds_field_as_the_scheduler() {
assert!(super::valid_schedule("0 17 3 * * *"));
assert!(!super::valid_schedule("17 3 * * *"));
assert!(!super::valid_schedule("invalid"));
}
}
File diff suppressed because it is too large. Load diff
+128 -717
View File
@@ -1,5 +1,8 @@
mod management;
mod metadata_fix;
mod setup;
mod statistics;
mod v2;
pub mod views;
use std::sync::Arc;
@@ -19,15 +22,7 @@ use crate::config::AppConfig;
use crate::i18n::I18n;
use crate::scheduler::{JobRegistry, SchedulerHandle};
use crate::user::User;
use views::{
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewApproveForm,
ReviewsBulkForm, SetImageBody, SetupForm, UploadImageBody, UserForm,
};
#[derive(Debug, Deserialize)]
struct ReviewsQuery {
status: Option<String>,
}
use setup::SetupForm;
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
#[derive(Debug)]
@@ -71,11 +66,6 @@ impl AdminApp {
}
}
#[derive(Debug, Deserialize)]
struct SettingsQuery {
saved: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PathId {
id: i64,
@@ -97,11 +87,6 @@ struct PathNameRunId {
run_id: i64,
}
#[derive(Debug, Deserialize)]
struct ReleasesQuery {
artist_id: Option<i64>,
}
impl App for AdminApp {
fn name(&self) -> &'static str {
"admin"
@@ -121,7 +106,7 @@ impl App for AdminApp {
if count > 0 {
return Ok(auth::redirect("/admin/"));
}
views::setup_page(i18n, String::new())
setup::setup_page(i18n, String::new())
.await?
.into_response()
})
@@ -132,7 +117,7 @@ impl App for AdminApp {
if count > 0 {
return Ok(auth::redirect("/admin/"));
}
views::setup_submit(i18n, &db, &session, form).await
setup::setup_submit(i18n, &db, &session, form).await
},
),
"admin_setup",
@@ -179,6 +164,26 @@ impl App for AdminApp {
},
"admin_v2_dashboard",
),
Route::with_handler_and_name(
"/v2/api/statistics",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database,
query: UrlQuery<statistics::StatisticsQuery>| {
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;
statistics::statistics(session, db, pg_pool, query.0).await
}
})
},
"admin_statistics",
),
Route::with_handler_and_name(
"/v2/api/reviews",
{
@@ -232,6 +237,25 @@ impl App for AdminApp {
},
"admin_v2_reviews_bulk",
),
Route::with_handler_and_name(
"/v2/api/users/manage",
cot::router::method::post(management::manage_user),
"admin_manage_user",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/schedule",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>,
json: Json<management::ScheduleRequest>| {
let handle = Arc::clone(&handle);
async move {
management::schedule(session, db, &handle, &path.0.name, json).await
}
}
}),
"admin_job_schedule",
),
Route::with_handler_and_name(
"/v2/api/users",
{
@@ -583,7 +607,7 @@ impl App for AdminApp {
.expect("admin pool")
})
.await;
v2::job_run_detail(session, db, pg_pool, path.0.run_id).await
v2::job_run_detail(session, db, pg_pool, &path.0.name, path.0.run_id).await
}
},
)
@@ -807,6 +831,86 @@ impl App for AdminApp {
},
"admin_v2_library_releases_merge",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/expand",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::ExpandRequest>| {
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;
metadata_fix::expand(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_expand",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/plan",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::PlanRequest>| {
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;
metadata_fix::propose(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_plan",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/apply",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::ApplyRequest>| {
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;
metadata_fix::apply(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_apply",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/history",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database| {
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;
metadata_fix::history(session, db, pg_pool).await
}
})
},
"admin_v2_metadata_fix_history",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/undo",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::UndoRequest>| {
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;
metadata_fix::undo(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_undo",
),
// -- Dashboard ----------------------------------------------------
Route::with_handler_and_name(
"/",
@@ -826,699 +930,6 @@ impl App for AdminApp {
},
"admin_index",
),
// -- Debug --------------------------------------------------------
Route::with_handler_and_name(
"/debug",
{
let config = Arc::clone(&self.config);
move |session: Session, db: Database, i18n: I18n| {
let config = Arc::clone(&config);
async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::debug_handler(admin, i18n, &config, &db)
.await?
.into_response()
}
}
},
"admin_debug",
),
// -- Settings -----------------------------------------------------
Route::with_handler_and_name(
"/settings",
get({
let config = Arc::clone(&self.config);
move |session: Session, db: Database, i18n: I18n,
query: UrlQuery<SettingsQuery>| {
let config = Arc::clone(&config);
async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let saved = query.0.saved.as_deref() == Some("1");
views::settings_handler(admin, i18n, &config, &db, saved)
.await?
.into_response()
}
}
})
.post({
let config = Arc::clone(&self.config);
move |session: Session, db: Database, i18n: I18n,
form: RequestForm<OidcSettingsForm>| {
let config = Arc::clone(&config);
async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::settings_submit(admin, i18n, &config, &db, form).await
}
}
}),
"admin_settings",
),
// -- Settings probe (HTMX fragment) -----------------------------------
Route::with_handler_and_name(
"/settings/probe",
{
let config = Arc::clone(&self.config);
move |session: Session, db: Database, i18n: I18n| {
let config = Arc::clone(&config);
async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::settings_probe_handler(admin, i18n, &config, &db)
.await?
.into_response()
}
}
},
"admin_settings_probe",
),
// -- Users --------------------------------------------------------
Route::with_handler_and_name(
"/users",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::users_list(admin, i18n, &db).await?.into_response()
},
"admin_users",
),
Route::with_handler_and_name(
"/users/new",
get(|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::users_new(admin, i18n).await?.into_response()
})
.post(
|session: Session, db: Database, form: RequestForm<UserForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::users_create(admin, &db, form).await
},
),
"admin_users_new",
),
Route::with_handler_and_name(
"/users/{id}/edit",
get(
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::users_edit(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
)
.post(
|session: Session, db: Database, path: Path<PathId>,
form: RequestForm<UserForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::users_update(admin, &db, path.0.id, form).await
},
),
"admin_users_edit",
),
Route::with_handler_and_name(
"/users/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::users_delete(admin, &db, path.0.id).await
},
),
"admin_users_delete",
),
// -- Artists ------------------------------------------------------
Route::with_handler_and_name(
"/artists",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_list(admin, i18n, &db).await?.into_response()
},
"admin_artists",
),
Route::with_handler_and_name(
"/artists/new",
get(|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_new(admin, i18n).await?.into_response()
})
.post(
|session: Session, db: Database, form: RequestForm<ArtistForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_create(admin, &db, form).await
},
),
"admin_artists_new",
),
Route::with_handler_and_name(
"/artists/{id}/edit",
get(
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_edit(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
)
.post(
|session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ArtistForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_update(admin, &db, path.0.id, form).await
},
),
"admin_artists_edit",
),
Route::with_handler_and_name(
"/artists/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_delete(admin, &db, path.0.id).await
},
),
"admin_artists_delete",
),
Route::with_handler_and_name(
"/artists/{id}/available-covers",
get(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_available_covers(admin, &db, path.0.id).await
},
),
"admin_artists_available_covers",
),
Route::with_handler_and_name(
"/artists/{id}/set-image",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>,
json: Json<SetImageBody>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_set_image(admin, &db, path.0.id, json.0).await
},
),
"admin_artists_set_image",
),
Route::with_handler_and_name(
"/artists/{id}/upload-image",
cot::router::method::post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathId>,
json: Json<UploadImageBody>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
let (live_config, _) = AppConfig::load_with_db(&db).await;
views::artists_upload_image(admin, &db, pg_pool, &live_config, path.0.id, json.0).await
}
}
}),
"admin_artists_upload_image",
),
// -- Releases -----------------------------------------------------
Route::with_handler_and_name(
"/releases",
|session: Session, db: Database, i18n: I18n,
query: UrlQuery<ReleasesQuery>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_list(admin, i18n, &db, query.0.artist_id)
.await?
.into_response()
},
"admin_releases",
),
Route::with_handler_and_name(
"/releases/new",
get(|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_new(admin, i18n, &db).await?.into_response()
})
.post(
|session: Session, db: Database,
form: RequestForm<ReleaseForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_create(admin, &db, form).await
},
),
"admin_releases_new",
),
Route::with_handler_and_name(
"/releases/{id}/edit",
get(
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_edit(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
)
.post(
|session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ReleaseForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_update(admin, &db, path.0.id, form).await
},
),
"admin_releases_edit",
),
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
Route::with_handler_and_name(
"/releases/{id}/delete",
cot::router::method::post(move |session: Session, db: Database, path: Path<PathId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
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;
views::releases_delete(admin, &db, pg_pool, path.0.id).await
}
}),
"admin_releases_delete",
)
},
// -- Media Files --------------------------------------------------
Route::with_handler_and_name(
"/media-files",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::media_files_list(admin, i18n, &db).await?.into_response()
},
"admin_media_files",
),
Route::with_handler_and_name(
"/media-files/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::media_files_delete(admin, &db, path.0.id).await
},
),
"admin_media_files_delete",
),
// -- Jobs ---------------------------------------------------------
Route::with_handler_and_name(
"/jobs",
{
let registry = Arc::clone(&self.registry);
move |session: Session, db: Database, i18n: I18n| {
let registry = Arc::clone(&registry);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::jobs_list(admin, i18n, &db, &registry).await?.into_response()
}
}
},
"admin_jobs",
),
Route::with_handler_and_name(
"/jobs/metadata_backfill/run-options",
cot::router::method::post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database,
form: RequestForm<MetadataBackfillForm>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::metadata_backfill_run(admin, &db, pg_pool, form).await
}
}
}),
"admin_metadata_backfill_run",
),
Route::with_handler_and_name(
"/jobs/lastfm_popularity/run-options",
cot::router::method::post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database,
form: RequestForm<views::LastfmPopularityForm>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::lastfm_popularity_run(admin, &db, pg_pool, form).await
}
}
}),
"admin_lastfm_popularity_run",
),
Route::with_handler_and_name(
"/jobs/{name}/run",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_run_now(admin, &handle, &path.0.name).await
}
}
}),
"admin_job_run",
),
Route::with_handler_and_name(
"/jobs/{name}/toggle",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_toggle_enabled(admin, &db, &handle, &path.0.name).await
}
}
}),
"admin_job_toggle",
),
Route::with_handler_and_name(
"/jobs/{name}/cron",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>,
form: RequestForm<CronForm>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_update_cron(admin, &db, &handle, &path.0.name, form).await
}
}
}),
"admin_job_cron",
),
Route::with_handler_and_name(
"/jobs/{name}/runs/{run_id}",
{
move |session: Session, db: Database, i18n: I18n,
path: Path<PathNameRunId>| {
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_run_detail(admin, i18n, &db, &path.0.name, path.0.run_id)
.await?
.into_response()
}
}
},
"admin_job_run_detail",
),
Route::with_handler_and_name(
"/jobs/{name}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, i18n: I18n,
path: Path<PathName>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::job_detail(admin, i18n, &db, pg_pool, &path.0.name)
.await?
.into_response()
}
}
},
"admin_job_detail",
),
// -- Reviews: clear -----------------------------------------------
Route::with_handler_and_name(
"/reviews/clear",
cot::router::method::post(
|session: Session, db: Database,
query: UrlQuery<ReviewsQuery>| async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::reviews_clear(admin, &db, query.0.status.as_deref()).await
},
),
"admin_reviews_clear",
),
Route::with_handler_and_name(
"/reviews/bulk",
cot::router::method::post(
|session: Session, db: Database,
form: RequestForm<ReviewsBulkForm>| async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::reviews_bulk(admin, &db, form).await
},
),
"admin_reviews_bulk",
),
// -- Reviews ------------------------------------------------------
Route::with_handler_and_name(
"/reviews",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, i18n: I18n,
query: UrlQuery<ReviewsQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::reviews_list(admin, i18n, &db, pg_pool, query.0.status.as_deref())
.await?
.into_response()
}
}
},
"admin_reviews",
),
Route::with_handler_and_name(
"/reviews/{id}",
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_detail(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
"admin_review_detail",
),
Route::with_handler_and_name(
"/reviews/{id}/approve",
cot::router::method::post({
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>,
form: RequestForm<ReviewApproveForm>| {
let config = Arc::clone(&config);
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::review_approve(admin, &config, &db, pg_pool, path.0.id, form).await
}
}
}),
"admin_review_approve",
),
Route::with_handler_and_name(
"/reviews/{id}/reject",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_reject(admin, &db, path.0.id).await
},
),
"admin_review_reject",
),
Route::with_handler_and_name(
"/reviews/{id}/requeue",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_requeue(admin, &db, path.0.id).await
},
),
"admin_review_requeue",
),
])
}
@@ -1544,7 +955,7 @@ impl App for AdminApp {
}
}
/// Start an options-based run for both admin interfaces. Ordinary scheduler
/// Start an options-based run for the admin interface. Ordinary scheduler
/// entry points continue to call Job::run with light-mode defaults.
async fn start_lastfm_popularity(
db: &Database,
+56
View File
@@ -0,0 +1,56 @@
use crate::auth;
use crate::i18n::{I18n, Translations};
use crate::user::User;
use cot::Template;
use cot::db::Database;
use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::request::extractors::RequestForm;
use cot::response::IntoResponse;
use cot::session::Session;
#[derive(Debug, Template)]
#[template(path = "admin/setup.html")]
struct SetupTemplate {
t: &'static Translations,
message: String,
}
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
let template = SetupTemplate { t: i18n.t, message };
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
pub struct SetupForm {
username: String,
password: String,
confirm_password: String,
}
pub async fn setup_submit(
i18n: I18n,
db: &Database,
session: &Session,
form: RequestForm<SetupForm>,
) -> cot::Result<cot::response::Response> {
let RequestForm(result) = form;
let data = match result {
FormResult::Ok(data) => data,
FormResult::ValidationError(_) => {
return setup_page(i18n, String::new()).await?.into_response();
}
};
if data.password != data.confirm_password {
let msg = i18n.t.setup_mismatch.to_owned();
return setup_page(i18n, msg).await?.into_response();
}
let user = User::create(db, &data.username, None, None, &data.password, "admin")
.await
.map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?;
auth::login(session, user.id_val()).await?;
Ok(auth::redirect("/admin/"))
}
+22
View File
@@ -0,0 +1,22 @@
WITH listens AS (
SELECT le.*, COALESCE(NULLIF(u.display_name, ''), u.username::text, 'Deleted user #' || le.user_id) AS user_name,
COALESCE(NULLIF(le.metadata_json->>'title', ''), 'Unknown track') AS title,
COALESCE(le.metadata_json->>'release_title', '') AS release_title,
ARRAY(SELECT DISTINCT trim(name) FROM jsonb_array_elements_text(
COALESCE(le.metadata_json->'artist_names', '[]'::jsonb) ||
COALESCE(le.metadata_json->'featured_artist_names', '[]'::jsonb)
) AS names(name) WHERE trim(name) <> '' ORDER BY trim(name)) AS artist_names,
CASE WHEN le.origin_device_id = ident.device_id THEN 'instance' ELSE 'synced' END AS source,
COALESCE(NULLIF(d.name, ''), le.origin_device_id) AS device_name
FROM furumusic__listen_event le
LEFT JOIN furumusic__user u ON u.id = le.user_id
LEFT JOIN furumusic__fed_device_identity ident ON ident.user_id = le.user_id
LEFT JOIN furumusic__fed_device d ON d.user_id = le.user_id AND d.device_id = le.origin_device_id
WHERE le.started_at_ms >= $1 AND le.started_at_ms < $2
AND ($3::bigint IS NULL OR le.user_id = $3)
AND ($4::text IS NULL OR le.content_id = $4)
), filtered AS (
SELECT * FROM listens
WHERE ($5::text IS NULL OR EXISTS (SELECT 1 FROM unnest(artist_names) n WHERE lower(n) = lower($5)))
AND ($6 = 'all' OR source = $6)
)
+403
View File
@@ -0,0 +1,403 @@
use chrono::{NaiveDate, Utc};
use cot::{db::Database, http::StatusCode, json::Json, response::IntoResponse, session::Session};
use serde::Deserialize;
use serde_json::Value;
use sqlx::PgPool;
use super::v2::{json_error, require_admin_json};
const LISTENS: &str = include_str!("sql/listens.sql");
const PAGE_SIZE: i64 = 25;
#[derive(Debug, Default, Deserialize)]
pub(super) struct StatisticsQuery {
pub from: Option<String>,
pub to: Option<String>,
pub user_id: Option<i64>,
pub content_id: Option<String>,
pub artist: Option<String>,
pub source: Option<String>,
pub kind: Option<String>,
pub search: Option<String>,
pub rank_page: Option<i64>,
pub history_page: Option<i64>,
pub history_kind: Option<String>,
}
struct Filter {
from: i64,
to: i64,
user_id: Option<i64>,
content_id: Option<String>,
artist: Option<String>,
source: String,
}
impl Filter {
fn parse(query: &StatisticsQuery) -> Result<Self, String> {
fn date(value: &str) -> Result<i64, String> {
NaiveDate::parse_from_str(value, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|d| d.and_utc().timestamp_millis())
.filter(|d| *d >= 0)
.ok_or_else(|| "Use dates in YYYY-MM-DD format (1970 or later)".to_owned())
}
let to = match query.to.as_deref().filter(|s| !s.is_empty()) {
Some(value) => date(value)?
.checked_add(86_400_000)
.ok_or("Invalid end date")?,
None => Utc::now().timestamp_millis(),
};
let from = match query.from.as_deref().filter(|s| !s.is_empty()) {
Some("all") => 0,
Some(value) => date(value)?,
None => to.saturating_sub(30 * 86_400_000),
};
if from >= to {
return Err("Start date must not be after end date".into());
}
let source = query.source.as_deref().unwrap_or("all");
if !["all", "instance", "synced"].contains(&source) {
return Err("Unknown playback source".into());
}
if query.user_id.is_some_and(|id| id <= 0) {
return Err("Invalid user".into());
}
if query.artist.as_ref().is_some_and(|s| s.len() > 1024)
|| query.content_id.as_ref().is_some_and(|s| s.len() > 256)
|| query.search.as_ref().is_some_and(|s| s.len() > 1024)
{
return Err("Filter is too long".into());
}
Ok(Self {
from,
to,
user_id: query.user_id,
content_id: query.content_id.clone(),
artist: query.artist.clone(),
source: source.into(),
})
}
async fn query(&self, pool: &PgPool, suffix: &str) -> anyhow::Result<Value> {
let sql = format!("{LISTENS}{suffix}");
Ok(sqlx::query_scalar(&sql)
.bind(self.from)
.bind(self.to)
.bind(self.user_id)
.bind(&self.content_id)
.bind(&self.artist)
.bind(&self.source)
.fetch_one(pool)
.await?)
}
}
pub(super) async fn statistics(
session: Session,
db: Database,
pool: &PgPool,
query: StatisticsQuery,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let filter = match Filter::parse(&query) {
Ok(filter) => filter,
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
};
let kind = query.kind.as_deref().unwrap_or("artists");
if !["artists", "tracks", "releases", "users"].contains(&kind) {
return Ok(json_error(StatusCode::BAD_REQUEST, "Unknown ranking"));
}
let history_kind = query.history_kind.as_deref().unwrap_or("all");
if !["all", "plays", "short"].contains(&history_kind) {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"Unknown history filter",
));
}
let result = load(pool, &filter, &query, kind, history_kind)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(result).into_response()
}
async fn load(
pool: &PgPool,
filter: &Filter,
query: &StatisticsQuery,
kind: &str,
history_kind: &str,
) -> anyhow::Result<Value> {
let summary_sql = r#"
SELECT to_jsonb(s) FROM (
SELECT COUNT(*) AS events, COUNT(*) FILTER (WHERE qualified) AS plays,
COUNT(*) FILTER (WHERE NOT qualified) AS short_listens,
COUNT(*) FILTER (WHERE trim(both '"' from ended_reason) = 'finished') AS completed,
COALESCE(SUM(listened_ms), 0)::bigint / 1000 AS listened_seconds,
COUNT(DISTINCT user_id) FILTER (WHERE qualified) AS listeners,
COUNT(DISTINCT content_id) FILTER (WHERE qualified) AS tracks,
COUNT(*) FILTER (WHERE qualified AND source = 'instance') AS instance_plays,
COUNT(*) FILTER (WHERE qualified AND source = 'synced') AS synced_plays
FROM filtered
) s"#;
let daily_sql = r#",
bounds AS (
SELECT COALESCE(MIN(started_at_ms), $2) AS first_ms FROM filtered
), granularity AS (
SELECT CASE WHEN $2 - GREATEST($1, first_ms) > 120::bigint * 86400000
THEN 'month' ELSE 'day' END AS unit FROM bounds
), daily AS (
SELECT date_trunc(g.unit, to_timestamp(started_at_ms / 1000.0) AT TIME ZONE 'UTC') AS bucket,
COUNT(*) FILTER (WHERE qualified) AS plays,
COUNT(DISTINCT user_id) FILTER (WHERE qualified) AS listeners,
COALESCE(SUM(listened_ms), 0)::bigint / 1000 AS listened_seconds
FROM filtered CROSS JOIN granularity g GROUP BY bucket
), series AS (
SELECT generate_series((SELECT MIN(bucket) FROM daily), (SELECT MAX(bucket) FROM daily),
CASE WHEN unit = 'month' THEN interval '1 month' ELSE interval '1 day' END) AS bucket
FROM granularity
), filled AS (
SELECT s.bucket, COALESCE(d.plays, 0) AS plays, COALESCE(d.listeners, 0) AS listeners,
COALESCE(d.listened_seconds, 0) AS listened_seconds
FROM series s LEFT JOIN daily d USING (bucket)
)
SELECT jsonb_build_object('unit', (SELECT unit FROM granularity), 'items',
COALESCE((SELECT jsonb_agg(to_jsonb(d) ORDER BY bucket) FROM filled d), '[]'::jsonb))"#;
let (summary, daily, ranking, history, instance) = tokio::try_join!(
filter.query(pool, summary_sql),
filter.query(pool, daily_sql),
ranking(pool, filter, query, kind),
history(pool, filter, query, history_kind),
instance(pool),
)?;
Ok(
serde_json::json!({"summary": summary, "daily": daily, "ranking": ranking,
"history": history, "instance": instance, "from_ms": filter.from, "to_ms": filter.to}),
)
}
async fn ranking(
pool: &PgPool,
filter: &Filter,
query: &StatisticsQuery,
kind: &str,
) -> anyhow::Result<Value> {
// Only fixed SQL fragments are interpolated; every user filter is bound.
let (key, label, extra, join) = match kind {
"users" => ("user_id::text", "MIN(user_name)", "''::text", ""),
"tracks" => (
"content_id",
"MIN(title)",
"MIN(array_to_string(artist_names, ', '))",
"",
),
"releases" => (
"jsonb_build_array(release_title, artist_names)::text",
"MIN(NULLIF(release_title, ''))",
"MIN(array_to_string(artist_names, ', '))",
"",
),
_ => (
"lower(artist.name)",
"MIN(artist.name)",
"''::text",
"CROSS JOIN LATERAL (SELECT DISTINCT ON (lower(n)) n AS name FROM unnest(artist_names) n ORDER BY lower(n), n) artist",
),
};
let sql = format!(
r#"{LISTENS}, ranked AS (
SELECT {key} AS key, {label} AS label, {extra} AS detail, COUNT(*) AS plays,
COUNT(DISTINCT user_id) AS listeners,
SUM(listened_ms)::bigint / 1000 AS listened_seconds, MAX(started_at_ms) AS last_play_ms
FROM filtered {join} WHERE qualified GROUP BY {key}
), searched AS (
SELECT * FROM ranked WHERE label IS NOT NULL
AND ($7::text IS NULL OR strpos(lower(label || ' ' || detail), lower($7)) > 0)
), page AS (SELECT * FROM searched ORDER BY plays DESC, key LIMIT $8 OFFSET $9)
SELECT jsonb_build_object('total', (SELECT COUNT(*) FROM searched),
'items', COALESCE((SELECT jsonb_agg(to_jsonb(p) ORDER BY plays DESC, key) FROM page p), '[]'::jsonb))"#
);
let page = query.rank_page.unwrap_or(1).clamp(1, 1_000_000);
let mut result: Value = sqlx::query_scalar(&sql)
.bind(filter.from)
.bind(filter.to)
.bind(filter.user_id)
.bind(&filter.content_id)
.bind(&filter.artist)
.bind(&filter.source)
.bind(query.search.as_deref().filter(|s| !s.is_empty()))
.bind(PAGE_SIZE)
.bind((page - 1) * PAGE_SIZE)
.fetch_one(pool)
.await?;
result["page"] = page.into();
result["limit"] = PAGE_SIZE.into();
result["kind"] = kind.into();
Ok(result)
}
async fn history(
pool: &PgPool,
filter: &Filter,
query: &StatisticsQuery,
kind: &str,
) -> anyhow::Result<Value> {
let sql = format!(
r#"{LISTENS}, selected AS (
SELECT user_id, user_name, listen_id, content_id, title, artist_names, release_title,
started_at_ms, listened_ms, track_duration_ms, qualified,
trim(both '"' from ended_reason) AS ended_reason, device_name, source
FROM filtered WHERE $7 = 'all' OR ($7 = 'plays' AND qualified) OR ($7 = 'short' AND NOT qualified)
), page AS (SELECT * FROM selected ORDER BY started_at_ms DESC, user_id, listen_id LIMIT $8 OFFSET $9)
SELECT jsonb_build_object('total', (SELECT COUNT(*) FROM selected),
'items', COALESCE((SELECT jsonb_agg(to_jsonb(p) ORDER BY started_at_ms DESC, user_id, listen_id) FROM page p), '[]'::jsonb))"#
);
let page = query.history_page.unwrap_or(1).clamp(1, 1_000_000);
let mut result: Value = sqlx::query_scalar(&sql)
.bind(filter.from)
.bind(filter.to)
.bind(filter.user_id)
.bind(&filter.content_id)
.bind(&filter.artist)
.bind(&filter.source)
.bind(kind)
.bind(PAGE_SIZE)
.bind((page - 1) * PAGE_SIZE)
.fetch_one(pool)
.await?;
result["page"] = page.into();
result["limit"] = PAGE_SIZE.into();
Ok(result)
}
async fn instance(pool: &PgPool) -> anyhow::Result<Value> {
Ok(sqlx::query_scalar(r#"SELECT jsonb_build_object(
'users', (SELECT COUNT(*) FROM furumusic__user),
'tracks', (SELECT COUNT(*) FROM furumusic__track),
'releases', (SELECT COUNT(*) FROM furumusic__release),
'artists', (SELECT COUNT(*) FROM furumusic__artist),
'playlists', (SELECT COUNT(*) FROM furumusic__playlist),
'media_bytes', (SELECT COALESCE(SUM(file_size_bytes), 0)::bigint FROM furumusic__media_file),
'formats', (SELECT COALESCE(jsonb_agg(to_jsonb(f)), '[]'::jsonb) FROM (
SELECT COALESCE(audio_format, 'unknown') AS label, COUNT(*) AS count
FROM furumusic__media_file WHERE file_type = 'audio' GROUP BY audio_format ORDER BY count DESC
) f),
'reviews', (SELECT COALESCE(jsonb_agg(to_jsonb(r)), '[]'::jsonb) FROM (
SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status ORDER BY status
) r),
'jobs', (SELECT COALESCE(jsonb_agg(to_jsonb(j)), '[]'::jsonb) FROM (
SELECT status::text AS label, COUNT(*) AS count FROM furumusic__job_run GROUP BY status ORDER BY status
) j),
'scrobbles', (SELECT COALESCE(jsonb_agg(to_jsonb(s)), '[]'::jsonb) FROM (
SELECT status::text AS label, COUNT(*) AS count FROM furumusic__lastfm_scrobble_outbox GROUP BY status ORDER BY status
) s)
)"#).fetch_one(pool).await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_are_inclusive_utc_and_filters_are_validated() {
let q = StatisticsQuery {
from: Some("2026-11-01".into()),
to: Some("2026-11-01".into()),
..Default::default()
};
let f = Filter::parse(&q).unwrap();
assert_eq!(f.to - f.from, 86_400_000);
assert!(
Filter::parse(&StatisticsQuery {
from: Some("broken".into()),
..q
})
.is_err()
);
assert!(
Filter::parse(&StatisticsQuery {
source: Some("invalid".into()),
..Default::default()
})
.is_err()
);
}
#[tokio::test]
#[ignore = "requires FURU_STATISTICS_TEST_DATABASE_URL pointing at a disposable PostgreSQL"]
async fn postgres_statistics_drilldown_and_pagination() {
let url = std::env::var("FURU_STATISTICS_TEST_DATABASE_URL").expect("test database URL");
// Temporary tables are isolated to this one connection and disappear on close.
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.unwrap();
sqlx::raw_sql(include_str!("../../tests/statistics_fixture.sql"))
.execute(&pool)
.await
.unwrap();
let mut q = StatisticsQuery {
from: Some("2026-11-01".into()),
to: Some("2026-11-03".into()),
..Default::default()
};
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "artists", "all")
.await
.unwrap();
assert_eq!(result["summary"]["plays"], 32);
assert_eq!(result["summary"]["short_listens"], 1);
assert_eq!(result["summary"]["listeners"], 2);
assert_eq!(result["summary"]["listened_seconds"], 5618);
assert_eq!(result["summary"]["synced_plays"], 1);
assert_eq!(result["ranking"]["items"][0]["label"], "Artist A");
assert_eq!(result["ranking"]["items"][0]["plays"], 31); // duplicate artist credits never multiply plays
assert_eq!(result["ranking"]["items"][0]["listeners"], 2);
assert_eq!(result["history"]["total"], 33);
assert_eq!(result["history"]["items"].as_array().unwrap().len(), 25);
assert_eq!(result["daily"]["items"].as_array().unwrap().len(), 3);
assert_eq!(result["daily"]["items"][1]["plays"], 0); // fill the silent day
q.artist = Some("artist a".into());
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "users", "all")
.await
.unwrap();
assert_eq!(result["ranking"]["total"], 2);
assert_eq!(result["ranking"]["items"][0]["label"], "Alice");
assert_eq!(result["ranking"]["items"][0]["plays"], 30);
q.user_id = Some(2);
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "tracks", "short")
.await
.unwrap();
assert_eq!(result["summary"]["plays"], 1);
assert_eq!(result["history"]["total"], 1);
assert_eq!(result["history"]["items"][0]["ended_reason"], "skipped");
assert_eq!(result["ranking"]["items"][0]["label"], "First song"); // no local track exists
q.user_id = None;
q.artist = None;
q.history_page = Some(2);
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "releases", "all")
.await
.unwrap();
assert_eq!(result["history"]["items"].as_array().unwrap().len(), 8);
q.source = Some("synced".into());
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "users", "all")
.await
.unwrap();
assert_eq!(result["summary"]["plays"], 1);
q.source = Some("instance".into());
q.search = Some("' OR 1=1 --".into());
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "artists", "all")
.await
.unwrap();
assert_eq!(result["summary"]["plays"], 31);
assert_eq!(result["ranking"]["total"], 0);
q.user_id = Some(3);
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "users", "all")
.await
.unwrap();
assert_eq!(result["summary"]["plays"], 0);
assert_eq!(result["history"]["total"], 0);
pool.close().await;
}
}
+188 -70
View File
@@ -232,6 +232,9 @@ struct AdminUserRowDto {
is_active: bool,
is_online: bool,
last_seen_ms: Option<i64>,
plays: i64,
listened_seconds: i64,
last_play_ms: Option<i64>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -257,7 +260,7 @@ struct AdminUserStatsDto {
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserPlayDto {
history_id: i64,
history_id: String,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
@@ -432,6 +435,8 @@ struct JobRunDetailDto {
struct BulkReviewsResponse {
ok: bool,
affected: u64,
failed: u64,
skipped: u64,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -989,10 +994,14 @@ pub async fn bulk_reviews(
}
let action = body.action.trim();
if action != "delete" && action != "requeue" {
if action != "delete" && action != "requeue" && action != "approve" {
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown bulk action"));
}
if action == "approve" {
return Json(approve_selected_reviews(&db, pool, &body).await?).into_response();
}
let mode = body.mode.as_deref().unwrap_or("ids");
let affected = if mode == "filter" {
apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await?
@@ -1016,7 +1025,99 @@ pub async fn bulk_reviews(
}
};
Json(BulkReviewsResponse { ok: true, affected }).into_response()
Json(BulkReviewsResponse {
ok: true,
affected,
failed: 0,
skipped: 0,
})
.into_response()
}
fn selected_reviews_query(body: &BulkReviewsRequest) -> QueryBuilder<'static, Postgres> {
let mut qb = QueryBuilder::new("SELECT id FROM furumusic__pending_review WHERE 1=1");
if body.mode.as_deref() == Some("filter") {
let filter = body.filter.clone().unwrap_or_default();
push_review_filters(
&mut qb,
normalize_status(filter.status.as_deref()),
clean_search(filter.search.as_deref()).map(|s| format!("%{s}%")),
);
} else {
qb.push(" AND id = ANY(");
qb.push_bind(body.ids.clone().unwrap_or_default());
qb.push(")");
}
qb.push(" ORDER BY id");
qb
}
async fn approve_selected_reviews(
db: &Database,
pool: &PgPool,
body: &BulkReviewsRequest,
) -> cot::Result<BulkReviewsResponse> {
let ids = selected_reviews_query(body)
.build_query_as::<IdRow>()
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let (config, _) = AppConfig::load_with_db(db).await;
let mut result = BulkReviewsResponse {
ok: true,
affected: 0,
failed: 0,
skipped: 0,
};
for row in ids {
let Some(mut review) = crate::scheduler::PendingReview::get_by_id(db, row.id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
else {
result.skipped += 1;
continue;
};
if review.status_str() != "pending" {
result.skipped += 1;
continue;
}
// Claim one row at a time so overlapping bulk requests cannot import it twice.
let claimed = sqlx::query(
"UPDATE furumusic__pending_review SET status = 'processing', updated_at = $2 \
WHERE id = $1 AND status = 'pending'",
)
.bind(row.id)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if claimed.rows_affected() == 0 {
result.skipped += 1;
continue;
}
let approval = async {
let normalized = serde_json::from_str::<crate::agent::dto::NormalizedFields>(
review.result_json_str(),
)?;
finalize_review_approval(db, pool, &config, &mut review, &normalized).await
}
.await;
match approval {
Ok(()) => result.affected += 1,
Err(error) => {
tracing::error!(review_id = row.id, ?error, "bulk review approval failed");
review
.set_failed(db, &format!("Review approval failed: {error:#}"))
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
result.failed += 1;
}
}
}
result.ok = result.failed == 0;
Ok(result)
}
pub async fn approve_review(
@@ -1042,31 +1143,9 @@ pub async fn approve_review(
.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()
}
match finalize_review_approval(&db, pool, &live_config, &mut review, &normalized).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(error) => {
tracing::error!(?error, "review approval failed");
let _ = review.set_rejected(&db).await;
@@ -1078,6 +1157,36 @@ pub async fn approve_review(
}
}
async fn finalize_review_approval(
db: &Database,
pool: &PgPool,
config: &AppConfig,
review: &mut crate::scheduler::PendingReview,
normalized: &crate::agent::dto::NormalizedFields,
) -> anyhow::Result<()> {
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 stats = crate::scheduler::ProcessingStats::get_by_review_id(db, review.id_val())
.await
.unwrap_or(None);
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
crate::jobs::inbox_process::finalize_approved(
db,
pool,
config,
&input_path,
normalized,
&context,
&config.agent_storage_dir,
model_name.as_deref(),
)
.await?;
review.set_approved(db).await?;
Ok(())
}
pub async fn jobs(
session: Session,
db: Database,
@@ -1852,6 +1961,7 @@ pub async fn job_run_detail(
session: Session,
db: Database,
pool: &PgPool,
job_name: &str,
run_id: i64,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
@@ -1861,9 +1971,10 @@ pub async fn job_run_detail(
"SELECT id, job_name::text AS job_name, status::text AS status, started_at::text AS started_at, \
finished_at, duration_ms, trigger::text AS trigger, error_message, \
LEFT(COALESCE(log_output, ''), 1600) AS log_excerpt, log_output \
FROM furumusic__job_run WHERE id = $1",
FROM furumusic__job_run WHERE id = $1 AND job_name = $2",
)
.bind(run_id)
.bind(job_name)
.fetch_optional(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
@@ -2461,7 +2572,7 @@ pub async fn merge_releases(
.into_response()
}
async fn require_admin_json(
pub(super) async fn require_admin_json(
session: &Session,
db: &Database,
) -> Result<AuthenticatedUser, cot::response::Response> {
@@ -2474,7 +2585,7 @@ async fn require_admin_json(
Ok(user)
}
fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
pub(super) fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
let body = serde_json::json!({ "error": message });
cot::http::Response::builder()
.status(status)
@@ -2556,6 +2667,9 @@ struct AdminUserSqlRow {
email: Option<String>,
role: String,
is_active: bool,
plays: i64,
listened_seconds: i64,
last_play_ms: Option<i64>,
}
async fn load_admin_users_page(
@@ -2586,7 +2700,11 @@ async fn load_admin_users_page(
let total: i64 = count_qb.build_query_scalar().fetch_one(pool).await?;
let mut qb = QueryBuilder::<Postgres>::new(
"SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE 1=1",
"SELECT id, username::text, display_name, email, role::text, is_active, \
(SELECT COUNT(*) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS plays, \
(SELECT (COALESCE(SUM(listened_ms), 0) / 1000)::bigint FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id) AS listened_seconds, \
(SELECT MAX(started_at_ms) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS last_play_ms \
FROM furumusic__user WHERE 1=1",
);
if let Some(pattern) = pattern.as_ref() {
qb.push(" AND (username ILIKE ");
@@ -2629,7 +2747,11 @@ async fn load_admin_user_detail(
user_id: i64,
) -> anyhow::Result<Option<AdminUserDetailDto>> {
let row = sqlx::query_as::<_, AdminUserSqlRow>(
"SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE id = $1",
"SELECT id, username::text, display_name, email, role::text, is_active, \
(SELECT COUNT(*) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS plays, \
(SELECT (COALESCE(SUM(listened_ms), 0) / 1000)::bigint FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id) AS listened_seconds, \
(SELECT MAX(started_at_ms) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS last_play_ms \
FROM furumusic__user WHERE id = $1",
)
.bind(user_id)
.fetch_optional(pool)
@@ -2653,17 +2775,17 @@ async fn load_admin_user_detail(
lastfm_connected,
) = tokio::try_join!(
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1"
"SELECT COUNT(*) FROM furumusic__listen_event WHERE user_id = $1 AND qualified"
)
.bind(user_id)
.fetch_one(pool),
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1 AND completed"
"SELECT COUNT(*) FROM furumusic__listen_event WHERE user_id = $1 AND qualified AND ended_reason = '\"finished\"'"
)
.bind(user_id)
.fetch_one(pool),
sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(SUM(duration_listened), 0)::bigint FROM furumusic__play_history WHERE user_id = $1"
"SELECT (COALESCE(SUM(listened_ms), 0) / 1000)::bigint FROM furumusic__listen_event WHERE user_id = $1"
)
.bind(user_id)
.fetch_one(pool),
@@ -2729,34 +2851,27 @@ async fn load_admin_user_recent_plays(
limit: i64,
) -> anyhow::Result<Vec<AdminUserPlayDto>> {
let rows = sqlx::query_as::<_, AdminUserPlaySqlRow>(
r#"SELECT ph.id AS history_id,
ph.played_at::text AS played_at,
ph.duration_listened,
ph.completed,
t.id AS track_id,
t.title::text AS title,
COALESCE(NULLIF(STRING_AGG(a.name::text, ', ' ORDER BY ta.position), ''), 'Unknown artist') AS artists,
t.release_id,
COALESCE(r.title::text, '') AS release_title,
r.year AS release_year,
t.cover_file_id,
r.cover_file_id AS release_cover_file_id,
t.duration_seconds AS track_duration_seconds,
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
mf.audio_format,
mf.audio_bitrate
FROM furumusic__play_history ph
JOIN furumusic__track t ON t.id = ph.track_id
r#"SELECT le.listen_id AS history_id,
to_timestamp(le.started_at_ms / 1000.0)::text AS played_at,
LEAST(le.listened_ms / 1000, 2147483647)::integer AS duration_listened,
le.ended_reason = '"finished"' AS completed,
COALESCE(t.id, 0) AS track_id,
COALESCE(le.metadata_json->>'title', 'Unknown track') AS title,
COALESCE((SELECT string_agg(name, ', ') FROM jsonb_array_elements_text(
COALESCE(le.metadata_json->'artist_names', '[]'::jsonb)) names(name)), 'Unknown artist') AS artists,
COALESCE(t.release_id, 0) AS release_id,
COALESCE(le.metadata_json->>'release_title', '') AS release_title,
r.year AS release_year, t.cover_file_id, r.cover_file_id AS release_cover_file_id,
COALESCE(le.track_duration_ms / 1000.0, 0)::float8 AS track_duration_seconds,
COALESCE(mf.uploader_name, '')::text AS uploader_name,
mf.audio_format, mf.audio_bitrate
FROM furumusic__listen_event le
LEFT JOIN furumusic__track_ref tr ON tr.content_id = le.content_id
LEFT JOIN furumusic__track t ON t.id = COALESCE(le.local_track_id, tr.local_track_id)
LEFT JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main'
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ph.user_id = $1
GROUP BY ph.id, ph.played_at, ph.duration_listened, ph.completed, t.id, t.title,
t.release_id, r.title, r.year, t.cover_file_id, r.cover_file_id,
t.duration_seconds, mf.uploader_name, mf.audio_format, mf.audio_bitrate
ORDER BY ph.played_at DESC, ph.id DESC
LIMIT $2"#,
WHERE le.user_id = $1 AND le.qualified
ORDER BY le.started_at_ms DESC, le.listen_id DESC LIMIT $2"#,
)
.bind(user_id)
.bind(limit)
@@ -2787,7 +2902,7 @@ async fn load_admin_user_recent_plays(
#[derive(Debug, sqlx::FromRow)]
struct AdminUserPlaySqlRow {
history_id: i64,
history_id: String,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
@@ -2824,6 +2939,9 @@ fn admin_user_row(
email: row.email,
role: row.role,
is_active: row.is_active,
plays: row.plays,
listened_seconds: row.listened_seconds,
last_play_ms: row.last_play_ms,
is_online: last_seen_ms.is_some_and(|value| value <= online_cutoff_ms),
last_seen_ms,
}
@@ -3443,13 +3561,13 @@ async fn fetch_library_item(
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \
r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \
COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \
COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \
COUNT(DISTINCT (ph.user_id, ph.listen_id)) FILTER (WHERE ph.listen_id IS NOT NULL)::bigint AS tertiary_count, \
r.updated_at::text AS updated_at \
FROM furumusic__release r \
LEFT JOIN furumusic__track t ON t.release_id = r.id \
LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \
LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
LEFT JOIN furumusic__track_ref playback_ref ON playback_ref.local_track_id = t.id LEFT JOIN furumusic__listen_event ph ON ph.content_id = playback_ref.content_id AND ph.qualified \
WHERE r.id = $1 \
GROUP BY r.id",
)
@@ -3479,13 +3597,13 @@ async fn fetch_library_item(
"SELECT t.id, t.title::text AS title, \
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
COUNT(DISTINCT (ph.user_id, ph.listen_id)) FILTER (WHERE ph.listen_id IS NOT NULL)::bigint AS secondary_count, \
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
t.updated_at::text AS updated_at \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
LEFT JOIN furumusic__track_ref playback_ref ON playback_ref.local_track_id = t.id LEFT JOIN furumusic__listen_event ph ON ph.content_id = playback_ref.content_id AND ph.qualified \
LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \
WHERE t.id = $1 \
GROUP BY t.id, r.title",
@@ -4256,13 +4374,13 @@ async fn load_release_items(
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \
r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \
COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \
COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \
COUNT(DISTINCT (ph.user_id, ph.listen_id)) FILTER (WHERE ph.listen_id IS NOT NULL)::bigint AS tertiary_count, \
r.updated_at::text AS updated_at \
FROM furumusic__release r \
LEFT JOIN furumusic__track t ON t.release_id = r.id \
LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \
LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
LEFT JOIN furumusic__track_ref playback_ref ON playback_ref.local_track_id = t.id LEFT JOIN furumusic__listen_event ph ON ph.content_id = playback_ref.content_id AND ph.qualified \
WHERE 1=1",
);
if let Some(pattern) = search_pattern {
@@ -4292,14 +4410,14 @@ async fn load_track_items(
"SELECT t.id, t.title::text AS title, \
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
COUNT(DISTINCT (ph.user_id, ph.listen_id)) FILTER (WHERE ph.listen_id IS NOT NULL)::bigint AS secondary_count, \
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
t.updated_at::text AS updated_at \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
LEFT JOIN furumusic__track_ref playback_ref ON playback_ref.local_track_id = t.id LEFT JOIN furumusic__listen_event ph ON ph.content_id = playback_ref.content_id AND ph.qualified \
LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \
WHERE 1=1",
);
-2170
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -443,6 +443,9 @@ pub async fn merge_existing(
}
// A repeated YouTube import must still resolve to this existing media ID.
if let Some(sha) = context["sha256"].as_str() {
if let Err(error) = crate::import_playlists::link_media(pool, sha, candidate.media_id).await {
tracing::warn!(%error, "could not link deduplicated import to destination playlists");
}
if let Err(error) = sqlx::query(
r#"INSERT INTO furumusic__youtube_import_media (item_id, media_file_id)
SELECT DISTINCT item.id, $1
@@ -794,7 +797,7 @@ mod tests {
CREATE TABLE furumusic__track_embedding (track_id bigint);
CREATE TABLE test_likes (track_id bigint REFERENCES furumusic__track(id));
CREATE TABLE test_playlist_tracks (track_id bigint REFERENCES furumusic__track(id));
CREATE TABLE furumusic__local_upload (id text PRIMARY KEY, user_id bigint,
CREATE TABLE furumusic__local_upload (target_playlist_id bigint, id text PRIMARY KEY, user_id bigint,
filename text, size_bytes bigint, status text, inbox_path text, error text,
created_at text, updated_at text, completed_at text);
INSERT INTO furumusic__artist VALUES (1, 'Artist', 'artist');
@@ -969,6 +972,7 @@ mod tests {
"song.mp3",
3,
"user_uploads/6/batch/song.mp3",
None,
)
.await
.unwrap();
@@ -979,6 +983,7 @@ mod tests {
"song.mp3",
3,
"user_uploads/6/batch/song.mp3",
None,
)
.await
.unwrap();
@@ -1014,7 +1019,8 @@ mod tests {
7,
"song.mp3",
3,
"user_uploads/7/song.mp3"
"user_uploads/7/song.mp3",
None,
)
.await
.is_err()
+2
View File
@@ -408,6 +408,8 @@ translations! {
player_youtube_select_all: "Select all" , "Отметить все";
player_youtube_clear_selection: "Clear selection" , "Снять все";
player_youtube_selected_count: "selected" , "выбрано";
player_import_destination: "Add downloads to a playlist" , "Добавить загруженное в плейлист";
player_import_destination_hint: "Selected audio tracks will be added automatically after processing, including tracks already in the library." , "Выбранные аудиотреки будут добавлены автоматически после обработки, включая уже имеющиеся в библиотеке.";
player_youtube_destination: "Add imported tracks to playlist" , "Добавить импортированные треки в плейлист";
player_youtube_no_destination: "Do not add to a playlist" , "Не добавлять в плейлист";
player_youtube_create_playlist: "Create a new playlist" , "Создать новый плейлист";
+280
View File
@@ -0,0 +1,280 @@
//! Durable playlist destinations for file and torrent imports.
use anyhow::{Context, bail};
use sqlx::PgPool;
pub async fn validate_target(
pool: &PgPool,
user_id: i64,
playlist_id: Option<i64>,
) -> anyhow::Result<()> {
if let Some(id) = playlist_id {
let owned: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM furumusic__playlist WHERE id=$1 AND owner_id=$2)",
)
.bind(id)
.bind(user_id)
.fetch_one(pool)
.await?;
if id <= 0 || !owned {
bail!("selected playlist does not exist or is not yours");
}
}
Ok(())
}
pub async fn register(
pool: &PgPool,
user_id: i64,
playlist_id: i64,
input_path: &str,
sha256: &str,
) -> anyhow::Result<()> {
validate_target(pool, user_id, Some(playlist_id)).await?;
let result = sqlx::query(
r#"INSERT INTO furumusic__import_playlist_target
(user_id, playlist_id, input_path, source_sha256)
VALUES ($1,$2,$3,$4)
ON CONFLICT (user_id, input_path) DO UPDATE SET source_sha256=EXCLUDED.source_sha256
WHERE furumusic__import_playlist_target.playlist_id=EXCLUDED.playlist_id
AND furumusic__import_playlist_target.source_sha256=EXCLUDED.source_sha256"#,
)
.bind(user_id)
.bind(playlist_id)
.bind(input_path)
.bind(sha256)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
bail!("this upload already has a different playlist destination");
}
Ok(())
}
/// Fingerprint deduplication may resolve a differently encoded file to an existing media ID.
pub async fn link_media(pool: &PgPool, source_sha256: &str, media_id: i64) -> anyhow::Result<()> {
if source_sha256.is_empty() {
return Ok(());
}
sqlx::query("UPDATE furumusic__import_playlist_target SET media_file_id=$2 WHERE source_sha256=$1 AND fulfilled_at IS NULL")
.bind(source_sha256).bind(media_id).execute(pool).await?;
reconcile(pool, None).await
}
/// Runs after imports and from background discovery, including after a restart.
/// Fulfilled requests are retained as receipts so later user removals stay removed.
pub async fn reconcile(pool: &PgPool, user_id: Option<i64>) -> anyhow::Result<()> {
let requests: Vec<(i64, i64, String)> = sqlx::query_as(
"SELECT user_id,playlist_id,input_path FROM furumusic__import_playlist_target WHERE fulfilled_at IS NULL AND ($1::bigint IS NULL OR user_id=$1) ORDER BY user_id,playlist_id,input_path"
).bind(user_id).fetch_all(pool).await?;
for (user_id, playlist_id, path) in requests {
let mut transaction = pool.begin().await?;
let pending: Option<String> = sqlx::query_scalar(
"SELECT input_path FROM furumusic__import_playlist_target WHERE user_id=$1 AND input_path=$2 AND fulfilled_at IS NULL FOR UPDATE"
).bind(user_id).bind(&path).fetch_optional(&mut *transaction).await?;
if pending.is_none() {
continue;
}
let track_ids: Vec<i64> = sqlx::query_scalar(r#"
SELECT DISTINCT track.id FROM furumusic__import_playlist_target request
JOIN furumusic__media_file media ON media.id=request.media_file_id
OR (request.media_file_id IS NULL AND media.sha256_hash=request.source_sha256 AND media.file_type='audio')
JOIN furumusic__track track ON track.audio_file_id=media.id
WHERE request.user_id=$1 AND request.input_path=$2 ORDER BY track.id
"#).bind(user_id).bind(&path).fetch_all(&mut *transaction).await?;
if track_ids.is_empty() {
continue;
}
let mut added = Vec::new();
for track_id in track_ids {
if append_in_transaction(&mut transaction, user_id, playlist_id, track_id).await? {
added.push(track_id);
}
}
sqlx::query("UPDATE furumusic__import_playlist_target SET fulfilled_at=$3 WHERE user_id=$1 AND input_path=$2")
.bind(user_id).bind(path).bind(now_string()).execute(&mut *transaction).await?;
transaction.commit().await?;
record_added(pool, user_id, playlist_id, &added).await;
}
Ok(())
}
fn now_string() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
pub(crate) async fn append_track_to_target_playlist(
pool: &PgPool,
user_id: i64,
playlist_id: i64,
track_id: i64,
) -> anyhow::Result<bool> {
let mut transaction = pool.begin().await?;
let added = append_in_transaction(&mut transaction, user_id, playlist_id, track_id).await?;
transaction.commit().await?;
if added {
record_added(pool, user_id, playlist_id, &[track_id]).await;
}
Ok(added)
}
async fn append_in_transaction(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
user_id: i64,
playlist_id: i64,
track_id: i64,
) -> anyhow::Result<bool> {
let playlist: Option<i64> = sqlx::query_scalar(
r#"SELECT id FROM furumusic__playlist
WHERE id = $1 AND owner_id = $2
FOR UPDATE"#,
)
.bind(playlist_id)
.bind(user_id)
.fetch_optional(&mut **transaction)
.await?;
if playlist.is_none() {
return Ok(false);
}
let already_present: bool = sqlx::query_scalar(
r#"SELECT EXISTS (
SELECT 1 FROM furumusic__playlist_track
WHERE playlist_id = $1 AND track_id = $2
)"#,
)
.bind(playlist_id)
.bind(track_id)
.fetch_one(&mut **transaction)
.await?;
if already_present {
return Ok(false);
}
let position: i64 = sqlx::query_scalar(
r#"SELECT COALESCE(MAX(position), -1)::bigint + 1
FROM furumusic__playlist_track WHERE playlist_id = $1"#,
)
.bind(playlist_id)
.fetch_one(&mut **transaction)
.await?;
let position = i32::try_from(position).context("target playlist has too many tracks")?;
let now = now_string();
sqlx::query(
r#"INSERT INTO furumusic__playlist_track
(playlist_id, track_id, position, added_at, added_by_user_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(playlist_id)
.bind(track_id)
.bind(position)
.bind(&now)
.bind(user_id)
.execute(&mut **transaction)
.await?;
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
.bind(&now)
.bind(playlist_id)
.execute(&mut **transaction)
.await?;
Ok(true)
}
async fn record_added(pool: &PgPool, user_id: i64, playlist_id: i64, track_ids: &[i64]) {
if track_ids.is_empty() {
return;
}
if let Err(error) = crate::federation::devices::record_playlist_tracks_added(
pool,
user_id,
playlist_id,
track_ids,
)
.await
{
tracing::warn!(playlist_id, %error, "federation operation for an automatic import playlist addition was not recorded");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "requires disposable PostgreSQL in FURU_IMPORT_TEST_DATABASE_URL"]
async fn import_destinations_are_durable_owned_and_idempotent() {
let url = std::env::var("FURU_IMPORT_TEST_DATABASE_URL").unwrap();
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.unwrap();
sqlx::raw_sql(r#"
CREATE TEMP TABLE furumusic__playlist(id bigint PRIMARY KEY,owner_id bigint,updated_at text);
CREATE TEMP TABLE furumusic__media_file(id bigint PRIMARY KEY,sha256_hash text,file_type text);
CREATE TEMP TABLE furumusic__track(id bigint PRIMARY KEY,audio_file_id bigint);
CREATE TEMP TABLE furumusic__playlist_track(playlist_id bigint,track_id bigint,position int,added_at text,added_by_user_id bigint);
CREATE TEMP TABLE furumusic__import_playlist_target(user_id bigint,playlist_id bigint REFERENCES furumusic__playlist ON DELETE CASCADE,input_path text,source_sha256 text,media_file_id bigint,fulfilled_at text,PRIMARY KEY(user_id,input_path));
INSERT INTO furumusic__playlist VALUES (10,1,''),(20,2,'');
"#).execute(&pool).await.unwrap();
assert!(validate_target(&pool, 1, Some(20)).await.is_err());
assert!(validate_target(&pool, 1, Some(999)).await.is_err());
validate_target(&pool, 1, None).await.unwrap();
register(&pool, 1, 10, "batch/a.flac", "hash-a")
.await
.unwrap();
register(&pool, 1, 10, "batch/a.flac", "hash-a")
.await
.unwrap();
assert!(
register(&pool, 1, 10, "batch/a.flac", "changed-hash")
.await
.is_err()
);
reconcile(&pool, None).await.unwrap();
let count = || {
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM furumusic__playlist_track")
.fetch_one(&pool)
};
assert_eq!(count().await.unwrap(), 0);
sqlx::raw_sql("INSERT INTO furumusic__media_file VALUES (100,'hash-a','audio'); INSERT INTO furumusic__track VALUES (101,100),(102,100);").execute(&pool).await.unwrap();
reconcile(&pool, Some(2)).await.unwrap();
assert_eq!(count().await.unwrap(), 0);
reconcile(&pool, Some(1)).await.unwrap();
assert_eq!(count().await.unwrap(), 2);
// A second upload of identical audio must not append duplicates.
register(&pool, 1, 10, "torrent/b.flac", "hash-a")
.await
.unwrap();
reconcile(&pool, None).await.unwrap();
assert_eq!(count().await.unwrap(), 2);
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id=101")
.execute(&pool)
.await
.unwrap();
reconcile(&pool, None).await.unwrap();
assert_eq!(count().await.unwrap(), 1);
// Fingerprint matching can map a differently encoded upload to the same media.
register(&pool, 2, 20, "batch/a.mp3", "different-encoding")
.await
.unwrap();
link_media(&pool, "different-encoding", 100).await.unwrap();
assert_eq!(count().await.unwrap(), 3);
let positions: Vec<i32> = sqlx::query_scalar(
"SELECT position FROM furumusic__playlist_track WHERE playlist_id=20 ORDER BY position",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(positions, vec![0, 1]);
// Deleting a destination removes pending work without blocking future imports.
register(&pool, 2, 20, "batch/pending.wav", "pending")
.await
.unwrap();
sqlx::query("DELETE FROM furumusic__playlist WHERE id=20")
.execute(&pool)
.await
.unwrap();
reconcile(&pool, None).await.unwrap();
pool.close().await;
}
}
+3
View File
@@ -75,6 +75,9 @@ impl Job for InboxDiscoverJob {
outcome: run_outcome,
};
if let Err(error) = crate::import_playlists::reconcile(&ctx.pool, None).await {
log.warn(&format!("Could not reconcile import playlists: {error}"));
}
let config = &ctx.config;
if config.agent_inbox_dir.is_empty() {
+4
View File
@@ -1271,6 +1271,10 @@ pub async fn finalize_approved(
}
}
if let Err(error) = crate::import_playlists::link_media(pool, sha256, media_file.id_val()).await {
tracing::warn!(%error, "could not add imported tracks to destination playlists; will retry");
}
if let Err(error) =
crate::youtube::sync_target_playlists_for_imported_media(pool, media_file.id_val()).await
{
+14 -7
View File
@@ -103,6 +103,7 @@ pub struct LocalUploadDto {
pub created_at: String,
pub updated_at: String,
pub completed_at: Option<String>,
pub target_playlist_id: Option<i64>,
}
#[derive(Debug, Clone, FromRow)]
@@ -117,6 +118,7 @@ struct LocalUploadRow {
created_at: String,
updated_at: String,
completed_at: Option<String>,
target_playlist_id: Option<i64>,
}
impl LocalUploadRow {
@@ -130,6 +132,7 @@ impl LocalUploadRow {
created_at: self.created_at.clone(),
updated_at: self.updated_at.clone(),
completed_at: self.completed_at.clone(),
target_playlist_id: self.target_playlist_id,
}
}
}
@@ -141,18 +144,20 @@ pub async fn create(
filename: &str,
size_bytes: u64,
inbox_path: &str,
target_playlist_id: Option<i64>,
) -> anyhow::Result<()> {
let now = now_string();
let created = sqlx::query(
r#"INSERT INTO furumusic__local_upload
(id, user_id, filename, size_bytes, status, inbox_path, error,
created_at, updated_at, completed_at)
VALUES ($1, $2, $3, $4, 'uploading', $5, NULL, $6, $6, NULL)
created_at, updated_at, completed_at, target_playlist_id)
VALUES ($1, $2, $3, $4, 'uploading', $5, NULL, $6, $6, NULL, $7)
ON CONFLICT (id) DO UPDATE SET status='uploading', error=NULL, updated_at=$6
WHERE furumusic__local_upload.user_id=$2
AND furumusic__local_upload.inbox_path=$5
AND furumusic__local_upload.size_bytes=$4
AND furumusic__local_upload.status IN ('uploading', 'failed')"#,
AND furumusic__local_upload.status IN ('uploading', 'failed')
AND furumusic__local_upload.target_playlist_id IS NOT DISTINCT FROM $7"#,
)
.bind(id)
.bind(user_id)
@@ -160,6 +165,7 @@ pub async fn create(
.bind(i64::try_from(size_bytes).unwrap_or(i64::MAX))
.bind(inbox_path)
.bind(now)
.bind(target_playlist_id)
.execute(pool)
.await?;
if created.rows_affected() == 0 {
@@ -178,7 +184,7 @@ pub async fn successful_receipt(
) -> anyhow::Result<Option<LocalUploadDto>> {
let row = sqlx::query_as::<_, LocalUploadRow>(
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
created_at, updated_at, completed_at
created_at, updated_at, completed_at, target_playlist_id
FROM furumusic__local_upload WHERE id=$1 AND user_id=$2 AND inbox_path=$3
AND status NOT IN ('uploading','failed')"#,
)
@@ -205,9 +211,10 @@ pub async fn list(
inbox_dir: &str,
) -> anyhow::Result<Vec<LocalUploadDto>> {
sync_statuses(pool, user_id, inbox_dir).await?;
crate::import_playlists::reconcile(pool, Some(user_id)).await?;
let rows: Vec<LocalUploadRow> = sqlx::query_as(
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
created_at, updated_at, completed_at
created_at, updated_at, completed_at, target_playlist_id
FROM furumusic__local_upload
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
@@ -244,7 +251,7 @@ async fn sync_statuses(pool: &PgPool, user_id: i64, inbox_dir: &str) -> anyhow::
let rows: Vec<LocalUploadRow> = sqlx::query_as(
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
created_at, updated_at, completed_at
created_at, updated_at, completed_at, target_playlist_id
FROM furumusic__local_upload
WHERE user_id = $1 AND status <> 'complete'"#,
)
@@ -353,7 +360,7 @@ async fn update_status(
async fn load(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<LocalUploadRow> {
sqlx::query_as(
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
created_at, updated_at, completed_at
created_at, updated_at, completed_at, target_playlist_id
FROM furumusic__local_upload WHERE id = $1 AND user_id = $2"#,
)
.bind(id)
+1
View File
@@ -5,6 +5,7 @@ mod auth;
mod config;
mod federation;
mod i18n;
mod import_playlists;
mod jobs;
mod lastfm;
mod library_cleanup;
+11 -37
View File
@@ -607,7 +607,7 @@ async fn render_db_metrics(out: &mut String, pool: &PgPool) {
out,
pool,
"furumusic_play_history_total",
"SELECT COUNT(*) FROM furumusic__play_history",
"SELECT COUNT(*) FROM furumusic__listen_event WHERE qualified",
)
.await;
}
@@ -821,6 +821,9 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/admin/setup",
"/admin/v2",
"/admin/v2/api/dashboard",
"/admin/v2/api/statistics",
"/admin/v2/api/users/manage",
"/admin/v2/api/jobs/{name}/schedule",
"/admin/v2/api/reviews",
"/admin/v2/api/reviews/bulk",
"/admin/v2/api/users",
@@ -844,40 +847,6 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
"/admin/v2/api/library/item/upload-image",
"/admin/v2/api/library/bulk",
"/admin/v2/api/library/releases/merge",
"/admin/debug",
"/admin/settings",
"/admin/settings/probe",
"/admin/users",
"/admin/users/new",
"/admin/users/{id}/edit",
"/admin/users/{id}/delete",
"/admin/artists",
"/admin/artists/new",
"/admin/artists/{id}/edit",
"/admin/artists/{id}/delete",
"/admin/artists/{id}/available-covers",
"/admin/artists/{id}/set-image",
"/admin/artists/{id}/upload-image",
"/admin/releases",
"/admin/releases/new",
"/admin/releases/{id}/edit",
"/admin/releases/{id}/delete",
"/admin/media-files",
"/admin/media-files/{id}/delete",
"/admin/jobs",
"/admin/jobs/metadata_backfill/run-options",
"/admin/jobs/{name}/run",
"/admin/jobs/{name}/toggle",
"/admin/jobs/{name}/cron",
"/admin/jobs/{name}/runs/{run_id}",
"/admin/jobs/{name}",
"/admin/reviews/clear",
"/admin/reviews/bulk",
"/admin/reviews",
"/admin/reviews/{id}",
"/admin/reviews/{id}/approve",
"/admin/reviews/{id}/reject",
"/admin/reviews/{id}/requeue",
"/api/player/me",
"/api/player/lastfm/status",
"/api/player/lastfm/connect",
@@ -946,13 +915,18 @@ mod tests {
#[test]
fn known_http_route_matches_declared_dynamic_routes() {
assert_eq!(known_http_route("/admin/debug"), None);
assert_eq!(
known_http_route("/admin/v2/api/statistics"),
Some("/admin/v2/api/statistics")
);
assert_eq!(
known_http_route("/api/player/stream/42"),
Some("/api/player/stream/{track_id}")
);
assert_eq!(
known_http_route("/admin/jobs/metadata_backfill/runs/123"),
Some("/admin/jobs/{name}/runs/{run_id}")
known_http_route("/admin/v2/api/jobs/metadata_backfill/runs/123"),
Some("/admin/v2/api/jobs/{name}/runs/{run_id}")
);
assert_eq!(
known_http_route("/share/playlist/abcDEF123"),
+73 -84
View File
@@ -78,10 +78,6 @@ impl Artist {
Self::objects().all(db).await
}
pub async fn get_by_id(db: &Database, artist_id: i64) -> cot::db::Result<Option<Self>> {
Self::get_by_primary_key(db, Auto::Fixed(artist_id)).await
}
pub async fn create(
db: &Database,
name: &str,
@@ -102,41 +98,9 @@ impl Artist {
Ok(artist)
}
pub async fn update_name(&mut self, db: &Database, name: &str) -> cot::db::Result<()> {
self.name = LimitedString::new(name).unwrap();
self.name_sort = LimitedString::new(&normalize_name(name)).unwrap();
self.updated_at = now_iso();
self.save(db).await
}
pub async fn set_image_file_id(
&mut self,
db: &Database,
file_id: Option<i64>,
) -> cot::db::Result<()> {
self.image_file_id = file_id;
self.updated_at = now_iso();
self.save(db).await
}
pub async fn delete_by_id(db: &Database, artist_id: i64) -> cot::db::Result<()> {
cot::db::query!(Artist, $id == Auto::Fixed(artist_id))
.delete(db)
.await?;
Ok(())
}
pub fn id_val(&self) -> i64 {
self.id.unwrap()
}
pub fn name_str(&self) -> &str {
&self.name
}
pub fn is_hidden(&self) -> bool {
self.is_hidden
}
}
// ---------------------------------------------------------------------------
@@ -286,47 +250,9 @@ impl ReleaseArtist {
.await
}
pub async fn find_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<Vec<Self>> {
cot::db::query!(ReleaseArtist, $artist_id == artist_id)
.all(db)
.await
}
pub async fn count_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<u64> {
cot::db::query!(ReleaseArtist, $artist_id == artist_id)
.count(db)
.await
}
pub async fn set_artists(
db: &Database,
release_id: i64,
artist_ids: &[i64],
) -> cot::db::Result<()> {
// Remove existing links
cot::db::query!(ReleaseArtist, $release_id == release_id)
.delete(db)
.await?;
// Insert new links
for (pos, &aid) in artist_ids.iter().enumerate() {
let mut link = Self {
id: Auto::auto(),
release_id,
artist_id: aid,
position: pos as i32,
};
link.insert(db).await?;
}
Ok(())
}
pub fn artist_id(&self) -> i64 {
self.artist_id
}
pub fn release_id(&self) -> i64 {
self.release_id
}
}
// ---------------------------------------------------------------------------
@@ -377,12 +303,6 @@ pub struct TrackArtist {
}
impl TrackArtist {
pub async fn count_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<u64> {
cot::db::query!(TrackArtist, $artist_id == artist_id)
.count(db)
.await
}
pub async fn create(
db: &Database,
track_id: i64,
@@ -556,10 +476,6 @@ pub struct PlaybackState {
}
impl Track {
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
Self::objects().all(db).await
}
pub async fn create(
db: &Database,
title: &str,
@@ -2852,6 +2768,76 @@ pub mod db_migrations {
&[Operation::custom(add_lastfm_lookup_state).build()];
}
#[cot::db::migrations::migration_op]
async fn create_metadata_fix_history(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db.raw("CREATE TABLE furumusic__metadata_fix_history (id BIGSERIAL PRIMARY KEY, instruction TEXT NOT NULL, before_json TEXT NOT NULL, after_json TEXT NOT NULL, created_release_ids BIGINT[] NOT NULL DEFAULT '{}', created_by BIGINT NOT NULL, created_at VARCHAR(32) NOT NULL, undone_by BIGINT, undone_at VARCHAR(32))").await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0050CreateMetadataFixHistory;
impl migrations::Migration for M0050CreateMetadataFixHistory {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0050_create_metadata_fix_history";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0049_add_lastfm_lookup_state",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_metadata_fix_history).build()];
}
#[cot::db::migrations::migration_op]
async fn add_listen_statistics_indexes(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db.raw("CREATE INDEX IF NOT EXISTS idx_listen_event_time ON furumusic__listen_event (started_at_ms DESC)").await?;
ctx.db.raw("CREATE INDEX IF NOT EXISTS idx_listen_event_content_plays ON furumusic__listen_event (content_id) WHERE qualified").await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0051AddListenStatisticsIndexes;
impl migrations::Migration for M0051AddListenStatisticsIndexes {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0051_add_listen_statistics_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0050_create_metadata_fix_history",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_listen_statistics_indexes).build()];
}
#[cot::db::migrations::migration_op]
async fn add_import_playlist_destinations(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db.raw("ALTER TABLE furumusic__torrent_session ADD COLUMN target_playlist_id BIGINT REFERENCES furumusic__playlist(id) ON DELETE SET NULL").await?;
ctx.db.raw("ALTER TABLE furumusic__local_upload ADD COLUMN target_playlist_id BIGINT REFERENCES furumusic__playlist(id) ON DELETE SET NULL").await?;
ctx.db.raw("CREATE TABLE furumusic__import_playlist_target (user_id BIGINT NOT NULL, playlist_id BIGINT NOT NULL REFERENCES furumusic__playlist(id) ON DELETE CASCADE, input_path TEXT NOT NULL, source_sha256 TEXT NOT NULL, media_file_id BIGINT REFERENCES furumusic__media_file(id) ON DELETE SET NULL, fulfilled_at TEXT, PRIMARY KEY(user_id,input_path))").await?;
ctx.db.raw("CREATE INDEX idx_import_playlist_pending_hash ON furumusic__import_playlist_target(source_sha256) WHERE fulfilled_at IS NULL").await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0052AddImportPlaylistDestinations;
impl migrations::Migration for M0052AddImportPlaylistDestinations {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0052_add_import_playlist_destinations";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0051_add_listen_statistics_indexes",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_import_playlist_destinations).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -2892,5 +2878,8 @@ pub mod db_migrations {
&M0047CreateYouTubeImportMediaLinks,
&M0048AddPlaybackCoordinationColumns,
&M0049AddLastfmLookupState,
&M0050CreateMetadataFixHistory,
&M0051AddListenStatisticsIndexes,
&M0052AddImportPlaylistDestinations,
];
}
+38
View File
@@ -5157,6 +5157,26 @@ async fn local_upload_handler(
));
}
let target_playlist_id = match request.headers().get("x-furumusic-target-playlist-id") {
None => None,
Some(header) => match header
.to_str()
.ok()
.and_then(|s| s.parse::<i64>().ok())
.filter(|id| *id > 0)
{
Some(id) => Some(id),
None => {
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid target playlist ID"));
}
},
};
if let Err(error) =
crate::import_playlists::validate_target(pool, user.id, target_playlist_id).await
{
return Ok(json_error(StatusCode::BAD_REQUEST, &error.to_string()));
}
let batch_header_id = request
.headers()
.get("x-furumusic-upload-batch-id")
@@ -5273,6 +5293,12 @@ async fn local_upload_handler(
.await
.map_err(|err| cot::Error::internal(err.to_string()))?
{
if upload.target_playlist_id != target_playlist_id {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"upload playlist destination cannot change on retry",
));
}
return Json(LocalUploadResponse { ok: true, upload }).into_response();
}
if let Err(err) = crate::local_uploads::create(
@@ -5282,12 +5308,23 @@ async fn local_upload_handler(
&display_name,
bytes.len() as u64,
&inbox_path,
target_playlist_id,
)
.await
{
return Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()));
}
if let Some(playlist_id) = target_playlist_id {
use sha2::{Digest, Sha256};
let sha256 = format!("{:x}", Sha256::digest(&bytes));
if let Err(error) =
crate::import_playlists::register(pool, user.id, playlist_id, &inbox_path, &sha256).await
{
return Ok(json_error(StatusCode::BAD_REQUEST, &error.to_string()));
}
}
let write_result = crate::local_uploads::save_file(&destination, &upload_id, &bytes).await;
if let Err(err) = write_result {
let message = format!("could not save uploaded file: {err}");
@@ -9772,6 +9809,7 @@ impl App for PlayerApp {
pg_pool,
&path.0.id,
json.0.selected_files,
json.0.target_playlist_id,
live_config.agent_inbox_dir,
user.id,
proxy_url.as_deref(),
+9 -82
View File
@@ -80,19 +80,8 @@ impl ScheduledJob {
cron_expression: &str,
) -> cot::db::Result<Self> {
if let Some(mut existing) = Self::get_by_name(db, name).await? {
// Update cron expression and description if they changed
// The registered schedule is a default for new jobs; retain admin overrides.
let mut changed = false;
if existing.cron_expression.as_str() != cron_expression {
tracing::info!(
job = name,
old = existing.cron_expression.as_str(),
new = cron_expression,
"Updating cron expression"
);
existing.cron_expression = LimitedString::new(cron_expression).unwrap();
existing.next_run_at = compute_next_run(cron_expression);
changed = true;
}
if existing.description != description {
existing.description = description.to_owned();
changed = true;
@@ -131,10 +120,6 @@ impl ScheduledJob {
&self.cron_expression
}
pub fn enabled(&self) -> bool {
self.enabled
}
pub fn last_run_at_str(&self) -> &str {
self.last_run_at.as_deref().unwrap_or("")
}
@@ -683,69 +668,6 @@ impl ProcessingStats {
.await?;
Ok(all.into_iter().next())
}
pub async fn list_by_review_ids(
pool: &sqlx::PgPool,
ids: &[i64],
) -> anyhow::Result<HashMap<i64, ProcessingStatsRow>> {
if ids.is_empty() {
return Ok(HashMap::new());
}
// Build comma-separated ID list
let id_list: String = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
let query = format!(
"SELECT pending_review_id, model_name, llm_duration_ms, prompt_tokens, completion_tokens \
FROM furumusic__processing_stats WHERE pending_review_id IN ({id_list})"
);
let rows = sqlx::query_as::<_, ProcessingStatsRow>(&query)
.fetch_all(pool)
.await?;
let map = rows.into_iter().map(|r| (r.pending_review_id, r)).collect();
Ok(map)
}
pub fn model_name_str(&self) -> &str {
&self.model_name
}
pub fn duration_display(&self) -> String {
if self.llm_duration_ms >= 1000 {
format!("{:.1}s", self.llm_duration_ms as f64 / 1000.0)
} else {
format!("{}ms", self.llm_duration_ms)
}
}
pub fn tokens_display(&self) -> String {
format!("{}/{}", self.prompt_tokens, self.completion_tokens)
}
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ProcessingStatsRow {
pub pending_review_id: i64,
pub model_name: String,
pub llm_duration_ms: i64,
pub prompt_tokens: i64,
pub completion_tokens: i64,
}
impl ProcessingStatsRow {
pub fn duration_display(&self) -> String {
if self.llm_duration_ms >= 1000 {
format!("{:.1}s", self.llm_duration_ms as f64 / 1000.0)
} else {
format!("{}ms", self.llm_duration_ms)
}
}
pub fn tokens_display(&self) -> String {
format!("{}/{}", self.prompt_tokens, self.completion_tokens)
}
}
// ---------------------------------------------------------------------------
@@ -1288,8 +1210,13 @@ impl SchedulerHandle {
}
}
// Add new cron job
self.add_cron_job(job_name, new_cron).await?;
// Editing a disabled job must not start it.
let job = ScheduledJob::get_by_name(&self.shared_db, job_name)
.await?
.ok_or_else(|| anyhow::anyhow!("job not found: {job_name}"))?;
if job.enabled {
self.add_cron_job(job_name, new_cron).await?;
}
// Update DB
if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&self.shared_db, job_name).await
@@ -1297,7 +1224,7 @@ impl SchedulerHandle {
sched_job.cron_expression = LimitedString::new(new_cron).unwrap();
sched_job.next_run_at = compute_next_run(new_cron);
sched_job.updated_at = now_iso();
let _ = sched_job.save(&self.shared_db).await;
sched_job.save(&self.shared_db).await?;
}
Ok(())
+178 -4
View File
@@ -58,6 +58,7 @@ pub struct TorrentJobDto {
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub completed_at: Option<String>,
pub target_playlist_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
@@ -93,6 +94,7 @@ pub struct TorrentPreviewRequest {
#[derive(Debug, Deserialize)]
pub struct TorrentStartRequest {
pub target_playlist_id: Option<i64>,
pub selected_files: Vec<usize>,
}
@@ -153,6 +155,7 @@ struct TorrentJob {
created_at: String,
updated_at: String,
completed_at: Option<String>,
target_playlist_id: Option<i64>,
}
#[derive(Debug, FromRow)]
@@ -176,6 +179,7 @@ struct TorrentSessionRow {
created_at: String,
updated_at: String,
completed_at: Option<String>,
target_playlist_id: Option<i64>,
}
impl TorrentSessionRow {
@@ -226,6 +230,7 @@ impl TorrentSessionRow {
let peer_stats = live.map(|l| &l.snapshot.peer_stats);
TorrentJobDto {
target_playlist_id: self.target_playlist_id,
id: self.id.clone(),
name: self.name.clone(),
info_hash: self.info_hash.clone(),
@@ -275,6 +280,7 @@ impl TorrentSessionRow {
status: TorrentJobStatus::from_str(&self.status),
output_dir: temp_root.join(&id).join("download"),
selected_files,
target_playlist_id: self.target_playlist_id,
handle: None,
downloaded_bytes: i64_to_u64(self.downloaded_bytes),
uploaded_bytes: i64_to_u64(self.uploaded_bytes),
@@ -341,6 +347,7 @@ impl TorrentJob {
let peer_stats = live.map(|l| &l.snapshot.peer_stats);
TorrentJobDto {
target_playlist_id: self.target_playlist_id,
id: self.id.clone(),
name: self.name.clone(),
info_hash: self.info_hash.clone(),
@@ -430,7 +437,7 @@ impl TorrentService {
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
files_json, selected_files_json, status, total_size, selected_size,
downloaded_bytes, uploaded_bytes, progress_percent, error,
created_at, updated_at, completed_at
created_at, updated_at, completed_at, target_playlist_id
FROM furumusic__torrent_session
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
@@ -441,6 +448,7 @@ impl TorrentService {
.fetch_all(pool)
.await?;
crate::import_playlists::reconcile(pool, Some(user_id)).await?;
let handles = {
let jobs = self.jobs.lock().await;
jobs.iter()
@@ -604,6 +612,7 @@ impl TorrentService {
status: TorrentJobStatus::Preview,
output_dir,
selected_files,
target_playlist_id: None,
handle: None,
downloaded_bytes: 0,
uploaded_bytes: 0,
@@ -727,6 +736,7 @@ impl TorrentService {
status: TorrentJobStatus::Preview,
output_dir,
selected_files,
target_playlist_id: None,
handle: None,
downloaded_bytes: 0,
uploaded_bytes: 0,
@@ -794,6 +804,7 @@ impl TorrentService {
pool: &PgPool,
id: &str,
selected_files: Vec<usize>,
target_playlist_id: Option<i64>,
inbox_dir: String,
uploader_user_id: i64,
proxy_url: Option<&str>,
@@ -806,6 +817,8 @@ impl TorrentService {
}
let inbox_dir = validate_inbox_dir(&inbox_dir)?;
crate::import_playlists::validate_target(pool, uploader_user_id, target_playlist_id)
.await?;
self.ensure_memory_job(pool, uploader_user_id, id).await?;
let (torrent_bytes, output_dir, selected_size) = {
@@ -823,7 +836,13 @@ impl TorrentService {
bail!("torrent job is already running");
}
validate_selection(&job.files, &selected_files)?;
if job.status != TorrentJobStatus::Preview
&& job.target_playlist_id != target_playlist_id
{
bail!("playlist destination cannot change after a torrent has started");
}
job.status = TorrentJobStatus::Downloading;
job.target_playlist_id = target_playlist_id;
job.selected_files = selected_files.clone();
job.downloaded_bytes = 0;
job.uploaded_bytes = 0;
@@ -1032,9 +1051,14 @@ impl TorrentService {
inbox_dir: &Path,
uploader_user_id: i64,
) -> anyhow::Result<()> {
let (name, files, selected_files, output_dir) = {
// The playlist may have been deleted while this torrent was downloading.
let persisted_target = load_row(pool, uploader_user_id, id)
.await?
.target_playlist_id;
let (name, files, selected_files, output_dir, target_playlist_id) = {
let mut jobs = self.jobs.lock().await;
let job = jobs.get_mut(id).context("torrent job not found")?;
job.target_playlist_id = persisted_target;
job.refresh_progress();
job.status = TorrentJobStatus::Moving;
job.updated_at = now_string();
@@ -1043,6 +1067,7 @@ impl TorrentService {
job.files.clone(),
job.selected_files.clone(),
job.output_dir.clone(),
job.target_playlist_id,
)
};
@@ -1052,6 +1077,7 @@ impl TorrentService {
let destination_root = inbox_dir
.join("user_uploads")
.join(uploader_user_id.to_string())
.join(format!("torrent-{id}"))
.join(sanitize_path_component(&name));
tokio::fs::create_dir_all(&destination_root).await?;
@@ -1064,6 +1090,22 @@ impl TorrentService {
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent).await?;
}
if let Some(playlist_id) = target_playlist_id
&& crate::jobs::inbox_discover::is_audio_file(&file.name)
{
let input_path =
crate::media_paths::path_for_root(&inbox_dir.to_string_lossy(), &destination)
.context("torrent destination escaped inbox")?;
let sha = crate::agent::mover::sha256_file(&source).await?;
crate::import_playlists::register(
pool,
uploader_user_id,
playlist_id,
&input_path,
&sha,
)
.await?;
}
move_file(&source, &destination).await?;
}
@@ -1103,7 +1145,7 @@ async fn load_row(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<Torre
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
files_json, selected_files_json, status, total_size, selected_size,
downloaded_bytes, uploaded_bytes, progress_percent, error,
created_at, updated_at, completed_at
created_at, updated_at, completed_at, target_playlist_id
FROM furumusic__torrent_session
WHERE id = $1 AND user_id = $2"#,
)
@@ -1244,13 +1286,15 @@ async fn mark_job_started(
progress_percent = 0,
error = NULL,
updated_at = $4,
completed_at = NULL
completed_at = NULL,
target_playlist_id = $5
WHERE id = $1"#,
)
.bind(id)
.bind(serde_json::to_string(selected_files)?)
.bind(u64_to_i64(dto.selected_size))
.bind(now_string())
.bind(dto.target_playlist_id)
.execute(pool)
.await?;
Ok(())
@@ -1434,3 +1478,133 @@ async fn move_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
Err(err) => Err(err.into()),
}
}
#[cfg(test)]
mod playlist_tests {
use super::*;
#[tokio::test]
#[ignore = "requires disposable PostgreSQL in FURU_IMPORT_TEST_DATABASE_URL"]
async fn torrent_playlist_survives_restore_and_registers_selected_audio() {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&std::env::var("FURU_IMPORT_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
sqlx::raw_sql(r#"
CREATE TEMP TABLE furumusic__playlist(id bigint PRIMARY KEY,owner_id bigint);
INSERT INTO furumusic__playlist VALUES (10,1);
CREATE TEMP TABLE furumusic__import_playlist_target(user_id bigint,playlist_id bigint,input_path text,source_sha256 text,media_file_id bigint,fulfilled_at text,PRIMARY KEY(user_id,input_path));
CREATE TEMP TABLE furumusic__torrent_session(
id text PRIMARY KEY,user_id bigint,name text,info_hash text,source_kind text,source_label text,torrent_bytes bytea,
files_json text,selected_files_json text,status text,total_size bigint DEFAULT 0,selected_size bigint DEFAULT 0,
downloaded_bytes bigint DEFAULT 0,uploaded_bytes bigint DEFAULT 0,progress_percent float8 DEFAULT 0,
error text,created_at text,updated_at text,completed_at text,target_playlist_id bigint);
"#).execute(&pool).await.unwrap();
let root = std::env::temp_dir().join(format!("furumi-torrent-test-{}", Uuid::new_v4()));
let mut service = TorrentService::new(Arc::new(OnceCell::new()));
service.temp_root = root.join("torrents");
let output = service.temp_root.join("job/download");
tokio::fs::create_dir_all(&output).await.unwrap();
let files: Vec<_> = ["a.flac", "b.mp3", "cover.jpg"]
.iter()
.enumerate()
.map(|(index, name)| TorrentFileDto {
index,
name: name.to_string(),
components: vec![name.to_string()],
length: 3,
selected: index != 1,
})
.collect();
for file in &files {
tokio::fs::write(output.join(&file.name), b"abc")
.await
.unwrap();
}
let mut job = TorrentJob {
id: "job".into(),
user_id: 1,
name: "Soundtrack".into(),
info_hash: "hash".into(),
source_kind: "torrent_file".into(),
source_label: None,
torrent_bytes: vec![],
files,
selected_files: vec![0, 2],
target_playlist_id: None,
output_dir: output,
status: TorrentJobStatus::Preview,
handle: None,
downloaded_bytes: 0,
uploaded_bytes: 0,
progress_percent: 0.0,
error: None,
created_at: now_string(),
updated_at: now_string(),
completed_at: None,
};
sqlx::query("INSERT INTO furumusic__torrent_session (id,user_id,name,info_hash,source_kind,torrent_bytes,files_json,selected_files_json,status,created_at,updated_at) VALUES ('job',1,'Soundtrack','hash','torrent_file',$1,$2,'[0,2]','resolving',$3,$3)")
.bind(Vec::<u8>::new()).bind(serde_json::to_string(&job.files).unwrap()).bind(now_string()).execute(&pool).await.unwrap();
update_resolved_job(&pool, &job).await.unwrap();
job.target_playlist_id = Some(10);
mark_job_started(&pool, "job", &[0, 2], &job.dto())
.await
.unwrap();
let restored = load_row(&pool, 1, "job")
.await
.unwrap()
.into_job(&service.temp_root)
.unwrap();
assert_eq!(restored.target_playlist_id, Some(10));
assert_eq!(restored.dto().target_playlist_id, Some(10));
service.jobs.lock().await.insert("job".into(), restored);
let inbox = root.join("inbox");
service
.finalize_completed(&pool, "job", &inbox, 1)
.await
.unwrap();
let paths: Vec<String> =
sqlx::query_scalar("SELECT input_path FROM furumusic__import_playlist_target")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(paths, vec!["user_uploads/1/torrent-job/Soundtrack/a.flac"]);
assert!(inbox.join(&paths[0]).exists());
assert!(
!inbox
.join("user_uploads/1/torrent-job/Soundtrack/b.mp3")
.exists()
);
assert_eq!(load_row(&pool, 1, "job").await.unwrap().status, "complete");
// Deleting the destination while downloading clears its persisted FK;
// an older in-memory choice must not prevent the audio from being imported.
sqlx::query("UPDATE furumusic__torrent_session SET target_playlist_id=NULL WHERE id='job'")
.execute(&pool)
.await
.unwrap();
sqlx::query("DELETE FROM furumusic__playlist WHERE id=10")
.execute(&pool)
.await
.unwrap();
let source = service.temp_root.join("job/download");
tokio::fs::create_dir_all(&source).await.unwrap();
tokio::fs::write(source.join("a.flac"), b"abc")
.await
.unwrap();
service
.finalize_completed(&pool, "job", &inbox, 1)
.await
.unwrap();
assert_eq!(
service
.memory_job_dto("job")
.await
.unwrap()
.target_playlist_id,
None
);
tokio::fs::remove_dir_all(root).await.unwrap();
pool.close().await;
}
}
-14
View File
@@ -26,11 +26,6 @@ pub struct User {
// ---------------------------------------------------------------------------
impl User {
/// List all users.
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
Self::objects().all(db).await
}
/// Get a user by primary key.
pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result<Option<Self>> {
Self::get_by_primary_key(db, Auto::Fixed(user_id)).await
@@ -121,21 +116,12 @@ impl User {
pub fn username_str(&self) -> &str {
&self.username
}
pub fn email_str(&self) -> String {
self.email
.as_ref()
.map(|e| e.to_string())
.unwrap_or_default()
}
pub fn display_name_str(&self) -> String {
self.display_name
.as_ref()
.map(|d| d.to_string())
.unwrap_or_default()
}
pub fn role_str(&self) -> &str {
&self.role
}
pub fn is_active(&self) -> bool {
self.is_active
}
+6 -82
View File
@@ -2089,91 +2089,15 @@ async fn sync_target_playlist_tracks_for_user(pool: &PgPool, user_id: i64) -> an
.await?;
for (playlist_id, owner_id, track_id, _, _) in candidates {
append_track_to_target_playlist(pool, owner_id, playlist_id, track_id).await?;
}
Ok(())
}
async fn append_track_to_target_playlist(
pool: &PgPool,
user_id: i64,
playlist_id: i64,
track_id: i64,
) -> anyhow::Result<bool> {
let mut transaction = pool.begin().await?;
let playlist: Option<i64> = sqlx::query_scalar(
r#"SELECT id FROM furumusic__playlist
WHERE id = $1 AND owner_id = $2
FOR UPDATE"#,
)
.bind(playlist_id)
.bind(user_id)
.fetch_optional(&mut *transaction)
.await?;
if playlist.is_none() {
transaction.rollback().await?;
return Ok(false);
}
let already_present: bool = sqlx::query_scalar(
r#"SELECT EXISTS (
SELECT 1 FROM furumusic__playlist_track
WHERE playlist_id = $1 AND track_id = $2
)"#,
)
.bind(playlist_id)
.bind(track_id)
.fetch_one(&mut *transaction)
.await?;
if already_present {
transaction.commit().await?;
return Ok(false);
}
let position: i64 = sqlx::query_scalar(
r#"SELECT COALESCE(MAX(position), -1)::bigint + 1
FROM furumusic__playlist_track WHERE playlist_id = $1"#,
)
.bind(playlist_id)
.fetch_one(&mut *transaction)
.await?;
let position = i32::try_from(position).context("target playlist has too many tracks")?;
let now = now_string();
sqlx::query(
r#"INSERT INTO furumusic__playlist_track
(playlist_id, track_id, position, added_at, added_by_user_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(playlist_id)
.bind(track_id)
.bind(position)
.bind(&now)
.bind(user_id)
.execute(&mut *transaction)
.await?;
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
.bind(&now)
.bind(playlist_id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
if let Err(error) = crate::federation::devices::record_playlist_tracks_added(
pool,
user_id,
playlist_id,
&[track_id],
)
.await
{
tracing::warn!(
crate::import_playlists::append_track_to_target_playlist(
pool,
owner_id,
playlist_id,
track_id,
%error,
"federation operation for an automatic YouTube playlist addition was not recorded"
);
)
.await?;
}
Ok(true)
Ok(())
}
async fn refresh_parent(pool: &PgPool, job_id: &str) -> anyhow::Result<()> {
-133
View File
@@ -1,133 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}{% endblock admin_title %}
{% block content %}
<h1>{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}</h1>
<form method="post" action="{% if is_edit %}/admin/artists/{{ form_artist_id }}/edit{% else %}/admin/artists/new{% endif %}">
<table>
<tr>
<td><label for="name">{{ t.artists_name }}</label></td>
<td><input name="name" id="name" value="{{ form_name }}" required style="width:100%"></td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% if is_edit %}
<hr style="margin: 2rem 0;">
<h2>{{ t.artists_image }}</h2>
<div id="artist-image-section" style="margin-top: 1rem;">
<!-- Current image -->
<div style="margin-bottom: 1.5rem;">
{% match current_image_url %}
{% when Some with (url) %}
<img id="current-image" src="{{ url }}" alt="" style="max-width: 200px; max-height: 200px; border-radius: 6px; border: 1px solid #ddd;">
<br>
<button type="button" onclick="removeImage()" style="margin-top: .5rem; padding: .3rem 1rem; cursor: pointer;">{{ t.artists_remove_image }}</button>
{% when None %}
<p style="color: #888;">{{ t.artists_no_image }}</p>
{% endmatch %}
</div>
<!-- Upload custom image -->
<div style="margin-bottom: 1.5rem;">
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_upload_image }}</h3>
<input type="file" id="image-upload" accept="image/*" style="margin-bottom: .5rem;">
<br>
<button type="button" id="upload-btn" onclick="uploadImage()" style="padding: .3rem 1rem; cursor: pointer;" disabled>{{ t.artists_upload }}</button>
</div>
<!-- Pick from album covers -->
<div>
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_pick_cover }}</h3>
<div id="covers-grid" style="display: flex; flex-wrap: wrap; gap: .75rem;">
<p style="color: #888;" id="covers-loading">...</p>
</div>
</div>
</div>
<script>
const artistId = {{ form_artist_id }};
// Enable upload button when file selected
document.getElementById('image-upload').addEventListener('change', function() {
document.getElementById('upload-btn').disabled = !this.files.length;
});
// Upload custom image
function uploadImage() {
const fileInput = document.getElementById('image-upload');
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function() {
const base64 = reader.result.split(',')[1];
fetch('/admin/artists/' + artistId + '/upload-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: base64,
filename: file.name,
mime_type: file.type || 'image/jpeg'
})
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
};
reader.readAsDataURL(file);
}
// Set image from album cover
function setImage(mediaFileId) {
fetch('/admin/artists/' + artistId + '/set-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_file_id: mediaFileId })
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
}
// Remove image
function removeImage() {
fetch('/admin/artists/' + artistId + '/set-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_file_id: null })
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
}
// Load available covers
fetch('/admin/artists/' + artistId + '/available-covers')
.then(function(r) { return r.json(); })
.then(function(covers) {
const grid = document.getElementById('covers-grid');
grid.innerHTML = '';
if (!covers.length) {
grid.innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
return;
}
covers.forEach(function(c) {
const div = document.createElement('div');
div.style.cssText = 'cursor:pointer; text-align:center;';
div.title = c.release_title;
div.innerHTML = '<img src="' + c.cover_url + '" alt="" style="width:100px; height:100px; object-fit:cover; border-radius:4px; border:2px solid #ddd;">'
+ '<br><small style="font-size:.75rem; color:#666;">' + c.release_title.substring(0, 20) + '</small>';
div.onclick = function() { setImage(c.media_file_id); };
grid.appendChild(div);
});
})
.catch(function() {
document.getElementById('covers-grid').innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
});
</script>
{% endif %}
{% endblock content %}
-45
View File
@@ -1,45 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_artists }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.artists_heading }}</h1>
<p style="margin-bottom: 1rem;">
<a href="/admin/artists/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.artists_add }}</a>
</p>
{% if rows.is_empty() %}
<p>{{ t.artists_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.artists_name }}</th>
<th>{{ t.artists_releases }}</th>
<th>{{ t.artists_tracks }}</th>
<th>{{ t.artists_hidden }}</th>
<th>{{ t.artists_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.artist.id_val() }}</td>
<td>{{ row.artist.name_str() }}</td>
<td>
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ row.release_count }}</a>
</td>
<td>{{ row.track_count }}</td>
<td>{{ row.artist.is_hidden() }}</td>
<td>
<a href="/admin/artists/{{ row.artist.id_val() }}/edit">{{ t.artists_edit }}</a>
&nbsp;|&nbsp;
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ t.artists_view_releases }}</a>
&nbsp;|&nbsp;
<form method="post" action="/admin/artists/{{ row.artist.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.artists_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.artists_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock content %}
-82
View File
@@ -1,82 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_debug }}{% endblock admin_title %}
{% block content %}
<style>
.cfg-key { position: relative; display: inline-flex; align-items: center; gap: .35rem; }
.cfg-info-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 50%;
background: #ccc; color: #fff; font-size: 11px; font-weight: 700;
cursor: help; flex-shrink: 0; line-height: 1;
}
.cfg-info-btn:hover { background: #888; }
.cfg-popup {
position: absolute; left: 100%; top: 50%;
transform: translateY(-50%); z-index: 10;
background: #1a1a2e; color: #eee; padding: .55rem .75rem;
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.25);
font-size: .8rem; white-space: nowrap; min-width: 220px;
margin-left: 8px;
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity .15s, visibility 0s .3s;
user-select: text;
}
/* invisible bridge so cursor can travel from icon to popup */
.cfg-popup::before {
content: ''; position: absolute; right: 100%; top: 0; bottom: 0;
width: 12px;
}
.cfg-key:hover .cfg-popup {
opacity: 1; visibility: visible; pointer-events: auto;
transition: opacity .15s, visibility 0s;
}
.cfg-popup dt { color: #999; font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; margin-top: .35rem; }
.cfg-popup dt:first-child { margin-top: 0; }
.cfg-popup dd { margin: .1rem 0 0; }
.cfg-popup code { background: rgba(255,255,255,.12); color: #fff; padding: .05rem .3rem; border-radius: 3px; font-size: .8rem; }
</style>
<h1>{{ t.debug_heading }}</h1>
<h2>{{ t.debug_build_info }}</h2>
<table>
<tr><th>{{ t.debug_field }}</th><th>{{ t.debug_value }}</th></tr>
<tr><td>Package</td><td><code>{{ build.pkg_name }}</code></td></tr>
<tr><td>Version</td><td><code>{{ build.pkg_version }}</code></td></tr>
<tr><td>Profile</td><td><code>{{ build.profile }}</code></td></tr>
<tr><td>Target</td><td><code>{{ build.target }}</code></td></tr>
<tr><td>Rustc</td><td><code>{{ build.rustc_version }}</code></td></tr>
<tr><td>{{ t.debug_db_status }}</td><td><code>{{ db_status }}</code></td></tr>
</table>
<h2>{{ t.debug_app_config }}</h2>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
{% for entry in config_entries %}
<tr>
<td>
<span class="cfg-key">
<code>{{ entry.key }}</code>
<span class="cfg-info-btn">i</span>
<dl class="cfg-popup">
<dt>env var</dt>
<dd><code>{{ entry.env_var }}</code></dd>
<dt>default</dt>
<dd><code>{% if entry.default_value == "" %}(empty){% else %}{{ entry.default_value }}{% endif %}</code></dd>
<dt>source</dt>
<dd><code>{{ entry.source }}</code></dd>
</dl>
</span>
</td>
<td><code>{{ entry.value }}</code></td>
<td><span class="badge badge-{{ entry.source }}">{{ entry.source }}</span></td>
</tr>
{% endfor %}
</table>
{% endblock content %}
-8
View File
@@ -1,8 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_dashboard }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.admin_heading }}</h1>
<p><a href="/admin/debug">{{ t.admin_debug_link }}</a></p>
{% endblock content %}
-120
View File
@@ -1,120 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ job.name_str() }}{% endblock admin_title %}
{% block content %}
<h1>{{ job.name_str() }}</h1>
<table>
<tr><th>{{ t.jobs_description }}</th><td>{{ job.description_str() }}</td></tr>
<tr><th>{{ t.jobs_cron }}</th><td><code>{{ job.cron_expression_str() }}</code></td></tr>
<tr><th>{{ t.jobs_enabled }}</th><td>{% if job.enabled() %}&#9989;{% else %}&#10060;{% endif %}</td></tr>
<tr><th>{{ t.jobs_last_run }}</th><td>{{ job.last_run_at_str() }}</td></tr>
<tr><th>{{ t.jobs_next_run }}</th><td>{{ job.next_run_at_str() }}</td></tr>
</table>
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
{% if job.name_str() != "metadata_backfill" && job.name_str() != "lastfm_popularity" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
{% else %}
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</div>
{% if job.name_str() == "lastfm_popularity" %}
<form method="post" action="/admin/jobs/lastfm_popularity/run-options" style="margin:1rem 0;">
<p>{{ t.jobs_lastfm_light_help }}</p>
<label style="display:block; margin-bottom:1rem;">
<input type="checkbox" name="refresh_existing"> {{ t.jobs_lastfm_full }}
</label>
<button type="submit">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() == "metadata_backfill" %}
<h2>{{ t.jobs_metadata_backfill_options }}</h2>
<form method="post" action="/admin/jobs/metadata_backfill/run-options" style="margin:0 0 1.5rem; padding:1rem; background:#fff; border:1px solid #e0e0e0; border-radius:6px;">
<fieldset style="border:0; margin:0 0 .75rem; padding:0;">
<legend style="font-weight:600; margin-bottom:.5rem;">{{ t.jobs_metadata_backfill_fields }}</legend>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_bitrate" checked> audio_bitrate
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_sample_rate" checked> audio_sample_rate
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_bit_depth" checked> audio_bit_depth
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="duration_seconds" checked> duration_seconds
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="local_genres" checked> local genres from files
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="lastfm_tags" checked> Last.fm tags
</label>
</fieldset>
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
<label style="display:inline-flex; gap:.35rem; align-items:center;">
<input type="radio" name="mode" value="fill_missing" checked> {{ t.jobs_metadata_backfill_fill_missing }}
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center;">
<input type="radio" name="mode" value="overwrite"> {{ t.jobs_metadata_backfill_overwrite }}
</label>
</div>
<button type="submit" style="padding:.45rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_metadata_backfill_run }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<h2>{{ t.jobs_cron }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
</form>
{% endif %}
<h2>{{ t.jobs_run_history }}</h2>
{% if runs.is_empty() %}
<p>No runs yet.</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.jobs_run_status }}</th>
<th>{{ t.jobs_run_started }}</th>
<th>{{ t.jobs_run_duration }}</th>
<th>{{ t.jobs_run_trigger }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for run in runs %}
<tr>
<td>{{ run.id_val() }}</td>
<td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td>
<td>{{ run.started_at_str() }}</td>
<td>{{ run.duration_display() }}</td>
<td>{{ run.trigger_str() }}</td>
<td><a href="/admin/jobs/{{ job.name_str() }}/runs/{{ run.id_val() }}">{{ t.reviews_view }}</a></td>
</tr>
{% endfor %}
</table>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/jobs">&larr; {{ t.jobs_back_to_list }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-processing { background: #d1ecf1; color: #0c5460; }
</style>
{% endblock content %}
-31
View File
@@ -1,31 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.jobs_run_detail }} #{{ run.id_val() }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.jobs_run_detail }} #{{ run.id_val() }}</h1>
<table>
<tr><th>{{ t.jobs_run_status }}</th><td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td></tr>
<tr><th>{{ t.jobs_run_trigger }}</th><td>{{ run.trigger_str() }}</td></tr>
<tr><th>{{ t.jobs_run_started }}</th><td>{{ run.started_at_str() }}</td></tr>
<tr><th>{{ t.jobs_run_duration }}</th><td>{{ run.duration_display() }}</td></tr>
</table>
{% if !run.error_message_str().is_empty() %}
<h2>{{ t.jobs_run_error }}</h2>
<pre style="background:#f8d7da; color:#721c24; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ run.error_message_str() }}</pre>
{% endif %}
{% if !run.log_output_str().is_empty() %}
<h2>{{ t.jobs_run_log }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem; max-height:40em; overflow-y:auto;">{{ run.log_output_str() }}</pre>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/jobs/{{ job_name }}">&larr; {{ t.jobs_back_to_job }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-processing { background: #d1ecf1; color: #0c5460; }
</style>
{% endblock content %}
-46
View File
@@ -1,46 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_jobs }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.jobs_heading }}</h1>
<table>
<tr>
<th>{{ t.jobs_name }}</th>
<th>{{ t.jobs_description }}</th>
<th>{{ t.jobs_cron }}</th>
<th>{{ t.jobs_enabled }}</th>
<th>{{ t.jobs_last_run }}</th>
<th>{{ t.jobs_next_run }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for job in jobs %}
<tr>
<td><a href="/admin/jobs/{{ job.name_str() }}">{{ job.name_str() }}</a></td>
<td>{{ job.description_str() }}</td>
<td><code>{{ job.cron_expression_str() }}</code></td>
<td>{% if job.enabled() %}&#9989;{% else %}&#10060;{% endif %}</td>
<td>{{ job.last_run_at_str() }}</td>
<td>{{ job.next_run_at_str() }}</td>
<td style="display:flex;gap:.3rem;">
{% if job.name_str() == "metadata_backfill" %}
<a href="/admin/jobs/{{ job.name_str() }}" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer; text-decoration:none;">{{ t.jobs_metadata_backfill_options }}</a>
{% else %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
{% else %}
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endblock content %}
-64
View File
@@ -1,64 +0,0 @@
{% extends "base.html" %}
{% block title %}{% block admin_title %}{{ t.nav_admin }}{% endblock admin_title %} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
.admin-version { display: inline-block; margin-left: .35rem; color: #999; font-size: .72rem; font-weight: 500; vertical-align: baseline; }
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
nav.sidebar a:hover { background: #16213e; color: #fff; }
.main-wrap { flex: 1; display: flex; flex-direction: column; }
.topbar { display: flex; justify-content: flex-end; align-items: center; gap: 1rem; padding: .5rem 1.5rem; background: #fff; border-bottom: 1px solid #e0e0e0; }
.user-info { font-size: .85rem; color: #555; }
.lang-switch { display: flex; gap: .25rem; }
.lang-switch a { text-decoration: none; padding: .25rem .5rem; border-radius: 4px; font-size: .9rem; color: #555; }
.lang-switch a:hover { background: #eee; color: #111; }
.lang-switch a.active { font-weight: 700; color: #111; }
.logout-link { text-decoration: none; font-size: .85rem; color: #555; padding: .25rem .5rem; border-radius: 4px; }
.logout-link:hover { background: #eee; color: #111; }
.main { flex: 1; padding: 2rem; overflow-x: auto; }
table { border-collapse: collapse; width: 100%; margin-bottom: 1.5rem; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid #eee; }
th { background: #f0f0f0; font-weight: 600; }
h1 { margin-bottom: 1rem; }
h2 { margin: 1.5rem 0 .5rem; }
code { background: #f4f4f4; padding: .1rem .35rem; border-radius: 3px; font-size: .85em; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 600; letter-spacing: .02em; }
.badge-default { background: #e9ecef; color: #555; }
.badge-database { background: #d1ecf1; color: #0c5460; }
.badge-env { background: #fff3cd; color: #856404; }
</style>
{% endblock head_extra %}
{% block body %}
<nav class="sidebar">
<h2>{{ t.site_name }} {{ t.nav_admin }} <span class="admin-version">v{{ t.app_version() }}</span></h2>
<a href="/admin/">{{ t.nav_dashboard }}</a>
<a href="/admin/artists">{{ t.nav_artists }}</a>
<a href="/admin/releases">{{ t.nav_releases }}</a>
<a href="/admin/media-files">{{ t.nav_media_files }}</a>
<a href="/admin/jobs">{{ t.nav_jobs }}</a>
<a href="/admin/reviews">{{ t.nav_reviews }}</a>
<a href="/admin/users">{{ t.nav_users }}</a>
<a href="/admin/settings">{{ t.nav_settings }}</a>
<a href="/admin/debug">{{ t.nav_debug }}</a>
</nav>
<div class="main-wrap">
<div class="topbar">
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
<div class="lang-switch">
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
</div>
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
</div>
<div class="main">
{% block content %}{% endblock content %}
</div>
</div>
{% endblock body %}
-51
View File
@@ -1,51 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_media_files }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.media_files_heading }}</h1>
{% if rows.is_empty() %}
<p>{{ t.media_files_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.media_files_filename }}</th>
<th>{{ t.media_files_type }}</th>
<th>{{ t.media_files_format }}</th>
<th>{{ t.media_files_size }}</th>
<th>{{ t.media_files_track }}</th>
<th>{{ t.media_files_path }}</th>
<th>{{ t.media_files_created }}</th>
<th>{{ t.media_files_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.media_file.id_val() }}</td>
<td>{{ row.media_file.original_filename_str() }}</td>
<td>{{ row.media_file.file_type_str() }}</td>
<td>{{ row.media_file.audio_format_str() }}</td>
<td>{{ row.media_file.file_size_display() }}</td>
<td>
{% if row.track_title.is_empty() %}
<span class="badge badge-orphan">{{ t.media_files_orphan }}</span>
{% else %}
{{ row.track_title }}
{% endif %}
</td>
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.media_file.file_path_str() }}">{{ row.media_file.file_path_str() }}</td>
<td>{{ row.media_file.created_at_str() }}</td>
<td>
<form method="post" action="/admin/media-files/{{ row.media_file.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.media_files_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.media_files_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<style>
.badge-orphan { background: #fff3cd; color: #856404; }
</style>
{% endblock content %}
-32
View File
@@ -1,32 +0,0 @@
{% if !agent_enabled %}
<p style="color:#999;">{{ t.settings_agent_status_disabled }}</p>
{% else if agent_llm_url.is_empty() %}
<p style="color:#999;">{{ t.settings_agent_status_no_url }}</p>
{% else if agent_probe.ok %}
<div style="border:1px solid #28a745; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#f0fff0;">
<p style="margin:0 0 .5rem; font-weight:bold; color:#28a745;">{{ t.settings_agent_status_ok }}</p>
{% if !agent_probe.model_intro.is_empty() %}
<blockquote style="border-left:3px solid #28a745; padding-left:.75rem; margin:.5rem 0; color:#333; font-style:italic;">{{ agent_probe.model_intro }}</blockquote>
{% endif %}
<table style="font-size:.85rem; margin-top:.5rem;">
{% if !agent_probe.model_name.is_empty() %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_model_name }}</td><td><code>{{ agent_probe.model_name }}</code></td></tr>
{% endif %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_latency }}</td><td>{{ agent_probe.latency_ms }} ms</td></tr>
{% if let Some(pt) = agent_probe.prompt_tokens %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_prompt_tokens }}</td><td>{{ pt }}</td></tr>
{% endif %}
{% if let Some(ct) = agent_probe.completion_tokens %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_completion_tokens }}</td><td>{{ ct }}</td></tr>
{% endif %}
{% if let Some(tps) = agent_probe.tokens_per_sec %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_tokens_per_sec }}</td><td>{{ format!("{:.1}", tps) }}</td></tr>
{% endif %}
</table>
</div>
{% else %}
<div style="border:1px solid #dc3545; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#fff5f5;">
<p style="margin:0 0 .5rem; font-weight:bold; color:#dc3545;">{{ t.settings_agent_status_error }}</p>
<p style="margin:0; font-size:.85rem; color:#666;">{{ agent_probe.error }}</p>
</div>
{% endif %}
-130
View File
@@ -1,130 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}{% endblock admin_title %}
{% block content %}
<h1>{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}</h1>
<form method="post" action="{% if is_edit %}/admin/releases/{{ form_release_id }}/edit{% else %}/admin/releases/new{% endif %}" id="release-form">
<table>
<tr>
<td><label for="title">{{ t.releases_title }}</label></td>
<td><input name="title" id="title" value="{{ form_title }}" required 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 form_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="year">{{ t.releases_year }}</label></td>
<td><input name="year" id="year" type="number" min="1900" max="2100" value="{{ form_year }}" style="width:100%"></td>
</tr>
<tr>
<td><label>{{ t.releases_artists }}</label></td>
<td>
<div id="artist-tags" style="display:flex; flex-wrap:wrap; gap:.3rem; margin-bottom:.5rem;"></div>
<div style="display:flex; gap:.5rem;">
<input list="artist-list" id="artist-input" placeholder="{{ t.releases_select_artist }}" style="flex:1; padding:.4rem;">
<datalist id="artist-list">
{% for a in artists %}
<option value="{{ a.name_str() }}" data-id="{{ a.id_val() }}"></option>
{% endfor %}
</datalist>
<button type="button" onclick="addArtist()" style="padding:.4rem .8rem;">+</button>
</div>
<input type="hidden" name="artist_id" id="artist-ids-hidden" value="">
</td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
<script>
(function() {
// Artist data from server
var allArtists = [
{% for a in artists %}
{ id: {{ a.id_val() }}, name: "{{ a.name_str() }}" },
{% endfor %}
];
// Currently selected artist IDs
var selectedIds = [{% for aid in form_artist_ids %}{{ aid }},{% endfor %}];
var tagsContainer = document.getElementById('artist-tags');
var hiddenInput = document.getElementById('artist-ids-hidden');
var artistInput = document.getElementById('artist-input');
function findArtistByName(name) {
var lower = name.toLowerCase().trim();
for (var i = 0; i < allArtists.length; i++) {
if (allArtists[i].name.toLowerCase() === lower) return allArtists[i];
}
return null;
}
function findArtistById(id) {
for (var i = 0; i < allArtists.length; i++) {
if (allArtists[i].id === id) return allArtists[i];
}
return null;
}
function syncHidden() {
hiddenInput.value = selectedIds.join(',');
}
function renderTags() {
tagsContainer.innerHTML = '';
for (var i = 0; i < selectedIds.length; i++) {
var artist = findArtistById(selectedIds[i]);
if (!artist) continue;
var tag = document.createElement('span');
tag.style.cssText = 'display:inline-flex; align-items:center; gap:.3rem; padding:.2rem .5rem; background:#e9ecef; border-radius:4px; font-size:.85rem;';
tag.textContent = artist.name;
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '\u00d7';
btn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:1rem; color:#c00; padding:0; line-height:1;';
btn.setAttribute('data-id', artist.id);
btn.onclick = function() {
var rid = parseInt(this.getAttribute('data-id'));
selectedIds = selectedIds.filter(function(x) { return x !== rid; });
renderTags();
syncHidden();
};
tag.appendChild(btn);
tagsContainer.appendChild(tag);
}
}
window.addArtist = function() {
var artist = findArtistByName(artistInput.value);
if (!artist) return;
if (selectedIds.indexOf(artist.id) === -1) {
selectedIds.push(artist.id);
renderTags();
syncHidden();
}
artistInput.value = '';
};
// Allow pressing Enter in the artist input to add
artistInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
window.addArtist();
}
});
// Initial render
renderTags();
syncHidden();
})();
</script>
{% endblock content %}
-51
View File
@@ -1,51 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_releases }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.releases_heading }}</h1>
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:1rem; flex-wrap:wrap;">
<a href="/admin/releases/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.releases_add }}</a>
<form method="get" action="/admin/releases" style="display:flex; gap:.5rem; align-items:center;">
<label for="artist_id" style="font-size:.85rem; color:#555;">{{ t.releases_filter_label }}:</label>
<select name="artist_id" id="artist_id" onchange="this.form.submit()" style="padding:.35rem .5rem; border:1px solid #ccc; border-radius:4px;">
<option value="">{{ t.releases_filter_all }}</option>
{% for a in artists %}
<option value="{{ a.id_val() }}"{% match filter_artist_id %}{% when Some with (fid) %}{% if *fid == a.id_val() %} selected{% endif %}{% when None %}{% endmatch %}>{{ a.name_str() }}</option>
{% endfor %}
</select>
</form>
</div>
{% if rows.is_empty() %}
<p>{{ t.releases_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.releases_title }}</th>
<th>{{ t.releases_artists }}</th>
<th>{{ t.releases_type }}</th>
<th>{{ t.releases_year }}</th>
<th>{{ t.releases_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.release.id_val() }}</td>
<td>{{ row.release.title_str() }}</td>
<td>{% if row.artist_names.is_empty() %}<span style="color:#999;">{{ t.releases_no_artist }}</span>{% else %}{{ row.artist_names }}{% endif %}</td>
<td><code>{{ row.release.release_type_str() }}</code></td>
<td>{{ row.release.year_display() }}</td>
<td>
<a href="/admin/releases/{{ row.release.id_val() }}/edit">{{ t.releases_edit }}</a>
&nbsp;|&nbsp;
<form method="post" action="/admin/releases/{{ row.release.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.releases_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.releases_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock content %}
-111
View File
@@ -1,111 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}Review #{{ review.id_val() }}{% endblock admin_title %}
{% block content %}
<h1>Review #{{ review.id_val() }}</h1>
<table>
<tr><th>{{ t.reviews_status }}</th><td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td></tr>
<tr><th>{{ t.reviews_type }}</th><td>{{ review.review_type_str() }}</td></tr>
<tr><th>{{ t.reviews_input_path }}</th><td style="word-break:break-all;">{{ review.input_path_str() }}</td></tr>
<tr><th>{{ t.reviews_confidence }}</th><td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td></tr>
<tr><th>{{ t.reviews_created }}</th><td>{{ review.created_at_str() }}</td></tr>
{% match stats %}
{% when Some with (s) %}
<tr><th>{{ t.reviews_model }}</th><td>{{ s.model_name_str() }}</td></tr>
<tr><th>{{ t.reviews_llm_duration }}</th><td>{{ s.duration_display() }}</td></tr>
<tr><th>{{ t.reviews_tokens }}</th><td>{{ s.tokens_display() }}</td></tr>
{% when None %}
{% endmatch %}
</table>
{% if !error_message.is_empty() %}
<div style="margin: 1rem 0; padding: 1rem; background: #f8d7da; color: #721c24; border-radius: 6px;">
<strong>{{ t.reviews_error }}:</strong> {{ error_message }}
</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;">
<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>
</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() && 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 %}
<p style="margin-top:1rem;"><a href="/admin/reviews">&larr; {{ t.reviews_back_to_list }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-queued { background: #d1ecf1; color: #0c5460; }
.badge-processing { background: #cce5ff; color: #004085; }
</style>
{% endblock content %}
-276
View File
@@ -1,276 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_reviews }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.reviews_heading }}</h1>
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
<a href="/admin/reviews?status=rejected" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "rejected" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_rejected }}</a>
<a href="/admin/reviews?status=queued" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "queued" %} #17a2b8; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_queued }}</a>
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
{% if !rows.is_empty() %}
<span style="flex:1;"></span>
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
</form>
{% endif %}
</div>
{% if rows.is_empty() %}
<p>{{ t.reviews_empty }}</p>
{% else %}
<form id="reviews-bulk-form" method="post" action="/admin/reviews/bulk" style="margin:0;">
<input type="hidden" name="selected_ids" id="selected-review-ids" value="">
<input type="hidden" name="status_filter" value="{{ status_filter }}">
<div class="review-bulk-toolbar">
<button type="button" id="select-shown-reviews" class="review-toolbar-button">{{ t.reviews_select_all }}</button>
<button type="button" id="clear-review-selection" class="review-toolbar-button">{{ t.reviews_clear_selection }}</button>
<button type="submit" name="action" value="delete" class="review-danger-button" disabled>{{ t.reviews_delete_selected }}</button>
<button type="submit" name="action" value="requeue" class="review-primary-button" disabled>{{ t.reviews_requeue_selected }}</button>
<span id="review-selection-summary" class="review-selection-summary">{{ t.reviews_selected_none }}</span>
</div>
<table>
<tr>
<th class="review-select-cell"></th>
<th>ID</th>
<th>{{ t.reviews_status }}</th>
<th>{{ t.reviews_type }}</th>
<th>{{ t.reviews_input_path }}</th>
<th>{{ t.reviews_tags }}</th>
<th>{{ t.reviews_confidence }}</th>
<th>{{ t.reviews_model }}</th>
<th>{{ t.reviews_llm_duration }}</th>
<th>{{ t.reviews_tokens }}</th>
<th>{{ t.reviews_created }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td class="review-select-cell">
<input type="checkbox" class="review-select" value="{{ row.review.id_val() }}" data-status="{{ row.review.status_str() }}" aria-label="Select review {{ row.review.id_val() }}">
</td>
<td><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
<td>{{ row.review.review_type_str() }}</td>
<td class="review-input-path" title="{{ row.review.input_path_str() }}">{{ row.display_input_path }}</td>
<td class="review-tag-cell">
{% for tag in row.media_tags %}
<span class="review-tag review-tag-{{ tag.kind }}">{{ tag.label }}</span>
{% endfor %}
</td>
<td>{% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&row.review.id_val()) %}
{% when Some with (s) %}
<td>{{ s.model_name }}</td>
<td>{{ s.duration_display() }}</td>
<td>{{ s.tokens_display() }}</td>
{% when None %}
<td>-</td>
<td>-</td>
<td>-</td>
{% endmatch %}
<td>{{ row.review.created_at_str() }}</td>
<td>
<a href="/admin/reviews/{{ row.review.id_val() }}">{{ t.reviews_view }}</a>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endif %}
<style>
.review-bulk-toolbar {
margin-bottom: .75rem;
display: flex;
gap: .5rem;
align-items: center;
flex-wrap: wrap;
}
.review-toolbar-button,
.review-danger-button,
.review-primary-button {
padding: .35rem .7rem;
border-radius: 4px;
border: 1px solid #ced4da;
background: #fff;
color: #212529;
cursor: pointer;
}
.review-danger-button {
border-color: #dc3545;
color: #dc3545;
}
.review-primary-button {
border-color: #17a2b8;
color: #0c5460;
}
.review-danger-button:disabled,
.review-primary-button:disabled {
cursor: not-allowed;
opacity: .45;
}
.review-selection-summary {
min-height: 1.7rem;
padding: .35rem .6rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .9rem;
white-space: nowrap;
}
.review-select-cell {
width: 2.25rem;
text-align: center;
}
.review-select {
width: 1rem;
height: 1rem;
}
.review-input-path {
max-width: 34rem;
white-space: normal;
overflow-wrap: anywhere;
}
.review-tag-cell {
max-width: 18rem;
}
.review-tag {
display: inline-block;
margin: .1rem .15rem .1rem 0;
padding: .12rem .35rem;
border: 1px solid #ced4da;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .8rem;
white-space: nowrap;
}
.review-tag-format { border-color: #9ec5fe; background: #e7f1ff; color: #084298; }
.review-tag-bitrate { border-color: #a3cfbb; background: #d1e7dd; color: #0f5132; }
.review-tag-sample { border-color: #ffda6a; background: #fff3cd; color: #664d03; }
.review-tag-depth { border-color: #d0bfff; background: #f0e7ff; color: #3d246c; }
.review-tag-size { border-color: #ced4da; background: #f8f9fa; color: #495057; }
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-queued { background: #d1ecf1; color: #0c5460; }
.badge-processing { background: #cce5ff; color: #004085; }
</style>
<script>
(() => {
const form = document.getElementById("reviews-bulk-form");
if (!form) {
return;
}
const checkboxes = Array.from(form.querySelectorAll(".review-select"));
const selectedIdsInput = document.getElementById("selected-review-ids");
const summary = document.getElementById("review-selection-summary");
const selectShownButton = document.getElementById("select-shown-reviews");
const clearSelectionButton = document.getElementById("clear-review-selection");
const submitButtons = Array.from(form.querySelectorAll("button[type='submit']"));
const selected = new Set();
const statusCounts = new Map();
const labels = {
pending: "{{ t.reviews_filter_pending }}",
approved: "{{ t.reviews_filter_approved }}",
rejected: "{{ t.reviews_filter_rejected }}",
queued: "{{ t.reviews_filter_queued }}",
processing: "{{ t.reviews_filter_processing }}",
auto_approved: "{{ t.reviews_filter_auto_approved }}",
failed: "{{ t.reviews_filter_failed }}"
};
function setStatusCount(status, delta) {
const next = (statusCounts.get(status) || 0) + delta;
if (next > 0) {
statusCounts.set(status, next);
} else {
statusCounts.delete(status);
}
}
function syncControls() {
selectedIdsInput.value = Array.from(selected).join(",");
const total = selected.size;
for (const button of submitButtons) {
button.disabled = total === 0;
}
if (total === 0) {
summary.textContent = "{{ t.reviews_selected_none }}";
return;
}
const parts = Array.from(statusCounts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([status, count]) => `${labels[status] || status}: ${count}`);
summary.textContent = `{{ t.reviews_selected_prefix }}: ${total} (${parts.join(", ")})`;
}
function setChecked(checkbox, checked) {
const id = checkbox.value;
const isSelected = selected.has(id);
checkbox.checked = checked;
if (isSelected === checked) {
return;
}
const status = checkbox.dataset.status || "unknown";
if (checked) {
selected.add(id);
setStatusCount(status, 1);
} else {
selected.delete(id);
setStatusCount(status, -1);
}
}
for (const checkbox of checkboxes) {
checkbox.addEventListener("change", () => {
setChecked(checkbox, checkbox.checked);
syncControls();
});
}
selectShownButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, true);
}
syncControls();
});
clearSelectionButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, false);
}
syncControls();
});
form.addEventListener("submit", (event) => {
syncControls();
if (selected.size === 0) {
event.preventDefault();
alert("{{ t.reviews_none_selected_confirm }}");
return;
}
const action = event.submitter ? event.submitter.value : "";
const message = action === "requeue"
? "{{ t.reviews_requeue_selected_confirm }}"
: "{{ t.reviews_delete_selected_confirm }}";
if (!confirm(message)) {
event.preventDefault();
}
});
syncControls();
})();
</script>
{% endblock content %}
-166
View File
@@ -1,166 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_settings }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.settings_heading }}</h1>
{% if saved %}
<p style="color: green; font-weight: bold; margin-bottom: 1rem;">{{ t.settings_saved }}</p>
{% endif %}
<form method="post" action="/admin/settings">
<h2>{{ t.settings_auth }}</h2>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
<tr>
<td><label for="auth_password_enabled">{{ t.settings_password_login }}</label></td>
<td><input type="checkbox" name="auth_password_enabled" id="auth_password_enabled" value="on"{% if auth_password_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ auth_password_enabled_source }}">{{ auth_password_enabled_source }}</span></td>
</tr>
<tr>
<td><label for="auth_sso_enabled">{{ t.settings_sso_login }}</label></td>
<td><input type="checkbox" name="auth_sso_enabled" id="auth_sso_enabled" value="on"{% if auth_sso_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ auth_sso_enabled_source }}">{{ auth_sso_enabled_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_oidc }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_oidc_help }}</p>
<p style="font-size:.85rem;color:#666;margin-bottom:1rem;">
<strong>{{ t.settings_oidc_callback }}:</strong>
<code id="oidc-callback-url"></code>
</p>
<script>document.getElementById('oidc-callback-url').textContent=location.origin+'/auth/oidc/callback';</script>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
<tr>
<td><label for="oidc_button_text">{{ t.settings_oidc_button }}</label></td>
<td><input name="oidc_button_text" id="oidc_button_text" value="{{ oidc_button_text }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_button_text_source }}">{{ oidc_button_text_source }}</span></td>
</tr>
<tr>
<td><label for="oidc_issuer">oidc_issuer</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_issuer_help }}</span></td>
<td><input name="oidc_issuer" id="oidc_issuer" value="{{ oidc_issuer }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_issuer_source }}">{{ oidc_issuer_source }}</span></td>
</tr>
<tr>
<td><label for="oidc_client_id">oidc_client_id</label></td>
<td><input name="oidc_client_id" id="oidc_client_id" value="{{ oidc_client_id }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_client_id_source }}">{{ oidc_client_id_source }}</span></td>
</tr>
<tr>
<td><label for="oidc_client_secret">oidc_client_secret</label></td>
<td><input name="oidc_client_secret" id="oidc_client_secret" type="password" value="{{ oidc_client_secret }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_client_secret_source }}">{{ oidc_client_secret_source }}</span></td>
</tr>
<tr>
<td><label for="oidc_admin_groups">{{ t.settings_oidc_admin_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_admin_groups_help }}</span></td>
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
</tr>
<tr>
<td><label for="oidc_user_groups">{{ t.settings_oidc_user_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_user_groups_help }}</span></td>
<td><input name="oidc_user_groups" id="oidc_user_groups" value="{{ oidc_user_groups }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_user_groups_source }}">{{ oidc_user_groups_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_api }}</h2>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
<tr>
<td><label for="swagger_enabled">{{ t.settings_swagger }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_swagger_help }}</span></td>
<td><input type="checkbox" name="swagger_enabled" id="swagger_enabled" value="on"{% if swagger_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ swagger_enabled_source }}">{{ swagger_enabled_source }}</span></td>
</tr>
<tr>
<td><label for="lastfm_api_key">{{ t.settings_lastfm_api_key }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_api_key_help }}</span></td>
<td><input type="password" name="lastfm_api_key" id="lastfm_api_key" value="{{ lastfm_api_key }}"></td>
<td><span class="badge badge-{{ lastfm_api_key_source }}">{{ lastfm_api_key_source }}</span></td>
</tr>
<tr>
<td><label for="lastfm_shared_secret">{{ t.settings_lastfm_shared_secret }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_shared_secret_help }}</span></td>
<td><input type="password" name="lastfm_shared_secret" id="lastfm_shared_secret" value="{{ lastfm_shared_secret }}"></td>
<td><span class="badge badge-{{ lastfm_shared_secret_source }}">{{ lastfm_shared_secret_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_agent }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_agent_help }}</p>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
<tr>
<td><label for="agent_enabled">{{ t.settings_agent_enabled }}</label></td>
<td><input type="checkbox" name="agent_enabled" id="agent_enabled" value="on"{% if agent_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ agent_enabled_source }}">{{ agent_enabled_source }}</span></td>
</tr>
<tr>
<td><label for="agent_inbox_dir">{{ t.settings_agent_inbox }}</label></td>
<td><input name="agent_inbox_dir" id="agent_inbox_dir" value="{{ agent_inbox_dir }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_inbox_dir_source }}">{{ agent_inbox_dir_source }}</span></td>
</tr>
<tr>
<td><label for="agent_storage_dir">{{ t.settings_agent_storage }}</label></td>
<td><input name="agent_storage_dir" id="agent_storage_dir" value="{{ agent_storage_dir }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_storage_dir_source }}">{{ agent_storage_dir_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_url">{{ t.settings_agent_llm_url }}</label></td>
<td><input name="agent_llm_url" id="agent_llm_url" value="{{ agent_llm_url }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_url_source }}">{{ agent_llm_url_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_model">{{ t.settings_agent_llm_model }}</label></td>
<td><input name="agent_llm_model" id="agent_llm_model" value="{{ agent_llm_model }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_model_source }}">{{ agent_llm_model_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_auth">{{ t.settings_agent_llm_auth }}</label></td>
<td><input name="agent_llm_auth" id="agent_llm_auth" type="password" value="{{ agent_llm_auth }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_auth_source }}">{{ agent_llm_auth_source }}</span></td>
</tr>
<tr>
<td><label for="agent_confidence_threshold">{{ t.settings_agent_threshold }}</label></td>
<td><input name="agent_confidence_threshold" id="agent_confidence_threshold" value="{{ agent_confidence_threshold }}" style="width:6em"></td>
<td><span class="badge badge-{{ agent_confidence_threshold_source }}">{{ agent_confidence_threshold_source }}</span></td>
</tr>
<tr>
<td><label for="agent_context_limit">{{ t.settings_agent_context }}</label></td>
<td><input name="agent_context_limit" id="agent_context_limit" value="{{ agent_context_limit }}" style="width:6em"></td>
<td><span class="badge badge-{{ agent_context_limit_source }}">{{ agent_context_limit_source }}</span></td>
</tr>
<tr>
<td><label for="agent_concurrency">{{ t.settings_agent_concurrency }}</label></td>
<td><input name="agent_concurrency" id="agent_concurrency" value="{{ agent_concurrency }}" type="number" min="1" max="32" style="width:6em"></td>
<td><span class="badge badge-{{ agent_concurrency_source }}">{{ agent_concurrency_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_agent_status }}</h2>
<div hx-get="/admin/settings/probe" hx-trigger="load" hx-swap="innerHTML">
<p style="color:#999;">
<span class="htmx-indicator" style="display:inline;">
&#9696; {{ t.settings_agent_status_loading }}...
</span>
</p>
</div>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% endblock content %}
-40
View File
@@ -1,40 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}{% endblock admin_title %}
{% block content %}
<h1>{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}</h1>
<form method="post" action="{% if is_edit %}/admin/users/{{ form_user_id }}/edit{% else %}/admin/users/new{% endif %}">
<table>
<tr>
<td><label for="username">{{ t.users_username }}</label></td>
<td><input name="username" id="username" value="{{ form_username }}" required style="width:100%"></td>
</tr>
<tr>
<td><label for="email">{{ t.users_email }}</label></td>
<td><input name="email" id="email" type="email" value="{{ form_email }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="display_name">{{ t.users_display_name }}</label></td>
<td><input name="display_name" id="display_name" value="{{ form_display_name }}" style="width:100%"></td>
</tr>
<tr>
<td><label for="password">{{ t.login_password }}</label></td>
<td>
<input name="password" id="password" type="password"{% if !is_edit %} required{% endif %} style="width:100%">
{% if is_edit %}<br><small style="color:#888;">{{ t.users_password_hint }}</small>{% endif %}
</td>
</tr>
<tr>
<td><label for="role">{{ t.users_role }}</label></td>
<td>
<select name="role" id="role" style="width:100%; padding:.4rem;">
<option value="user"{% if form_role == "user" %} selected{% endif %}>user</option>
<option value="admin"{% if form_role == "admin" %} selected{% endif %}>admin</option>
</select>
</td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% endblock content %}
-37
View File
@@ -1,37 +0,0 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_users }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.users_heading }}</h1>
<p style="margin-bottom: 1rem;">
<a href="/admin/users/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.users_add }}</a>
</p>
<table>
<tr>
<th>{{ t.users_username }}</th>
<th>{{ t.users_email }}</th>
<th>{{ t.users_display_name }}</th>
<th>{{ t.users_role }}</th>
<th>{{ t.users_active }}</th>
<th>{{ t.users_actions }}</th>
</tr>
{% for u in users %}
<tr>
<td>{{ u.username_str() }}</td>
<td>{{ u.email_str() }}</td>
<td>{{ u.display_name_str() }}</td>
<td>{{ u.role_str() }}</td>
<td>{{ u.is_active() }}</td>
<td>
<a href="/admin/users/{{ u.id_val() }}/edit">{{ t.users_edit }}</a>
&nbsp;|&nbsp;
<form method="post" action="/admin/users/{{ u.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.users_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.users_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock content %}
+662 -56
View File
@@ -183,6 +183,35 @@ button {
padding: 18px;
}
.repair-shell { display:grid;grid-template-columns:290px minmax(0,1fr);gap:14px;height:calc(100dvh - 116px);min-height:520px; }
.repair-side,.repair-work { min-height:0;display:flex;flex-direction:column;gap:12px; }
.repair-side .panel,.repair-plan,.repair-chat { min-height:0;display:flex;flex-direction:column; }
.repair-side .panel { flex:1; }
.repair-scroll { overflow:auto;min-height:0;padding:12px; }
.repair-work .repair-plan { flex:1; }
.repair-work .repair-chat { height:220px;flex:none; }
.repair-group { margin:12px 0 5px;color:var(--text-secondary);font-size:11px;font-weight:800;text-transform:uppercase; }
.repair-entity { display:flex;align-items:flex-start;gap:8px;padding:6px 0;font-size:12px; }
.repair-entity span { overflow-wrap:anywhere; }
.repair-search-results { max-height:160px;overflow:auto;border:1px solid var(--border-color);border-radius:6px;margin-top:6px; }
.repair-search-results button { display:block;width:100%;padding:8px;background:transparent;color:var(--text-primary);text-align:left;cursor:pointer; }
.repair-search-results button:hover { background:var(--bg-hover); }
.repair-diff { width:100%;border-collapse:collapse;font-size:12px; }
.repair-diff th,.repair-diff td { text-align:left;border-bottom:1px solid var(--border-color);padding:7px;vertical-align:top; }
.repair-diff th { color:var(--text-subdued);font-size:10px;text-transform:uppercase; }
.repair-diff input,.repair-diff select { width:100%;min-width:90px;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:5px;padding:6px; }
.repair-diff .compact { min-width:65px;max-width:90px; }
.repair-message { padding:7px 9px;border-radius:7px;max-width:86%;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere; }
.repair-message.user { background:#16452a;align-self:flex-end; }
.repair-message.assistant { background:var(--bg-elevated);align-self:flex-start; }
.repair-composer { border-top:1px solid var(--border-color);padding:9px;display:flex;gap:8px;align-items:end; }
.repair-composer textarea { flex:1;min-width:0;resize:vertical;max-height:100px;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:6px;padding:8px; }
.repair-thinking { display:flex;align-items:center;gap:8px;color:var(--amber);font-size:12px;padding:10px; }
.repair-thinking::before { content:"";width:10px;height:10px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:repair-spin .8s linear infinite; }
@keyframes repair-spin { to { transform:rotate(360deg); } }
.repair-section-title { margin:16px 0 6px;font-size:12px;font-weight:800;color:var(--text-secondary); }
.repair-history { font-size:11px;line-height:1.5; }
.stats-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
@@ -417,6 +446,7 @@ tbody tr:hover {
.col-tags { width: 250px; }
.col-time { width: 132px; }
.users-table { min-width: 1080px; table-layout: auto; }
.users-table-wrap {
max-height: calc(100vh - 300px);
}
@@ -1979,11 +2009,58 @@ tbody tr:hover {
color: var(--text-primary);
font-size: 12px;
}
.statistics-filters { padding: 20px; }
.statistics-toolbar { align-items: end; flex-wrap: wrap; margin-top: 18px; }
.statistics-toolbar label { display: grid; gap: 7px; color: var(--text-subdued); font-size: 12px; }
.statistics-toolbar .search { width: 200px; }
.statistics-chips { flex-wrap: wrap; margin-top: 12px; }
.statistics-note { font-size: 12px; line-height: 1.7; margin: 14px 0 0; }
.statistics-error { color: #fca5a5; margin-top: 12px; }
.statistics-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 18px 0; }
.statistics-panel { margin-top: 20px; overflow: hidden; }
.statistics-chart { display: flex; align-items: end; gap: 12px; overflow-x: auto; padding: 24px; min-height: 190px; }
.statistics-bar-column { min-width: 58px; flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 11px; }
.statistics-bar-column small { white-space: nowrap; color: var(--text-subdued); font-size: 10px; }
.statistics-bar { width: 100%; max-width: 60px; background: #6bd6a0; border-radius: 4px 4px 0 0; }
.statistics-table { table-layout: auto; min-width: 720px; }
.statistics-table td { vertical-align: top; }
.statistics-link { border: 0; padding: 0; background: none; color: #8ee4b4; cursor: pointer; text-align: left; font: inherit; }
.statistics-link:hover { text-decoration: underline; }
.statistics-link:disabled { color: inherit; cursor: default; text-decoration: none; }
.artist-stat-link { margin-right: 10px; font-size: 12px; }
.statistics-service-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 24px; padding: 20px; }
.statistics-service-row { display: flex; justify-content: space-between; gap: 16px; margin-top: 10px; font-size: 13px; }
@media (max-width: 800px) { .statistics-ranking-head { align-items: stretch; flex-direction: column; gap: 12px; } .statistics-toolbar .search { width: 160px; } }
@media (max-width: 800px) {
html:has(.statistics-view), body:has(.statistics-view) { min-width: 0; }
.statistics-view { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.statistics-view .sidebar { border-right: 0; border-bottom: 1px solid var(--border-color); }
.statistics-view .sidebar .brand, .statistics-view .sidebar .nav-label, .statistics-view .sidebar .nav-group:last-child { display: none; }
.statistics-view .sidebar .nav-group { display: flex; overflow-x: auto; gap: 4px; padding: 8px; }
.statistics-view .sidebar .nav-btn { width: auto; flex: none; white-space: nowrap; padding: 10px; }
.statistics-view .sidebar .nav-count { display: none; }
.statistics-view .topbar { height: auto; padding: 12px; flex-wrap: wrap; }
.statistics-view .page-title p { line-height: 1.5; }
.statistics-view .content { padding: 12px; }
.statistics-view .panel-head { flex-wrap: wrap; gap: 12px; }
.statistics-view .statistics-filters { padding: 14px; }
.statistics-view .statistics-toolbar { gap: 12px; }
.statistics-view .statistics-toolbar label { flex: 1; min-width: 135px; }
.statistics-view .statistics-toolbar .search { width: 100%; }
.statistics-view .statistics-ranking-head .toolbar { flex-wrap: wrap; }
.statistics-view .statistics-ranking-head .search { width: 100%; }
.statistics-view .statistics-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.statistics-view .action-strip { flex-wrap: wrap; gap: 10px; }
}
</style>
{% endblock head_extra %}
{% block content %}
<div class="admin-shell" x-data="adminV2()" x-init="init()" x-cloak>
<div class="admin-shell" :class="{'statistics-view': activeView === 'statistics'}" x-data="adminV2()" x-cloak>
<aside class="sidebar">
<div class="brand">
<div class="brand-title">
@@ -2012,8 +2089,9 @@ tbody tr:hover {
</button>
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="openTools()">
<i data-lucide="wrench"></i>
<span>Future Tools</span>
<span>Library Tools</span>
</button>
<button class="nav-btn" :class="{active: activeView === 'statistics'}" @click="openStatistics()"><i data-lucide="chart-no-axes-combined"></i><span>Statistics</span></button>
<button class="nav-btn" :class="{active: activeView === 'users'}" @click="openUsers()">
<i data-lucide="users"></i>
<span>Users</span>
@@ -2057,10 +2135,6 @@ tbody tr:hover {
<i data-lucide="refresh-cw"></i>
Refresh
</button>
<a class="btn" href="/admin/debug">
<i data-lucide="bug"></i>
Debug
</a>
<a class="btn" href="/">
<i data-lucide="music-2"></i>
Player
@@ -2100,26 +2174,30 @@ tbody tr:hover {
<div class="action-strip">
<div class="toolbar">
<button class="btn" @click="selectVisibleReviews()">
<button class="btn" @click="selectVisibleReviews()" :disabled="reviewBulkBusy">
<i data-lucide="check-square"></i>
Select visible
</button>
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
<button class="btn" @click="selectReviewFilter()" :disabled="reviewBulkBusy || reviews.total === 0">
<i data-lucide="list-checks"></i>
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
</button>
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
<button class="btn" @click="clearReviewSelection()" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="x"></i>
Clear
</button>
</div>
<div class="toolbar">
<span class="selection-summary" x-text="reviewSelectionSummary()"></span>
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="selectedReviewCount() === 0">
<button class="btn primary" @click="bulkReviews('approve')" :disabled="reviewBulkBusy || selectedPendingReviewCount() === 0">
<i data-lucide="check"></i>
<span x-text="reviewBulkBusy && reviewBulkAction === 'approve' ? 'Approving…' : 'Approve selected'"></span>
</button>
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="rotate-ccw"></i>
Requeue selected
</button>
<button class="btn danger" @click="bulkReviews('delete')" :disabled="selectedReviewCount() === 0">
<button class="btn danger" @click="bulkReviews('delete')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="trash-2"></i>
Delete selected
</button>
@@ -2143,7 +2221,7 @@ tbody tr:hover {
<template x-for="row in reviews.items" :key="row.id">
<tr @click="openReview(row)">
<td class="col-check" @click.stop>
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" />
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" :disabled="reviewBulkBusy" />
</td>
<td><span class="badge" :class="row.status" x-text="row.status"></span></td>
<td>
@@ -2260,7 +2338,7 @@ tbody tr:hover {
<div class="job-facts">
<div class="job-fact">
<span>Cron</span>
<span :title="activeJob?.cron_expression || ''" x-text="cronLabel(activeJob)"></span>
<span :title="activeJob?.cron_expression || ''" x-text="cronLabel(activeJob)"></span><button class="btn" @click="scheduleDraft = activeJob.cron_expression; scheduleEditing = true">Edit schedule</button><form x-show="scheduleEditing" @submit.prevent="saveSchedule()" class="toolbar"><input class="search" aria-label="Cron schedule" x-model="scheduleDraft" required><button class="btn" type="submit">Save schedule</button><button class="btn" type="button" @click="scheduleEditing = false">Cancel</button></form>
</div>
<div class="job-fact">
<span>Next</span>
@@ -2457,6 +2535,59 @@ tbody tr:hover {
</div>
</div>
<div class="content" x-show="activeView === 'statistics'">
<section class="panel statistics-filters">
<div class="panel-title"><strong>Listening statistics</strong><span>Explore an artist or track to see exactly who listened. Dates and charts use UTC.</span></div>
<div class="toolbar statistics-toolbar">
<label>Period<select class="search" x-model="statisticsPeriod" @change="statisticsSetPeriod()"><option value="today">Today</option><option value="7">7 days</option><option value="30">30 days</option><option value="all">All time</option><option value="custom">Custom dates</option></select></label>
<label x-show="statisticsPeriod === 'custom'">From<input class="search" type="date" x-model="statisticsFilter.from" @change="statisticsReload()"></label>
<label x-show="statisticsPeriod === 'custom'">Through<input class="search" type="date" x-model="statisticsFilter.to" @change="statisticsReload()"></label>
<label>Playback source<select class="search" x-model="statisticsFilter.source" @change="statisticsReload()"><option value="all">All devices</option><option value="instance">This instance</option><option value="synced">Synced devices</option></select></label>
<label>Find a user<input class="search" placeholder="Name, username or email" x-model="statisticsUserSearch" @input.debounce.300ms="statisticsFindUsers()"></label>
</div>
<div class="toolbar" x-show="statisticsUserSearch"><template x-for="user in statisticsUserOptions" :key="user.id"><button class="btn" @click="statisticsSelectUser(user.id, user.display_name || user.username)" x-text="user.display_name || user.username"></button></template><span class="muted" x-show="!statisticsUserOptions.length">No matching users</span></div>
<div class="toolbar statistics-chips">
<button class="btn" x-show="statisticsFilter.user_id" @click="statisticsClearFilter('user_id')" x-text="'User: ' + statisticsUserLabel + ' ×'"></button>
<button class="btn" x-show="statisticsFilter.artist" @click="statisticsClearFilter('artist')" x-text="'Artist: ' + statisticsFilter.artist + ' ×'"></button>
<button class="btn" x-show="statisticsFilter.content_id" @click="statisticsClearFilter('content_id')" x-text="'Track: ' + statisticsTrackLabel + ' ×'"></button>
<button class="btn" x-show="statisticsFilter.user_id || statisticsFilter.artist || statisticsFilter.content_id" @click="statisticsClearSelection()">All listeners and music</button>
</div>
<p class="muted statistics-note">A play counts after a natural finish, half the track, or four minutes. Short listens are recorded from five seconds. Synced device history is shown separately from this instance.</p>
<p class="statistics-error" role="alert" x-show="statisticsError" x-text="statisticsError"></p>
<span class="muted" role="status" x-show="statisticsLoading">Loading statistics…</span>
</section>
<div x-show="statisticsData && !statisticsError" :aria-busy="statisticsLoading" :style="statisticsLoading ? 'opacity: .55; pointer-events: none' : ''">
<section class="stats-strip statistics-summary">
<template x-for="cell in statisticsCards()" :key="cell.label"><div class="stat-cell"><div class="stat-value" x-text="cell.value"></div><div class="stat-label" x-text="cell.label"></div></div></template>
</section>
<section class="panel statistics-panel">
<div class="panel-head"><div class="panel-title"><strong>Listening over time</strong><span x-text="statisticsData?.daily.unit === 'month' ? 'Monthly totals · UTC' : 'Daily totals · UTC'"></span></div></div>
<div class="statistics-chart" role="img" aria-label="Plays over time; each bar is labelled with its date and number of plays">
<template x-for="day in (statisticsData?.daily.items || [])" :key="day.bucket"><div class="statistics-bar-column" :title="day.bucket.slice(0,10) + ': ' + day.plays + ' plays, ' + day.listeners + ' listeners'"><span x-text="fmt(day.plays)"></span><div class="statistics-bar" :style="'height:' + Math.max(2, 120 * day.plays / statisticsChartMax()) + 'px'"></div><small x-text="day.bucket.slice(0,10)"></small></div></template>
<div class="empty" x-show="!(statisticsData?.daily.items || []).length">No listening activity in this period</div>
</div>
</section>
<section class="panel statistics-panel">
<div class="panel-head statistics-ranking-head"><div class="panel-title"><strong x-text="statisticsFilter.artist || statisticsFilter.content_id ? 'Popularity and listeners for this selection' : 'Popularity and listeners'"></strong><span>Click an artist or track to see its listeners; click a listener for their history.</span></div>
<div class="toolbar"><select class="search" x-model="statisticsKind" @change="statisticsRankPage = 1; loadStatistics()"><option value="artists">Artists</option><option value="tracks">Tracks</option><option value="releases">Releases</option><option value="users">Listeners</option></select><input class="search" placeholder="Search this ranking" x-model="statisticsSearch" @input.debounce.300ms="statisticsRankPage = 1; loadStatistics()"></div>
</div>
<div class="table-wrap"><table class="statistics-table"><thead><tr><th x-text="statisticsKind === 'users' ? 'Listener' : 'Name'"></th><th>Plays</th><th>Listeners</th><th>Listened time</th><th>Last play</th></tr></thead><tbody><template x-for="item in (statisticsData?.ranking.items || [])" :key="item.key"><tr><td><button class="statistics-link" :disabled="statisticsKind === 'releases'" @click="statisticsExplore(item)" x-text="item.label"></button><div class="secondary-line" x-text="item.detail"></div></td><td x-text="fmt(item.plays)"></td><td x-text="fmt(item.listeners)"></td><td x-text="durationApprox(item.listened_seconds)"></td><td x-text="statisticsDate(item.last_play_ms)"></td></tr></template></tbody></table><div class="empty" x-show="!(statisticsData?.ranking.items || []).length">No plays match this selection</div></div>
<div class="action-strip"><span x-text="statisticsRange('ranking')"></span><div class="toolbar"><button class="btn" @click="statisticsRankPage--; loadStatistics()" :disabled="statisticsRankPage <= 1">Previous</button><button class="btn" @click="statisticsRankPage++; loadStatistics()" :disabled="!statisticsHasNext('ranking')">Next</button></div></div>
</section>
<section class="panel statistics-panel">
<div class="panel-head"><div class="panel-title"><strong>Listening history</strong><span>All matching events, including tracks no longer in the library. Artist names reflect the metadata recorded at the time.</span></div><select class="search" x-model="statisticsHistoryKind" @change="statisticsHistoryPage = 1; loadStatistics()"><option value="all">All events</option><option value="plays">Counted plays</option><option value="short">Short listens</option></select></div>
<div class="table-wrap"><table class="statistics-table"><thead><tr><th>When</th><th>Listener</th><th>Track / artist</th><th>Listened</th><th>Result</th><th>Device</th></tr></thead><tbody><template x-for="play in (statisticsData?.history.items || [])" :key="play.user_id + ':' + play.listen_id"><tr><td x-text="statisticsDate(play.started_at_ms)"></td><td><button class="statistics-link" @click="statisticsSelectUser(play.user_id, play.user_name)" x-text="play.user_name"></button></td><td><button class="statistics-link" @click="statisticsSelectTrack(play.content_id, play.title)" x-text="play.title"></button><div class="secondary-line"><template x-for="name in play.artist_names" :key="name"><button class="statistics-link artist-stat-link" @click="statisticsSelectArtist(name)" x-text="name"></button></template></div><div class="secondary-line" x-text="play.release_title"></div></td><td x-text="durationApprox(play.listened_ms / 1000) + (play.track_duration_ms ? ' / ' + durationApprox(play.track_duration_ms / 1000) : '')"></td><td><span class="badge" :class="play.qualified ? 'ok' : 'disabled'" x-text="play.qualified ? 'play' : 'short'"></span><div class="secondary-line" x-text="play.ended_reason"></div></td><td><span x-text="play.device_name"></span><div class="secondary-line" x-text="play.source === 'instance' ? 'This instance' : 'Synced device'"></div></td></tr></template></tbody></table><div class="empty" x-show="!(statisticsData?.history.items || []).length">No listening events match this selection</div></div>
<div class="action-strip"><span x-text="statisticsRange('history')"></span><div class="toolbar"><button class="btn" @click="statisticsHistoryPage--; loadStatistics()" :disabled="statisticsHistoryPage <= 1">Previous</button><button class="btn" @click="statisticsHistoryPage++; loadStatistics()" :disabled="!statisticsHasNext('history')">Next</button></div></div>
</section>
<section class="panel statistics-panel">
<div class="panel-head"><div class="panel-title"><strong>Instance overview</strong><span>Current library and service totals, independent of the listening filters above.</span></div></div>
<section class="stats-strip statistics-summary"><template x-for="cell in statisticsInstanceCards()" :key="cell.label"><div class="stat-cell"><div class="stat-value" x-text="cell.value"></div><div class="stat-label" x-text="cell.label"></div></div></template></section>
<div class="statistics-service-grid"><template x-for="group in ['formats', 'reviews', 'jobs', 'scrobbles']" :key="group"><div><strong x-text="({formats:'Audio formats', reviews:'Processing queue', jobs:'Job runs', scrobbles:'Last.fm outbox'})[group]"></strong><template x-for="row in (statisticsData?.instance[group] || [])" :key="row.label"><div class="statistics-service-row"><span x-text="row.label"></span><strong x-text="fmt(row.count)"></strong></div></template><div class="muted" x-show="!(statisticsData?.instance[group] || []).length">No records</div></div></template></div>
<div class="statistics-service-grid"><template x-for="disk in runtime.storage || []" :key="disk.label"><div><strong x-text="disk.label"></strong><p class="muted" x-text="disk.free_bytes == null ? 'Storage unavailable' : formatBytes(disk.free_bytes) + ' free of ' + formatBytes(disk.total_bytes)"></p></div></template></div>
</section>
</div>
</div>
<div class="content" x-show="activeView === 'users'">
<section class="stats-strip">
<div class="stat-cell">
@@ -2475,6 +2606,7 @@ tbody tr:hover {
<span x-text="usersSubtitle()"></span>
</div>
<div class="toolbar">
<button class="btn primary" @click="editUser()">Add user</button>
<input class="search" placeholder="Search users" x-model="userSearch" @input.debounce.350ms="loadUsers()" />
<button class="btn" @click="loadUsers(false)">
<i data-lucide="refresh-cw"></i>
@@ -2488,7 +2620,7 @@ tbody tr:hover {
<tr>
<th class="user-status-column">Status</th>
<th>User</th>
<th class="user-role-column">Role</th>
<th>Plays</th><th>Listened time</th><th>Last play</th><th class="user-role-column">Role</th>
<th class="user-active-column">Account</th>
<th class="user-seen-column">Last activity</th>
</tr>
@@ -2503,7 +2635,7 @@ tbody tr:hover {
<div class="primary-line" x-text="user.display_name || user.username"></div>
<div class="secondary-line" x-text="user.email ? user.username + ' · ' + user.email : user.username"></div>
</td>
<td><span x-text="user.role"></span></td>
<td x-text="fmt(user.plays)"></td><td x-text="durationApprox(user.listened_seconds)"></td><td x-text="user.last_play_ms ? statisticsDate(user.last_play_ms) : '—'"></td><td><span x-text="user.role"></span></td>
<td>
<span class="badge" :class="user.is_active ? 'ok' : 'disabled'" x-text="user.is_active ? 'active' : 'disabled'"></span>
</td>
@@ -2531,28 +2663,55 @@ tbody tr:hover {
</div>
<div class="content" x-show="activeView === 'tools'">
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>Future Tools</strong>
<span>Reserved operation slots for complex library maintenance</span>
</div>
<div class="repair-shell">
<aside class="repair-side">
<section class="panel">
<div class="panel-head"><div class="panel-title"><strong>Metadata Repair</strong><span>Select problematic entities</span></div></div>
<div class="repair-scroll">
<div class="toolbar"><select class="search" x-model="repairSearchKind" :disabled="repairBusy"><option value="artists">Artists</option><option value="releases">Releases</option><option value="tracks">Tracks</option></select></div>
<input class="search" style="width:100%;margin-top:8px" placeholder="Search library…" x-model="repairSearch" @input.debounce.300ms="searchRepair()" :disabled="repairBusy" />
<div class="repair-search-results" x-show="repairSearchResults.length">
<template x-for="item in repairSearchResults" :key="item.kind + ':' + item.id"><button type="button" @click="addRepairEntity(item)" x-text="item.title + (item.subtitle ? ' · ' + item.subtitle : '')"></button></template>
</div>
<div class="repair-group">Selected and related · <span x-text="repairIncludedCount()"></span></div>
<div class="muted" x-show="!repairExpanded && repairSelected.length === 0">Search and select an artist, release, or track.</div>
<template x-for="kind in ['artists','releases','tracks']" :key="kind">
<div x-show="repairRows(kind).length">
<div class="repair-group" x-text="kind"></div>
<template x-for="item in repairRows(kind)" :key="kind + item.id">
<label class="repair-entity"><input type="checkbox" :checked="repairIncluded[kind + ':' + item.id] !== false" @change="setRepairIncluded(kind,item.id,$event.target.checked)" :disabled="repairBusy" /><span x-text="(item.name || item.title) + ' #' + item.id"></span></label>
</template>
</div>
</template>
<div class="toolbar" style="margin-top:12px"><button class="btn" @click="expandRepair()" :disabled="repairSelected.length === 0 || repairBusy">Show related</button><button class="btn" @click="clearRepair()" :disabled="repairBusy">Clear</button></div>
<div class="muted" style="margin-top:8px">Uncheck any related item to exclude it from the plan. Audio files stay in their current folders.</div>
</div>
</section>
<section class="panel" style="max-height:160px"><div class="panel-head"><div class="panel-title"><strong>History</strong><span>Recent applied plans</span></div></div><div class="repair-scroll repair-history"><template x-for="entry in repairHistory" :key="entry.id"><div class="repair-entity"><span style="flex:1" x-text="'#' + entry.id + ' · ' + entry.instruction.slice(0,55) + (entry.undone_at ? ' · undone' : '')"></span><button class="btn" x-show="!entry.undone_at" @click="undoRepair(entry.id)" :disabled="repairBusy">Undo</button></div></template><div class="muted" x-show="repairHistory.length === 0">No applied plans yet.</div></div></section>
</aside>
<div class="repair-work">
<section class="panel repair-plan">
<div class="panel-head"><div class="panel-title"><strong>Proposed changes</strong><span x-text="repairIncomplete ? (repairBusy ? 'Processing tracks: ' : 'Paused at track: ') + repairTrackOffset + ' / ' + repairTrackTotal : (repairPlan ? 'Review and edit before applying' : 'The agent’s plan will appear here')"></span></div><div class="toolbar"><span class="selection-summary" x-show="repairPlan" x-text="repairChangeCount() + ' changes'"></span><button class="btn primary" x-show="repairPlan" @click="applyRepair()" :disabled="repairBusy || repairIncomplete || !repairChangeCount()">Apply plan</button></div></div>
<div class="repair-scroll" :style="repairBusy ? 'pointer-events:none' : ''">
<div class="repair-thinking" x-show="repairBusy && repairStage === 'thinking'">Agent is reviewing the selected metadata…</div>
<div class="empty" x-show="!repairPlan && !(repairBusy && repairStage === 'thinking')">Select entities and describe the correction in the chat below.</div>
<template x-if="repairPlan"><div>
<div class="repair-section-title" x-show="repairPlan.questions.length">Questions to review</div>
<template x-for="question in repairPlan.questions" :key="question"><div class="repair-entity" style="color:var(--amber)" x-text="question"></div></template>
<div class="repair-section-title" x-show="repairPlan.artists.length">Artists</div>
<table class="repair-diff" x-show="repairPlan.artists.length"><thead><tr><th>Before</th><th>After</th><th></th></tr></thead><tbody><template x-for="(edit,index) in repairPlan.artists" :key="edit.id"><tr><td x-text="repairBefore('artists',edit.id)?.name || ''"></td><td><input x-model="edit.name" /></td><td><button class="btn" @click="repairPlan.artists.splice(index,1)">Remove</button></td></tr></template></tbody></table>
<div class="repair-section-title" x-show="repairPlan.releases.length">Releases</div>
<table class="repair-diff" x-show="repairPlan.releases.length"><thead><tr><th>Before</th><th>After</th><th>Type</th><th>Year</th><th>Artist IDs</th><th></th></tr></thead><tbody><template x-for="(edit,index) in repairPlan.releases" :key="edit.id || edit.key"><tr><td><div x-text="edit.id ? (repairBefore('releases',edit.id)?.title || '') : 'New release'"></div><small class="muted" x-show="edit.id" x-text="edit.id ? [repairBefore('releases',edit.id)?.release_type,repairBefore('releases',edit.id)?.year].filter(x => x != null).join(' · ') : ''"></small></td><td><input x-model="edit.title" /></td><td><select x-model="edit.release_type"><template x-for="type in ['album','single','ep','compilation','mixtape','live','soundtrack','remix','demo']" :key="type"><option :value="type" x-text="type"></option></template></select></td><td><input class="compact" type="number" x-model.number="edit.year" /></td><td><input :value="edit.artist_ids.join(', ')" @change="edit.artist_ids = parseRepairIds($event.target.value)" /></td><td><button class="btn" @click="repairPlan.releases.splice(index,1)">Remove</button></td></tr></template></tbody></table>
<div class="toolbar" x-show="repairPlan"><button class="btn" @click="addRepairRelease()">+ New release</button></div>
<div class="repair-section-title" x-show="repairPlan.tracks.length">Tracks</div>
<table class="repair-diff" x-show="repairPlan.tracks.length"><thead><tr><th>Before</th><th>After</th><th>Release</th><th>#</th><th>Disc</th><th>Year</th><th>Artist IDs</th><th></th></tr></thead><tbody><template x-for="(edit,index) in repairPlan.tracks" :key="edit.id"><tr><td><div x-text="repairBefore('tracks',edit.id)?.title || ''"></div><small class="muted" x-text="'#' + (repairBefore('tracks',edit.id)?.track_number ?? '—') + ' · release #' + (repairBefore('tracks',edit.id)?.release_id || '')"></small></td><td><input x-model="edit.title" /></td><td><select x-model="edit.target_release"><template x-for="release in repairTargetReleases()" :key="release.value"><option :value="release.value" x-text="release.label"></option></template></select></td><td><input class="compact" type="number" x-model.number="edit.track_number" /></td><td><input class="compact" type="number" x-model.number="edit.disc_number" /></td><td><input class="compact" type="number" x-model.number="edit.year" /></td><td><input :value="edit.artist_ids.join(', ')" @change="edit.artist_ids = parseRepairIds($event.target.value)" /></td><td><button class="btn" @click="repairPlan.tracks.splice(index,1)">Remove</button></td></tr></template></tbody></table>
<div class="toolbar"><button class="btn" @click="addRepairTrack()">+ Track edit</button></div>
</div></template>
</div>
</section>
<section class="panel repair-chat"><div class="repair-scroll" style="display:flex;flex-direction:column;gap:7px" x-ref="repairChat"><template x-for="(turn,index) in repairChat" :key="index"><div class="repair-message" :class="turn.role" x-text="turn.content"></div></template><div class="muted" x-show="repairChat.length === 0">Describe what should change. The agent will respond in your language.</div></div><form class="repair-composer" @submit.prevent="sendRepair()"><textarea rows="2" placeholder="Describe the metadata problem…" x-model="repairDraft" @keydown.ctrl.enter.prevent="sendRepair()"></textarea><button class="btn primary" type="submit" :disabled="repairBusy || !repairDraft.trim() || !repairIncludedCount()" x-text="repairIncomplete && repairDraft.trim() === repairPendingInstruction ? 'Retry' : 'Send'"></button></form></section>
</div>
<div style="padding:14px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px">
<button class="btn" style="height:72px" @click="mockAction('Artist merge wizard placeholder')">
<i data-lucide="git-merge"></i>
Artist merge
</button>
<button class="btn" style="height:72px" @click="mockAction('Release split/move tracks placeholder')">
<i data-lucide="scissors"></i>
Split release
</button>
<button class="btn" style="height:72px" @click="mockAction('Metadata enrichment rerun placeholder')">
<i data-lucide="sparkles"></i>
Enrich metadata
</button>
</div>
</section>
</div>
</div>
<div class="content" x-show="activeView === 'settings'">
@@ -3171,12 +3330,21 @@ tbody tr:hover {
</div>
</main>
<div class="modal-backdrop" x-show="userEditorOpen" x-transition @click.self="userEditorOpen = false">
<section class="modal user-modal"><div class="modal-head"><strong x-text="userDraft.id ? 'Edit user' : 'Add user'"></strong><button class="btn" @click="userEditorOpen = false">Close</button></div>
<form class="modal-body" @submit.prevent="saveUser()">
<template x-for="field in ['username', 'email', 'display_name', 'password']" :key="field"><label class="field"><span x-text="field === 'password' && userDraft.id ? 'Password (leave blank to keep)' : field.replace('_', ' ')"></span><input class="search" :type="field === 'password' ? 'password' : (field === 'email' ? 'email' : 'text')" :autocomplete="field === 'password' ? 'new-password' : 'off'" :required="field === 'username' || (field === 'password' && !userDraft.id)" maxlength="255" x-model="userDraft[field]"></label></template>
<label class="field">Role<select class="search" x-model="userDraft.role"><option value="user">User</option><option value="admin">Administrator</option></select></label>
<button class="btn primary" type="submit" :disabled="userSaving">Save user</button>
</form></section>
</div>
<div class="modal-backdrop" x-show="userModalOpen && activeUserDetail" x-transition @click.self="userModalOpen = false">
<section class="modal user-modal">
<div class="modal-head">
<div class="panel-title">
<strong x-text="activeUserDetail ? (activeUserDetail.user.display_name || activeUserDetail.user.username) : 'User'"></strong>
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail.user) + ' · ' + activeUserDetail.user.role : ''"></span>
<strong x-text="activeUserDetail ? (activeUserDetail?.user.display_name || activeUserDetail?.user.username) : 'User'"></strong>
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail?.user) + ' · ' + activeUserDetail?.user.role : ''"></span>
</div>
<button class="icon-btn" @click="userModalOpen = false">
<i data-lucide="x"></i>
@@ -3192,19 +3360,19 @@ tbody tr:hover {
<section class="user-summary-grid">
<div class="user-summary-card">
<span>Status</span>
<strong x-text="userStatusLabel(activeUserDetail.user)"></strong>
<strong x-text="userStatusLabel(activeUserDetail?.user)"></strong>
</div>
<div class="user-summary-card">
<span>Last activity</span>
<strong x-text="userLastSeenLabel(activeUserDetail.user)"></strong>
<strong x-text="userLastSeenLabel(activeUserDetail?.user)"></strong>
</div>
<div class="user-summary-card">
<span>Account</span>
<strong x-text="activeUserDetail.user.is_active ? 'active' : 'disabled'"></strong>
<strong x-text="activeUserDetail?.user.is_active ? 'active' : 'disabled'"></strong>
</div>
<div class="user-summary-card">
<span>Last.fm</span>
<strong x-text="activeUserDetail.stats.lastfm_connected ? 'connected' : 'not connected'"></strong>
<strong x-text="activeUserDetail?.stats.lastfm_connected ? 'connected' : 'not connected'"></strong>
</div>
</section>
<section class="stats-strip user-stats-grid">
@@ -3218,13 +3386,14 @@ tbody tr:hover {
<div class="field">
<label>Profile</label>
<div class="user-profile-facts">
<div><span>Username</span><strong x-text="activeUserDetail.user.username"></strong></div>
<div><span>Email</span><strong x-text="activeUserDetail.user.email || 'not set'"></strong></div>
<div><span>Display name</span><strong x-text="activeUserDetail.user.display_name || 'not set'"></strong></div>
<div><span>Role</span><strong x-text="activeUserDetail.user.role"></strong></div>
<div><span>Username</span><strong x-text="activeUserDetail?.user.username"></strong></div>
<div><span>Email</span><strong x-text="activeUserDetail?.user.email || 'not set'"></strong></div>
<div><span>Display name</span><strong x-text="activeUserDetail?.user.display_name || 'not set'"></strong></div>
<div><span>Role</span><strong x-text="activeUserDetail?.user.role"></strong></div>
</div>
</div>
</div>
<div class="toolbar" style="margin:16px 0"><button class="btn primary" @click="openStatistics(activeUserDetail?.user)">Full history and statistics</button><button class="btn" @click="editUser(activeUserDetail?.user)">Edit user</button><button class="btn" @click="deleteUser(activeUserDetail?.user)">Delete user</button></div>
<div x-show="userModalTab === 'activity'">
<div class="user-activity-list">
<template x-for="play in (activeUserDetail?.recent_plays || [])" :key="play.history_id">
@@ -3723,7 +3892,7 @@ tbody tr:hover {
<label>Available release images</label>
</div>
<div class="cover-grid" x-show="editorDetail && editorDetail.available_covers && editorDetail.available_covers.length">
<template x-for="cover in editorDetail.available_covers" :key="cover.media_file_id">
<template x-for="cover in (editorDetail?.available_covers || [])" :key="cover.media_file_id">
<button class="cover-option" type="button" :class="{active: editorDetail.current_image_url && editorDetail.current_image_url.includes('/cover/' + cover.media_file_id + '/')}" @click="setLibraryImage(cover.media_file_id)">
<img :src="cover.cover_url" alt="" />
<span x-text="cover.release_title"></span>
@@ -3769,6 +3938,192 @@ function adminV2() {
runtime: { agent: {}, storage: [], node: {} },
libraryOverview: {},
reviews: { items: [], total: 0, total_all: 0, limit: 80, offset: 0, status_counts: [] },
statisticsData: null,
statisticsLoading: false,
statisticsError: '',
statisticsRequest: 0,
statisticsPeriod: '30',
statisticsFilter: { from: '', to: '', source: 'all', user_id: '', artist: '', content_id: '' },
statisticsKind: 'artists',
statisticsSearch: '',
statisticsRankPage: 1,
statisticsHistoryPage: 1,
statisticsHistoryKind: 'all',
statisticsUserSearch: '',
statisticsUserOptions: [],
statisticsUserLabel: '',
statisticsTrackLabel: '',
openStatistics(user = null) {
this.activeView = 'statistics';
this.userModalOpen = false;
this.setRoute('#statistics');
if (user) {
this.statisticsFilter.user_id = user.id;
this.statisticsUserLabel = user.display_name || user.username;
this.statisticsFilter.artist = '';
this.statisticsFilter.content_id = '';
this.statisticsPeriod = 'all';
this.statisticsKind = 'artists';
this.statisticsFilter.source = 'all';
this.statisticsSearch = '';
this.statisticsHistoryKind = 'all';
this.statisticsUserSearch = '';
}
this.statisticsSetPeriod();
},
statisticsSetPeriod() {
if (this.statisticsPeriod === 'all') {
this.statisticsFilter.from = 'all';
this.statisticsFilter.to = '';
} else if (this.statisticsPeriod !== 'custom') {
const now = new Date();
this.statisticsFilter.to = now.toISOString().slice(0,10);
now.setUTCDate(now.getUTCDate() - (this.statisticsPeriod === 'today' ? 0 : Number(this.statisticsPeriod) - 1));
this.statisticsFilter.from = now.toISOString().slice(0,10);
} else if (this.statisticsFilter.from === 'all') {
this.statisticsFilter.from = '';
}
this.statisticsReload();
},
statisticsReload() {
this.statisticsRankPage = 1;
this.statisticsHistoryPage = 1;
return this.loadStatistics();
},
async loadStatistics() {
const requestId = ++this.statisticsRequest;
this.statisticsLoading = true;
this.statisticsError = '';
const params = new URLSearchParams();
for (const [key, value] of Object.entries(this.statisticsFilter)) {
if (value !== '' && value != null) params.set(key, value);
}
params.set('kind', this.statisticsKind);
params.set('search', this.statisticsSearch);
params.set('rank_page', this.statisticsRankPage);
params.set('history_page', this.statisticsHistoryPage);
params.set('history_kind', this.statisticsHistoryKind);
try {
const data = await this.request(`${this.apiBase}/statistics?${params}`);
if (requestId === this.statisticsRequest) this.statisticsData = data;
} catch (error) {
if (requestId === this.statisticsRequest) this.statisticsError = error.message;
} finally {
if (requestId === this.statisticsRequest) this.statisticsLoading = false;
this.icons();
}
},
async statisticsFindUsers() {
const search = this.statisticsUserSearch.trim();
if (!search) { this.statisticsUserOptions = []; return; }
try {
const data = await this.request(`${this.apiBase}/users?limit=20&search=${encodeURIComponent(search)}`);
if (search === this.statisticsUserSearch.trim()) this.statisticsUserOptions = data.items;
} catch (error) { this.showToast(error.message); }
},
statisticsSelectUser(id, name) {
this.statisticsFilter.user_id = id;
this.statisticsUserLabel = name;
this.statisticsUserSearch = '';
this.statisticsUserOptions = [];
this.statisticsSearch = '';
this.statisticsKind = 'artists';
return this.statisticsReload();
},
statisticsSelectArtist(name) {
this.statisticsFilter.artist = name;
this.statisticsKind = 'users';
this.statisticsSearch = '';
return this.statisticsReload();
},
statisticsSelectTrack(id, name) {
this.statisticsFilter.content_id = id;
this.statisticsTrackLabel = name;
this.statisticsKind = 'users';
this.statisticsSearch = '';
return this.statisticsReload();
},
statisticsExplore(item) {
if (this.statisticsKind === 'users') return this.statisticsSelectUser(Number(item.key), item.label);
if (this.statisticsKind === 'artists') return this.statisticsSelectArtist(item.label);
if (this.statisticsKind === 'tracks') return this.statisticsSelectTrack(item.key, item.label);
},
statisticsClearFilter(key) {
this.statisticsFilter[key] = '';
this.statisticsSearch = '';
return this.statisticsReload();
},
statisticsClearSelection() {
for (const key of ['user_id', 'artist', 'content_id']) this.statisticsFilter[key] = '';
this.statisticsSearch = '';
return this.statisticsReload();
},
statisticsCards() {
const s = this.statisticsData?.summary || {};
return [
{ label: 'Counted plays', value: this.fmt(s.plays || 0) },
{ label: 'Unique listeners', value: this.fmt(s.listeners || 0) },
{ label: 'Listened time · all events', value: this.durationApprox(s.listened_seconds || 0) },
{ label: 'Unique tracks played', value: this.fmt(s.tracks || 0) },
{ label: 'Finished tracks', value: this.fmt(s.completed || 0) },
{ label: 'Short listens', value: this.fmt(s.short_listens || 0) },
{ label: 'Plays on this instance', value: this.fmt(s.instance_plays || 0) },
{ label: 'Plays on synced devices', value: this.fmt(s.synced_plays || 0) }
];
},
statisticsInstanceCards() {
const s = this.statisticsData?.instance || {};
return ['users', 'tracks', 'releases', 'artists', 'playlists'].map(key => ({ label: key, value: this.fmt(s[key] || 0) }))
.concat([{ label: 'Media files', value: this.formatBytes(s.media_bytes || 0) }]);
},
statisticsChartMax() { return Math.max(1, ...(this.statisticsData?.daily.items || []).map(d => Number(d.plays))); },
statisticsDate(ms) { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; },
statisticsRange(key) {
const page = this.statisticsData?.[key];
if (!page?.total) return '0 results';
return `${(page.page - 1) * page.limit + 1}–${Math.min(page.page * page.limit, page.total)} of ${this.fmt(page.total)}`;
},
statisticsHasNext(key) { const p = this.statisticsData?.[key]; return p && p.page * p.limit < p.total; },
userEditorOpen: false,
userSaving: false,
userDraft: {id:null, username:'', email:'', display_name:'', password:'', role:'user'},
scheduleEditing: false,
scheduleDraft: '',
editUser(user = null) {
this.userModalOpen = false;
this.userDraft = {id:user?.id || null, username:user?.username || '', email:user?.email || '', display_name:user?.display_name || '', password:'', role:user?.role || 'user'};
this.userEditorOpen = true;
},
async saveUser() {
if (this.userSaving) return;
this.userSaving = true;
try {
await this.request(`${this.apiBase}/users/manage`, {method:'POST', body:JSON.stringify({...this.userDraft, action:this.userDraft.id ? 'update' : 'create'})});
this.userDraft.password = '';
this.userEditorOpen = false;
await this.loadUsers(false);
this.showToast('User saved');
} catch (error) { this.showToast(error.message); }
finally { this.userSaving = false; }
},
async deleteUser(user) {
if (!confirm(`Delete user ${user.username}?`)) return;
try {
await this.request(`${this.apiBase}/users/manage`, {method:'POST', body:JSON.stringify({action:'delete', id:user.id})});
this.userModalOpen = false;
await this.loadUsers(false);
} catch (error) { this.showToast(error.message); }
},
async saveSchedule() {
try {
await this.request(`${this.apiBase}/jobs/${encodeURIComponent(this.activeJobName)}/schedule`, {method:'POST', body:JSON.stringify({cron:this.scheduleDraft})});
this.scheduleEditing = false;
await this.loadJobs();
} catch (error) { this.showToast(error.message); }
},
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
usersLoading: false,
userSearch: '',
@@ -3778,6 +4133,8 @@ function adminV2() {
reviewFilter: { status: null, search: '' },
selectedReviewIds: {},
reviewSelectionScope: 'ids',
reviewBulkBusy: false,
reviewBulkAction: null,
activeReview: null,
reviewDraft: {
title: '',
@@ -3847,6 +4204,27 @@ function adminV2() {
mergeDetails: [],
mergeArtistSearch: '',
mergeDraft: { release_ids: [], target_release_id: null, title: '', release_type: 'album', year: '', hidden: 'false', cover_file_id: null, artist_ids: [], tracks: [] },
repairSearchKind: 'releases',
repairSearch: '',
repairSearchResults: [],
repairSearchToken: 0,
repairSelected: [],
repairExpanded: null,
repairIncluded: {},
repairDraft: '',
repairChat: [],
repairPlan: null,
repairSnapshot: null,
repairInstruction: '',
repairBusy: false,
repairStage: '',
repairIncomplete: false,
repairTrackOffset: 0,
repairTrackTotal: 0,
repairPendingInstruction: '',
repairReference: '',
repairExpandedReference: '',
repairHistory: [],
settings: { values: {}, sources: {}, youtube_cookie_files: [], lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
settingsDraft: {
auth_password_enabled: false,
@@ -3926,6 +4304,7 @@ function adminV2() {
},
async refreshAll() {
if (this.routeReady && this.activeView === 'statistics') await this.loadStatistics();
this.loading = true;
try {
const data = await this.request(`${this.apiBase}/dashboard`);
@@ -3956,6 +4335,8 @@ function adminV2() {
]);
} else if (this.activeView === 'users') {
await Promise.allSettled([this.loadUsers(false)]);
} else if (this.activeView === 'statistics') {
return;
} else if (this.activeView === 'settings') {
await Promise.allSettled([
this.loadFederation(false),
@@ -3993,6 +4374,8 @@ function adminV2() {
this.libraryKind = nextKind;
} else if (view === 'settings') {
this.activeView = 'settings';
} else if (view === 'statistics') {
this.activeView = 'statistics';
} else if (view === 'users') {
this.activeView = 'users';
} else if (view === 'tools') {
@@ -4030,11 +4413,15 @@ function adminV2() {
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
await this.loadSettingsProbe(false);
}
} else if (this.activeView === 'statistics') {
if (updateRoute) this.setRoute('#statistics');
this.statisticsSetPeriod();
} else if (this.activeView === 'users') {
if (updateRoute) this.setRoute('#users');
await this.loadUsers(false);
} else if (this.activeView === 'tools' && updateRoute) {
this.setRoute('#tools');
} else if (this.activeView === 'tools') {
if (updateRoute) this.setRoute('#tools');
await this.loadRepairHistory();
}
},
@@ -4073,6 +4460,212 @@ function adminV2() {
openTools() {
this.activeView = 'tools';
this.setRoute('#tools');
this.loadRepairHistory();
},
async searchRepair() {
const token = ++this.repairSearchToken;
const search = this.repairSearch.trim();
if (!search) { this.repairSearchResults = []; return; }
try {
const params = new URLSearchParams({kind:this.repairSearchKind,search,limit:'25',offset:'0'});
const result = await this.request(`${this.apiBase}/library?${params}`);
if (token === this.repairSearchToken) this.repairSearchResults = result.items || [];
} catch (error) { this.showToast(error.message); }
},
addRepairEntity(item) {
if (this.repairBusy) return;
if (this.repairSelected.some(x => x.kind === item.kind && Number(x.id) === Number(item.id))) return;
this.repairSelected.push({kind:item.kind,id:Number(item.id),title:item.title});
this.repairExpanded = null;
this.repairPlan = null;
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairPendingInstruction = '';
this.repairReference = '';
this.repairExpandedReference = '';
this.repairSearch = '';
this.repairSearchResults = [];
},
clearRepair() {
this.repairSelected = [];
this.repairExpanded = null;
this.repairIncluded = {};
this.repairPlan = null;
this.repairSnapshot = null;
this.repairChat = [];
this.repairDraft = '';
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairTrackTotal = 0;
this.repairPendingInstruction = '';
this.repairReference = '';
this.repairExpandedReference = '';
},
repairSelection(expanded = false) {
const selection = {artists:[],releases:[],tracks:[]};
if (expanded && this.repairExpanded) {
for (const kind of Object.keys(selection)) {
for (const item of this.repairExpanded.snapshot[kind] || []) {
if (this.repairIncluded[kind + ':' + item.id] !== false) selection[kind].push(item.id);
}
}
} else {
for (const item of this.repairSelected) if (this.repairIncluded[item.kind + ':' + item.id] !== false) selection[item.kind].push(item.id);
}
return selection;
},
repairRows(kind) {
if (this.repairExpanded) return this.repairExpanded.snapshot[kind] || [];
return this.repairSelected.filter(item => item.kind === kind);
},
repairIncludedCount() {
return Object.values(this.repairSelection(true)).reduce((total, ids) => total + ids.length, 0);
},
setRepairIncluded(kind,id,checked) {
if (this.repairBusy) return;
this.repairIncluded[kind + ':' + id] = checked;
this.repairPlan = null;
this.repairSnapshot = null;
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairPendingInstruction = '';
this.repairReference = '';
this.repairExpandedReference = '';
},
async expandRepair() {
if (this.repairSelected.length === 0) return;
this.repairBusy = true;
this.repairStage = 'expanding';
try {
this.repairExpanded = await this.request(`${this.apiBase}/metadata-fix/expand`, {
method:'POST',body:JSON.stringify({selection:this.repairSelection(false),reference:this.repairReference || null})
});
this.repairExpandedReference = this.repairReference;
this.repairPlan = null;
this.repairSnapshot = null;
this.repairIncluded = {};
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairPendingInstruction = '';
} catch (error) { this.showToast(error.message); }
finally { this.repairBusy = false; this.repairStage = ''; }
},
repairBefore(kind,id) { return (this.repairSnapshot?.[kind] || []).find(item => Number(item.id) === Number(id)); },
repairArtistIds(kind,id) {
const relations = this.repairSnapshot?.[kind === 'releases' ? 'release_artists' : 'track_artists'] || [];
return relations.filter(row => Number(row.owner_id) === Number(id)).map(row => Number(row.artist_id));
},
repairTargetReleases() {
const existing = (this.repairSnapshot?.releases || []).map(row => ({value:'existing:' + row.id,label:row.title + ' #' + row.id}));
for (const track of this.repairSnapshot?.tracks || []) {
if (!existing.some(row => row.value === 'existing:' + track.release_id)) existing.push({value:'existing:' + track.release_id,label:'Current release #' + track.release_id});
}
const created = (this.repairPlan?.releases || []).filter(row => row.key && !row.id).map(row => ({value:'new:' + row.key,label:(row.title || 'New release') + ' · new'}));
return existing.concat(created);
},
parseRepairIds(value) { return [...new Set(String(value).split(',').map(x => Number(x.trim())).filter(x => Number.isInteger(x) && x > 0))]; },
addRepairRelease() {
if (!this.repairPlan) return;
const key = 'manual_' + Date.now();
const first = this.repairSnapshot?.releases?.[0];
this.repairPlan.releases.push({id:null,key,title:'',release_type:'album',year:first?.year ?? null,artist_ids:first ? this.repairArtistIds('releases',first.id) : []});
},
addRepairTrack() {
if (!this.repairPlan || !this.repairSnapshot) return;
const row = this.repairSnapshot.tracks.find(x => !this.repairPlan.tracks.some(edit => edit.id === x.id));
if (!row) { this.showToast('All selected tracks are already in the plan'); return; }
this.repairPlan.tracks.push({id:row.id,title:row.title,target_release:'existing:' + row.release_id,track_number:row.track_number,disc_number:row.disc_number,year:row.year,artist_ids:this.repairArtistIds('tracks',row.id)});
},
repairChangeCount() { return this.repairPlan ? this.repairPlan.artists.length + this.repairPlan.releases.length + this.repairPlan.tracks.length : 0; },
repairPlanPayload() {
const plan = JSON.parse(JSON.stringify(this.repairPlan));
for (const edit of [...plan.releases, ...plan.tracks]) {
for (const field of ['year','track_number','disc_number']) {
if (field in edit && (edit[field] === '' || edit[field] == null)) edit[field] = null;
}
}
return plan;
},
async sendRepair() {
const instruction = this.repairDraft.trim();
if (!instruction || this.repairBusy || !this.repairIncludedCount()) return;
if (!this.repairReference) {
const source = this.repairChat.find(turn => turn.role === 'user' && turn.content.includes('### ') && /^\s*\d+\.\s+\S/m.test(turn.content));
if (source) this.repairReference = source.content;
}
const resuming = this.repairIncomplete && this.repairPendingInstruction === instruction;
if (instruction.includes('### ') && /^\s*\d+\.\s+\S/m.test(instruction)) this.repairReference = instruction;
if (!resuming) {
this.repairTrackOffset = 0;
this.repairTrackTotal = 0;
}
this.repairBusy = true;
this.repairStage = 'thinking';
this.repairIncomplete = true;
this.repairPendingInstruction = instruction;
this.repairTrackTotal = this.repairSelection(true).tracks.length;
this.repairDraft = '';
const previous = this.repairChat.slice(-6);
if (!resuming) this.repairChat.push({role:'user',content:instruction});
try {
if (!this.repairExpanded || this.repairExpandedReference !== this.repairReference) {
this.repairExpanded = await this.request(`${this.apiBase}/metadata-fix/expand`, {method:'POST',body:JSON.stringify({selection:this.repairSelection(false),reference:this.repairReference || null})});
this.repairExpandedReference = this.repairReference;
}
while (true) {
const result = await this.request(`${this.apiBase}/metadata-fix/plan`, {method:'POST',body:JSON.stringify({selection:this.repairSelection(true),instruction,reference:this.repairReference || null,primary_artist_ids:this.repairSelected.filter(item => item.kind === 'artists').map(item => item.id),conversation:previous,current_plan:this.repairPlan ? this.repairPlanPayload() : null,track_offset:this.repairTrackOffset})});
this.repairPlan = result.plan;
this.repairSnapshot = result.snapshot;
this.repairTrackTotal = result.total_tracks;
this.repairTrackOffset = result.next_track_offset ?? result.total_tracks;
if (result.next_track_offset == null) {
this.repairChat.push({role:'assistant',content:result.message});
this.repairIncomplete = false;
this.repairPendingInstruction = '';
break;
}
}
this.repairInstruction = instruction;
} catch (error) {
this.repairDraft = instruction;
this.showToast(error.message);
} finally {
this.repairBusy = false;
this.repairStage = '';
this.$nextTick(() => { if (this.$refs.repairChat) this.$refs.repairChat.scrollTop = this.$refs.repairChat.scrollHeight; });
}
},
async applyRepair() {
if (!this.repairPlan || !this.repairSnapshot || this.repairBusy || this.repairIncomplete || !this.repairChangeCount()) return;
this.repairBusy = true;
this.repairStage = 'applying';
try {
const result = await this.request(`${this.apiBase}/metadata-fix/apply`, {method:'POST',body:JSON.stringify({snapshot:this.repairSnapshot,plan:this.repairPlanPayload(),instruction:this.repairInstruction})});
this.showToast('Applied · history #' + result.history_id);
this.repairPlan = null;
this.repairSnapshot = null;
this.repairExpanded = null;
await this.loadRepairHistory();
await this.loadLibrary(false);
} catch (error) { this.showToast(error.message); }
finally { this.repairBusy = false; this.repairStage = ''; }
},
async loadRepairHistory() {
try { this.repairHistory = await this.request(`${this.apiBase}/metadata-fix/history`); }
catch (error) { this.showToast(error.message); }
},
async undoRepair(historyId) {
if (this.repairBusy || !confirm('Undo this metadata repair?')) return;
this.repairBusy = true;
try {
await this.request(`${this.apiBase}/metadata-fix/undo`, {method:'POST',body:JSON.stringify({history_id:historyId})});
await this.loadRepairHistory();
this.repairExpanded = null;
this.repairPlan = null;
this.showToast('Metadata repair undone');
} catch (error) { this.showToast(error.message); }
finally { this.repairBusy = false; }
},
openUsers() {
@@ -4546,6 +5139,11 @@ function adminV2() {
return Object.keys(this.selectedReviewIds).length;
},
selectedPendingReviewCount() {
if (this.reviewSelectionScope === 'filter') return this.filterStatusCounts().pending || 0;
return Object.values(this.selectedReviewIds).filter(status => status === 'pending').length;
},
reviewSelectionSummary() {
const total = this.selectedReviewCount();
if (!total) return 'Nothing selected';
@@ -4568,8 +5166,10 @@ function adminV2() {
},
async bulkReviews(action) {
if (this.reviewBulkBusy) return;
const count = this.selectedReviewCount();
if (!count) return;
if (action === 'approve' && !this.selectedPendingReviewCount()) return;
if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return;
const payload = {
action,
@@ -4580,16 +5180,24 @@ function adminV2() {
search: this.reviewFilter.search || null
}
};
this.reviewBulkBusy = true;
this.reviewBulkAction = action;
try {
const result = await this.request(`${this.apiBase}/reviews/bulk`, {
method: 'POST',
body: JSON.stringify(payload)
});
this.showToast(`${result.affected} review row(s) updated`);
this.showToast(action === 'approve'
? `${result.affected} review(s) approved · ${result.failed || 0} failed · ${result.skipped || 0} skipped`
: `${result.affected} review row(s) updated`);
this.clearReviewSelection();
await this.loadReviews(false);
if (action === 'approve') await this.loadLibrary(false);
} catch (error) {
this.showToast(error.message);
} finally {
this.reviewBulkBusy = false;
this.reviewBulkAction = null;
}
},
@@ -5730,8 +6338,9 @@ function adminV2() {
pageTitle() {
if (this.activeView === 'library') return 'Library Workbench';
if (this.activeView === 'jobs') return 'Tasks';
if (this.activeView === 'tools') return 'Future Tools';
if (this.activeView === 'tools') return 'Library Tools';
if (this.activeView === 'settings') return 'Settings';
if (this.activeView === 'statistics') return 'Statistics';
if (this.activeView === 'users') return 'Users';
return 'Review Queue';
},
@@ -5739,8 +6348,9 @@ function adminV2() {
pageSubtitle() {
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, tracks, and playlists';
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
if (this.activeView === 'tools') return 'Guided metadata repair and maintenance history';
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
if (this.activeView === 'statistics') return 'Listening, popularity, individual histories and instance health';
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
return 'Full-screen review triage with filter-aware bulk actions';
},
@@ -5914,10 +6524,6 @@ function adminV2() {
return new Intl.NumberFormat().format(value || 0);
},
mockAction(message) {
this.showToast(message);
},
showToast(message) {
this.toastMessage = message;
setTimeout(() => {
+26 -1
View File
@@ -495,6 +495,18 @@
</button>
</div>
</div>
<div class="youtube-preview-destination">
<label for="torrent-target-playlist">{{ t.player_import_destination }}</label>
<div class="youtube-preview-destination-fields" :class="{ creating: $store.torrents.torrentPlaylistChoice === '__new__' }">
<select id="torrent-target-playlist" x-model="$store.torrents.torrentPlaylistChoice" @change="$store.torrents.importPlaylistChoiceChanged('torrent')" :disabled="$store.torrents.torrentDestinationLocked()">
<option value="">{{ t.player_youtube_no_destination }}</option>
<template x-for="playlist in $store.torrents.youtubeOwnedPlaylists()" :key="playlist.id"><option :value="String(playlist.id)" x-text="playlist.title"></option></template>
<option value="__new__">{{ t.player_youtube_create_playlist }}</option>
</select>
<template x-if="$store.torrents.torrentPlaylistChoice === '__new__'"><input type="text" maxlength="255" autocomplete="off" x-model="$store.torrents.torrentNewPlaylistTitle" placeholder="{{ t.player_youtube_new_playlist_name }}" :disabled="$store.torrents.torrentDestinationLocked()"></template>
</div>
<p>{{ t.player_import_destination_hint }}</p>
</div>
<div class="torrent-tree-toolbar">
<div class="torrent-selected-summary"
x-text="$store.torrents.selected.size + ' {{ t.player_selected }} - ' + $store.torrents.bytes($store.torrents.selectedBytes())"></div>
@@ -594,6 +606,18 @@
<small>{{ t.player_audio_folder_hint }}</small>
</div>
<div class="youtube-preview-destination" x-show="$store.torrents.localFiles.length > 0">
<label for="local-target-playlist">{{ t.player_import_destination }}</label>
<div class="youtube-preview-destination-fields" :class="{ creating: $store.torrents.localPlaylistChoice === '__new__' }">
<select id="local-target-playlist" x-model="$store.torrents.localPlaylistChoice" @change="$store.torrents.importPlaylistChoiceChanged('local')" :disabled="$store.torrents.localFilesUploading || $store.torrents.localPlaylistLocked">
<option value="">{{ t.player_youtube_no_destination }}</option>
<template x-for="playlist in $store.torrents.youtubeOwnedPlaylists()" :key="playlist.id"><option :value="String(playlist.id)" x-text="playlist.title"></option></template>
<option value="__new__">{{ t.player_youtube_create_playlist }}</option>
</select>
<template x-if="$store.torrents.localPlaylistChoice === '__new__'"><input type="text" maxlength="255" autocomplete="off" x-model="$store.torrents.localNewPlaylistTitle" placeholder="{{ t.player_youtube_new_playlist_name }}" :disabled="$store.torrents.localFilesUploading || $store.torrents.localPlaylistLocked"></template>
</div>
<p>{{ t.player_import_destination_hint }}</p>
</div>
<div class="file-upload-selection" x-show="$store.torrents.localFiles.length > 0">
<span x-text="$store.torrents.localUploadSummary()"></span>
<div>
@@ -602,7 +626,7 @@
:disabled="$store.torrents.localFilesUploading">{{ t.player_cancel }}</button>
<button type="button" class="modal-btn modal-btn-primary"
@click="$store.torrents.uploadLocalFiles()"
:disabled="$store.torrents.localFilesUploading">{{ t.player_upload_selected_files }}</button>
:disabled="$store.torrents.localFilesUploading || !$store.torrents.importDestinationValid('local')">{{ t.player_upload_selected_files }}</button>
</div>
</div>
@@ -635,6 +659,7 @@
<div class="file-upload-history-title" x-text="item.filename"></div>
<div class="file-upload-history-meta"
x-text="$store.torrents.bytes(item.size_bytes) + ' · ' + $store.torrents.formatUploadDate(item.created_at)"></div>
<div class="file-upload-history-meta" x-show="item.target_playlist_id" x-text="T.youtubePlaylist + ': ' + ($store.torrents.youtubePlaylistTitle(item.target_playlist_id) || '#' + item.target_playlist_id)"></div>
<p class="youtube-item-error" x-show="item.error" x-text="item.error"></p>
</div>
<span class="torrent-status-badge"
+114 -67
View File
@@ -785,6 +785,7 @@ document.addEventListener('alpine:init', () => {
progress: 0,
_saveTimer: null,
_historyRecorded: false,
_historyTrack: null,
_nowPlayingSent: false,
_playbackStartedAt: null,
_listenId: null,
@@ -836,6 +837,7 @@ document.addEventListener('alpine:init', () => {
this._sendNowPlaying();
});
audio.addEventListener('pause', () => {
this._trackListenedDelta();
this.isPlaying = false;
this._lastAudioTime = audio.currentTime || 0;
});
@@ -858,8 +860,19 @@ document.addEventListener('alpine:init', () => {
}
// Save state on page unload
window.addEventListener('beforeunload', () => {
if (this._isLocalPlaybackDevice()) this._saveStateSync();
window.addEventListener('pagehide', () => {
if (this._isLocalPlaybackDevice()) {
this._saveStateSync();
this._recordHistory('stopped', true);
}
});
window.addEventListener('pageshow', (event) => {
if (event.persisted && this._historyRecorded) {
this._historyRecorded = false;
this._resetPlaybackTracking();
this._lastAudioTime = audio.currentTime || 0;
if (this.isPlaying) this._playbackStartedAt = Math.floor(Date.now() / 1000);
}
});
},
@@ -928,7 +941,9 @@ document.addEventListener('alpine:init', () => {
},
_playLocal(track, options = {}) {
this._recordHistory('replaced');
this.currentTrack = track;
this._historyTrack = track;
this._prefetchedQueueKey = null;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this._localSourceTrackId = track.id;
@@ -1060,7 +1075,7 @@ document.addEventListener('alpine:init', () => {
const queue = Alpine.store('queue');
if (queue.tracks.length === 0) return;
this._recordHistoryIfListenThresholdReached();
this._recordHistory('skipped');
let nextIdx;
if (this.repeatMode === 'one') {
@@ -1481,6 +1496,7 @@ document.addEventListener('alpine:init', () => {
: tracks[idx];
if (currentTrack) {
this.currentTrack = currentTrack;
this._historyTrack = currentTrack;
queue.syncCurrentIndexToTrack(currentTrack);
this._localSourceTrackId = currentTrack.id;
this._historyRecorded = false;
@@ -1504,38 +1520,40 @@ document.addEventListener('alpine:init', () => {
} catch {}
},
_recordHistory(endedReason) {
if (this._historyRecorded || !this.currentTrack) return;
if (!Number.isInteger(Number(this.currentTrack.id)) || this.currentTrack.federated_cache) {
return;
}
this._historyRecorded = true;
_recordHistory(endedReason, unloading = false) {
const track = this._historyTrack;
if (this._historyRecorded || !track || !this._playbackStartedAt) return;
if (!Number.isInteger(Number(track.id)) || track.federated_cache) return;
this._trackListenedDelta();
const completed = endedReason === 'finished';
const listenedSeconds = this._historyListenedSeconds(completed);
fetch('/api/player/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: this.currentTrack.id,
listen_id: this._listenId,
started_at: this._playbackStartedAt,
duration_listened: listenedSeconds,
completed: completed,
ended_reason: endedReason,
}),
}).catch(() => {});
},
_recordHistoryIfListenThresholdReached() {
if (this._historyRecorded || !this.currentTrack) return false;
this._trackListenedDelta();
const duration = this._trackDuration();
if (duration <= 0) return false;
const listened = Math.floor(Number(this._listenedSeconds || 0));
const threshold = Math.ceil(duration / 2);
if (threshold <= 0 || listened < threshold) return false;
this._recordHistory('unknown');
return true;
if (!completed && listenedSeconds < 5) return;
const listenId = this._listenId;
const body = JSON.stringify({
track_id: track.id,
listen_id: listenId,
started_at: this._playbackStartedAt,
duration_listened: listenedSeconds,
completed,
ended_reason: endedReason,
});
this._historyRecorded = true;
if (unloading && navigator.sendBeacon('/api/player/history', new Blob([body], {type:'application/json'}))) return;
// Retry the captured event, never the next track. The server deduplicates by listen_id.
const send = async () => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch('/api/player/history', {
method: 'POST', headers: {'Content-Type':'application/json'}, body, keepalive: true,
});
if (response.ok) return;
if (response.status >= 400 && response.status < 500) break;
} catch (_) {}
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)));
}
if (this._listenId === listenId) this._historyRecorded = false;
};
send();
},
_resetPlaybackTracking() {
@@ -1580,16 +1598,8 @@ document.addEventListener('alpine:init', () => {
},
_historyListenedSeconds(completed) {
const duration = this._trackDuration();
const listened = Number(this._listenedSeconds || 0);
const finalGrace = completed ? 1 : 0;
const precise = Math.floor(listened + finalGrace);
if (precise > 0) return precise;
const current = Number(audio.currentTime || this.currentTime || 0);
if (duration > 0 && Number.isFinite(current)) {
return Math.floor(Math.min(current, duration));
}
return Math.floor(current || 0);
// Seeking is not listening time. Allow rounding grace only at a natural finish.
return Math.max(0, Math.floor(Number(this._listenedSeconds || 0) + (completed ? 1 : 0)));
},
_sendNowPlaying() {
@@ -4632,6 +4642,12 @@ document.addEventListener('alpine:init', () => {
youtubeUrl: '',
youtubePreview: null,
youtubePreviewSelected: new Set(),
torrentPlaylistChoice: '',
torrentNewPlaylistTitle: '',
localPlaylistChoice: '',
localNewPlaylistTitle: '',
localPlaylistLocked: false,
localTargetPlaylistId: null,
youtubePlaylistChoice: '',
youtubeNewPlaylistTitle: '',
youtubePlaylistSyncKey: '',
@@ -4704,6 +4720,7 @@ document.addEventListener('alpine:init', () => {
open() {
if (!this.downloadsEnabled) return;
this.modal = true;
Alpine.store('playlists')?.reload?.();
this.message = '';
this.error = false;
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
@@ -4874,34 +4891,41 @@ document.addEventListener('alpine:init', () => {
return this.youtubeOwnedPlaylists().find(playlist => Number(playlist.id) === wanted)?.title || '';
},
async resolveYoutubeTargetPlaylist() {
if (this.youtubePlaylistChoice !== '__new__') {
const playlistId = Number(this.youtubePlaylistChoice || 0);
importPlaylistChoiceChanged(kind) {
const choice = kind + 'PlaylistChoice';
const title = kind + 'NewPlaylistTitle';
if (this[choice] !== '__new__' || String(this[title] || '').trim()) return;
const file = this.localFiles[0];
const suggestion = kind === 'torrent' ? this.previewData?.name
: (file?.webkitRelativePath?.split('/').slice(0, -1).join('/') || (this.localFiles.length === 1 ? file?.name?.replace(/\.[^.]+$/, '') : ''));
this[title] = String(suggestion || '').slice(0,255);
},
importDestinationValid(kind) {
return this[kind + 'PlaylistChoice'] !== '__new__' || String(this[kind + 'NewPlaylistTitle'] || '').trim().length > 0;
},
torrentDestinationLocked() {
return this.loading || (this.currentJob && !['preview', 'resolving'].includes(this.currentJob.status));
},
async resolveYoutubeTargetPlaylist() { return this.resolveImportTargetPlaylist('youtube'); },
async resolveImportTargetPlaylist(kind) {
const choice = kind + 'PlaylistChoice';
const titleKey = kind + 'NewPlaylistTitle';
if (this[choice] !== '__new__') {
const playlistId = Number(this[choice] || 0);
return playlistId > 0 ? playlistId : null;
}
const title = String(this.youtubeNewPlaylistTitle || '').trim();
const title = String(this[titleKey] || '').trim();
if (!title) throw new Error(T.youtubePlaylistCreateFailed);
const res = await fetch('/api/player/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({title}),
});
const playlist = await res.json().catch(() => null);
if (!res.ok || Number(playlist?.id || 0) <= 0) {
throw new Error(playlist?.error || T.youtubePlaylistCreateFailed);
}
// Switch to the created playlist before starting the job. If the
// YouTube request fails, retrying will reuse it instead of creating
// another playlist with the same name.
this.youtubePlaylistChoice = String(playlist.id);
if (!res.ok || Number(playlist?.id || 0) <= 0) throw new Error(playlist?.error || T.youtubePlaylistCreateFailed);
// Retrying an upload reuses this playlist, including after a failed start request.
this[choice] = String(playlist.id);
const playlists = Alpine.store('playlists');
if (playlists) {
playlists.list = [
...(playlists.list || []).filter(item => Number(item.id) !== Number(playlist.id)),
playlist,
];
playlists.list = [...(playlists.list || []).filter(item => Number(item.id) !== Number(playlist.id)), playlist];
await playlists.reload();
}
return Number(playlist.id);
@@ -5325,6 +5349,7 @@ document.addEventListener('alpine:init', () => {
actionButtonDisabled() {
return this.loading
|| (!this.isCurrentDownloading() && !this.importDestinationValid('torrent'))
|| this.isCurrentCompletedLocked()
|| this.normalizedStatus(this.currentJob) === 'resolving'
|| !this.previewData
@@ -5341,7 +5366,8 @@ document.addEventListener('alpine:init', () => {
sessionMeta(job) {
if (!job) return '';
const size = this.bytes(job.selected_size || job.total_size);
return this.progressValue(job).toFixed(1) + '% - ' + size;
const title = this.youtubePlaylistTitle(job.target_playlist_id);
return this.progressValue(job).toFixed(1) + '% - ' + size + (title ? ' · ' + T.youtubePlaylist + ': ' + title : '');
},
async loadAgentStatus() {
@@ -5878,6 +5904,8 @@ document.addEventListener('alpine:init', () => {
this.uploadProgressText = '';
this.previewData = preview;
this.currentJob = job;
this.torrentPlaylistChoice = job?.target_playlist_id ? String(job.target_playlist_id) : '';
this.torrentNewPlaylistTitle = '';
const selected = Array.isArray(data.selected_files) && data.selected_files.length
? data.selected_files
: (preview.files || []).filter(f => f.selected).map(f => f.index);
@@ -5893,6 +5921,7 @@ document.addEventListener('alpine:init', () => {
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
this.sessions = Array.isArray(data) ? data : [];
if (this.sessions.some(job => job.target_playlist_id)) Alpine.store('playlists')?.reload?.();
this._syncCurrentJobFromSessions();
await this._refreshResolvedSelection();
} catch (err) {
@@ -5982,6 +6011,10 @@ document.addEventListener('alpine:init', () => {
this.localFiles = selected;
this.localFilesBatchId = selected.length ? crypto.randomUUID() : null;
this.localFileUploadIds = new WeakMap();
this.localPlaylistLocked = false;
this.localTargetPlaylistId = null;
this.localPlaylistChoice = '';
this.localNewPlaylistTitle = '';
this.localFilesDragging = false;
this.uploadProgress = 0;
this.uploadProgressText = '';
@@ -5997,6 +6030,10 @@ document.addEventListener('alpine:init', () => {
this.localFiles = [];
this.localFilesBatchId = null;
this.localFileUploadIds = new WeakMap();
this.localPlaylistLocked = false;
this.localTargetPlaylistId = null;
this.localPlaylistChoice = '';
this.localNewPlaylistTitle = '';
this.uploadProgress = 0;
this.uploadProgressText = '';
},
@@ -6033,6 +6070,7 @@ document.addEventListener('alpine:init', () => {
}
xhr.setRequestHeader('X-Furumusic-Upload-Id', uploadId);
xhr.setRequestHeader('X-Furumusic-Upload-Batch-Id', this.localFilesBatchId);
if (this.localTargetPlaylistId) xhr.setRequestHeader('X-Furumusic-Target-Playlist-Id', String(this.localTargetPlaylistId));
if (file.webkitRelativePath) {
xhr.setRequestHeader('X-Furumusic-Relative-Path', encodeURIComponent(file.webkitRelativePath));
}
@@ -6055,7 +6093,7 @@ document.addEventListener('alpine:init', () => {
},
async uploadLocalFiles() {
if (this.localFilesUploading || this.localFiles.length === 0) return;
if (this.localFilesUploading || this.localFiles.length === 0 || !this.importDestinationValid('local')) return;
const batchId = this.localFilesBatchId;
this.localFilesUploading = true;
this.uploadProgress = 0;
@@ -6063,7 +6101,13 @@ document.addEventListener('alpine:init', () => {
this._setMessage(T.uploadingFiles);
const totalBytes = this.localUploadBytes();
let loadedBefore = 0;
let batchStarted = false;
try {
if (!this.localPlaylistLocked) {
this.localTargetPlaylistId = await this.resolveImportTargetPlaylist('local');
this.localPlaylistLocked = true;
}
batchStarted = true;
for (const file of [...this.localFiles]) {
const data = await this.uploadLocalFile(file, loadedBefore, totalBytes);
if (data?.upload) {
@@ -6085,7 +6129,7 @@ document.addEventListener('alpine:init', () => {
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
try {
if (batchStarted) try {
const response = await fetch('/api/player/uploads/local', {
method: 'POST',
headers: {
@@ -6111,6 +6155,7 @@ document.addEventListener('alpine:init', () => {
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.fileUploadLoadFailed);
this.localUploadHistory = Array.isArray(data) ? data : [];
if (this.localUploadHistory.some(item => item.target_playlist_id)) Alpine.store('playlists')?.reload?.();
} catch (err) {
if (!silent) this._setMessage(err.message || T.fileUploadLoadFailed, true);
} finally {
@@ -6361,6 +6406,7 @@ document.addEventListener('alpine:init', () => {
},
async start() {
if (!this.importDestinationValid('torrent')) return;
if (!this.previewData || this.loading) return;
const selected = this.selectedArray();
if (selected.length === 0) {
@@ -6371,10 +6417,11 @@ document.addEventListener('alpine:init', () => {
this.loading = true;
this._setMessage(T.startingDownload);
try {
const targetPlaylistId = await this.resolveImportTargetPlaylist('torrent');
const res = await fetch(`/api/player/torrents/${this.previewData.id}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selected_files: selected }),
body: JSON.stringify({ selected_files: selected, target_playlist_id: targetPlaylistId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.startFailed);
+2 -1
View File
@@ -6666,7 +6666,8 @@ button.user-stat:hover {
flex-wrap: wrap;
}
.youtube-preview-destination-fields {
.youtube-preview-destination-fields,
.youtube-preview-destination-fields.creating {
grid-column: auto;
grid-row: auto;
grid-template-columns: 1fr;
+41
View File
@@ -0,0 +1,41 @@
const assert = require('node:assert/strict');
const {readFileSync} = require('node:fs');
const {test} = require('node:test');
const vm = require('node:vm');
const template = readFileSync(`${__dirname}/../templates/admin/v2.html`, 'utf8');
const script = template.match(/<script>\s*(function adminV2\(\)[\s\S]*?)<\/script>/)[1];
function app() {
const context = vm.createContext({URLSearchParams, Date}); vm.runInContext(script, context);
const a = context.adminV2(); a.icons = () => {}; a.setRoute = () => {}; return a;
}
test('artist → listeners → user retains artist and period filters', async () => {
const a = app(); const urls=[];
a.request = async url => { urls.push(new URL(url, 'http://localhost').searchParams); return {}; };
a.statisticsFilter.from = '2026-11-01'; a.statisticsHistoryPage = 5;
await a.statisticsSelectArtist('Artist & Friends');
assert.equal(urls[0].get('kind'), 'users'); assert.equal(urls[0].get('artist'), 'Artist & Friends');
assert.equal(urls[0].get('history_page'), '1');
await a.statisticsExplore({key:'23', label:'Alice'});
assert.equal(urls[1].get('user_id'), '23'); assert.equal(urls[1].get('artist'), 'Artist & Friends');
assert.equal(urls[1].get('from'), '2026-11-01');
await a.statisticsClearFilter('user_id'); assert.equal(urls[2].get('user_id'), null);
assert.equal(urls[2].get('artist'), 'Artist & Friends');
});
test('latest filters win when earlier requests arrive late', async () => {
const a = app(); const responses=[];
a.request = () => new Promise(resolve => responses.push(resolve));
const first = a.loadStatistics(); const second = a.statisticsSelectArtist('Second');
responses[1]({marker:2}); await second; responses[0]({marker:1}); await first;
assert.equal(a.statisticsData.marker, 2); assert.equal(a.statisticsLoading, false);
});
test('errors are shown separately from an empty result', async () => {
const a = app(); a.request = async () => {throw new Error('Database unavailable')};
await a.loadStatistics(); assert.equal(a.statisticsError, 'Database unavailable'); assert.equal(a.statisticsLoading, false);
});
test('opening a user starts full history without an earlier artist selection', () => {
const a = app(); a.statisticsFilter.artist = 'Previous'; a.statisticsSearch = 'old search'; a.statisticsHistoryKind = 'short'; a.statisticsFilter.source = 'synced'; a.statisticsSetPeriod = () => {};
a.openStatistics({id:4, username:'user4'});
assert.equal(a.statisticsFilter.user_id, 4); assert.equal(a.statisticsFilter.artist, '');
assert.equal(a.statisticsSearch, ''); assert.equal(a.statisticsHistoryKind, 'all'); assert.equal(a.statisticsFilter.source, 'all');
assert.equal(a.statisticsPeriod, 'all'); assert.equal(a.activeView, 'statistics'); assert.equal(a.userModalOpen, false);
});
+104
View File
@@ -0,0 +1,104 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { test } = require('node:test');
const vm = require('node:vm');
const template = readFileSync(`${__dirname}/../templates/admin/v2.html`, 'utf8');
const script = template.match(/<script>\s*(function adminV2\(\)[\s\S]*?)<\/script>/)[1];
function createApp() {
const context = vm.createContext({});
vm.runInContext(script, context);
const app = context.adminV2();
app.showToast = message => { app.toastMessage = message; };
app.loadReviews = async () => { app.reviewsReloaded = true; };
app.loadLibrary = async () => { app.libraryReloaded = true; };
return app;
}
test('approves individually selected reviews and reports partial failures', async () => {
const app = createApp();
app.toggleReview({ id: 12, status: 'pending' });
app.toggleReview({ id: 34, status: 'pending' });
app.toggleReview({ id: 56, status: 'failed' });
assert.equal(app.selectedPendingReviewCount(), 2);
app.request = async (url, options) => {
assert.equal(url, '/admin/v2/api/reviews/bulk');
assert.equal(options.method, 'POST');
assert.deepEqual(JSON.parse(options.body), {
action: 'approve', mode: 'ids', ids: [12, 34, 56],
filter: { status: null, search: null }
});
assert.equal(app.reviewBulkBusy, true);
return { ok: false, affected: 1, failed: 1, skipped: 1 };
};
await app.bulkReviews('approve');
assert.equal(app.toastMessage, '1 review(s) approved · 1 failed · 1 skipped');
assert.equal(app.selectedReviewCount(), 0);
assert.equal(app.reviewsReloaded, true);
assert.equal(app.libraryReloaded, true);
assert.equal(app.reviewBulkBusy, false);
});
test('select all approves the whole searched filter across pages', async () => {
const app = createApp();
app.reviewFilter = { status: 'pending', search: 'Album' };
app.reviews = { total: 320, items: [{ id: 1, status: 'pending' }] };
app.selectReviewFilter();
assert.equal(app.selectedPendingReviewCount(), 320);
app.request = async (_url, options) => {
assert.deepEqual(JSON.parse(options.body), {
action: 'approve', mode: 'filter', ids: [],
filter: { status: 'pending', search: 'Album' }
});
return { affected: 320, failed: 0, skipped: 0 };
};
await app.bulkReviews('approve');
assert.equal(app.toastMessage, '320 review(s) approved · 0 failed · 0 skipped');
});
test('mixed status filter enables approval only when pending reviews exist', () => {
const app = createApp();
app.reviews = { total: 10, status_counts: [{ status: 'pending', count: 3 }, { status: 'queued', count: 7 }] };
app.selectReviewFilter();
assert.equal(app.selectedPendingReviewCount(), 3);
app.reviewFilter.status = 'queued';
assert.equal(app.selectedPendingReviewCount(), 0);
});
test('empty and non-pending selections do not submit approval', async () => {
const app = createApp();
app.request = async () => { assert.fail('unexpected request'); };
await app.bulkReviews('approve');
app.toggleReview({ id: 1, status: 'queued' });
await app.bulkReviews('approve');
});
test('blocks duplicate bulk submissions while approval is running', async () => {
const app = createApp();
app.toggleReview({ id: 1, status: 'pending' });
let finish;
let calls = 0;
app.request = async () => {
calls += 1;
return new Promise(resolve => { finish = resolve; });
};
const approval = app.bulkReviews('approve');
await app.bulkReviews('approve');
await app.bulkReviews('requeue');
assert.equal(calls, 1);
finish({ affected: 1 });
await approval;
assert.equal(app.reviewBulkBusy, false);
});
test('request failure retains selection and restores bulk controls', async () => {
const app = createApp();
app.toggleReview({ id: 1, status: 'pending' });
app.request = async () => { throw new Error('Connection lost'); };
await app.bulkReviews('approve');
assert.equal(app.selectedReviewCount(), 1);
assert.equal(app.toastMessage, 'Connection lost');
assert.equal(app.reviewBulkBusy, false);
assert.equal(app.reviewBulkAction, null);
});
+63
View File
@@ -0,0 +1,63 @@
// Run only against a fresh disposable instance, with the import agent disabled.
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert=require('node:assert/strict');
const {spawnSync}=require('node:child_process');
const base=process.env.FURU_TEST_URL || 'http://127.0.0.1:3019';
const container=process.env.FURU_TEST_PG_CONTAINER || 'furumi-import-playlist-test-pg';
function sql(statement) {
const result=spawnSync('docker',['exec','-i',container,'psql','-U','postgres','-d','furumi_import_test','-At','-v','ON_ERROR_STOP=1'],{input:statement,encoding:'utf8'});
assert.equal(result.status,0,result.stderr);return result.stdout.trim();
}
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try {
const context=await browser.newContext({viewport:{width:1440,height:1000}});
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/admin/setup');
await page.locator('[name=username]').fill('importer');
await page.locator('[name=password]').fill('import-test-password');
await page.locator('[name=confirm_password]').fill('import-test-password');
await page.locator('form button[type=submit]').click();await page.waitForURL('**/admin/v2');
await page.goto(base+'/');
await page.evaluate(()=>{Alpine.store('torrents').open();Alpine.store('torrents').showSourceTab('files');});
await page.locator('#direct-audio-file-input').setInputFiles([
{name:'theme.wav',mimeType:'audio/wav',buffer:Buffer.from('test-audio-one')},
{name:'credits.wav',mimeType:'audio/wav',buffer:Buffer.from('test-audio-two')},
]);
await page.locator('#local-target-playlist').selectOption('__new__');
await page.getByPlaceholder('New playlist name',{exact:true}).fill('Game Soundtrack');
await page.screenshot({path:'/tmp/furumi-import-desktop.png'});
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:'/tmp/furumi-import-mobile.png'});
await page.getByRole('button',{name:'Upload selected files',exact:true}).click();
await page.waitForFunction(()=>!Alpine.store('torrents').localFilesUploading && Alpine.store('torrents').localFiles.length===0);
const history=await (await context.request.get(base+'/api/player/uploads/local/history')).json();
assert.equal(history.length,2,JSON.stringify(history));
const target=history[0].target_playlist_id;assert.ok(target>0);assert.equal(history[1].target_playlist_id,target);
assert.equal(sql('SELECT count(*) FROM furumusic__import_playlist_target;'),'2');
// Simulate the importer resolving both uploaded files to library tracks.
sql(`INSERT INTO furumusic__media_file (file_type,file_path,original_filename,mime_type,file_size_bytes,sha256_hash,created_at)
SELECT 'audio',input_path,input_path,'audio/wav',14,source_sha256,'2026-11-12T00:00:00Z' FROM furumusic__import_playlist_target;
INSERT INTO furumusic__track (title,title_sort,release_id,duration_seconds,audio_file_id,is_hidden,created_at,updated_at)
SELECT original_filename,original_filename,0,120,id,false,'2026-11-12T00:00:00Z','2026-11-12T00:00:00Z' FROM furumusic__media_file;`);
assert.equal((await context.request.get(base+'/api/player/uploads/local/history')).status(),200);
assert.equal(sql(`SELECT count(*) FROM furumusic__playlist_track WHERE playlist_id=${target};`),'2');
await context.request.get(base+'/api/player/uploads/local/history');
assert.equal(sql(`SELECT count(*) FROM furumusic__playlist_track WHERE playlist_id=${target};`),'2');
const invalid=await context.request.post(base+'/api/player/uploads/local',{headers:{'X-Furumusic-Target-Playlist-Id':'99999','X-Furumusic-Filename':'bad.wav'},data:Buffer.from('bad')});
assert.equal(invalid.status(),400);
// Render the torrent picker with a local preview fixture; no external torrent network.
await page.setViewportSize({width:1440,height:1000});
await page.evaluate(()=>{
const s=Alpine.store('torrents');s.showSourceTab('torrents');s._stopRefresh();s._stopPoll();
s._applySession({job:{id:'preview-test',name:'Movie OST',status:'preview'},preview:{id:'preview-test',name:'Movie OST',total_size:20,files:[{index:0,name:'theme.flac',components:['theme.flac'],length:20,selected:true}]},selected_files:[0]});
});
await page.locator('#torrent-target-playlist').selectOption(String(target));
assert.equal(await page.evaluate(()=>Alpine.store('torrents').torrentPlaylistChoice),String(target));
await page.screenshot({path:'/tmp/furumi-torrent-playlist.png'});
await page.evaluate(()=>{Alpine.store('torrents').currentJob.status='paused';});
assert.equal(await page.locator('#torrent-target-playlist').isDisabled(),true);
assert.deepEqual(errors,[]);
console.log('PASS: file batch → new playlist, persistent destinations, imported tracks, idempotency, invalid destination, torrent picker, desktop/mobile');
} finally {await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1});
+89
View File
@@ -0,0 +1,89 @@
// Run against a fresh disposable instance and the named disposable PostgreSQL container.
// PLAYWRIGHT_MODULE may point to an existing Playwright installation.
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const {spawnSync} = require('node:child_process');
const base = process.env.FURU_TEST_URL || 'http://127.0.0.1:3018';
(async () => {
const browser = await chromium.launch({headless:true, executablePath:process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try {
const context = await browser.newContext({viewport:{width:1440,height:1100}, ignoreHTTPSErrors:true});
const page = await context.newPage(); const errors=[];
page.on('pageerror', error => errors.push(error.message));
const anonymous = await context.request.get(`${base}/admin/v2/api/statistics`);
assert.equal(anonymous.status(),401);
await page.goto(`${base}/admin/setup`);
await page.getByLabel('Username', {exact:true}).fill('alice');
await page.getByLabel('Password', {exact:true}).fill('statistics-test-password');
await page.locator('[name=confirm_password]').fill('statistics-test-password');
await page.locator('form button[type=submit]').click();
await page.waitForURL('**/admin/v2');
await page.getByRole('button', {name:'Users',exact:true}).click();
await page.getByRole('button', {name:'Add user',exact:true}).click();
const editor = page.locator('.modal-backdrop').filter({has:page.getByRole('button',{name:'Save user',exact:true})});
await editor.getByLabel('username', {exact:true}).fill('bob');
await editor.getByLabel('password', {exact:true}).fill('statistics-test-password');
await editor.getByRole('button',{name:'Save user',exact:true}).click();
await editor.waitFor({state:'hidden'});
const sql = `INSERT INTO furumusic__listen_event
(user_id,listen_id,content_id,local_track_id,origin_device_id,started_at_ms,listened_ms,track_duration_ms,ended_reason,qualified,metadata_json,created_at)
SELECT 1,'ui-'||n,'content-one',NULL,'web-a',1793491200000+n*1000,180000,180000,'"finished"',true,
'{"title":"First song","artist_names":["Artist A"],"release_title":"Album"}'::jsonb,'2026-11-01T00:00:00Z' FROM generate_series(1,30) n;
INSERT INTO furumusic__listen_event
(user_id,listen_id,content_id,local_track_id,origin_device_id,started_at_ms,listened_ms,track_duration_ms,ended_reason,qualified,metadata_json,created_at)
VALUES (2,'ui-bob','content-one',NULL,'web-b',1793664000000,90000,180000,'"skipped"',true,
'{"title":"First song","artist_names":["Artist A"],"release_title":"Album"}','2026-11-03T00:00:00Z');`;
const seeded = spawnSync('docker',['exec','-i','furumi-statistics-test-pg','psql','-U','postgres','-d','furumi_statistics_test','-v','ON_ERROR_STOP=1'],{input:sql, encoding:'utf8'});
assert.equal(seeded.status,0,seeded.stderr);
await page.getByRole('button',{name:'Statistics',exact:true}).click();
const area = page.locator('.content').filter({has:page.getByText('Listening statistics',{exact:true})});
await area.getByRole('button',{name:'Artist A',exact:true}).first().waitFor();
const artistResponse = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('artist=Artist+A'));
await area.getByRole('button',{name:'Artist A',exact:true}).first().click();
assert.equal((await artistResponse).status(),200);
await area.getByRole('button',{name:'bob',exact:true}).first().waitFor();
const bobResponse = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('user_id=2'));
await area.getByRole('button',{name:'bob',exact:true}).first().click();
const bob = await (await bobResponse).json();
assert.equal(bob.summary.plays,1); assert.equal(bob.history.items[0].user_name,'bob');
assert.equal(bob.history.items[0].title,'First song');
const cleared = page.waitForResponse(r => r.url().includes('/api/statistics?') && !r.url().includes('user_id=') && !r.url().includes('artist='));
await area.getByRole('button',{name:'All listeners and music'}).click();
await cleared;
await area.getByText('Loading statistics…',{exact:true}).waitFor({state:'hidden'});
await page.screenshot({path:'/tmp/furumi-statistics-desktop.png',fullPage:false});
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:'/tmp/furumi-statistics-mobile.png',fullPage:false});
await page.setViewportSize({width:1440,height:1100});
const usersResponse = await context.request.get(`${base}/admin/v2/api/users`); const users = await usersResponse.json();
assert.equal(users.items.find(u=>u.username==='bob').plays,1);
const detailResponse = await context.request.get(`${base}/admin/v2/api/users/2`); const detail = await detailResponse.json();
assert.equal(detail.stats.plays,1); assert.equal(detail.recent_plays[0].title,'First song');
const next = await context.request.get(`${base}/admin/v2/api/statistics?from=all&history_page=2`);
assert.equal((await next.json()).history.items.length,6);
for (const path of ['/admin/debug','/admin/users','/admin/settings','/admin/artists','/admin/reviews','/admin/jobs']) {
assert.equal((await context.request.get(base+path)).status(),404,path);
}
assert.equal((await context.request.get(`${base}/admin/v2/api/statistics?from=invalid`)).status(),400);
const jobs = await (await context.request.get(`${base}/admin/v2/api/jobs`)).json();
const job = jobs.find(j => j.name === 'metadata_backfill');
if (job.enabled) assert.equal((await context.request.post(`${base}/admin/v2/api/jobs/${job.name}/toggle`)).status(),200);
assert.equal((await context.request.post(`${base}/admin/v2/api/jobs/${job.name}/schedule`,{data:{cron:'0 17 3 * * *'}})).status(),200);
await context.request.get(`${base}/admin/v2/api/dashboard`);
const changedJob = (await (await context.request.get(`${base}/admin/v2/api/jobs`)).json()).find(j=>j.name===job.name);
assert.equal(changedJob.enabled,false); assert.equal(changedJob.cron_expression,'0 17 3 * * *');
const ownDelete = await context.request.post(`${base}/admin/v2/api/users/manage`, {data:{action:'delete',id:1}});
assert.equal(ownDelete.status(),400);
const listenerContext = await browser.newContext({ignoreHTTPSErrors:true});
const login = await listenerContext.newPage();
await login.goto(`${base}/login`);
await login.locator('[name=username]').fill('bob');
await login.locator('[name=password]').fill('statistics-test-password');
await login.locator('form button[type=submit]').click();
await login.waitForURL(base + '/');
assert.equal((await listenerContext.request.get(`${base}/admin/v2/api/statistics`)).status(),403);
await listenerContext.close();
assert.deepEqual(errors,[]);
console.log('PASS: setup, user creation, artist → listeners → user history, counts, pagination, invalid filters, v1 removal, desktop/mobile rendering');
} finally { await browser.close(); }
})().catch(error => {console.error(error); process.exitCode=1});
+75
View File
@@ -0,0 +1,75 @@
const assert = require('node:assert/strict');
const {readFileSync} = require('node:fs');
const {test} = require('node:test');
const vm = require('node:vm');
const {randomUUID} = require('node:crypto');
const template = readFileSync(`${__dirname}/../templates/player/scripts.html`, 'utf8');
function downloads(fetchImpl) {
let code = template.slice(template.indexOf(" Alpine.store('torrents', {"), template.indexOf(" Alpine.store('playlists', {"));
code = code.replace(/\{\{[^}]+\}\}/g,'true').replace(/sourceTab: .*?,\n/,"sourceTab: 'files',\n");
const stores={playlists:{list:[{id:10,title:'Mine',kind:'user',is_own:true},{id:20,title:'Other',kind:'user',is_own:false}],reload:async()=>{}}};
const requests=[];
class XHR {
headers={}; upload={}; status=200; responseText='{}';
open(method,url) {this.url=url;}
setRequestHeader(key,value) {this.headers[key]=value;}
send(file) {requests.push({file,headers:this.headers});this.onload();}
}
const context=vm.createContext({Alpine:{store:(name,value)=>value?(stores[name]=value):stores[name]},fetch:fetchImpl,
T:new Proxy({}, {get:(_,key)=>key}),crypto:{randomUUID},XMLHttpRequest:XHR,redirectOnUnauthorized:()=>{},console,setInterval:()=>0,clearInterval:()=>{}});
vm.runInContext(code,context);
const store=stores.torrents;
store._poll=()=>{};store.loadSessions=async()=>{};store.loadAgentStatus=async()=>{};store.loadLocalUploadHistory=async()=>{};
return {store,requests};
}
test('destinations are independent, owned playlists only, new titles required',()=>{
const {store:s}=downloads();
assert.deepEqual(Array.from(s.youtubeOwnedPlaylists(),p=>p.id),[10]);
s.localPlaylistChoice='__new__';assert.equal(s.importDestinationValid('local'),false);
assert.equal(s.importDestinationValid('torrent'),true);
s.localFiles=[{name:'song.flac',webkitRelativePath:'Soundtrack/song.flac'}];s.importPlaylistChoiceChanged('local');
assert.equal(s.localNewPlaylistTitle,'Soundtrack');assert.equal(s.importDestinationValid('local'),true);
});
test('failed torrent starts reuse the newly created playlist and send only selected files',async()=>{
const calls=[];
const {store:s}=downloads(async(url,opts)=>{
calls.push({url,data:JSON.parse(opts.body)});
return url==='/api/player/playlists'?{ok:true,json:async()=>({id:33,title:'Soundtrack',kind:'user',is_own:true})}:{ok:false,json:async()=>({error:'retry'})};
});
s.previewData={id:'torrent-1'};s.selected=new Set([1,3]);s.torrentPlaylistChoice='__new__';s.torrentNewPlaylistTitle='Soundtrack';
await s.start();await s.start();
assert.equal(calls.filter(c=>c.url==='/api/player/playlists').length,1);
assert.deepEqual(calls[1].data,{selected_files:[1,3],target_playlist_id:33});
assert.deepEqual(calls[2].data,calls[1].data);assert.equal(s.loading,false);
});
test('local files carry the same playlist destination and retries retain upload IDs',async()=>{
const {store:s,requests}=downloads();const f={name:'a.flac',size:10};
s.setLocalFiles([f]);s.localTargetPlaylistId=10;
await s.uploadLocalFile(f,0,10);await s.uploadLocalFile(f,0,10);
assert.equal(requests[0].headers['X-Furumusic-Target-Playlist-Id'],'10');
assert.equal(requests[0].headers['X-Furumusic-Upload-Id'],requests[1].headers['X-Furumusic-Upload-Id']);
s.clearLocalFiles();s.setLocalFiles([f]);await s.uploadLocalFile(f,0,10);
assert.equal(requests[2].headers['X-Furumusic-Target-Playlist-Id'],undefined);
});
test('partially failed batches keep their destination, create one playlist and retry remaining files',async()=>{
let creates=0;let attempts=0;
const {store:s}=downloads(async(url)=>{
if(url==='/api/player/playlists') {creates++;return {ok:true,json:async()=>({id:44})};}
return {ok:true};
});
s.setLocalFiles([{name:'a.mp3',size:1},{name:'b.mp3',size:1}]);s.localPlaylistChoice='__new__';s.localNewPlaylistTitle='OST';
s.uploadLocalFile=async()=>{attempts++;if(attempts===2)throw Error('offline');return {};};
await s.uploadLocalFiles();assert.equal(s.localFiles.length,1);assert.equal(s.localPlaylistLocked,true);
assert.equal(s.localTargetPlaylistId,44);await s.uploadLocalFiles();
assert.equal(creates,1);assert.equal(s.localFiles.length,0);assert.equal(s.localFilesUploading,false);
});
test('playlist creation failure leaves the batch editable and does not signal upload completion',async()=>{
const calls=[];const {store:s}=downloads(async(url)=>{calls.push(url);return {ok:false,json:async()=>({error:'failed'})};});
s.setLocalFiles([{name:'a.mp3',size:1}]);s.localPlaylistChoice='__new__';s.localNewPlaylistTitle='OST';
await s.uploadLocalFiles();assert.equal(s.localPlaylistLocked,false);assert.equal(s.localFilesUploading,false);
assert.deepEqual(calls,['/api/player/playlists']);assert.equal(s.localFiles.length,1);
});
test('torrent destination locks after start',()=>{
const {store:s}=downloads();s.currentJob={status:'preview'};assert.equal(s.torrentDestinationLocked(),false);
s.currentJob.status='paused';assert.equal(s.torrentDestinationLocked(),true);
});
+52
View File
@@ -0,0 +1,52 @@
const assert = require('node:assert/strict');
const {readFileSync} = require('node:fs');
const {test} = require('node:test');
const vm = require('node:vm');
const template = readFileSync(`${__dirname}/../templates/player/scripts.html`, 'utf8');
function player(fetchImpl = async () => ({ok:true}), beacon = () => true) {
// Exercise the production methods with audio and network stubs.
const methods = template.slice(template.indexOf(' _recordHistory(endedReason'), template.indexOf(' _sendNowPlaying()'));
const audio = {currentTime:60};
const context = vm.createContext({audio, fetch:fetchImpl, navigator:{sendBeacon:beacon}, Blob,
globalThis:{crypto:{randomUUID:()=> 'uuid'}}, setTimeout: fn => { fn(); }, console});
vm.runInContext('this.methods = {' + methods + '};', context);
return Object.assign(context.methods, {
currentTrack:{id:8,duration_seconds:180}, _historyTrack:{id:8,duration_seconds:180},
_localSourceTrackId:8, _listenId:'event1', _playbackStartedAt:123456,
_historyRecorded:false, _listenedSeconds:60, _lastAudioTime:60, isPlaying:true,
});
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('switching tracks records short sessions, and completion is not counted twice', async () => {
const calls=[]; const p=player(async (_url, options)=> {calls.push(JSON.parse(options.body)); return {ok:true};});
p._listenedSeconds=8; p._recordHistory('replaced'); await flush();
assert.equal(calls[0].duration_listened,8); assert.equal(calls[0].ended_reason,'replaced');
p._recordHistory('skipped'); await flush(); assert.equal(calls.length,1);
});
test('natural finish records final duration; seeking does not create listened time', async () => {
const calls=[]; const p=player(async (_url, options)=> {calls.push(JSON.parse(options.body)); return {ok:true};});
p._listenedSeconds=179.4; p._recordHistory('finished'); await flush();
assert.equal(calls[0].duration_listened,180); assert.equal(calls[0].completed,true);
p._listenedSeconds=0; assert.equal(p._historyListenedSeconds(false),0);
});
test('failed submission retries the original event even after track changes', async () => {
const calls=[]; const p=player(async (_url,options)=> {calls.push(JSON.parse(options.body)); return {ok:calls.length>1,status:503};});
p._recordHistory('skipped'); p.currentTrack={id:9}; p._listenId='event2';
await flush(); assert.equal(calls.length,2); assert.deepEqual(calls[0], calls[1]);
assert.equal(calls[0].track_id,8);
});
test('page exit uses beacon and does not duplicate a recorded event', () => {
const calls=[]; const p=player(()=>assert.fail('unexpected fetch'), (url,blob)=>{calls.push({url,blob});return true;});
p._recordHistory('stopped',true); p._recordHistory('stopped',true);
assert.equal(calls.length,1); assert.equal(calls[0].url,'/api/player/history');
});
test('remote UI updates cannot attribute local audio to the wrong track', async () => {
let body; const p=player(async (_url,options)=> {body=JSON.parse(options.body);return {ok:true}});
p.currentTrack={id:999}; p._recordHistory('replaced'); await flush(); assert.equal(body.track_id,8);
});
test('unstarted tracks and sub-five-second interrupted sessions are not recorded', async () => {
const p=player(()=>assert.fail('unexpected fetch'));
p._playbackStartedAt=null; p._recordHistory('finished');
p._playbackStartedAt=123; p._listenedSeconds=4; p._recordHistory('skipped'); await flush();
assert.equal(p._historyRecorded,false);
});
+31
View File
@@ -0,0 +1,31 @@
CREATE TEMP TABLE furumusic__user (id bigint PRIMARY KEY, username text, display_name text);
CREATE TEMP TABLE furumusic__fed_device_identity (user_id bigint PRIMARY KEY, device_id text);
CREATE TEMP TABLE furumusic__fed_device (user_id bigint, device_id text, name text, PRIMARY KEY(user_id, device_id));
CREATE TEMP TABLE furumusic__listen_event (
user_id bigint, listen_id text, content_id text, local_track_id bigint, origin_device_id text,
started_at_ms bigint, listened_ms bigint, track_duration_ms bigint, ended_reason text,
qualified boolean, metadata_json jsonb, PRIMARY KEY(user_id, listen_id)
);
CREATE TEMP TABLE furumusic__track (id bigint);
CREATE TEMP TABLE furumusic__release (id bigint);
CREATE TEMP TABLE furumusic__artist (id bigint);
CREATE TEMP TABLE furumusic__playlist (id bigint);
CREATE TEMP TABLE furumusic__media_file (file_size_bytes bigint, audio_format text, file_type text);
CREATE TEMP TABLE furumusic__pending_review (status text);
CREATE TEMP TABLE furumusic__job_run (status text);
CREATE TEMP TABLE furumusic__lastfm_scrobble_outbox (status text);
INSERT INTO furumusic__user VALUES (1, 'alice', 'Alice'), (2, 'bob', 'Bob'), (3, 'silent', NULL);
INSERT INTO furumusic__fed_device_identity VALUES (1, 'web-a'), (2, 'web-b');
INSERT INTO furumusic__fed_device VALUES (1, 'phone', 'Alice phone');
INSERT INTO furumusic__listen_event
SELECT 1, 'listen-' || n, 'content-one', NULL, 'web-a',
1793491200000 + n * 1000, 180000, 180000, '"finished"', true,
'{"title":"First song", "artist_names":["Artist A"], "featured_artist_names":["Artist A", "Guest"], "release_title":"Album"}'::jsonb
FROM generate_series(1, 30) n;
INSERT INTO furumusic__listen_event VALUES
(2, 'listen-1', 'content-one', NULL, 'web-b', 1793664000000, 90000, 180000, '"skipped"', true,
'{"title":"First song", "artist_names":["Artist A"], "release_title":"Album"}'),
(1, 'phone-1', 'content-two', NULL, 'phone', 1793664001000, 120000, 240000, '"replaced"', true,
'{"title":"Remote song", "artist_names":["Artist B"], "release_title":"Remote album"}'),
(2, 'short-1', 'content-one', NULL, 'web-b', 1793664002000, 8000, 180000, '"skipped"', false,
'{"title":"First song", "artist_names":["Artist A"]}');