diff --git a/Cargo.lock b/Cargo.lock index 6556dde..6eb416d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1988,7 +1988,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.12.0" +version = "0.12.2" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 59e4c44..3bcf24b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.12.0" +version = "0.12.2" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/README.md b/README.md index 57d79ee..5b7cbcb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/admin/management.rs b/src/admin/management.rs new file mode 100644 index 0000000..4245f9a --- /dev/null +++ b/src/admin/management.rs @@ -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, + #[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, +) -> cot::Result { + 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>, + >, + name: &str, + Json(body): Json, +) -> cot::Result { + 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")); + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index f8f15b9..6c24bfa 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,6 +1,8 @@ +mod management; mod metadata_fix; +mod setup; +mod statistics; mod v2; -pub mod views; use std::sync::Arc; @@ -20,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, -} +use setup::SetupForm; /// Build-time metadata baked in by `build.rs` and Cargo env vars. #[derive(Debug)] @@ -72,11 +66,6 @@ impl AdminApp { } } -#[derive(Debug, Deserialize)] -struct SettingsQuery { - saved: Option, -} - #[derive(Debug, Deserialize)] struct PathId { id: i64, @@ -98,11 +87,6 @@ struct PathNameRunId { run_id: i64, } -#[derive(Debug, Deserialize)] -struct ReleasesQuery { - artist_id: Option, -} - impl App for AdminApp { fn name(&self) -> &'static str { "admin" @@ -122,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() }) @@ -133,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", @@ -180,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| { + 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", { @@ -233,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, + json: Json| { + 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", { @@ -584,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 } }, ) @@ -907,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| { - 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| { - 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| 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| 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, - form: RequestForm| 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| 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| 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| 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, - form: RequestForm| 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| 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| 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, - json: Json| 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, - json: Json| { - 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| 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| 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| 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, - form: RequestForm| 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| { - 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| 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(®istry); - 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, ®istry).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| { - 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| { - 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| { - 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| { - 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, - form: RequestForm| { - 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| { - 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| { - 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| 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| 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| { - 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| 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, - form: RequestForm| { - 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| 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| 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", - ), ]) } @@ -1625,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, diff --git a/src/admin/setup.rs b/src/admin/setup.rs new file mode 100644 index 0000000..611b7a3 --- /dev/null +++ b/src/admin/setup.rs @@ -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 { + 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, +) -> cot::Result { + 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/")) +} diff --git a/src/admin/sql/listens.sql b/src/admin/sql/listens.sql new file mode 100644 index 0000000..229f11a --- /dev/null +++ b/src/admin/sql/listens.sql @@ -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) +) diff --git a/src/admin/statistics.rs b/src/admin/statistics.rs new file mode 100644 index 0000000..49b5cba --- /dev/null +++ b/src/admin/statistics.rs @@ -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, + pub to: Option, + pub user_id: Option, + pub content_id: Option, + pub artist: Option, + pub source: Option, + pub kind: Option, + pub search: Option, + pub rank_page: Option, + pub history_page: Option, + pub history_kind: Option, +} + +struct Filter { + from: i64, + to: i64, + user_id: Option, + content_id: Option, + artist: Option, + source: String, +} + +impl Filter { + fn parse(query: &StatisticsQuery) -> Result { + fn date(value: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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; + } +} diff --git a/src/admin/v2.rs b/src/admin/v2.rs index 0307e34..34b307e 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -232,6 +232,9 @@ struct AdminUserRowDto { is_active: bool, is_online: bool, last_seen_ms: Option, + plays: i64, + listened_seconds: i64, + last_play_ms: Option, } #[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, completed: bool, @@ -1958,6 +1961,7 @@ pub async fn job_run_detail( session: Session, db: Database, pool: &PgPool, + job_name: &str, run_id: i64, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { @@ -1967,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()))?; @@ -2662,6 +2667,9 @@ struct AdminUserSqlRow { email: Option, role: String, is_active: bool, + plays: i64, + listened_seconds: i64, + last_play_ms: Option, } async fn load_admin_users_page( @@ -2692,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::::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 "); @@ -2735,7 +2747,11 @@ async fn load_admin_user_detail( user_id: i64, ) -> anyhow::Result> { 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) @@ -2759,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), @@ -2835,34 +2851,27 @@ async fn load_admin_user_recent_plays( limit: i64, ) -> anyhow::Result> { 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) @@ -2893,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, completed: bool, @@ -2930,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, } @@ -3549,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", ) @@ -3585,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", @@ -4362,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 { @@ -4398,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", ); diff --git a/src/admin/views.rs b/src/admin/views.rs deleted file mode 100644 index 57edc96..0000000 --- a/src/admin/views.rs +++ /dev/null @@ -1,2170 +0,0 @@ -use cot::db::{Database, Model}; -use cot::form::{Form, FormResult}; -use cot::html::Html; -use cot::request::extractors::RequestForm; -use cot::response::IntoResponse; -use cot::session::Session; -use cot::{Body, Template}; - -use std::collections::HashMap; -use std::sync::Arc; - -use super::BUILD_INFO; -use crate::agent; -use crate::auth::{self, AuthenticatedUser}; -use crate::config::{AppConfig, ConfigEntry, ConfigSources}; -use crate::i18n::{I18n, Translations}; -use crate::music::{Artist, MediaFile, RELEASE_TYPES, Release, ReleaseArtist, Track, TrackArtist}; -use crate::scheduler::{self, JobRegistry, JobRun, PendingReview, ScheduledJob}; -use crate::user::User; - -use crate::agent::AgentProbeResult; - -/// A config entry for display in the unified debug table. -#[derive(Debug)] -pub struct ConfigDisplayEntry { - pub key: String, - pub env_var: String, - pub value: String, - pub default_value: String, - pub source: &'static str, -} - -/// Secret field names that should be redacted in the debug view. -const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"]; - -fn is_secret(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - SECRET_FIELDS.iter().any(|s| lower.contains(s)) - || lower.contains("secret") - || lower.contains("token") -} - -fn redact(value: &str) -> String { - if value.is_empty() { - String::new() - } else { - "********".into() - } -} - -#[derive(Debug, Template)] -#[template(path = "admin/debug.html")] -struct DebugTemplate<'a> { - t: &'static Translations, - user_name: String, - user_role: String, - build: &'a super::BuildInfo, - config_entries: Vec, - db_status: String, -} - -fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec { - let defaults = AppConfig::default(); - - macro_rules! entry { - ($field:ident, $value:expr, $default:expr) => {{ - let raw = $value; - let default_raw = $default; - let secret = is_secret(stringify!($field)); - let display = if secret { redact(&raw) } else { raw }; - let default_display = if secret { - redact(&default_raw) - } else { - default_raw - }; - ConfigDisplayEntry { - key: stringify!($field).into(), - env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()), - value: display, - default_value: default_display, - source: sources.$field.code(), - } - }}; - } - - vec![ - entry!( - database_url, - config.database_url.clone(), - defaults.database_url.clone() - ), - entry!( - oidc_issuer, - config.oidc_issuer.clone(), - defaults.oidc_issuer.clone() - ), - entry!( - oidc_client_id, - config.oidc_client_id.clone(), - defaults.oidc_client_id.clone() - ), - entry!( - oidc_client_secret, - config.oidc_client_secret.clone(), - defaults.oidc_client_secret.clone() - ), - entry!( - log_level, - config.log_level.clone(), - defaults.log_level.clone() - ), - entry!( - auth_password_enabled, - config.auth_password_enabled.to_string(), - defaults.auth_password_enabled.to_string() - ), - entry!( - auth_sso_enabled, - config.auth_sso_enabled.to_string(), - defaults.auth_sso_enabled.to_string() - ), - entry!( - oidc_button_text, - config.oidc_button_text.clone(), - defaults.oidc_button_text.clone() - ), - entry!( - oidc_admin_groups, - config.oidc_admin_groups.clone(), - defaults.oidc_admin_groups.clone() - ), - entry!( - oidc_user_groups, - config.oidc_user_groups.clone(), - defaults.oidc_user_groups.clone() - ), - entry!( - swagger_enabled, - config.swagger_enabled.to_string(), - defaults.swagger_enabled.to_string() - ), - entry!( - agent_enabled, - config.agent_enabled.to_string(), - defaults.agent_enabled.to_string() - ), - entry!( - agent_inbox_dir, - config.agent_inbox_dir.clone(), - defaults.agent_inbox_dir.clone() - ), - entry!( - agent_storage_dir, - config.agent_storage_dir.clone(), - defaults.agent_storage_dir.clone() - ), - entry!( - agent_llm_url, - config.agent_llm_url.clone(), - defaults.agent_llm_url.clone() - ), - entry!( - agent_llm_model, - config.agent_llm_model.clone(), - defaults.agent_llm_model.clone() - ), - entry!( - agent_llm_auth, - config.agent_llm_auth.clone(), - defaults.agent_llm_auth.clone() - ), - entry!( - agent_confidence_threshold, - config.agent_confidence_threshold.to_string(), - defaults.agent_confidence_threshold.to_string() - ), - entry!( - agent_context_limit, - config.agent_context_limit.to_string(), - defaults.agent_context_limit.to_string() - ), - entry!( - agent_concurrency, - config.agent_concurrency.to_string(), - defaults.agent_concurrency.to_string() - ), - entry!( - lastfm_api_key, - config.lastfm_api_key.clone(), - defaults.lastfm_api_key.clone() - ), - entry!( - lastfm_shared_secret, - config.lastfm_shared_secret.clone(), - defaults.lastfm_shared_secret.clone() - ), - ] -} - -pub async fn debug_handler( - admin: AuthenticatedUser, - i18n: I18n, - _startup_config: &AppConfig, - db: &Database, -) -> cot::Result { - let (config, sources) = AppConfig::load_with_db(db).await; - - let db_status = match db.raw("SELECT 1").await { - Ok(_) => i18n.t.debug_db_connected.to_owned(), - Err(e) => format!("{}: {e}", i18n.t.debug_db_error), - }; - - let template = DebugTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - build: &BUILD_INFO, - config_entries: config_display_entries(&config, &sources), - db_status, - }; - Ok(Html::new(template.render()?)) -} - -// --------------------------------------------------------------------------- -// Settings page -// --------------------------------------------------------------------------- - -#[derive(Debug, Template)] -#[template(path = "admin/settings.html")] -struct SettingsTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - saved: bool, - auth_password_enabled: bool, - auth_password_enabled_source: &'static str, - auth_sso_enabled: bool, - auth_sso_enabled_source: &'static str, - oidc_button_text: String, - oidc_button_text_source: &'static str, - oidc_issuer: String, - oidc_issuer_source: &'static str, - oidc_client_id: String, - oidc_client_id_source: &'static str, - oidc_client_secret: String, - oidc_client_secret_source: &'static str, - oidc_admin_groups: String, - oidc_admin_groups_source: &'static str, - oidc_user_groups: String, - oidc_user_groups_source: &'static str, - swagger_enabled: bool, - swagger_enabled_source: &'static str, - agent_enabled: bool, - agent_enabled_source: &'static str, - agent_inbox_dir: String, - agent_inbox_dir_source: &'static str, - agent_storage_dir: String, - agent_storage_dir_source: &'static str, - agent_llm_url: String, - agent_llm_url_source: &'static str, - agent_llm_model: String, - agent_llm_model_source: &'static str, - agent_llm_auth: String, - agent_llm_auth_source: &'static str, - agent_confidence_threshold: String, - agent_confidence_threshold_source: &'static str, - agent_context_limit: String, - agent_context_limit_source: &'static str, - agent_concurrency: String, - agent_concurrency_source: &'static str, - lastfm_api_key: String, - lastfm_api_key_source: &'static str, - lastfm_shared_secret: String, - lastfm_shared_secret_source: &'static str, -} - -pub async fn settings_handler( - admin: AuthenticatedUser, - i18n: I18n, - _startup_config: &AppConfig, - db: &Database, - saved: bool, -) -> cot::Result { - let (config, sources) = AppConfig::load_with_db(db).await; - - let template = SettingsTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - saved, - auth_password_enabled: config.auth_password_enabled, - auth_password_enabled_source: sources.auth_password_enabled.code(), - auth_sso_enabled: config.auth_sso_enabled, - auth_sso_enabled_source: sources.auth_sso_enabled.code(), - oidc_button_text: config.oidc_button_text, - oidc_button_text_source: sources.oidc_button_text.code(), - oidc_issuer: config.oidc_issuer, - oidc_issuer_source: sources.oidc_issuer.code(), - oidc_client_id: config.oidc_client_id, - oidc_client_id_source: sources.oidc_client_id.code(), - oidc_client_secret: config.oidc_client_secret, - oidc_client_secret_source: sources.oidc_client_secret.code(), - oidc_admin_groups: config.oidc_admin_groups, - oidc_admin_groups_source: sources.oidc_admin_groups.code(), - oidc_user_groups: config.oidc_user_groups, - oidc_user_groups_source: sources.oidc_user_groups.code(), - swagger_enabled: config.swagger_enabled, - swagger_enabled_source: sources.swagger_enabled.code(), - agent_enabled: config.agent_enabled, - agent_enabled_source: sources.agent_enabled.code(), - agent_inbox_dir: config.agent_inbox_dir.clone(), - agent_inbox_dir_source: sources.agent_inbox_dir.code(), - agent_storage_dir: config.agent_storage_dir.clone(), - agent_storage_dir_source: sources.agent_storage_dir.code(), - agent_llm_url: config.agent_llm_url.clone(), - agent_llm_url_source: sources.agent_llm_url.code(), - agent_llm_model: config.agent_llm_model.clone(), - agent_llm_model_source: sources.agent_llm_model.code(), - agent_llm_auth: config.agent_llm_auth.clone(), - agent_llm_auth_source: sources.agent_llm_auth.code(), - agent_confidence_threshold: config.agent_confidence_threshold.to_string(), - agent_confidence_threshold_source: sources.agent_confidence_threshold.code(), - agent_context_limit: config.agent_context_limit.to_string(), - agent_context_limit_source: sources.agent_context_limit.code(), - agent_concurrency: config.agent_concurrency.to_string(), - agent_concurrency_source: sources.agent_concurrency.code(), - lastfm_api_key: config.lastfm_api_key.clone(), - lastfm_api_key_source: sources.lastfm_api_key.code(), - lastfm_shared_secret: config.lastfm_shared_secret.clone(), - lastfm_shared_secret_source: sources.lastfm_shared_secret.code(), - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Form)] -pub struct OidcSettingsForm { - auth_password_enabled: Option, - auth_sso_enabled: Option, - oidc_button_text: Option, - oidc_issuer: Option, - oidc_client_id: Option, - oidc_client_secret: Option, - oidc_admin_groups: Option, - oidc_user_groups: Option, - swagger_enabled: Option, - agent_enabled: Option, - agent_inbox_dir: Option, - agent_storage_dir: Option, - agent_llm_url: Option, - agent_llm_model: Option, - agent_llm_auth: Option, - agent_confidence_threshold: Option, - agent_context_limit: Option, - agent_concurrency: Option, - lastfm_api_key: Option, - lastfm_shared_secret: Option, -} - -pub async fn settings_submit( - _admin: AuthenticatedUser, - _i18n: I18n, - _startup_config: &AppConfig, - db: &Database, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - match result { - FormResult::Ok(data) => { - let pw_enabled = if data.auth_password_enabled.is_some() { - "true" - } else { - "false" - }; - let sso_enabled = if data.auth_sso_enabled.is_some() { - "true" - } else { - "false" - }; - let swagger = if data.swagger_enabled.is_some() { - "true" - } else { - "false" - }; - let agent_en = if data.agent_enabled.is_some() { - "true" - } else { - "false" - }; - let oidc_button_text = data.oidc_button_text.unwrap_or_default(); - let oidc_issuer = data.oidc_issuer.unwrap_or_default(); - let oidc_client_id = data.oidc_client_id.unwrap_or_default(); - let oidc_client_secret = data.oidc_client_secret.unwrap_or_default(); - let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default(); - let oidc_user_groups = data.oidc_user_groups.unwrap_or_default(); - let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default(); - let agent_storage_dir = data.agent_storage_dir.unwrap_or_default(); - let agent_llm_url = data.agent_llm_url.unwrap_or_default(); - let agent_llm_model = data.agent_llm_model.unwrap_or_default(); - let agent_llm_auth = data.agent_llm_auth.unwrap_or_default(); - let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default(); - let agent_context_limit = data.agent_context_limit.unwrap_or_default(); - let agent_concurrency = data.agent_concurrency.unwrap_or_default(); - let lastfm_api_key = data.lastfm_api_key.unwrap_or_default(); - let lastfm_shared_secret = data.lastfm_shared_secret.unwrap_or_default(); - let fields: [(&str, &str); 20] = [ - ("auth_password_enabled", pw_enabled), - ("auth_sso_enabled", sso_enabled), - ("oidc_button_text", &oidc_button_text), - ("oidc_issuer", &oidc_issuer), - ("oidc_client_id", &oidc_client_id), - ("oidc_client_secret", &oidc_client_secret), - ("oidc_admin_groups", &oidc_admin_groups), - ("oidc_user_groups", &oidc_user_groups), - ("swagger_enabled", swagger), - ("agent_enabled", agent_en), - ("agent_inbox_dir", &agent_inbox_dir), - ("agent_storage_dir", &agent_storage_dir), - ("agent_llm_url", &agent_llm_url), - ("agent_llm_model", &agent_llm_model), - ("agent_llm_auth", &agent_llm_auth), - ("agent_confidence_threshold", &agent_confidence_threshold), - ("agent_context_limit", &agent_context_limit), - ("agent_concurrency", &agent_concurrency), - ("lastfm_api_key", &lastfm_api_key), - ("lastfm_shared_secret", &lastfm_shared_secret), - ]; - for (key, value) in fields { - let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned()); - if let Err(e) = entry.save(db).await { - tracing::error!(key, error = %e, "failed to save config entry"); - return Err(e.into()); - } - } - - Ok(auth::redirect("/admin/settings?saved=1")) - } - FormResult::ValidationError(_ctx) => { - tracing::warn!("settings form validation failed"); - Ok(auth::redirect("/admin/settings")) - } - } -} - -// --------------------------------------------------------------------------- -// Agent probe fragment (loaded via HTMX) -// --------------------------------------------------------------------------- - -#[derive(Debug, Template)] -#[template(path = "admin/probe_fragment.html")] -struct ProbeFragmentTemplate { - t: &'static Translations, - agent_enabled: bool, - agent_llm_url: String, - agent_probe: AgentProbeResult, -} - -pub async fn settings_probe_handler( - _admin: AuthenticatedUser, - i18n: I18n, - _startup_config: &AppConfig, - db: &Database, -) -> cot::Result { - let (config, _sources) = AppConfig::load_with_db(db).await; - - let probe = if config.agent_enabled && !config.agent_llm_url.is_empty() { - agent::probe_llm( - &config.agent_llm_url, - &config.agent_llm_model, - &config.agent_llm_auth, - ) - .await - } else { - AgentProbeResult::default() - }; - - let template = ProbeFragmentTemplate { - t: i18n.t, - agent_enabled: config.agent_enabled, - agent_llm_url: config.agent_llm_url, - agent_probe: probe, - }; - Ok(Html::new(template.render()?)) -} - -// --------------------------------------------------------------------------- -// User management -// --------------------------------------------------------------------------- - -#[derive(Debug, Template)] -#[template(path = "admin/users.html")] -struct UsersTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - users: Vec, -} - -pub async fn users_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result { - let users = User::list_all(db).await.unwrap_or_default(); - let template = UsersTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - users, - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Template)] -#[template(path = "admin/user_form.html")] -struct UserFormTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - is_edit: bool, - form_user_id: i64, - form_username: String, - form_email: String, - form_display_name: String, - form_role: String, -} - -pub async fn users_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result { - let template = UserFormTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - is_edit: false, - form_user_id: 0, - form_username: String::new(), - form_email: String::new(), - form_display_name: String::new(), - form_role: "user".into(), - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Form)] -pub struct UserForm { - username: String, - email: String, - display_name: String, - password: String, - role: String, -} - -pub async fn users_create( - _admin: AuthenticatedUser, - db: &Database, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - match result { - FormResult::Ok(data) => { - let email = if data.email.is_empty() { - None - } else { - Some(data.email.as_str()) - }; - let display_name = if data.display_name.is_empty() { - None - } else { - Some(data.display_name.as_str()) - }; - User::create( - db, - &data.username, - email, - display_name, - &data.password, - &data.role, - ) - .await - .map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?; - Ok(auth::redirect("/admin/users")) - } - FormResult::ValidationError(_) => Ok(auth::redirect("/admin/users/new")), - } -} - -pub async fn users_edit( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - user_id: i64, -) -> cot::Result { - let target = User::get_by_id(db, user_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("user not found"))?; - let template = UserFormTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - is_edit: true, - form_user_id: target.id_val(), - form_username: target.username_str().to_owned(), - form_email: target.email_str(), - form_display_name: target.display_name_str(), - form_role: target.role_str().to_owned(), - }; - Ok(Html::new(template.render()?)) -} - -pub async fn users_update( - _admin: AuthenticatedUser, - db: &Database, - user_id: i64, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - match result { - FormResult::Ok(data) => { - let mut target = User::get_by_id(db, user_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("user not found"))?; - let email = if data.email.is_empty() { - None - } else { - Some(data.email.as_str()) - }; - let display_name = if data.display_name.is_empty() { - None - } else { - Some(data.display_name.as_str()) - }; - let new_password = if data.password.is_empty() { - None - } else { - Some(data.password.as_str()) - }; - target - .update_fields( - db, - &data.username, - email, - display_name, - new_password, - &data.role, - ) - .await - .map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?; - Ok(auth::redirect("/admin/users")) - } - FormResult::ValidationError(_) => { - Ok(auth::redirect(&format!("/admin/users/{user_id}/edit"))) - } - } -} - -pub async fn users_delete( - _admin: AuthenticatedUser, - db: &Database, - user_id: i64, -) -> cot::Result> { - User::delete_by_id(db, user_id) - .await - .map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?; - Ok(auth::redirect("/admin/users")) -} - -// --------------------------------------------------------------------------- -// First-run setup page -// --------------------------------------------------------------------------- - -#[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 { - 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, -) -> cot::Result { - 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/")) -} - -// --------------------------------------------------------------------------- -// Artist management -// --------------------------------------------------------------------------- - -/// Row for artist list with computed stats. -#[derive(Debug)] -pub struct ArtistRow { - pub artist: Artist, - pub release_count: u64, - pub track_count: u64, -} - -#[derive(Debug, Template)] -#[template(path = "admin/artists.html")] -struct ArtistsTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - rows: Vec, -} - -pub async fn artists_list( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, -) -> cot::Result { - let artists = Artist::list_all(db).await.unwrap_or_default(); - let mut rows = Vec::with_capacity(artists.len()); - for artist in artists { - let release_count = ReleaseArtist::count_by_artist(db, artist.id_val()) - .await - .unwrap_or(0); - let track_count = TrackArtist::count_by_artist(db, artist.id_val()) - .await - .unwrap_or(0); - rows.push(ArtistRow { - artist, - release_count, - track_count, - }); - } - let template = ArtistsTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - rows, - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Template)] -#[template(path = "admin/artist_form.html")] -struct ArtistFormTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - is_edit: bool, - form_artist_id: i64, - form_name: String, - current_image_url: Option, -} - -pub async fn artists_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result { - let template = ArtistFormTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - is_edit: false, - form_artist_id: 0, - form_name: String::new(), - current_image_url: None, - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Form)] -pub struct ArtistForm { - name: String, -} - -pub async fn artists_create( - _admin: AuthenticatedUser, - db: &Database, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - match result { - FormResult::Ok(data) => { - Artist::create(db, &data.name, None) - .await - .map_err(|e| cot::Error::internal(format!("failed to create artist: {e}")))?; - Ok(auth::redirect("/admin/artists")) - } - FormResult::ValidationError(_) => Ok(auth::redirect("/admin/artists/new")), - } -} - -pub async fn artists_edit( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - artist_id: i64, -) -> cot::Result { - let artist = Artist::get_by_id(db, artist_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("artist not found"))?; - - let current_image_url = match artist.image_file_id { - Some(fid) => MediaFile::get_by_id(db, fid) - .await - .ok() - .flatten() - .map(|mf| format!("/api/player/cover/{}/large", mf.id_val())), - None => None, - }; - - let template = ArtistFormTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - is_edit: true, - form_artist_id: artist.id_val(), - form_name: artist.name_str().to_owned(), - current_image_url, - }; - Ok(Html::new(template.render()?)) -} - -pub async fn artists_update( - _admin: AuthenticatedUser, - db: &Database, - artist_id: i64, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - match result { - FormResult::Ok(data) => { - let mut artist = Artist::get_by_id(db, artist_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("artist not found"))?; - artist - .update_name(db, &data.name) - .await - .map_err(|e| cot::Error::internal(format!("failed to update artist: {e}")))?; - Ok(auth::redirect("/admin/artists")) - } - FormResult::ValidationError(_) => { - Ok(auth::redirect(&format!("/admin/artists/{artist_id}/edit"))) - } - } -} - -pub async fn artists_delete( - _admin: AuthenticatedUser, - db: &Database, - artist_id: i64, -) -> cot::Result> { - Artist::delete_by_id(db, artist_id) - .await - .map_err(|e| cot::Error::internal(format!("failed to delete artist: {e}")))?; - Ok(auth::redirect("/admin/artists")) -} - -// --------------------------------------------------------------------------- -// Artist image endpoints -// --------------------------------------------------------------------------- - -/// JSON response for available album covers for an artist. -#[derive(serde::Serialize)] -pub struct AvailableCover { - pub media_file_id: i64, - pub release_title: String, - pub cover_url: String, -} - -pub async fn artists_available_covers( - _admin: AuthenticatedUser, - db: &Database, - artist_id: i64, -) -> cot::Result> { - let links = ReleaseArtist::find_by_artist(db, artist_id) - .await - .unwrap_or_default(); - - let mut covers: Vec = Vec::new(); - for link in &links { - if let Ok(Some(release)) = Release::get_by_id(db, link.release_id()).await { - if let Some(cover_fid) = release.cover_file_id { - covers.push(AvailableCover { - media_file_id: cover_fid, - release_title: release.title_str().to_owned(), - cover_url: format!("/api/player/cover/{cover_fid}/medium"), - }); - } - } - } - - let json = serde_json::to_string(&covers).unwrap_or_else(|_| "[]".into()); - let resp = cot::http::Response::builder() - .status(cot::http::StatusCode::OK) - .header(cot::http::header::CONTENT_TYPE, "application/json") - .body(Body::fixed(json)) - .expect("valid response"); - Ok(resp) -} - -#[derive(serde::Deserialize)] -pub struct SetImageBody { - pub media_file_id: Option, -} - -pub async fn artists_set_image( - _admin: AuthenticatedUser, - db: &Database, - artist_id: i64, - parsed: SetImageBody, -) -> cot::Result> { - let mut artist = Artist::get_by_id(db, artist_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("artist not found"))?; - - // Validate media file exists when setting (not removing) - if let Some(fid) = parsed.media_file_id { - MediaFile::get_by_id(db, fid) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("media file not found"))?; - } - - artist - .set_image_file_id(db, parsed.media_file_id) - .await - .map_err(|e| cot::Error::internal(format!("failed to set image: {e}")))?; - - let resp = cot::http::Response::builder() - .status(cot::http::StatusCode::OK) - .header(cot::http::header::CONTENT_TYPE, "application/json") - .body(Body::fixed(r#"{"ok":true}"#)) - .expect("valid response"); - Ok(resp) -} - -#[derive(serde::Deserialize)] -pub struct UploadImageBody { - pub data: String, - pub filename: String, - pub mime_type: String, -} - -pub async fn artists_upload_image( - _admin: AuthenticatedUser, - db: &Database, - pool: &sqlx::PgPool, - config: &AppConfig, - artist_id: i64, - parsed: UploadImageBody, -) -> cot::Result> { - let mut artist = Artist::get_by_id(db, artist_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("artist not found"))?; - - let storage_dir = &config.agent_storage_dir; - if storage_dir.is_empty() { - return Err(cot::Error::internal("agent_storage_dir is not configured")); - } - - // Decode base64 image data - use base64::Engine; - let image_data = base64::engine::general_purpose::STANDARD - .decode(&parsed.data) - .map_err(|e| cot::Error::internal(format!("invalid base64: {e}")))?; - - // Build a CoverImage and reuse save_cover_to_storage - let cover = crate::agent::cover_art::CoverImage { - data: image_data, - mime_type: parsed.mime_type.clone(), - source: crate::agent::cover_art::CoverSource::FolderFile(std::path::PathBuf::from( - &parsed.filename, - )), - }; - - let cover_file_id = crate::agent::cover_art::save_cover_to_storage( - db, - pool, - storage_dir, - artist.name_str(), - "__artist_image__", - &cover, - ) - .await - .map_err(|e| cot::Error::internal(format!("failed to save image: {e}")))?; - - artist - .set_image_file_id(db, Some(cover_file_id)) - .await - .map_err(|e| cot::Error::internal(format!("failed to set image: {e}")))?; - - let resp = cot::http::Response::builder() - .status(cot::http::StatusCode::OK) - .header(cot::http::header::CONTENT_TYPE, "application/json") - .body(Body::fixed(r#"{"ok":true}"#)) - .expect("valid response"); - Ok(resp) -} - -// --------------------------------------------------------------------------- -// Release management -// --------------------------------------------------------------------------- - -/// Row for release list with resolved artist names. -#[derive(Debug)] -pub struct ReleaseRow { - pub release: Release, - pub artist_names: String, -} - -#[derive(Debug, Template)] -#[template(path = "admin/releases.html")] -struct ReleasesTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - rows: Vec, - artists: Vec, - filter_artist_id: Option, -} - -/// Build a map of artist_id → artist_name from a list of artists. -fn artist_name_map(artists: &[Artist]) -> HashMap { - artists - .iter() - .map(|a| (a.id_val(), a.name_str().to_owned())) - .collect() -} - -/// Resolve artist names for a release, joined by ", ". -async fn resolve_artist_names( - db: &Database, - release_id: i64, - names: &HashMap, -) -> String { - let links = ReleaseArtist::find_by_release(db, release_id) - .await - .unwrap_or_default(); - let mut sorted = links; - sorted.sort_by_key(|l| l.position); - sorted - .iter() - .filter_map(|l| names.get(&l.artist_id())) - .cloned() - .collect::>() - .join(", ") -} - -pub async fn releases_list( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - filter_artist_id: Option, -) -> cot::Result { - let all_artists = Artist::list_all(db).await.unwrap_or_default(); - let names = artist_name_map(&all_artists); - - let releases = Release::list_all(db).await.unwrap_or_default(); - - // If filtering by artist, find the set of release_ids for that artist - let filtered_release_ids: Option> = match filter_artist_id { - Some(aid) => { - let links = ReleaseArtist::find_by_artist(db, aid) - .await - .unwrap_or_default(); - Some(links.iter().map(|l| l.release_id()).collect()) - } - None => None, - }; - - let mut rows = Vec::new(); - for release in releases { - if let Some(ref ids) = filtered_release_ids { - if !ids.contains(&release.id_val()) { - continue; - } - } - let artist_names = resolve_artist_names(db, release.id_val(), &names).await; - rows.push(ReleaseRow { - release, - artist_names, - }); - } - - let template = ReleasesTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - rows, - artists: all_artists, - filter_artist_id, - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Template)] -#[template(path = "admin/release_form.html")] -struct ReleaseFormTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - is_edit: bool, - form_release_id: i64, - form_title: String, - form_release_type: String, - form_year: String, - form_artist_ids: Vec, - artists: Vec, - release_types: &'static [(&'static str, &'static str, &'static str)], - lang_code: &'static str, -} - -pub async fn releases_new( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, -) -> cot::Result { - let artists = Artist::list_all(db).await.unwrap_or_default(); - let template = ReleaseFormTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - is_edit: false, - form_release_id: 0, - form_title: String::new(), - form_release_type: "album".into(), - form_year: String::new(), - form_artist_ids: Vec::new(), - artists, - release_types: RELEASE_TYPES, - lang_code: i18n.t.lang.code(), - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Form)] -pub struct ReleaseForm { - title: String, - release_type: String, - year: String, - artist_id: String, // comma-separated IDs or single ID -} - -fn parse_artist_ids(raw: &str) -> Vec { - raw.split(',') - .filter_map(|s| s.trim().parse::().ok()) - .collect() -} - -pub async fn releases_create( - _admin: AuthenticatedUser, - db: &Database, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - match result { - FormResult::Ok(data) => { - let year = data.year.trim().parse::().ok(); - let release = Release::create(db, &data.title, &data.release_type, year, None) - .await - .map_err(|e| cot::Error::internal(format!("failed to create release: {e}")))?; - let artist_ids = parse_artist_ids(&data.artist_id); - if !artist_ids.is_empty() { - ReleaseArtist::set_artists(db, release.id_val(), &artist_ids) - .await - .map_err(|e| cot::Error::internal(format!("failed to link artists: {e}")))?; - } - Ok(auth::redirect("/admin/releases")) - } - FormResult::ValidationError(_) => Ok(auth::redirect("/admin/releases/new")), - } -} - -pub async fn releases_edit( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - release_id: i64, -) -> cot::Result { - let release = Release::get_by_id(db, release_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("release not found"))?; - - let links = ReleaseArtist::find_by_release(db, release_id) - .await - .unwrap_or_default(); - let mut current_artist_ids: Vec = links.iter().map(|l| l.artist_id()).collect(); - current_artist_ids.sort(); - - let artists = Artist::list_all(db).await.unwrap_or_default(); - - let template = ReleaseFormTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - is_edit: true, - form_release_id: release.id_val(), - form_title: release.title_str().to_owned(), - form_release_type: release.release_type_str().to_owned(), - form_year: release.year_display(), - form_artist_ids: current_artist_ids, - artists, - release_types: RELEASE_TYPES, - lang_code: i18n.t.lang.code(), - }; - Ok(Html::new(template.render()?)) -} - -pub async fn releases_update( - _admin: AuthenticatedUser, - db: &Database, - release_id: i64, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - match result { - FormResult::Ok(data) => { - let mut release = Release::get_by_id(db, release_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("release not found"))?; - let year = data.year.trim().parse::().ok(); - release - .update_fields(db, &data.title, &data.release_type, year) - .await - .map_err(|e| cot::Error::internal(format!("failed to update release: {e}")))?; - let artist_ids = parse_artist_ids(&data.artist_id); - ReleaseArtist::set_artists(db, release_id, &artist_ids) - .await - .map_err(|e| cot::Error::internal(format!("failed to update artists: {e}")))?; - Ok(auth::redirect("/admin/releases")) - } - FormResult::ValidationError(_) => Ok(auth::redirect(&format!( - "/admin/releases/{release_id}/edit" - ))), - } -} - -pub async fn releases_delete( - _admin: AuthenticatedUser, - db: &Database, - pool: &sqlx::PgPool, - release_id: i64, -) -> cot::Result> { - let (config, _) = AppConfig::load_with_db(db).await; - crate::library_cleanup::delete_releases(pool, &[release_id], &config.agent_storage_dir) - .await - .map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?; - Ok(auth::redirect("/admin/releases")) -} - -// =========================================================================== -// Media Files -// =========================================================================== - -#[derive(Debug)] -pub struct MediaFileRow { - pub media_file: MediaFile, - pub track_title: String, -} - -#[derive(Debug, Template)] -#[template(path = "admin/media_files.html")] -struct MediaFilesTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - rows: Vec, -} - -pub async fn media_files_list( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, -) -> cot::Result { - let files = MediaFile::list_all(db).await.unwrap_or_default(); - let tracks = Track::list_all(db).await.unwrap_or_default(); - - // Build a map of audio_file_id → track title - let track_map: HashMap = tracks - .iter() - .map(|t| (t.audio_file_id, t.title.to_string())) - .collect(); - - let rows: Vec = files - .into_iter() - .map(|mf| { - let track_title = track_map.get(&mf.id_val()).cloned().unwrap_or_default(); - MediaFileRow { - media_file: mf, - track_title, - } - }) - .collect(); - - let template = MediaFilesTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - rows, - }; - Ok(Html::new(template.render()?)) -} - -pub async fn media_files_delete( - _admin: AuthenticatedUser, - db: &Database, - file_id: i64, -) -> cot::Result> { - MediaFile::delete_by_id(db, file_id) - .await - .map_err(|e| cot::Error::internal(format!("failed to delete media file: {e}")))?; - Ok(auth::redirect("/admin/media-files")) -} - -// =========================================================================== -// Jobs -// =========================================================================== - -#[derive(Debug, Template)] -#[template(path = "admin/jobs.html")] -struct JobsTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - jobs: Vec, -} - -pub async fn jobs_list( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - registry: &JobRegistry, -) -> cot::Result { - // Ensure all registered jobs exist in DB and remove orphans - sync_registered_jobs(db, registry).await; - - let jobs = ScheduledJob::list_all(db).await.unwrap_or_default(); - let template = JobsTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - jobs, - }; - Ok(Html::new(template.render()?)) -} - -/// Ensure the DB has a ScheduledJob row for every registered job and remove -/// rows for jobs that are no longer registered. -async fn sync_registered_jobs(db: &Database, registry: &JobRegistry) { - for job in registry.all_jobs() { - if let Err(e) = - ScheduledJob::upsert(db, job.name(), job.description(), job.default_cron()).await - { - tracing::error!("failed to upsert scheduled job {}: {e}", job.name()); - } - } - if let Ok(all) = ScheduledJob::list_all(db).await { - for sched_job in all { - if registry.get(sched_job.name_str()).is_none() { - tracing::warn!("Removing orphaned scheduled job '{}'", sched_job.name_str()); - let _ = ScheduledJob::delete_by_name(db, sched_job.name_str()).await; - } - } - } -} - -#[derive(Debug, Template)] -#[template(path = "admin/job_detail.html")] -struct JobDetailTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - job: ScheduledJob, - runs: Vec, -} - -#[derive(Debug, Form)] -pub struct MetadataBackfillForm { - audio_bitrate: Option, - audio_sample_rate: Option, - audio_bit_depth: Option, - duration_seconds: Option, - local_genres: Option, - lastfm_tags: Option, - mode: Option, -} - -pub async fn job_detail( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - pool: &sqlx::PgPool, - job_name: &str, -) -> cot::Result { - let job = ScheduledJob::get_by_name(db, job_name) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("job not found"))?; - - let runs = JobRun::list_by_job(pool, job_name, 50) - .await - .unwrap_or_default(); - - let template = JobDetailTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - job, - runs, - }; - Ok(Html::new(template.render()?)) -} - -pub async fn job_run_now( - _admin: AuthenticatedUser, - handle_cell: &Arc>>, - job_name: &str, -) -> cot::Result> { - if let Some(handle) = handle_cell.get() { - match handle.trigger_job_now(job_name).await { - Ok(_run_id) => {} - Err(e) => { - tracing::error!(?e, job_name, "manual job trigger failed"); - } - } - } else { - tracing::error!(job_name, "scheduler not ready, cannot trigger job"); - } - Ok(auth::redirect(&format!("/admin/jobs/{job_name}"))) -} - -#[derive(Debug, Form)] -pub struct LastfmPopularityForm { - refresh_existing: Option, -} - -pub async fn lastfm_popularity_run( - _admin: AuthenticatedUser, - db: &Database, - pool: &sqlx::PgPool, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - let FormResult::Ok(data) = result else { - return Ok(auth::redirect("/admin/jobs/lastfm_popularity")); - }; - let options = crate::jobs::lastfm_popularity::LastfmPopularityOptions { - refresh_existing: data.refresh_existing.is_some(), - }; - let run_id = super::start_lastfm_popularity(db, pool, options).await?; - Ok(auth::redirect(&format!( - "/admin/jobs/lastfm_popularity/runs/{run_id}" - ))) -} - -pub async fn metadata_backfill_run( - _admin: AuthenticatedUser, - db: &Database, - pool: &sqlx::PgPool, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - let data = match result { - FormResult::Ok(data) => data, - FormResult::ValidationError(_) => { - return Ok(auth::redirect("/admin/jobs/metadata_backfill")); - } - }; - - let options = crate::jobs::metadata_backfill::MetadataBackfillOptions { - audio_bitrate: data.audio_bitrate.is_some(), - audio_sample_rate: data.audio_sample_rate.is_some(), - audio_bit_depth: data.audio_bit_depth.is_some(), - duration_seconds: data.duration_seconds.is_some(), - local_genres: data.local_genres.is_some(), - lastfm_tags: data.lastfm_tags.is_some(), - musicbrainz_tags: true, - overwrite: data.mode.as_deref() == Some("overwrite"), - }; - - let mut run = JobRun::create_running(db, "metadata_backfill", "manual") - .await - .map_err(|e| cot::Error::internal(format!("failed to create job run: {e}")))?; - let run_id = run.id_val(); - let db = db.clone(); - let pool = pool.clone(); - let (live_config, _) = AppConfig::load_with_db(&db).await; - - tokio::spawn(async move { - let start = std::time::Instant::now(); - let ctx = scheduler::JobContext { - config: Arc::new(live_config), - db: db.clone(), - pool: pool.clone(), - run_id, - registry: Arc::new(JobRegistry::new()), - }; - let mut log = scheduler::JobLog::with_live_flush(pool.clone(), run_id); - let result = - crate::jobs::metadata_backfill::run_with_options(&ctx, &mut log, options).await; - let duration_ms = start.elapsed().as_millis() as i64; - match result { - Ok(()) => { - let _ = run.set_completed(&db, duration_ms, &log.output()).await; - } - Err(e) => { - let _ = run - .set_failed(&db, duration_ms, &log.output(), &e.to_string()) - .await; - } - } - }); - - Ok(auth::redirect(&format!( - "/admin/jobs/metadata_backfill/runs/{run_id}" - ))) -} - -pub async fn job_toggle_enabled( - _admin: AuthenticatedUser, - db: &Database, - handle_cell: &Arc>>, - job_name: &str, -) -> cot::Result> { - if job_name == "metadata_backfill" { - return Ok(auth::redirect("/admin/jobs/metadata_backfill")); - } - - let job = ScheduledJob::get_by_name(db, job_name) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("job not found"))?; - - let new_enabled = !job.enabled; - - if let Some(handle) = handle_cell.get() { - if let Err(e) = handle.toggle_job(job_name, new_enabled).await { - tracing::error!(?e, job_name, new_enabled, "toggle_job failed"); - } - } else { - tracing::error!(job_name, "scheduler not ready, cannot toggle job"); - } - - Ok(auth::redirect("/admin/jobs")) -} - -#[derive(Debug, Form)] -pub struct CronForm { - cron_expression: String, -} - -pub async fn job_update_cron( - _admin: AuthenticatedUser, - _db: &Database, - handle_cell: &Arc>>, - job_name: &str, - form: RequestForm, -) -> cot::Result> { - if job_name == "metadata_backfill" { - return Ok(auth::redirect("/admin/jobs/metadata_backfill")); - } - - let RequestForm(result) = form; - if let FormResult::Ok(data) = result { - if let Some(handle) = handle_cell.get() { - if let Err(e) = handle.reschedule_job(job_name, &data.cron_expression).await { - tracing::error!(?e, job_name, "reschedule_job failed"); - } - } else { - tracing::error!(job_name, "scheduler not ready, cannot update cron"); - } - } - Ok(auth::redirect(&format!("/admin/jobs/{job_name}"))) -} - -#[derive(Debug, Template)] -#[template(path = "admin/job_run_detail.html")] -struct JobRunDetailTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - run: JobRun, - job_name: String, -} - -pub async fn job_run_detail( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - _job_name: &str, - run_id: i64, -) -> cot::Result { - let run = JobRun::get_by_id(db, run_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("run not found"))?; - - let template = JobRunDetailTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - job_name: run.job_name.to_string(), - run, - }; - Ok(Html::new(template.render()?)) -} - -// =========================================================================== -// Reviews -// =========================================================================== - -#[derive(Debug, Template)] -#[template(path = "admin/reviews.html")] -struct ReviewsTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - rows: Vec, - stats_map: HashMap, - status_filter: String, -} - -#[derive(Debug)] -struct ReviewListRow { - review: PendingReview, - display_input_path: String, - media_tags: Vec, -} - -#[derive(Debug, Clone)] -struct ReviewMediaTag { - label: String, - kind: &'static str, -} - -#[derive(Debug, sqlx::FromRow)] -struct ReviewMediaTagRow { - sha256_hash: String, - original_filename: String, - file_size_bytes: i64, - audio_format: Option, - audio_bitrate: Option, - audio_sample_rate: Option, - audio_bit_depth: Option, -} - -fn compact_path_tail(path: &str, max_chars: usize) -> String { - let normalized = path.replace('\\', "/"); - if normalized.chars().count() <= max_chars { - return normalized; - } - - let segments = normalized.split('/').collect::>(); - let filename = segments.last().copied().unwrap_or(normalized.as_str()); - let filename_len = filename.chars().count(); - if filename_len + 4 <= max_chars { - return format!(".../{filename}"); - } - - if filename_len > max_chars { - let suffix_len = max_chars.saturating_sub(3); - let suffix = filename - .chars() - .skip(filename_len.saturating_sub(suffix_len)) - .collect::(); - return format!("...{suffix}"); - } - format!(".../{filename}") -} - -fn context_sha256(review: &PendingReview) -> Option { - let value = serde_json::from_str::(review.context_json_str()).ok()?; - let sha = value.get("sha256")?.as_str()?.trim(); - let is_sha256 = sha.len() == 64 && sha.chars().all(|ch| ch.is_ascii_hexdigit()); - is_sha256.then(|| sha.to_ascii_lowercase()) -} - -fn file_extension(filename: &str) -> Option { - std::path::Path::new(filename) - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.trim().to_ascii_lowercase()) - .filter(|ext| !ext.is_empty()) -} - -fn size_display(bytes: i64) -> String { - if bytes >= 1_073_741_824 { - format!("{:.1} GB", bytes as f64 / 1_073_741_824.0) - } else if bytes >= 1_048_576 { - format!("{:.1} MB", bytes as f64 / 1_048_576.0) - } else if bytes >= 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else { - format!("{bytes} B") - } -} - -fn review_tag(label: impl Into, kind: &'static str) -> ReviewMediaTag { - ReviewMediaTag { - label: label.into(), - kind, - } -} - -fn media_tags(row: &ReviewMediaTagRow) -> Vec { - let mut tags = Vec::new(); - if let Some(format) = row.audio_format.as_deref().filter(|s| !s.is_empty()) { - tags.push(review_tag(format.to_ascii_lowercase(), "format")); - } else if let Some(ext) = file_extension(&row.original_filename) { - tags.push(review_tag(ext, "format")); - } - if let Some(bitrate) = row.audio_bitrate { - tags.push(review_tag(format!("{bitrate} kbps"), "bitrate")); - } - if let Some(sample_rate) = row.audio_sample_rate { - if sample_rate % 1000 == 0 { - tags.push(review_tag(format!("{} kHz", sample_rate / 1000), "sample")); - } else { - tags.push(review_tag( - format!("{:.1} kHz", sample_rate as f64 / 1000.0), - "sample", - )); - } - } - if let Some(bit_depth) = row.audio_bit_depth { - tags.push(review_tag(format!("{bit_depth}-bit"), "depth")); - } - tags.push(review_tag(size_display(row.file_size_bytes), "size")); - tags -} - -async fn review_media_tags( - pool: &sqlx::PgPool, - reviews: &[PendingReview], -) -> HashMap> { - let mut hashes = reviews - .iter() - .filter_map(context_sha256) - .collect::>(); - hashes.sort(); - hashes.dedup(); - if hashes.is_empty() { - return HashMap::new(); - } - - let quoted = hashes - .iter() - .map(|hash| format!("'{hash}'")) - .collect::>() - .join(","); - let query = format!( - "SELECT sha256_hash::text AS sha256_hash, \ - original_filename::text AS original_filename, \ - file_size_bytes, \ - audio_format::text AS audio_format, \ - audio_bitrate, audio_sample_rate, audio_bit_depth \ - FROM furumusic__media_file \ - WHERE file_type = 'audio' AND sha256_hash IN ({quoted})" - ); - - match sqlx::query_as::<_, ReviewMediaTagRow>(&query) - .fetch_all(pool) - .await - { - Ok(rows) => rows - .into_iter() - .map(|row| (row.sha256_hash.to_ascii_lowercase(), media_tags(&row))) - .collect(), - Err(e) => { - tracing::warn!(error = %e, "failed to load review media tags"); - HashMap::new() - } - } -} - -pub async fn reviews_list( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - pool: &sqlx::PgPool, - status: Option<&str>, -) -> cot::Result { - let reviews = match status { - Some(s) if !s.is_empty() => PendingReview::list_by_status(db, s) - .await - .unwrap_or_default(), - _ => PendingReview::list_all(db).await.unwrap_or_default(), - }; - - let review_ids: Vec = reviews.iter().map(|r| r.id_val()).collect(); - let stats_map = scheduler::ProcessingStats::list_by_review_ids(pool, &review_ids) - .await - .unwrap_or_default(); - let media_tags = review_media_tags(pool, &reviews).await; - let rows = reviews - .into_iter() - .map(|review| { - let media_tags = context_sha256(&review) - .and_then(|sha| media_tags.get(&sha).cloned()) - .unwrap_or_default(); - let display_input_path = compact_path_tail(review.input_path_str(), 80); - ReviewListRow { - review, - display_input_path, - media_tags, - } - }) - .collect(); - - let template = ReviewsTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - rows, - stats_map, - status_filter: status.unwrap_or("").to_owned(), - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Template)] -#[template(path = "admin/review_detail.html")] -struct ReviewDetailTemplate { - t: &'static Translations, - user_name: String, - user_role: String, - review: PendingReview, - edit: ReviewEditFields, - release_types: &'static [(&'static str, &'static str, &'static str)], - lang_code: &'static str, - context_pretty: String, - result_pretty: String, - error_message: String, - stats: Option, -} - -#[derive(Debug, Default)] -struct ReviewEditFields { - title: String, - artist: String, - album: String, - year: String, - track_number: String, - genre: String, - featured_artists: String, - release_type: String, - notes: String, -} - -#[derive(Debug, Form)] -pub struct ReviewApproveForm { - title: String, - artist: String, - album: String, - year: String, - track_number: String, - genre: String, - featured_artists: String, - release_type: String, - notes: String, -} - -fn optional_trimmed(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -fn parse_optional_i32(value: &str) -> Option { - value.trim().parse::().ok() -} - -fn parse_featured_artists(value: &str) -> Vec { - value - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(str::to_owned) - .collect() -} - -fn edit_fields_from_normalized( - normalized: &crate::agent::dto::NormalizedFields, -) -> ReviewEditFields { - ReviewEditFields { - title: normalized.title.clone().unwrap_or_default(), - artist: normalized.artist.clone().unwrap_or_default(), - album: normalized.album.clone().unwrap_or_default(), - year: normalized.year.map(|v| v.to_string()).unwrap_or_default(), - track_number: normalized - .track_number - .map(|v| v.to_string()) - .unwrap_or_default(), - genre: normalized.genre.clone().unwrap_or_default(), - featured_artists: normalized.featured_artists.join(", "), - release_type: normalized - .release_type - .clone() - .unwrap_or_else(|| "album".to_owned()), - notes: normalized.notes.clone().unwrap_or_default(), - } -} - -fn normalized_from_result_json(result_json: &str) -> crate::agent::dto::NormalizedFields { - serde_json::from_str(result_json).unwrap_or_default() -} - -fn normalized_from_review_form(form: &ReviewApproveForm) -> crate::agent::dto::NormalizedFields { - crate::agent::dto::NormalizedFields { - title: optional_trimmed(&form.title), - artist: optional_trimmed(&form.artist), - album: optional_trimmed(&form.album), - year: parse_optional_i32(&form.year), - track_number: parse_optional_i32(&form.track_number), - genre: optional_trimmed(&form.genre), - featured_artists: parse_featured_artists(&form.featured_artists), - release_type: optional_trimmed(&form.release_type).or_else(|| Some("album".to_owned())), - confidence: Some(1.0), - notes: optional_trimmed(&form.notes), - } -} - -pub async fn review_detail( - admin: AuthenticatedUser, - i18n: I18n, - db: &Database, - review_id: i64, -) -> cot::Result { - let review = PendingReview::get_by_id(db, review_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("review not found"))?; - - let context_pretty = review - .context_json - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default()) - .unwrap_or_default(); - - let result_pretty = review - .result_json - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default()) - .unwrap_or_default(); - - let error_message = review.error_message_str().to_owned(); - - let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id) - .await - .unwrap_or(None); - let normalized = normalized_from_result_json(review.result_json_str()); - let edit = edit_fields_from_normalized(&normalized); - - let template = ReviewDetailTemplate { - t: i18n.t, - user_name: admin.name, - user_role: admin.role.code().to_owned(), - review, - edit, - release_types: RELEASE_TYPES, - lang_code: i18n.t.lang.code(), - context_pretty, - result_pretty, - error_message, - stats, - }; - Ok(Html::new(template.render()?)) -} - -pub async fn review_approve( - _admin: AuthenticatedUser, - _config: &Arc, - db: &Database, - pool: &sqlx::PgPool, - review_id: i64, - form: RequestForm, -) -> cot::Result> { - let mut review = PendingReview::get_by_id(db, review_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("review not found"))?; - - let RequestForm(form_result) = form; - let normalized = match form_result { - FormResult::Ok(data) => normalized_from_review_form(&data), - FormResult::ValidationError(_) => { - return Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))); - } - }; - let result_str = serde_json::to_string(&normalized) - .map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?; - review - .set_result_json(db, result_str) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; - let context_str = review.context_json_str().to_owned(); - let input_path = review.input_path_str().to_owned(); - - let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default(); - - // Load live config from DB so admin-set values are used - let (live_config, _) = AppConfig::load_with_db(db).await; - - // Look up the model name from processing stats (if LLM processed this review) - let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id) - .await - .unwrap_or(None); - let model_name_str = 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_str.as_deref(), - ) - .await - { - Ok(()) => { - let _ = review.set_approved(db).await; - } - Err(e) => { - tracing::error!(?e, "review approval failed"); - let _ = review.set_rejected(db).await; - } - } - - Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))) -} - -#[derive(Debug, Form)] -pub struct ReviewsBulkForm { - selected_ids: Option, - action: Option, - status_filter: Option, -} - -fn parse_review_ids(raw: &str) -> Vec { - let mut ids = raw - .split(',') - .filter_map(|part| part.trim().parse::().ok()) - .filter(|id| *id > 0) - .collect::>(); - ids.sort_unstable(); - ids.dedup(); - ids -} - -fn reviews_redirect(status: Option<&str>) -> String { - match status { - Some(s) if !s.is_empty() => format!("/admin/reviews?status={s}"), - _ => "/admin/reviews".to_owned(), - } -} - -pub async fn reviews_bulk( - _admin: AuthenticatedUser, - db: &Database, - form: RequestForm, -) -> cot::Result> { - let RequestForm(result) = form; - let data = match result { - FormResult::Ok(data) => data, - FormResult::ValidationError(_) => return Ok(auth::redirect("/admin/reviews")), - }; - - let redirect_url = reviews_redirect(data.status_filter.as_deref()); - let ids = parse_review_ids(data.selected_ids.as_deref().unwrap_or_default()); - if ids.is_empty() { - return Ok(auth::redirect(&redirect_url)); - } - - match data.action.as_deref() { - Some("delete") => { - PendingReview::delete_by_ids(db, &ids) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; - } - Some("requeue") => { - PendingReview::requeue_by_ids(db, &ids) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; - } - _ => {} - } - - Ok(auth::redirect(&redirect_url)) -} - -pub async fn review_reject( - _admin: AuthenticatedUser, - db: &Database, - review_id: i64, -) -> cot::Result> { - let mut review = PendingReview::get_by_id(db, review_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("review not found"))?; - let _ = review.set_rejected(db).await; - Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))) -} - -pub async fn reviews_clear( - _admin: AuthenticatedUser, - db: &Database, - status: Option<&str>, -) -> cot::Result> { - match status { - Some(s) if !s.is_empty() => { - PendingReview::delete_by_status(db, s) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; - } - _ => { - PendingReview::delete_all(db) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; - } - } - let redirect_url = match status { - Some(s) if !s.is_empty() => format!("/admin/reviews?status={s}"), - _ => "/admin/reviews".to_owned(), - }; - Ok(auth::redirect(&redirect_url)) -} - -pub async fn review_requeue( - _admin: AuthenticatedUser, - db: &Database, - review_id: i64, -) -> cot::Result> { - let mut review = PendingReview::get_by_id(db, review_id) - .await - .map_err(|e| cot::Error::internal(format!("db error: {e}")))? - .ok_or_else(|| cot::Error::internal("review not found"))?; - let _ = review.set_queued(db).await; - Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))) -} diff --git a/src/agent/dedup.rs b/src/agent/dedup.rs index 8822cdc..6065686 100644 --- a/src/agent/dedup.rs +++ b/src/agent/dedup.rs @@ -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() diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 577e7ca..0763d69 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -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" , "Создать новый плейлист"; diff --git a/src/import_playlists.rs b/src/import_playlists.rs new file mode 100644 index 0000000..efc866d --- /dev/null +++ b/src/import_playlists.rs @@ -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, +) -> 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) -> 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 = 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 = 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 { + 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 { + let playlist: Option = 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 = 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; + } +} diff --git a/src/jobs/inbox_discover.rs b/src/jobs/inbox_discover.rs index 98e17b0..2ca97ec 100644 --- a/src/jobs/inbox_discover.rs +++ b/src/jobs/inbox_discover.rs @@ -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() { diff --git a/src/jobs/inbox_process.rs b/src/jobs/inbox_process.rs index 794bcc0..bfd29aa 100644 --- a/src/jobs/inbox_process.rs +++ b/src/jobs/inbox_process.rs @@ -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 { diff --git a/src/local_uploads.rs b/src/local_uploads.rs index 1abd5e1..e68eac1 100644 --- a/src/local_uploads.rs +++ b/src/local_uploads.rs @@ -103,6 +103,7 @@ pub struct LocalUploadDto { pub created_at: String, pub updated_at: String, pub completed_at: Option, + pub target_playlist_id: Option, } #[derive(Debug, Clone, FromRow)] @@ -117,6 +118,7 @@ struct LocalUploadRow { created_at: String, updated_at: String, completed_at: Option, + target_playlist_id: Option, } 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, ) -> 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> { 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> { sync_statuses(pool, user_id, inbox_dir).await?; + crate::import_playlists::reconcile(pool, Some(user_id)).await?; let rows: Vec = 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 = 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 { 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) diff --git a/src/main.rs b/src/main.rs index d779a5e..004346a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod auth; mod config; mod federation; mod i18n; +mod import_playlists; mod jobs; mod lastfm; mod library_cleanup; diff --git a/src/metrics.rs b/src/metrics.rs index 407f40e..a3cbeb5 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -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"), diff --git a/src/music/mod.rs b/src/music/mod.rs index bb8d712..2ec4b03 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -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> { - 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, - ) -> 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> { - 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 { - 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 { - 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> { - Self::objects().all(db).await - } - pub async fn create( db: &Database, title: &str, @@ -2875,6 +2791,53 @@ pub mod db_migrations { &[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, @@ -2916,5 +2879,7 @@ pub mod db_migrations { &M0048AddPlaybackCoordinationColumns, &M0049AddLastfmLookupState, &M0050CreateMetadataFixHistory, + &M0051AddListenStatisticsIndexes, + &M0052AddImportPlaylistDestinations, ]; } diff --git a/src/player/mod.rs b/src/player/mod.rs index 84adc6a..5121b51 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -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::().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(), diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index c3e7228..253c019 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -80,19 +80,8 @@ impl ScheduledJob { cron_expression: &str, ) -> cot::db::Result { 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> { - 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::>() - .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(()) diff --git a/src/torrents.rs b/src/torrents.rs index fe2c325..246ca27 100644 --- a/src/torrents.rs +++ b/src/torrents.rs @@ -58,6 +58,7 @@ pub struct TorrentJobDto { pub created_at: Option, pub updated_at: Option, pub completed_at: Option, + pub target_playlist_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -93,6 +94,7 @@ pub struct TorrentPreviewRequest { #[derive(Debug, Deserialize)] pub struct TorrentStartRequest { + pub target_playlist_id: Option, pub selected_files: Vec, } @@ -153,6 +155,7 @@ struct TorrentJob { created_at: String, updated_at: String, completed_at: Option, + target_playlist_id: Option, } #[derive(Debug, FromRow)] @@ -176,6 +179,7 @@ struct TorrentSessionRow { created_at: String, updated_at: String, completed_at: Option, + target_playlist_id: Option, } 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, + target_playlist_id: Option, 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 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::::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 = + 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; + } +} diff --git a/src/user.rs b/src/user.rs index ef36e88..f528d9c 100644 --- a/src/user.rs +++ b/src/user.rs @@ -26,11 +26,6 @@ pub struct User { // --------------------------------------------------------------------------- impl User { - /// List all users. - pub async fn list_all(db: &Database) -> cot::db::Result> { - 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> { 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 } diff --git a/src/youtube.rs b/src/youtube.rs index 6aab662..d46dd30 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -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 { - let mut transaction = pool.begin().await?; - let playlist: Option = 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<()> { diff --git a/templates/admin/artist_form.html b/templates/admin/artist_form.html deleted file mode 100644 index fdc6bb0..0000000 --- a/templates/admin/artist_form.html +++ /dev/null @@ -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 %} -

{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}

- -
- - - - - -
- -
- -{% if is_edit %} -
-

{{ t.artists_image }}

- -
- -
- {% match current_image_url %} - {% when Some with (url) %} - -
- - {% when None %} -

{{ t.artists_no_image }}

- {% endmatch %} -
- - -
-

{{ t.artists_upload_image }}

- -
- -
- - -
-

{{ t.artists_pick_cover }}

-
-

...

-
-
-
- - -{% endif %} -{% endblock content %} diff --git a/templates/admin/artists.html b/templates/admin/artists.html deleted file mode 100644 index 1c704b0..0000000 --- a/templates/admin/artists.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.nav_artists }}{% endblock admin_title %} - -{% block content %} -

{{ t.artists_heading }}

- -

- {{ t.artists_add }} -

- -{% if rows.is_empty() %} -

{{ t.artists_empty }}

-{% else %} - - - - - - - - - - {% for row in rows %} - - - - - - - - - {% endfor %} -
ID{{ t.artists_name }}{{ t.artists_releases }}{{ t.artists_tracks }}{{ t.artists_hidden }}{{ t.artists_actions }}
{{ row.artist.id_val() }}{{ row.artist.name_str() }} - {{ row.release_count }} - {{ row.track_count }}{{ row.artist.is_hidden() }} - {{ t.artists_edit }} -  |  - {{ t.artists_view_releases }} -  |  -
- -
-
-{% endif %} -{% endblock content %} diff --git a/templates/admin/debug.html b/templates/admin/debug.html deleted file mode 100644 index 25f087c..0000000 --- a/templates/admin/debug.html +++ /dev/null @@ -1,82 +0,0 @@ -{% extends "admin/layout.html" %} - -{% block admin_title %}{{ t.nav_debug }}{% endblock admin_title %} - -{% block content %} - - -

{{ t.debug_heading }}

- -

{{ t.debug_build_info }}

- - - - - - - - -
{{ t.debug_field }}{{ t.debug_value }}
Package{{ build.pkg_name }}
Version{{ build.pkg_version }}
Profile{{ build.profile }}
Target{{ build.target }}
Rustc{{ build.rustc_version }}
{{ t.debug_db_status }}{{ db_status }}
- -

{{ t.debug_app_config }}

- - - - - - - {% for entry in config_entries %} - - - - - - {% endfor %} -
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}
- - {{ entry.key }} - i -
-
env var
-
{{ entry.env_var }}
-
default
-
{% if entry.default_value == "" %}(empty){% else %}{{ entry.default_value }}{% endif %}
-
source
-
{{ entry.source }}
-
-
-
{{ entry.value }}{{ entry.source }}
-{% endblock content %} diff --git a/templates/admin/index.html b/templates/admin/index.html deleted file mode 100644 index 8d1933d..0000000 --- a/templates/admin/index.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "admin/layout.html" %} - -{% block admin_title %}{{ t.nav_dashboard }}{% endblock admin_title %} - -{% block content %} -

{{ t.admin_heading }}

-

{{ t.admin_debug_link }}

-{% endblock content %} diff --git a/templates/admin/job_detail.html b/templates/admin/job_detail.html deleted file mode 100644 index acfc776..0000000 --- a/templates/admin/job_detail.html +++ /dev/null @@ -1,120 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ job.name_str() }}{% endblock admin_title %} - -{% block content %} -

{{ job.name_str() }}

- - - - - - - -
{{ t.jobs_description }}{{ job.description_str() }}
{{ t.jobs_cron }}{{ job.cron_expression_str() }}
{{ t.jobs_enabled }}{% if job.enabled() %}✅{% else %}❌{% endif %}
{{ t.jobs_last_run }}{{ job.last_run_at_str() }}
{{ t.jobs_next_run }}{{ job.next_run_at_str() }}
- -
- {% if job.name_str() != "metadata_backfill" && job.name_str() != "lastfm_popularity" %} -
- -
- {% endif %} - {% if job.name_str() != "metadata_backfill" %} -
- {% if job.enabled() %} - - {% else %} - - {% endif %} -
- {% endif %} -
- -{% if job.name_str() == "lastfm_popularity" %} -
-

{{ t.jobs_lastfm_light_help }}

- - -
-{% endif %} - -{% if job.name_str() == "metadata_backfill" %} -

{{ t.jobs_metadata_backfill_options }}

-
-
- {{ t.jobs_metadata_backfill_fields }} - - - - - - -
-
- - -
- -
-{% endif %} - -{% if job.name_str() != "metadata_backfill" %} -

{{ t.jobs_cron }}

-

{{ t.jobs_cron_help }}

-
- - -
-{% endif %} - -

{{ t.jobs_run_history }}

-{% if runs.is_empty() %} -

No runs yet.

-{% else %} - - - - - - - - - - {% for run in runs %} - - - - - - - - - {% endfor %} -
ID{{ t.jobs_run_status }}{{ t.jobs_run_started }}{{ t.jobs_run_duration }}{{ t.jobs_run_trigger }}{{ t.jobs_actions }}
{{ run.id_val() }}{{ run.status_str() }}{{ run.started_at_str() }}{{ run.duration_display() }}{{ run.trigger_str() }}{{ t.reviews_view }}
-{% endif %} - -

← {{ t.jobs_back_to_list }}

- - -{% endblock content %} diff --git a/templates/admin/job_run_detail.html b/templates/admin/job_run_detail.html deleted file mode 100644 index d277094..0000000 --- a/templates/admin/job_run_detail.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.jobs_run_detail }} #{{ run.id_val() }}{% endblock admin_title %} - -{% block content %} -

{{ t.jobs_run_detail }} #{{ run.id_val() }}

- - - - - - -
{{ t.jobs_run_status }}{{ run.status_str() }}
{{ t.jobs_run_trigger }}{{ run.trigger_str() }}
{{ t.jobs_run_started }}{{ run.started_at_str() }}
{{ t.jobs_run_duration }}{{ run.duration_display() }}
- -{% if !run.error_message_str().is_empty() %} -

{{ t.jobs_run_error }}

-
{{ run.error_message_str() }}
-{% endif %} - -{% if !run.log_output_str().is_empty() %} -

{{ t.jobs_run_log }}

-
{{ run.log_output_str() }}
-{% endif %} - -

← {{ t.jobs_back_to_job }}

- - -{% endblock content %} diff --git a/templates/admin/jobs.html b/templates/admin/jobs.html deleted file mode 100644 index 04ea248..0000000 --- a/templates/admin/jobs.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.nav_jobs }}{% endblock admin_title %} - -{% block content %} -

{{ t.jobs_heading }}

- - - - - - - - - - - - {% for job in jobs %} - - - - - - - - - - {% endfor %} -
{{ t.jobs_name }}{{ t.jobs_description }}{{ t.jobs_cron }}{{ t.jobs_enabled }}{{ t.jobs_last_run }}{{ t.jobs_next_run }}{{ t.jobs_actions }}
{{ job.name_str() }}{{ job.description_str() }}{{ job.cron_expression_str() }}{% if job.enabled() %}✅{% else %}❌{% endif %}{{ job.last_run_at_str() }}{{ job.next_run_at_str() }} - {% if job.name_str() == "metadata_backfill" %} - {{ t.jobs_metadata_backfill_options }} - {% else %} -
- -
- {% endif %} - {% if job.name_str() != "metadata_backfill" %} -
- {% if job.enabled() %} - - {% else %} - - {% endif %} -
- {% endif %} -
-{% endblock content %} diff --git a/templates/admin/layout.html b/templates/admin/layout.html deleted file mode 100644 index 3ff1be1..0000000 --- a/templates/admin/layout.html +++ /dev/null @@ -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 %} - - -{% endblock head_extra %} - -{% block body %} - -
-
- -
- EN - RU -
- {{ t.nav_logout }} -
-
- {% block content %}{% endblock content %} -
-
-{% endblock body %} diff --git a/templates/admin/media_files.html b/templates/admin/media_files.html deleted file mode 100644 index e372d99..0000000 --- a/templates/admin/media_files.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.nav_media_files }}{% endblock admin_title %} - -{% block content %} -

{{ t.media_files_heading }}

- -{% if rows.is_empty() %} -

{{ t.media_files_empty }}

-{% else %} - - - - - - - - - - - - - {% for row in rows %} - - - - - - - - - - - - {% endfor %} -
ID{{ t.media_files_filename }}{{ t.media_files_type }}{{ t.media_files_format }}{{ t.media_files_size }}{{ t.media_files_track }}{{ t.media_files_path }}{{ t.media_files_created }}{{ t.media_files_actions }}
{{ row.media_file.id_val() }}{{ row.media_file.original_filename_str() }}{{ row.media_file.file_type_str() }}{{ row.media_file.audio_format_str() }}{{ row.media_file.file_size_display() }} - {% if row.track_title.is_empty() %} - {{ t.media_files_orphan }} - {% else %} - {{ row.track_title }} - {% endif %} - {{ row.media_file.file_path_str() }}{{ row.media_file.created_at_str() }} -
- -
-
-{% endif %} - - -{% endblock content %} diff --git a/templates/admin/probe_fragment.html b/templates/admin/probe_fragment.html deleted file mode 100644 index 8b0c261..0000000 --- a/templates/admin/probe_fragment.html +++ /dev/null @@ -1,32 +0,0 @@ -{% if !agent_enabled %} -

{{ t.settings_agent_status_disabled }}

-{% else if agent_llm_url.is_empty() %} -

{{ t.settings_agent_status_no_url }}

-{% else if agent_probe.ok %} -
-

{{ t.settings_agent_status_ok }}

- {% if !agent_probe.model_intro.is_empty() %} -
{{ agent_probe.model_intro }}
- {% endif %} - - {% if !agent_probe.model_name.is_empty() %} - - {% endif %} - - {% if let Some(pt) = agent_probe.prompt_tokens %} - - {% endif %} - {% if let Some(ct) = agent_probe.completion_tokens %} - - {% endif %} - {% if let Some(tps) = agent_probe.tokens_per_sec %} - - {% endif %} -
{{ t.settings_agent_model_name }}{{ agent_probe.model_name }}
{{ t.settings_agent_latency }}{{ agent_probe.latency_ms }} ms
{{ t.settings_agent_prompt_tokens }}{{ pt }}
{{ t.settings_agent_completion_tokens }}{{ ct }}
{{ t.settings_agent_tokens_per_sec }}{{ format!("{:.1}", tps) }}
-
-{% else %} -
-

{{ t.settings_agent_status_error }}

-

{{ agent_probe.error }}

-
-{% endif %} diff --git a/templates/admin/release_form.html b/templates/admin/release_form.html deleted file mode 100644 index 406fadd..0000000 --- a/templates/admin/release_form.html +++ /dev/null @@ -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 %} -

{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}

- -
- - - - - - - - - - - - - - - - - -
- -
-
-
- - - {% for a in artists %} - - {% endfor %} - - -
- -
- -
- - -{% endblock content %} diff --git a/templates/admin/releases.html b/templates/admin/releases.html deleted file mode 100644 index 136a30b..0000000 --- a/templates/admin/releases.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.nav_releases }}{% endblock admin_title %} - -{% block content %} -

{{ t.releases_heading }}

- -
- {{ t.releases_add }} - -
- - -
-
- -{% if rows.is_empty() %} -

{{ t.releases_empty }}

-{% else %} - - - - - - - - - - {% for row in rows %} - - - - - - - - - {% endfor %} -
ID{{ t.releases_title }}{{ t.releases_artists }}{{ t.releases_type }}{{ t.releases_year }}{{ t.releases_actions }}
{{ row.release.id_val() }}{{ row.release.title_str() }}{% if row.artist_names.is_empty() %}{{ t.releases_no_artist }}{% else %}{{ row.artist_names }}{% endif %}{{ row.release.release_type_str() }}{{ row.release.year_display() }} - {{ t.releases_edit }} -  |  -
- -
-
-{% endif %} -{% endblock content %} diff --git a/templates/admin/review_detail.html b/templates/admin/review_detail.html deleted file mode 100644 index 7601a67..0000000 --- a/templates/admin/review_detail.html +++ /dev/null @@ -1,111 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}Review #{{ review.id_val() }}{% endblock admin_title %} - -{% block content %} -

Review #{{ review.id_val() }}

- - - - - - - - {% match stats %} - {% when Some with (s) %} - - - - {% when None %} - {% endmatch %} -
{{ t.reviews_status }}{{ review.status_str() }}
{{ t.reviews_type }}{{ review.review_type_str() }}
{{ t.reviews_input_path }}{{ review.input_path_str() }}
{{ t.reviews_confidence }}{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}
{{ t.reviews_created }}{{ review.created_at_str() }}
{{ t.reviews_model }}{{ s.model_name_str() }}
{{ t.reviews_llm_duration }}{{ s.duration_display() }}
{{ t.reviews_tokens }}{{ s.tokens_display() }}
- -{% if !error_message.is_empty() %} -
- {{ t.reviews_error }}: {{ error_message }} -
-{% endif %} - -{% if review.status_str() == "pending" %} -

{{ t.reviews_result }}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
-
-
- -
-
-{% else %} -
- {% if review.status_str() == "failed" || review.status_str() == "processing" %} -
- -
- {% endif %} -
-{% endif %} - -{% if !context_pretty.is_empty() %} -

{{ t.reviews_context }}

-
{{ context_pretty }}
-{% endif %} - -{% if !result_pretty.is_empty() && review.status_str() != "pending" %} -

{{ t.reviews_result }}

-
{{ result_pretty }}
-{% endif %} - -

← {{ t.reviews_back_to_list }}

- - -{% endblock content %} diff --git a/templates/admin/reviews.html b/templates/admin/reviews.html deleted file mode 100644 index 519ceb8..0000000 --- a/templates/admin/reviews.html +++ /dev/null @@ -1,276 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.nav_reviews }}{% endblock admin_title %} - -{% block content %} -

{{ t.reviews_heading }}

- -
- {{ t.reviews_filter_all }} - {{ t.reviews_filter_pending }} - {{ t.reviews_filter_approved }} - {{ t.reviews_filter_rejected }} - {{ t.reviews_filter_queued }} - {{ t.reviews_filter_processing }} - {{ t.reviews_filter_auto_approved }} - {{ t.reviews_filter_failed }} - {% if !rows.is_empty() %} - -
- -
- {% endif %} -
- -{% if rows.is_empty() %} -

{{ t.reviews_empty }}

-{% else %} -
- - -
- - - - - {{ t.reviews_selected_none }} -
- - - - - - - - - - - - - - - - {% for row in rows %} - - - - - - - - - {% match stats_map.get(&row.review.id_val()) %} - {% when Some with (s) %} - - - - {% when None %} - - - - {% endmatch %} - - - - {% endfor %} -
ID{{ t.reviews_status }}{{ t.reviews_type }}{{ t.reviews_input_path }}{{ t.reviews_tags }}{{ t.reviews_confidence }}{{ t.reviews_model }}{{ t.reviews_llm_duration }}{{ t.reviews_tokens }}{{ t.reviews_created }}{{ t.jobs_actions }}
- - {{ row.review.id_val() }}{{ row.review.status_str() }}{{ row.review.review_type_str() }}{{ row.display_input_path }} - {% for tag in row.media_tags %} - {{ tag.label }} - {% endfor %} - {% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}{{ s.model_name }}{{ s.duration_display() }}{{ s.tokens_display() }}---{{ row.review.created_at_str() }} - {{ t.reviews_view }} -
-
-{% endif %} - - - - -{% endblock content %} diff --git a/templates/admin/settings.html b/templates/admin/settings.html deleted file mode 100644 index 0a2c3aa..0000000 --- a/templates/admin/settings.html +++ /dev/null @@ -1,166 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.nav_settings }}{% endblock admin_title %} - -{% block content %} -

{{ t.settings_heading }}

- -{% if saved %} -

{{ t.settings_saved }}

-{% endif %} - -
- -

{{ t.settings_auth }}

- - - - - - - - - - - - - - - - -
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}
{{ auth_password_enabled_source }}
{{ auth_sso_enabled_source }}
- -

{{ t.settings_oidc }}

-

{{ t.settings_oidc_help }}

-

- {{ t.settings_oidc_callback }}: - -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}
{{ oidc_button_text_source }}

{{ t.settings_oidc_issuer_help }}
{{ oidc_issuer_source }}
{{ oidc_client_id_source }}
{{ oidc_client_secret_source }}

{{ t.settings_oidc_admin_groups_help }}
{{ oidc_admin_groups_source }}

{{ t.settings_oidc_user_groups_help }}
{{ oidc_user_groups_source }}
-

{{ t.settings_api }}

- - - - - - - - - - - - - - - - - - - - - -
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}

{{ t.settings_swagger_help }}
{{ swagger_enabled_source }}

{{ t.settings_lastfm_api_key_help }}
{{ lastfm_api_key_source }}

{{ t.settings_lastfm_shared_secret_help }}
{{ lastfm_shared_secret_source }}
- -

{{ t.settings_agent }}

-

{{ t.settings_agent_help }}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}
{{ agent_enabled_source }}
{{ agent_inbox_dir_source }}
{{ agent_storage_dir_source }}
{{ agent_llm_url_source }}
{{ agent_llm_model_source }}
{{ agent_llm_auth_source }}
{{ agent_confidence_threshold_source }}
{{ agent_context_limit_source }}
{{ agent_concurrency_source }}
- -

{{ t.settings_agent_status }}

-
-

- - ◠ {{ t.settings_agent_status_loading }}... - -

-
- - -
-{% endblock content %} diff --git a/templates/admin/user_form.html b/templates/admin/user_form.html deleted file mode 100644 index 9014f70..0000000 --- a/templates/admin/user_form.html +++ /dev/null @@ -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 %} -

{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}

- -
- - - - - - - - - - - - - - - - - - - - - -
- - {% if is_edit %}
{{ t.users_password_hint }}{% endif %} -
- -
- -
-{% endblock content %} diff --git a/templates/admin/users.html b/templates/admin/users.html deleted file mode 100644 index bbe1b84..0000000 --- a/templates/admin/users.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "admin/layout.html" %} -{% block admin_title %}{{ t.nav_users }}{% endblock admin_title %} - -{% block content %} -

{{ t.users_heading }}

- -

- {{ t.users_add }} -

- - - - - - - - - - - {% for u in users %} - - - - - - - - - {% endfor %} -
{{ t.users_username }}{{ t.users_email }}{{ t.users_display_name }}{{ t.users_role }}{{ t.users_active }}{{ t.users_actions }}
{{ u.username_str() }}{{ u.email_str() }}{{ u.display_name_str() }}{{ u.role_str() }}{{ u.is_active() }} - {{ t.users_edit }} -  |  -
- -
-
-{% endblock content %} diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 04dca7f..e6f61f2 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -446,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); } @@ -2008,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; } +} + {% endblock head_extra %} {% block content %} -
+