Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4141562e33 |
No files matched your search
Generated
+1
-1
@@ -1988,7 +1988,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.12.0"
|
||||
version = "0.12.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
use super::v2::{json_error, require_admin_json};
|
||||
use crate::{auth::Role, user::User};
|
||||
use cot::{db::Database, http::StatusCode, json::Json, response::IntoResponse, session::Session};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct UserRequest {
|
||||
action: String,
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
email: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
role: String,
|
||||
}
|
||||
|
||||
pub(super) async fn manage_user(
|
||||
session: Session,
|
||||
db: Database,
|
||||
Json(body): Json<UserRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let admin = match require_admin_json(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
if body.action == "delete" {
|
||||
let Some(id) = body.id.filter(|id| *id > 0) else {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "Invalid user"));
|
||||
};
|
||||
if id == admin.id {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"You cannot delete your own account",
|
||||
));
|
||||
}
|
||||
User::delete_by_id(&db, id).await?;
|
||||
} else {
|
||||
if !["create", "update"].contains(&body.action.as_str())
|
||||
|| body.username.trim().is_empty()
|
||||
|| body.username.chars().count() > 255
|
||||
|| Role::from_code(&body.role).is_none()
|
||||
|| (body.action == "create" && body.password.is_empty())
|
||||
{
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Provide a username, valid role, and a password for new accounts",
|
||||
));
|
||||
}
|
||||
let optional = |s: String| if s.trim().is_empty() { None } else { Some(s) };
|
||||
let email = optional(body.email);
|
||||
let display_name = optional(body.display_name);
|
||||
if body.action == "create" {
|
||||
if User::get_by_username(&db, body.username.trim())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(json_error(StatusCode::CONFLICT, "Username already exists"));
|
||||
}
|
||||
User::create(
|
||||
&db,
|
||||
body.username.trim(),
|
||||
email.as_deref(),
|
||||
display_name.as_deref(),
|
||||
&body.password,
|
||||
&body.role,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let Some(id) = body.id.filter(|id| *id > 0) else {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "Invalid user"));
|
||||
};
|
||||
if id == admin.id && body.role != "admin" {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"You cannot remove your own administrator role",
|
||||
));
|
||||
}
|
||||
let Some(mut user) = User::get_by_id(&db, id).await? else {
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "User not found"));
|
||||
};
|
||||
if User::get_by_username(&db, body.username.trim())
|
||||
.await?
|
||||
.is_some_and(|u| u.id_val() != id)
|
||||
{
|
||||
return Ok(json_error(StatusCode::CONFLICT, "Username already exists"));
|
||||
}
|
||||
user.update_fields(
|
||||
&db,
|
||||
body.username.trim(),
|
||||
email.as_deref(),
|
||||
display_name.as_deref(),
|
||||
(!body.password.is_empty()).then_some(body.password.as_str()),
|
||||
&body.role,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ScheduleRequest {
|
||||
cron: String,
|
||||
}
|
||||
|
||||
pub(super) async fn schedule(
|
||||
session: Session,
|
||||
db: Database,
|
||||
handle: &std::sync::Arc<
|
||||
tokio::sync::OnceCell<std::sync::Arc<crate::scheduler::SchedulerHandle>>,
|
||||
>,
|
||||
name: &str,
|
||||
Json(body): Json<ScheduleRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let cron = body.cron.trim();
|
||||
if !valid_schedule(cron) {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid cron schedule: include the seconds field",
|
||||
));
|
||||
}
|
||||
if crate::scheduler::ScheduledJob::get_by_name(&db, name)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "Job not found"));
|
||||
}
|
||||
let Some(handle) = handle.get() else {
|
||||
return Ok(json_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Scheduler is not ready",
|
||||
));
|
||||
};
|
||||
handle
|
||||
.reschedule_job(name, cron)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
fn valid_schedule(cron: &str) -> bool {
|
||||
cron.len() <= 100
|
||||
&& croner::parser::CronParser::builder()
|
||||
.seconds(croner::parser::Seconds::Required)
|
||||
.build()
|
||||
.parse(cron)
|
||||
.is_ok()
|
||||
&& crate::scheduler::compute_next_run(cron).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn schedules_use_the_same_seconds_field_as_the_scheduler() {
|
||||
assert!(super::valid_schedule("0 17 3 * * *"));
|
||||
assert!(!super::valid_schedule("17 3 * * *"));
|
||||
assert!(!super::valid_schedule("invalid"));
|
||||
}
|
||||
}
|
||||
+47
-717
@@ -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<String>,
|
||||
}
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PathId {
|
||||
id: i64,
|
||||
@@ -98,11 +87,6 @@ struct PathNameRunId {
|
||||
run_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleasesQuery {
|
||||
artist_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl App for AdminApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"admin"
|
||||
@@ -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<statistics::StatisticsQuery>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new().max_connections(5)
|
||||
.connect(&pool_config.database_url).await.expect("admin pool")
|
||||
}).await;
|
||||
statistics::statistics(session, db, pg_pool, query.0).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_statistics",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/reviews",
|
||||
{
|
||||
@@ -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<PathName>,
|
||||
json: Json<management::ScheduleRequest>| {
|
||||
let handle = Arc::clone(&handle);
|
||||
async move {
|
||||
management::schedule(session, db, &handle, &path.0.name, json).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_job_schedule",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/users",
|
||||
{
|
||||
@@ -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<SettingsQuery>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let saved = query.0.saved.as_deref() == Some("1");
|
||||
views::settings_handler(admin, i18n, &config, &db, saved)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
})
|
||||
.post({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
form: RequestForm<OidcSettingsForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::settings_submit(admin, i18n, &config, &db, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_settings",
|
||||
),
|
||||
// -- Settings probe (HTMX fragment) -----------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/settings/probe",
|
||||
{
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::settings_probe_handler(admin, i18n, &config, &db)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_settings_probe",
|
||||
),
|
||||
// -- Users --------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/users",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_list(admin, i18n, &db).await?.into_response()
|
||||
},
|
||||
"admin_users",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/new",
|
||||
get(|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_new(admin, i18n).await?.into_response()
|
||||
})
|
||||
.post(
|
||||
|session: Session, db: Database, form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_create(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_new",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/edit",
|
||||
get(
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_edit(admin, i18n, &db, path.0.id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|session: Session, db: Database, path: Path<PathId>,
|
||||
form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_update(admin, &db, path.0.id, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_edit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/delete",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_delete(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_users_delete",
|
||||
),
|
||||
// -- Artists ------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/artists",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_list(admin, i18n, &db).await?.into_response()
|
||||
},
|
||||
"admin_artists",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/artists/new",
|
||||
get(|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_new(admin, i18n).await?.into_response()
|
||||
})
|
||||
.post(
|
||||
|session: Session, db: Database, form: RequestForm<ArtistForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_create(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_artists_new",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/artists/{id}/edit",
|
||||
get(
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_edit(admin, i18n, &db, path.0.id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|session: Session, db: Database, path: Path<PathId>,
|
||||
form: RequestForm<ArtistForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_update(admin, &db, path.0.id, form).await
|
||||
},
|
||||
),
|
||||
"admin_artists_edit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/artists/{id}/delete",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_delete(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_artists_delete",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/artists/{id}/available-covers",
|
||||
get(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_available_covers(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_artists_available_covers",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/artists/{id}/set-image",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>,
|
||||
json: Json<SetImageBody>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::artists_set_image(admin, &db, path.0.id, json.0).await
|
||||
},
|
||||
),
|
||||
"admin_artists_set_image",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/artists/{id}/upload-image",
|
||||
cot::router::method::post({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database, path: Path<PathId>,
|
||||
json: Json<UploadImageBody>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(3)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).await;
|
||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||
views::artists_upload_image(admin, &db, pg_pool, &live_config, path.0.id, json.0).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_artists_upload_image",
|
||||
),
|
||||
// -- Releases -----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/releases",
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
query: UrlQuery<ReleasesQuery>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::releases_list(admin, i18n, &db, query.0.artist_id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
"admin_releases",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/releases/new",
|
||||
get(|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::releases_new(admin, i18n, &db).await?.into_response()
|
||||
})
|
||||
.post(
|
||||
|session: Session, db: Database,
|
||||
form: RequestForm<ReleaseForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::releases_create(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_releases_new",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/releases/{id}/edit",
|
||||
get(
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::releases_edit(admin, i18n, &db, path.0.id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|session: Session, db: Database, path: Path<PathId>,
|
||||
form: RequestForm<ReleaseForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::releases_update(admin, &db, path.0.id, form).await
|
||||
},
|
||||
),
|
||||
"admin_releases_edit",
|
||||
),
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
Route::with_handler_and_name(
|
||||
"/releases/{id}/delete",
|
||||
cot::router::method::post(move |session: Session, db: Database, path: Path<PathId>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
views::releases_delete(admin, &db, pg_pool, path.0.id).await
|
||||
}
|
||||
}),
|
||||
"admin_releases_delete",
|
||||
)
|
||||
},
|
||||
// -- Media Files --------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/media-files",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::media_files_list(admin, i18n, &db).await?.into_response()
|
||||
},
|
||||
"admin_media_files",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/media-files/{id}/delete",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::media_files_delete(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_media_files_delete",
|
||||
),
|
||||
// -- Jobs ---------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/jobs",
|
||||
{
|
||||
let registry = Arc::clone(&self.registry);
|
||||
move |session: Session, db: Database, i18n: I18n| {
|
||||
let registry = Arc::clone(®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<MetadataBackfillForm>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(3)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).await;
|
||||
views::metadata_backfill_run(admin, &db, pg_pool, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_metadata_backfill_run",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/lastfm_popularity/run-options",
|
||||
cot::router::method::post({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database,
|
||||
form: RequestForm<views::LastfmPopularityForm>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(3)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).await;
|
||||
views::lastfm_popularity_run(admin, &db, pg_pool, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_lastfm_popularity_run",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/{name}/run",
|
||||
cot::router::method::post({
|
||||
let handle = Arc::clone(&self.scheduler_handle);
|
||||
move |session: Session, db: Database, path: Path<PathName>| {
|
||||
let handle = Arc::clone(&handle);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::job_run_now(admin, &handle, &path.0.name).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_job_run",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/{name}/toggle",
|
||||
cot::router::method::post({
|
||||
let handle = Arc::clone(&self.scheduler_handle);
|
||||
move |session: Session, db: Database, path: Path<PathName>| {
|
||||
let handle = Arc::clone(&handle);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::job_toggle_enabled(admin, &db, &handle, &path.0.name).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_job_toggle",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/{name}/cron",
|
||||
cot::router::method::post({
|
||||
let handle = Arc::clone(&self.scheduler_handle);
|
||||
move |session: Session, db: Database, path: Path<PathName>,
|
||||
form: RequestForm<CronForm>| {
|
||||
let handle = Arc::clone(&handle);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::job_update_cron(admin, &db, &handle, &path.0.name, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_job_cron",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/{name}/runs/{run_id}",
|
||||
{
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathNameRunId>| {
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::job_run_detail(admin, i18n, &db, &path.0.name, path.0.run_id)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_job_run_detail",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/{name}",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathName>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(3)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).await;
|
||||
views::job_detail(admin, i18n, &db, pg_pool, &path.0.name)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_job_detail",
|
||||
),
|
||||
// -- Reviews: clear -----------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/reviews/clear",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database,
|
||||
query: UrlQuery<ReviewsQuery>| async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::reviews_clear(admin, &db, query.0.status.as_deref()).await
|
||||
},
|
||||
),
|
||||
"admin_reviews_clear",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/reviews/bulk",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database,
|
||||
form: RequestForm<ReviewsBulkForm>| async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::reviews_bulk(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_reviews_bulk",
|
||||
),
|
||||
// -- Reviews ------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/reviews",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
query: UrlQuery<ReviewsQuery>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(3)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).await;
|
||||
views::reviews_list(admin, i18n, &db, pg_pool, query.0.status.as_deref())
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_reviews",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/reviews/{id}",
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::review_detail(admin, i18n, &db, path.0.id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
"admin_review_detail",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/reviews/{id}/approve",
|
||||
cot::router::method::post({
|
||||
let config = Arc::clone(&self.config);
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database, path: Path<PathId>,
|
||||
form: RequestForm<ReviewApproveForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(3)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).await;
|
||||
views::review_approve(admin, &config, &db, pg_pool, path.0.id, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_review_approve",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/reviews/{id}/reject",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::review_reject(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_review_reject",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/reviews/{id}/requeue",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::review_requeue(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_review_requeue",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use crate::auth;
|
||||
use crate::i18n::{I18n, Translations};
|
||||
use crate::user::User;
|
||||
use cot::Template;
|
||||
use cot::db::Database;
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::request::extractors::RequestForm;
|
||||
use cot::response::IntoResponse;
|
||||
use cot::session::Session;
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/setup.html")]
|
||||
struct SetupTemplate {
|
||||
t: &'static Translations,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
|
||||
let template = SetupTemplate { t: i18n.t, message };
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct SetupForm {
|
||||
username: String,
|
||||
password: String,
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
pub async fn setup_submit(
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
session: &Session,
|
||||
form: RequestForm<SetupForm>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
return setup_page(i18n, String::new()).await?.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if data.password != data.confirm_password {
|
||||
let msg = i18n.t.setup_mismatch.to_owned();
|
||||
return setup_page(i18n, msg).await?.into_response();
|
||||
}
|
||||
|
||||
let user = User::create(db, &data.username, None, None, &data.password, "admin")
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?;
|
||||
|
||||
auth::login(session, user.id_val()).await?;
|
||||
Ok(auth::redirect("/admin/"))
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -0,0 +1,403 @@
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use cot::{db::Database, http::StatusCode, json::Json, response::IntoResponse, session::Session};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use super::v2::{json_error, require_admin_json};
|
||||
|
||||
const LISTENS: &str = include_str!("sql/listens.sql");
|
||||
const PAGE_SIZE: i64 = 25;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(super) struct StatisticsQuery {
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub content_id: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub rank_page: Option<i64>,
|
||||
pub history_page: Option<i64>,
|
||||
pub history_kind: Option<String>,
|
||||
}
|
||||
|
||||
struct Filter {
|
||||
from: i64,
|
||||
to: i64,
|
||||
user_id: Option<i64>,
|
||||
content_id: Option<String>,
|
||||
artist: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn parse(query: &StatisticsQuery) -> Result<Self, String> {
|
||||
fn date(value: &str) -> Result<i64, String> {
|
||||
NaiveDate::parse_from_str(value, "%Y-%m-%d")
|
||||
.ok()
|
||||
.and_then(|d| d.and_hms_opt(0, 0, 0))
|
||||
.map(|d| d.and_utc().timestamp_millis())
|
||||
.filter(|d| *d >= 0)
|
||||
.ok_or_else(|| "Use dates in YYYY-MM-DD format (1970 or later)".to_owned())
|
||||
}
|
||||
let to = match query.to.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(value) => date(value)?
|
||||
.checked_add(86_400_000)
|
||||
.ok_or("Invalid end date")?,
|
||||
None => Utc::now().timestamp_millis(),
|
||||
};
|
||||
let from = match query.from.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some("all") => 0,
|
||||
Some(value) => date(value)?,
|
||||
None => to.saturating_sub(30 * 86_400_000),
|
||||
};
|
||||
if from >= to {
|
||||
return Err("Start date must not be after end date".into());
|
||||
}
|
||||
let source = query.source.as_deref().unwrap_or("all");
|
||||
if !["all", "instance", "synced"].contains(&source) {
|
||||
return Err("Unknown playback source".into());
|
||||
}
|
||||
if query.user_id.is_some_and(|id| id <= 0) {
|
||||
return Err("Invalid user".into());
|
||||
}
|
||||
if query.artist.as_ref().is_some_and(|s| s.len() > 1024)
|
||||
|| query.content_id.as_ref().is_some_and(|s| s.len() > 256)
|
||||
|| query.search.as_ref().is_some_and(|s| s.len() > 1024)
|
||||
{
|
||||
return Err("Filter is too long".into());
|
||||
}
|
||||
Ok(Self {
|
||||
from,
|
||||
to,
|
||||
user_id: query.user_id,
|
||||
content_id: query.content_id.clone(),
|
||||
artist: query.artist.clone(),
|
||||
source: source.into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn query(&self, pool: &PgPool, suffix: &str) -> anyhow::Result<Value> {
|
||||
let sql = format!("{LISTENS}{suffix}");
|
||||
Ok(sqlx::query_scalar(&sql)
|
||||
.bind(self.from)
|
||||
.bind(self.to)
|
||||
.bind(self.user_id)
|
||||
.bind(&self.content_id)
|
||||
.bind(&self.artist)
|
||||
.bind(&self.source)
|
||||
.fetch_one(pool)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn statistics(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &PgPool,
|
||||
query: StatisticsQuery,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let filter = match Filter::parse(&query) {
|
||||
Ok(filter) => filter,
|
||||
Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)),
|
||||
};
|
||||
let kind = query.kind.as_deref().unwrap_or("artists");
|
||||
if !["artists", "tracks", "releases", "users"].contains(&kind) {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "Unknown ranking"));
|
||||
}
|
||||
let history_kind = query.history_kind.as_deref().unwrap_or("all");
|
||||
if !["all", "plays", "short"].contains(&history_kind) {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Unknown history filter",
|
||||
));
|
||||
}
|
||||
let result = load(pool, &filter, &query, kind, history_kind)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Json(result).into_response()
|
||||
}
|
||||
|
||||
async fn load(
|
||||
pool: &PgPool,
|
||||
filter: &Filter,
|
||||
query: &StatisticsQuery,
|
||||
kind: &str,
|
||||
history_kind: &str,
|
||||
) -> anyhow::Result<Value> {
|
||||
let summary_sql = r#"
|
||||
SELECT to_jsonb(s) FROM (
|
||||
SELECT COUNT(*) AS events, COUNT(*) FILTER (WHERE qualified) AS plays,
|
||||
COUNT(*) FILTER (WHERE NOT qualified) AS short_listens,
|
||||
COUNT(*) FILTER (WHERE trim(both '"' from ended_reason) = 'finished') AS completed,
|
||||
COALESCE(SUM(listened_ms), 0)::bigint / 1000 AS listened_seconds,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE qualified) AS listeners,
|
||||
COUNT(DISTINCT content_id) FILTER (WHERE qualified) AS tracks,
|
||||
COUNT(*) FILTER (WHERE qualified AND source = 'instance') AS instance_plays,
|
||||
COUNT(*) FILTER (WHERE qualified AND source = 'synced') AS synced_plays
|
||||
FROM filtered
|
||||
) s"#;
|
||||
let daily_sql = r#",
|
||||
bounds AS (
|
||||
SELECT COALESCE(MIN(started_at_ms), $2) AS first_ms FROM filtered
|
||||
), granularity AS (
|
||||
SELECT CASE WHEN $2 - GREATEST($1, first_ms) > 120::bigint * 86400000
|
||||
THEN 'month' ELSE 'day' END AS unit FROM bounds
|
||||
), daily AS (
|
||||
SELECT date_trunc(g.unit, to_timestamp(started_at_ms / 1000.0) AT TIME ZONE 'UTC') AS bucket,
|
||||
COUNT(*) FILTER (WHERE qualified) AS plays,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE qualified) AS listeners,
|
||||
COALESCE(SUM(listened_ms), 0)::bigint / 1000 AS listened_seconds
|
||||
FROM filtered CROSS JOIN granularity g GROUP BY bucket
|
||||
), series AS (
|
||||
SELECT generate_series((SELECT MIN(bucket) FROM daily), (SELECT MAX(bucket) FROM daily),
|
||||
CASE WHEN unit = 'month' THEN interval '1 month' ELSE interval '1 day' END) AS bucket
|
||||
FROM granularity
|
||||
), filled AS (
|
||||
SELECT s.bucket, COALESCE(d.plays, 0) AS plays, COALESCE(d.listeners, 0) AS listeners,
|
||||
COALESCE(d.listened_seconds, 0) AS listened_seconds
|
||||
FROM series s LEFT JOIN daily d USING (bucket)
|
||||
)
|
||||
SELECT jsonb_build_object('unit', (SELECT unit FROM granularity), 'items',
|
||||
COALESCE((SELECT jsonb_agg(to_jsonb(d) ORDER BY bucket) FROM filled d), '[]'::jsonb))"#;
|
||||
let (summary, daily, ranking, history, instance) = tokio::try_join!(
|
||||
filter.query(pool, summary_sql),
|
||||
filter.query(pool, daily_sql),
|
||||
ranking(pool, filter, query, kind),
|
||||
history(pool, filter, query, history_kind),
|
||||
instance(pool),
|
||||
)?;
|
||||
Ok(
|
||||
serde_json::json!({"summary": summary, "daily": daily, "ranking": ranking,
|
||||
"history": history, "instance": instance, "from_ms": filter.from, "to_ms": filter.to}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn ranking(
|
||||
pool: &PgPool,
|
||||
filter: &Filter,
|
||||
query: &StatisticsQuery,
|
||||
kind: &str,
|
||||
) -> anyhow::Result<Value> {
|
||||
// Only fixed SQL fragments are interpolated; every user filter is bound.
|
||||
let (key, label, extra, join) = match kind {
|
||||
"users" => ("user_id::text", "MIN(user_name)", "''::text", ""),
|
||||
"tracks" => (
|
||||
"content_id",
|
||||
"MIN(title)",
|
||||
"MIN(array_to_string(artist_names, ', '))",
|
||||
"",
|
||||
),
|
||||
"releases" => (
|
||||
"jsonb_build_array(release_title, artist_names)::text",
|
||||
"MIN(NULLIF(release_title, ''))",
|
||||
"MIN(array_to_string(artist_names, ', '))",
|
||||
"",
|
||||
),
|
||||
_ => (
|
||||
"lower(artist.name)",
|
||||
"MIN(artist.name)",
|
||||
"''::text",
|
||||
"CROSS JOIN LATERAL (SELECT DISTINCT ON (lower(n)) n AS name FROM unnest(artist_names) n ORDER BY lower(n), n) artist",
|
||||
),
|
||||
};
|
||||
let sql = format!(
|
||||
r#"{LISTENS}, ranked AS (
|
||||
SELECT {key} AS key, {label} AS label, {extra} AS detail, COUNT(*) AS plays,
|
||||
COUNT(DISTINCT user_id) AS listeners,
|
||||
SUM(listened_ms)::bigint / 1000 AS listened_seconds, MAX(started_at_ms) AS last_play_ms
|
||||
FROM filtered {join} WHERE qualified GROUP BY {key}
|
||||
), searched AS (
|
||||
SELECT * FROM ranked WHERE label IS NOT NULL
|
||||
AND ($7::text IS NULL OR strpos(lower(label || ' ' || detail), lower($7)) > 0)
|
||||
), page AS (SELECT * FROM searched ORDER BY plays DESC, key LIMIT $8 OFFSET $9)
|
||||
SELECT jsonb_build_object('total', (SELECT COUNT(*) FROM searched),
|
||||
'items', COALESCE((SELECT jsonb_agg(to_jsonb(p) ORDER BY plays DESC, key) FROM page p), '[]'::jsonb))"#
|
||||
);
|
||||
let page = query.rank_page.unwrap_or(1).clamp(1, 1_000_000);
|
||||
let mut result: Value = sqlx::query_scalar(&sql)
|
||||
.bind(filter.from)
|
||||
.bind(filter.to)
|
||||
.bind(filter.user_id)
|
||||
.bind(&filter.content_id)
|
||||
.bind(&filter.artist)
|
||||
.bind(&filter.source)
|
||||
.bind(query.search.as_deref().filter(|s| !s.is_empty()))
|
||||
.bind(PAGE_SIZE)
|
||||
.bind((page - 1) * PAGE_SIZE)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
result["page"] = page.into();
|
||||
result["limit"] = PAGE_SIZE.into();
|
||||
result["kind"] = kind.into();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn history(
|
||||
pool: &PgPool,
|
||||
filter: &Filter,
|
||||
query: &StatisticsQuery,
|
||||
kind: &str,
|
||||
) -> anyhow::Result<Value> {
|
||||
let sql = format!(
|
||||
r#"{LISTENS}, selected AS (
|
||||
SELECT user_id, user_name, listen_id, content_id, title, artist_names, release_title,
|
||||
started_at_ms, listened_ms, track_duration_ms, qualified,
|
||||
trim(both '"' from ended_reason) AS ended_reason, device_name, source
|
||||
FROM filtered WHERE $7 = 'all' OR ($7 = 'plays' AND qualified) OR ($7 = 'short' AND NOT qualified)
|
||||
), page AS (SELECT * FROM selected ORDER BY started_at_ms DESC, user_id, listen_id LIMIT $8 OFFSET $9)
|
||||
SELECT jsonb_build_object('total', (SELECT COUNT(*) FROM selected),
|
||||
'items', COALESCE((SELECT jsonb_agg(to_jsonb(p) ORDER BY started_at_ms DESC, user_id, listen_id) FROM page p), '[]'::jsonb))"#
|
||||
);
|
||||
let page = query.history_page.unwrap_or(1).clamp(1, 1_000_000);
|
||||
let mut result: Value = sqlx::query_scalar(&sql)
|
||||
.bind(filter.from)
|
||||
.bind(filter.to)
|
||||
.bind(filter.user_id)
|
||||
.bind(&filter.content_id)
|
||||
.bind(&filter.artist)
|
||||
.bind(&filter.source)
|
||||
.bind(kind)
|
||||
.bind(PAGE_SIZE)
|
||||
.bind((page - 1) * PAGE_SIZE)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
result["page"] = page.into();
|
||||
result["limit"] = PAGE_SIZE.into();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn instance(pool: &PgPool) -> anyhow::Result<Value> {
|
||||
Ok(sqlx::query_scalar(r#"SELECT jsonb_build_object(
|
||||
'users', (SELECT COUNT(*) FROM furumusic__user),
|
||||
'tracks', (SELECT COUNT(*) FROM furumusic__track),
|
||||
'releases', (SELECT COUNT(*) FROM furumusic__release),
|
||||
'artists', (SELECT COUNT(*) FROM furumusic__artist),
|
||||
'playlists', (SELECT COUNT(*) FROM furumusic__playlist),
|
||||
'media_bytes', (SELECT COALESCE(SUM(file_size_bytes), 0)::bigint FROM furumusic__media_file),
|
||||
'formats', (SELECT COALESCE(jsonb_agg(to_jsonb(f)), '[]'::jsonb) FROM (
|
||||
SELECT COALESCE(audio_format, 'unknown') AS label, COUNT(*) AS count
|
||||
FROM furumusic__media_file WHERE file_type = 'audio' GROUP BY audio_format ORDER BY count DESC
|
||||
) f),
|
||||
'reviews', (SELECT COALESCE(jsonb_agg(to_jsonb(r)), '[]'::jsonb) FROM (
|
||||
SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status ORDER BY status
|
||||
) r),
|
||||
'jobs', (SELECT COALESCE(jsonb_agg(to_jsonb(j)), '[]'::jsonb) FROM (
|
||||
SELECT status::text AS label, COUNT(*) AS count FROM furumusic__job_run GROUP BY status ORDER BY status
|
||||
) j),
|
||||
'scrobbles', (SELECT COALESCE(jsonb_agg(to_jsonb(s)), '[]'::jsonb) FROM (
|
||||
SELECT status::text AS label, COUNT(*) AS count FROM furumusic__lastfm_scrobble_outbox GROUP BY status ORDER BY status
|
||||
) s)
|
||||
)"#).fetch_one(pool).await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn dates_are_inclusive_utc_and_filters_are_validated() {
|
||||
let q = StatisticsQuery {
|
||||
from: Some("2026-11-01".into()),
|
||||
to: Some("2026-11-01".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f = Filter::parse(&q).unwrap();
|
||||
assert_eq!(f.to - f.from, 86_400_000);
|
||||
assert!(
|
||||
Filter::parse(&StatisticsQuery {
|
||||
from: Some("broken".into()),
|
||||
..q
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
Filter::parse(&StatisticsQuery {
|
||||
source: Some("invalid".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FURU_STATISTICS_TEST_DATABASE_URL pointing at a disposable PostgreSQL"]
|
||||
async fn postgres_statistics_drilldown_and_pagination() {
|
||||
let url = std::env::var("FURU_STATISTICS_TEST_DATABASE_URL").expect("test database URL");
|
||||
// Temporary tables are isolated to this one connection and disappear on close.
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&url)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(include_str!("../../tests/statistics_fixture.sql"))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut q = StatisticsQuery {
|
||||
from: Some("2026-11-01".into()),
|
||||
to: Some("2026-11-03".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "artists", "all")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["summary"]["plays"], 32);
|
||||
assert_eq!(result["summary"]["short_listens"], 1);
|
||||
assert_eq!(result["summary"]["listeners"], 2);
|
||||
assert_eq!(result["summary"]["listened_seconds"], 5618);
|
||||
assert_eq!(result["summary"]["synced_plays"], 1);
|
||||
assert_eq!(result["ranking"]["items"][0]["label"], "Artist A");
|
||||
assert_eq!(result["ranking"]["items"][0]["plays"], 31); // duplicate artist credits never multiply plays
|
||||
assert_eq!(result["ranking"]["items"][0]["listeners"], 2);
|
||||
assert_eq!(result["history"]["total"], 33);
|
||||
assert_eq!(result["history"]["items"].as_array().unwrap().len(), 25);
|
||||
assert_eq!(result["daily"]["items"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(result["daily"]["items"][1]["plays"], 0); // fill the silent day
|
||||
q.artist = Some("artist a".into());
|
||||
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "users", "all")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["ranking"]["total"], 2);
|
||||
assert_eq!(result["ranking"]["items"][0]["label"], "Alice");
|
||||
assert_eq!(result["ranking"]["items"][0]["plays"], 30);
|
||||
q.user_id = Some(2);
|
||||
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "tracks", "short")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["summary"]["plays"], 1);
|
||||
assert_eq!(result["history"]["total"], 1);
|
||||
assert_eq!(result["history"]["items"][0]["ended_reason"], "skipped");
|
||||
assert_eq!(result["ranking"]["items"][0]["label"], "First song"); // no local track exists
|
||||
q.user_id = None;
|
||||
q.artist = None;
|
||||
q.history_page = Some(2);
|
||||
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "releases", "all")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["history"]["items"].as_array().unwrap().len(), 8);
|
||||
q.source = Some("synced".into());
|
||||
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "users", "all")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["summary"]["plays"], 1);
|
||||
q.source = Some("instance".into());
|
||||
q.search = Some("' OR 1=1 --".into());
|
||||
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "artists", "all")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["summary"]["plays"], 31);
|
||||
assert_eq!(result["ranking"]["total"], 0);
|
||||
q.user_id = Some(3);
|
||||
let result = load(&pool, &Filter::parse(&q).unwrap(), &q, "users", "all")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["summary"]["plays"], 0);
|
||||
assert_eq!(result["history"]["total"], 0);
|
||||
pool.close().await;
|
||||
}
|
||||
}
|
||||
+54
-42
@@ -232,6 +232,9 @@ struct AdminUserRowDto {
|
||||
is_active: bool,
|
||||
is_online: bool,
|
||||
last_seen_ms: Option<i64>,
|
||||
plays: i64,
|
||||
listened_seconds: i64,
|
||||
last_play_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -257,7 +260,7 @@ struct AdminUserStatsDto {
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct AdminUserPlayDto {
|
||||
history_id: i64,
|
||||
history_id: String,
|
||||
played_at: String,
|
||||
duration_listened: Option<i32>,
|
||||
completed: bool,
|
||||
@@ -1958,6 +1961,7 @@ pub async fn job_run_detail(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &PgPool,
|
||||
job_name: &str,
|
||||
run_id: i64,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
@@ -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<String>,
|
||||
role: String,
|
||||
is_active: bool,
|
||||
plays: i64,
|
||||
listened_seconds: i64,
|
||||
last_play_ms: Option<i64>,
|
||||
}
|
||||
|
||||
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::<Postgres>::new(
|
||||
"SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE 1=1",
|
||||
"SELECT id, username::text, display_name, email, role::text, is_active, \
|
||||
(SELECT COUNT(*) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS plays, \
|
||||
(SELECT (COALESCE(SUM(listened_ms), 0) / 1000)::bigint FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id) AS listened_seconds, \
|
||||
(SELECT MAX(started_at_ms) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS last_play_ms \
|
||||
FROM furumusic__user WHERE 1=1",
|
||||
);
|
||||
if let Some(pattern) = pattern.as_ref() {
|
||||
qb.push(" AND (username ILIKE ");
|
||||
@@ -2735,7 +2747,11 @@ async fn load_admin_user_detail(
|
||||
user_id: i64,
|
||||
) -> anyhow::Result<Option<AdminUserDetailDto>> {
|
||||
let row = sqlx::query_as::<_, AdminUserSqlRow>(
|
||||
"SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE id = $1",
|
||||
"SELECT id, username::text, display_name, email, role::text, is_active, \
|
||||
(SELECT COUNT(*) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS plays, \
|
||||
(SELECT (COALESCE(SUM(listened_ms), 0) / 1000)::bigint FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id) AS listened_seconds, \
|
||||
(SELECT MAX(started_at_ms) FROM furumusic__listen_event le WHERE le.user_id = furumusic__user.id AND qualified) AS last_play_ms \
|
||||
FROM furumusic__user WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
@@ -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<Vec<AdminUserPlayDto>> {
|
||||
let rows = sqlx::query_as::<_, AdminUserPlaySqlRow>(
|
||||
r#"SELECT ph.id AS history_id,
|
||||
ph.played_at::text AS played_at,
|
||||
ph.duration_listened,
|
||||
ph.completed,
|
||||
t.id AS track_id,
|
||||
t.title::text AS title,
|
||||
COALESCE(NULLIF(STRING_AGG(a.name::text, ', ' ORDER BY ta.position), ''), 'Unknown artist') AS artists,
|
||||
t.release_id,
|
||||
COALESCE(r.title::text, '') AS release_title,
|
||||
r.year AS release_year,
|
||||
t.cover_file_id,
|
||||
r.cover_file_id AS release_cover_file_id,
|
||||
t.duration_seconds AS track_duration_seconds,
|
||||
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||
mf.audio_format,
|
||||
mf.audio_bitrate
|
||||
FROM furumusic__play_history ph
|
||||
JOIN furumusic__track t ON t.id = ph.track_id
|
||||
r#"SELECT le.listen_id AS history_id,
|
||||
to_timestamp(le.started_at_ms / 1000.0)::text AS played_at,
|
||||
LEAST(le.listened_ms / 1000, 2147483647)::integer AS duration_listened,
|
||||
le.ended_reason = '"finished"' AS completed,
|
||||
COALESCE(t.id, 0) AS track_id,
|
||||
COALESCE(le.metadata_json->>'title', 'Unknown track') AS title,
|
||||
COALESCE((SELECT string_agg(name, ', ') FROM jsonb_array_elements_text(
|
||||
COALESCE(le.metadata_json->'artist_names', '[]'::jsonb)) names(name)), 'Unknown artist') AS artists,
|
||||
COALESCE(t.release_id, 0) AS release_id,
|
||||
COALESCE(le.metadata_json->>'release_title', '') AS release_title,
|
||||
r.year AS release_year, t.cover_file_id, r.cover_file_id AS release_cover_file_id,
|
||||
COALESCE(le.track_duration_ms / 1000.0, 0)::float8 AS track_duration_seconds,
|
||||
COALESCE(mf.uploader_name, '')::text AS uploader_name,
|
||||
mf.audio_format, mf.audio_bitrate
|
||||
FROM furumusic__listen_event le
|
||||
LEFT JOIN furumusic__track_ref tr ON tr.content_id = le.content_id
|
||||
LEFT JOIN furumusic__track t ON t.id = COALESCE(le.local_track_id, tr.local_track_id)
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main'
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ph.user_id = $1
|
||||
GROUP BY ph.id, ph.played_at, ph.duration_listened, ph.completed, t.id, t.title,
|
||||
t.release_id, r.title, r.year, t.cover_file_id, r.cover_file_id,
|
||||
t.duration_seconds, mf.uploader_name, mf.audio_format, mf.audio_bitrate
|
||||
ORDER BY ph.played_at DESC, ph.id DESC
|
||||
LIMIT $2"#,
|
||||
WHERE le.user_id = $1 AND le.qualified
|
||||
ORDER BY le.started_at_ms DESC, le.listen_id DESC LIMIT $2"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
@@ -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<i32>,
|
||||
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",
|
||||
);
|
||||
|
||||
-2170
File diff suppressed because it is too large.
Load diff
+8
-2
@@ -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()
|
||||
|
||||
@@ -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" , "Создать новый плейлист";
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Durable playlist destinations for file and torrent imports.
|
||||
use anyhow::{Context, bail};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub async fn validate_target(
|
||||
pool: &PgPool,
|
||||
user_id: i64,
|
||||
playlist_id: Option<i64>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(id) = playlist_id {
|
||||
let owned: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM furumusic__playlist WHERE id=$1 AND owner_id=$2)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if id <= 0 || !owned {
|
||||
bail!("selected playlist does not exist or is not yours");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
pool: &PgPool,
|
||||
user_id: i64,
|
||||
playlist_id: i64,
|
||||
input_path: &str,
|
||||
sha256: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
validate_target(pool, user_id, Some(playlist_id)).await?;
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO furumusic__import_playlist_target
|
||||
(user_id, playlist_id, input_path, source_sha256)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (user_id, input_path) DO UPDATE SET source_sha256=EXCLUDED.source_sha256
|
||||
WHERE furumusic__import_playlist_target.playlist_id=EXCLUDED.playlist_id
|
||||
AND furumusic__import_playlist_target.source_sha256=EXCLUDED.source_sha256"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(playlist_id)
|
||||
.bind(input_path)
|
||||
.bind(sha256)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
bail!("this upload already has a different playlist destination");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fingerprint deduplication may resolve a differently encoded file to an existing media ID.
|
||||
pub async fn link_media(pool: &PgPool, source_sha256: &str, media_id: i64) -> anyhow::Result<()> {
|
||||
if source_sha256.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query("UPDATE furumusic__import_playlist_target SET media_file_id=$2 WHERE source_sha256=$1 AND fulfilled_at IS NULL")
|
||||
.bind(source_sha256).bind(media_id).execute(pool).await?;
|
||||
reconcile(pool, None).await
|
||||
}
|
||||
|
||||
/// Runs after imports and from background discovery, including after a restart.
|
||||
/// Fulfilled requests are retained as receipts so later user removals stay removed.
|
||||
pub async fn reconcile(pool: &PgPool, user_id: Option<i64>) -> anyhow::Result<()> {
|
||||
let requests: Vec<(i64, i64, String)> = sqlx::query_as(
|
||||
"SELECT user_id,playlist_id,input_path FROM furumusic__import_playlist_target WHERE fulfilled_at IS NULL AND ($1::bigint IS NULL OR user_id=$1) ORDER BY user_id,playlist_id,input_path"
|
||||
).bind(user_id).fetch_all(pool).await?;
|
||||
for (user_id, playlist_id, path) in requests {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let pending: Option<String> = sqlx::query_scalar(
|
||||
"SELECT input_path FROM furumusic__import_playlist_target WHERE user_id=$1 AND input_path=$2 AND fulfilled_at IS NULL FOR UPDATE"
|
||||
).bind(user_id).bind(&path).fetch_optional(&mut *transaction).await?;
|
||||
if pending.is_none() {
|
||||
continue;
|
||||
}
|
||||
let track_ids: Vec<i64> = sqlx::query_scalar(r#"
|
||||
SELECT DISTINCT track.id FROM furumusic__import_playlist_target request
|
||||
JOIN furumusic__media_file media ON media.id=request.media_file_id
|
||||
OR (request.media_file_id IS NULL AND media.sha256_hash=request.source_sha256 AND media.file_type='audio')
|
||||
JOIN furumusic__track track ON track.audio_file_id=media.id
|
||||
WHERE request.user_id=$1 AND request.input_path=$2 ORDER BY track.id
|
||||
"#).bind(user_id).bind(&path).fetch_all(&mut *transaction).await?;
|
||||
if track_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut added = Vec::new();
|
||||
for track_id in track_ids {
|
||||
if append_in_transaction(&mut transaction, user_id, playlist_id, track_id).await? {
|
||||
added.push(track_id);
|
||||
}
|
||||
}
|
||||
sqlx::query("UPDATE furumusic__import_playlist_target SET fulfilled_at=$3 WHERE user_id=$1 AND input_path=$2")
|
||||
.bind(user_id).bind(path).bind(now_string()).execute(&mut *transaction).await?;
|
||||
transaction.commit().await?;
|
||||
record_added(pool, user_id, playlist_id, &added).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_string() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
}
|
||||
|
||||
pub(crate) async fn append_track_to_target_playlist(
|
||||
pool: &PgPool,
|
||||
user_id: i64,
|
||||
playlist_id: i64,
|
||||
track_id: i64,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let added = append_in_transaction(&mut transaction, user_id, playlist_id, track_id).await?;
|
||||
transaction.commit().await?;
|
||||
if added {
|
||||
record_added(pool, user_id, playlist_id, &[track_id]).await;
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
async fn append_in_transaction(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
user_id: i64,
|
||||
playlist_id: i64,
|
||||
track_id: i64,
|
||||
) -> anyhow::Result<bool> {
|
||||
let playlist: Option<i64> = sqlx::query_scalar(
|
||||
r#"SELECT id FROM furumusic__playlist
|
||||
WHERE id = $1 AND owner_id = $2
|
||||
FOR UPDATE"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
if playlist.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let already_present: bool = sqlx::query_scalar(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM furumusic__playlist_track
|
||||
WHERE playlist_id = $1 AND track_id = $2
|
||||
)"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(track_id)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
if already_present {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let position: i64 = sqlx::query_scalar(
|
||||
r#"SELECT COALESCE(MAX(position), -1)::bigint + 1
|
||||
FROM furumusic__playlist_track WHERE playlist_id = $1"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
let position = i32::try_from(position).context("target playlist has too many tracks")?;
|
||||
let now = now_string();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO furumusic__playlist_track
|
||||
(playlist_id, track_id, position, added_at, added_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5)"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(track_id)
|
||||
.bind(position)
|
||||
.bind(&now)
|
||||
.bind(user_id)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||
.bind(&now)
|
||||
.bind(playlist_id)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn record_added(pool: &PgPool, user_id: i64, playlist_id: i64, track_ids: &[i64]) {
|
||||
if track_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = crate::federation::devices::record_playlist_tracks_added(
|
||||
pool,
|
||||
user_id,
|
||||
playlist_id,
|
||||
track_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(playlist_id, %error, "federation operation for an automatic import playlist addition was not recorded");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires disposable PostgreSQL in FURU_IMPORT_TEST_DATABASE_URL"]
|
||||
async fn import_destinations_are_durable_owned_and_idempotent() {
|
||||
let url = std::env::var("FURU_IMPORT_TEST_DATABASE_URL").unwrap();
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&url)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(r#"
|
||||
CREATE TEMP TABLE furumusic__playlist(id bigint PRIMARY KEY,owner_id bigint,updated_at text);
|
||||
CREATE TEMP TABLE furumusic__media_file(id bigint PRIMARY KEY,sha256_hash text,file_type text);
|
||||
CREATE TEMP TABLE furumusic__track(id bigint PRIMARY KEY,audio_file_id bigint);
|
||||
CREATE TEMP TABLE furumusic__playlist_track(playlist_id bigint,track_id bigint,position int,added_at text,added_by_user_id bigint);
|
||||
CREATE TEMP TABLE furumusic__import_playlist_target(user_id bigint,playlist_id bigint REFERENCES furumusic__playlist ON DELETE CASCADE,input_path text,source_sha256 text,media_file_id bigint,fulfilled_at text,PRIMARY KEY(user_id,input_path));
|
||||
INSERT INTO furumusic__playlist VALUES (10,1,''),(20,2,'');
|
||||
"#).execute(&pool).await.unwrap();
|
||||
assert!(validate_target(&pool, 1, Some(20)).await.is_err());
|
||||
assert!(validate_target(&pool, 1, Some(999)).await.is_err());
|
||||
validate_target(&pool, 1, None).await.unwrap();
|
||||
register(&pool, 1, 10, "batch/a.flac", "hash-a")
|
||||
.await
|
||||
.unwrap();
|
||||
register(&pool, 1, 10, "batch/a.flac", "hash-a")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
register(&pool, 1, 10, "batch/a.flac", "changed-hash")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
reconcile(&pool, None).await.unwrap();
|
||||
let count = || {
|
||||
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM furumusic__playlist_track")
|
||||
.fetch_one(&pool)
|
||||
};
|
||||
assert_eq!(count().await.unwrap(), 0);
|
||||
sqlx::raw_sql("INSERT INTO furumusic__media_file VALUES (100,'hash-a','audio'); INSERT INTO furumusic__track VALUES (101,100),(102,100);").execute(&pool).await.unwrap();
|
||||
reconcile(&pool, Some(2)).await.unwrap();
|
||||
assert_eq!(count().await.unwrap(), 0);
|
||||
reconcile(&pool, Some(1)).await.unwrap();
|
||||
assert_eq!(count().await.unwrap(), 2);
|
||||
// A second upload of identical audio must not append duplicates.
|
||||
register(&pool, 1, 10, "torrent/b.flac", "hash-a")
|
||||
.await
|
||||
.unwrap();
|
||||
reconcile(&pool, None).await.unwrap();
|
||||
assert_eq!(count().await.unwrap(), 2);
|
||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id=101")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
reconcile(&pool, None).await.unwrap();
|
||||
assert_eq!(count().await.unwrap(), 1);
|
||||
// Fingerprint matching can map a differently encoded upload to the same media.
|
||||
register(&pool, 2, 20, "batch/a.mp3", "different-encoding")
|
||||
.await
|
||||
.unwrap();
|
||||
link_media(&pool, "different-encoding", 100).await.unwrap();
|
||||
assert_eq!(count().await.unwrap(), 3);
|
||||
let positions: Vec<i32> = sqlx::query_scalar(
|
||||
"SELECT position FROM furumusic__playlist_track WHERE playlist_id=20 ORDER BY position",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(positions, vec![0, 1]);
|
||||
// Deleting a destination removes pending work without blocking future imports.
|
||||
register(&pool, 2, 20, "batch/pending.wav", "pending")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM furumusic__playlist WHERE id=20")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
reconcile(&pool, None).await.unwrap();
|
||||
pool.close().await;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -1271,6 +1271,10 @@ pub async fn finalize_approved(
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(error) = crate::import_playlists::link_media(pool, sha256, media_file.id_val()).await {
|
||||
tracing::warn!(%error, "could not add imported tracks to destination playlists; will retry");
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
crate::youtube::sync_target_playlists_for_imported_media(pool, media_file.id_val()).await
|
||||
{
|
||||
|
||||
+14
-7
@@ -103,6 +103,7 @@ pub struct LocalUploadDto {
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub target_playlist_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
@@ -117,6 +118,7 @@ struct LocalUploadRow {
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
completed_at: Option<String>,
|
||||
target_playlist_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl LocalUploadRow {
|
||||
@@ -130,6 +132,7 @@ impl LocalUploadRow {
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
completed_at: self.completed_at.clone(),
|
||||
target_playlist_id: self.target_playlist_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,18 +144,20 @@ pub async fn create(
|
||||
filename: &str,
|
||||
size_bytes: u64,
|
||||
inbox_path: &str,
|
||||
target_playlist_id: Option<i64>,
|
||||
) -> anyhow::Result<()> {
|
||||
let now = now_string();
|
||||
let created = sqlx::query(
|
||||
r#"INSERT INTO furumusic__local_upload
|
||||
(id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||
created_at, updated_at, completed_at)
|
||||
VALUES ($1, $2, $3, $4, 'uploading', $5, NULL, $6, $6, NULL)
|
||||
created_at, updated_at, completed_at, target_playlist_id)
|
||||
VALUES ($1, $2, $3, $4, 'uploading', $5, NULL, $6, $6, NULL, $7)
|
||||
ON CONFLICT (id) DO UPDATE SET status='uploading', error=NULL, updated_at=$6
|
||||
WHERE furumusic__local_upload.user_id=$2
|
||||
AND furumusic__local_upload.inbox_path=$5
|
||||
AND furumusic__local_upload.size_bytes=$4
|
||||
AND furumusic__local_upload.status IN ('uploading', 'failed')"#,
|
||||
AND furumusic__local_upload.status IN ('uploading', 'failed')
|
||||
AND furumusic__local_upload.target_playlist_id IS NOT DISTINCT FROM $7"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
@@ -160,6 +165,7 @@ pub async fn create(
|
||||
.bind(i64::try_from(size_bytes).unwrap_or(i64::MAX))
|
||||
.bind(inbox_path)
|
||||
.bind(now)
|
||||
.bind(target_playlist_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if created.rows_affected() == 0 {
|
||||
@@ -178,7 +184,7 @@ pub async fn successful_receipt(
|
||||
) -> anyhow::Result<Option<LocalUploadDto>> {
|
||||
let row = sqlx::query_as::<_, LocalUploadRow>(
|
||||
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||
created_at, updated_at, completed_at
|
||||
created_at, updated_at, completed_at, target_playlist_id
|
||||
FROM furumusic__local_upload WHERE id=$1 AND user_id=$2 AND inbox_path=$3
|
||||
AND status NOT IN ('uploading','failed')"#,
|
||||
)
|
||||
@@ -205,9 +211,10 @@ pub async fn list(
|
||||
inbox_dir: &str,
|
||||
) -> anyhow::Result<Vec<LocalUploadDto>> {
|
||||
sync_statuses(pool, user_id, inbox_dir).await?;
|
||||
crate::import_playlists::reconcile(pool, Some(user_id)).await?;
|
||||
let rows: Vec<LocalUploadRow> = sqlx::query_as(
|
||||
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||
created_at, updated_at, completed_at
|
||||
created_at, updated_at, completed_at, target_playlist_id
|
||||
FROM furumusic__local_upload
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC, id DESC
|
||||
@@ -244,7 +251,7 @@ async fn sync_statuses(pool: &PgPool, user_id: i64, inbox_dir: &str) -> anyhow::
|
||||
|
||||
let rows: Vec<LocalUploadRow> = sqlx::query_as(
|
||||
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||
created_at, updated_at, completed_at
|
||||
created_at, updated_at, completed_at, target_playlist_id
|
||||
FROM furumusic__local_upload
|
||||
WHERE user_id = $1 AND status <> 'complete'"#,
|
||||
)
|
||||
@@ -353,7 +360,7 @@ async fn update_status(
|
||||
async fn load(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<LocalUploadRow> {
|
||||
sqlx::query_as(
|
||||
r#"SELECT id, user_id, filename, size_bytes, status, inbox_path, error,
|
||||
created_at, updated_at, completed_at
|
||||
created_at, updated_at, completed_at, target_playlist_id
|
||||
FROM furumusic__local_upload WHERE id = $1 AND user_id = $2"#,
|
||||
)
|
||||
.bind(id)
|
||||
|
||||
@@ -5,6 +5,7 @@ mod auth;
|
||||
mod config;
|
||||
mod federation;
|
||||
mod i18n;
|
||||
mod import_playlists;
|
||||
mod jobs;
|
||||
mod lastfm;
|
||||
mod library_cleanup;
|
||||
|
||||
+11
-37
@@ -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"),
|
||||
|
||||
+49
-84
@@ -78,10 +78,6 @@ impl Artist {
|
||||
Self::objects().all(db).await
|
||||
}
|
||||
|
||||
pub async fn get_by_id(db: &Database, artist_id: i64) -> cot::db::Result<Option<Self>> {
|
||||
Self::get_by_primary_key(db, Auto::Fixed(artist_id)).await
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
name: &str,
|
||||
@@ -102,41 +98,9 @@ impl Artist {
|
||||
Ok(artist)
|
||||
}
|
||||
|
||||
pub async fn update_name(&mut self, db: &Database, name: &str) -> cot::db::Result<()> {
|
||||
self.name = LimitedString::new(name).unwrap();
|
||||
self.name_sort = LimitedString::new(&normalize_name(name)).unwrap();
|
||||
self.updated_at = now_iso();
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
pub async fn set_image_file_id(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
file_id: Option<i64>,
|
||||
) -> cot::db::Result<()> {
|
||||
self.image_file_id = file_id;
|
||||
self.updated_at = now_iso();
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
pub async fn delete_by_id(db: &Database, artist_id: i64) -> cot::db::Result<()> {
|
||||
cot::db::query!(Artist, $id == Auto::Fixed(artist_id))
|
||||
.delete(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn id_val(&self) -> i64 {
|
||||
self.id.unwrap()
|
||||
}
|
||||
|
||||
pub fn name_str(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.is_hidden
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -286,47 +250,9 @@ impl ReleaseArtist {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<Vec<Self>> {
|
||||
cot::db::query!(ReleaseArtist, $artist_id == artist_id)
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<u64> {
|
||||
cot::db::query!(ReleaseArtist, $artist_id == artist_id)
|
||||
.count(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_artists(
|
||||
db: &Database,
|
||||
release_id: i64,
|
||||
artist_ids: &[i64],
|
||||
) -> cot::db::Result<()> {
|
||||
// Remove existing links
|
||||
cot::db::query!(ReleaseArtist, $release_id == release_id)
|
||||
.delete(db)
|
||||
.await?;
|
||||
// Insert new links
|
||||
for (pos, &aid) in artist_ids.iter().enumerate() {
|
||||
let mut link = Self {
|
||||
id: Auto::auto(),
|
||||
release_id,
|
||||
artist_id: aid,
|
||||
position: pos as i32,
|
||||
};
|
||||
link.insert(db).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn artist_id(&self) -> i64 {
|
||||
self.artist_id
|
||||
}
|
||||
|
||||
pub fn release_id(&self) -> i64 {
|
||||
self.release_id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -377,12 +303,6 @@ pub struct TrackArtist {
|
||||
}
|
||||
|
||||
impl TrackArtist {
|
||||
pub async fn count_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<u64> {
|
||||
cot::db::query!(TrackArtist, $artist_id == artist_id)
|
||||
.count(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
track_id: i64,
|
||||
@@ -556,10 +476,6 @@ pub struct PlaybackState {
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
|
||||
Self::objects().all(db).await
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
title: &str,
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -5157,6 +5157,26 @@ async fn local_upload_handler(
|
||||
));
|
||||
}
|
||||
|
||||
let target_playlist_id = match request.headers().get("x-furumusic-target-playlist-id") {
|
||||
None => None,
|
||||
Some(header) => match header
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|id| *id > 0)
|
||||
{
|
||||
Some(id) => Some(id),
|
||||
None => {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid target playlist ID"));
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Err(error) =
|
||||
crate::import_playlists::validate_target(pool, user.id, target_playlist_id).await
|
||||
{
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, &error.to_string()));
|
||||
}
|
||||
|
||||
let batch_header_id = request
|
||||
.headers()
|
||||
.get("x-furumusic-upload-batch-id")
|
||||
@@ -5273,6 +5293,12 @@ async fn local_upload_handler(
|
||||
.await
|
||||
.map_err(|err| cot::Error::internal(err.to_string()))?
|
||||
{
|
||||
if upload.target_playlist_id != target_playlist_id {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"upload playlist destination cannot change on retry",
|
||||
));
|
||||
}
|
||||
return Json(LocalUploadResponse { ok: true, upload }).into_response();
|
||||
}
|
||||
if let Err(err) = crate::local_uploads::create(
|
||||
@@ -5282,12 +5308,23 @@ async fn local_upload_handler(
|
||||
&display_name,
|
||||
bytes.len() as u64,
|
||||
&inbox_path,
|
||||
target_playlist_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()));
|
||||
}
|
||||
|
||||
if let Some(playlist_id) = target_playlist_id {
|
||||
use sha2::{Digest, Sha256};
|
||||
let sha256 = format!("{:x}", Sha256::digest(&bytes));
|
||||
if let Err(error) =
|
||||
crate::import_playlists::register(pool, user.id, playlist_id, &inbox_path, &sha256).await
|
||||
{
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, &error.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let write_result = crate::local_uploads::save_file(&destination, &upload_id, &bytes).await;
|
||||
if let Err(err) = write_result {
|
||||
let message = format!("could not save uploaded file: {err}");
|
||||
@@ -9772,6 +9809,7 @@ impl App for PlayerApp {
|
||||
pg_pool,
|
||||
&path.0.id,
|
||||
json.0.selected_files,
|
||||
json.0.target_playlist_id,
|
||||
live_config.agent_inbox_dir,
|
||||
user.id,
|
||||
proxy_url.as_deref(),
|
||||
|
||||
+9
-82
@@ -80,19 +80,8 @@ impl ScheduledJob {
|
||||
cron_expression: &str,
|
||||
) -> cot::db::Result<Self> {
|
||||
if let Some(mut existing) = Self::get_by_name(db, name).await? {
|
||||
// Update cron expression and description if they changed
|
||||
// The registered schedule is a default for new jobs; retain admin overrides.
|
||||
let mut changed = false;
|
||||
if existing.cron_expression.as_str() != cron_expression {
|
||||
tracing::info!(
|
||||
job = name,
|
||||
old = existing.cron_expression.as_str(),
|
||||
new = cron_expression,
|
||||
"Updating cron expression"
|
||||
);
|
||||
existing.cron_expression = LimitedString::new(cron_expression).unwrap();
|
||||
existing.next_run_at = compute_next_run(cron_expression);
|
||||
changed = true;
|
||||
}
|
||||
if existing.description != description {
|
||||
existing.description = description.to_owned();
|
||||
changed = true;
|
||||
@@ -131,10 +120,6 @@ impl ScheduledJob {
|
||||
&self.cron_expression
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
pub fn last_run_at_str(&self) -> &str {
|
||||
self.last_run_at.as_deref().unwrap_or("")
|
||||
}
|
||||
@@ -683,69 +668,6 @@ impl ProcessingStats {
|
||||
.await?;
|
||||
Ok(all.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn list_by_review_ids(
|
||||
pool: &sqlx::PgPool,
|
||||
ids: &[i64],
|
||||
) -> anyhow::Result<HashMap<i64, ProcessingStatsRow>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
// Build comma-separated ID list
|
||||
let id_list: String = ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let query = format!(
|
||||
"SELECT pending_review_id, model_name, llm_duration_ms, prompt_tokens, completion_tokens \
|
||||
FROM furumusic__processing_stats WHERE pending_review_id IN ({id_list})"
|
||||
);
|
||||
let rows = sqlx::query_as::<_, ProcessingStatsRow>(&query)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let map = rows.into_iter().map(|r| (r.pending_review_id, r)).collect();
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub fn model_name_str(&self) -> &str {
|
||||
&self.model_name
|
||||
}
|
||||
|
||||
pub fn duration_display(&self) -> String {
|
||||
if self.llm_duration_ms >= 1000 {
|
||||
format!("{:.1}s", self.llm_duration_ms as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}ms", self.llm_duration_ms)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tokens_display(&self) -> String {
|
||||
format!("{}/{}", self.prompt_tokens, self.completion_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct ProcessingStatsRow {
|
||||
pub pending_review_id: i64,
|
||||
pub model_name: String,
|
||||
pub llm_duration_ms: i64,
|
||||
pub prompt_tokens: i64,
|
||||
pub completion_tokens: i64,
|
||||
}
|
||||
|
||||
impl ProcessingStatsRow {
|
||||
pub fn duration_display(&self) -> String {
|
||||
if self.llm_duration_ms >= 1000 {
|
||||
format!("{:.1}s", self.llm_duration_ms as f64 / 1000.0)
|
||||
} else {
|
||||
format!("{}ms", self.llm_duration_ms)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tokens_display(&self) -> String {
|
||||
format!("{}/{}", self.prompt_tokens, self.completion_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1288,8 +1210,13 @@ impl SchedulerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
// Add new cron job
|
||||
self.add_cron_job(job_name, new_cron).await?;
|
||||
// Editing a disabled job must not start it.
|
||||
let job = ScheduledJob::get_by_name(&self.shared_db, job_name)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("job not found: {job_name}"))?;
|
||||
if job.enabled {
|
||||
self.add_cron_job(job_name, new_cron).await?;
|
||||
}
|
||||
|
||||
// Update DB
|
||||
if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&self.shared_db, job_name).await
|
||||
@@ -1297,7 +1224,7 @@ impl SchedulerHandle {
|
||||
sched_job.cron_expression = LimitedString::new(new_cron).unwrap();
|
||||
sched_job.next_run_at = compute_next_run(new_cron);
|
||||
sched_job.updated_at = now_iso();
|
||||
let _ = sched_job.save(&self.shared_db).await;
|
||||
sched_job.save(&self.shared_db).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+178
-4
@@ -58,6 +58,7 @@ pub struct TorrentJobDto {
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub target_playlist_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -93,6 +94,7 @@ pub struct TorrentPreviewRequest {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TorrentStartRequest {
|
||||
pub target_playlist_id: Option<i64>,
|
||||
pub selected_files: Vec<usize>,
|
||||
}
|
||||
|
||||
@@ -153,6 +155,7 @@ struct TorrentJob {
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
completed_at: Option<String>,
|
||||
target_playlist_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
@@ -176,6 +179,7 @@ struct TorrentSessionRow {
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
completed_at: Option<String>,
|
||||
target_playlist_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl TorrentSessionRow {
|
||||
@@ -226,6 +230,7 @@ impl TorrentSessionRow {
|
||||
let peer_stats = live.map(|l| &l.snapshot.peer_stats);
|
||||
|
||||
TorrentJobDto {
|
||||
target_playlist_id: self.target_playlist_id,
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
info_hash: self.info_hash.clone(),
|
||||
@@ -275,6 +280,7 @@ impl TorrentSessionRow {
|
||||
status: TorrentJobStatus::from_str(&self.status),
|
||||
output_dir: temp_root.join(&id).join("download"),
|
||||
selected_files,
|
||||
target_playlist_id: self.target_playlist_id,
|
||||
handle: None,
|
||||
downloaded_bytes: i64_to_u64(self.downloaded_bytes),
|
||||
uploaded_bytes: i64_to_u64(self.uploaded_bytes),
|
||||
@@ -341,6 +347,7 @@ impl TorrentJob {
|
||||
let peer_stats = live.map(|l| &l.snapshot.peer_stats);
|
||||
|
||||
TorrentJobDto {
|
||||
target_playlist_id: self.target_playlist_id,
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
info_hash: self.info_hash.clone(),
|
||||
@@ -430,7 +437,7 @@ impl TorrentService {
|
||||
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
|
||||
files_json, selected_files_json, status, total_size, selected_size,
|
||||
downloaded_bytes, uploaded_bytes, progress_percent, error,
|
||||
created_at, updated_at, completed_at
|
||||
created_at, updated_at, completed_at, target_playlist_id
|
||||
FROM furumusic__torrent_session
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC, id DESC
|
||||
@@ -441,6 +448,7 @@ impl TorrentService {
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
crate::import_playlists::reconcile(pool, Some(user_id)).await?;
|
||||
let handles = {
|
||||
let jobs = self.jobs.lock().await;
|
||||
jobs.iter()
|
||||
@@ -604,6 +612,7 @@ impl TorrentService {
|
||||
status: TorrentJobStatus::Preview,
|
||||
output_dir,
|
||||
selected_files,
|
||||
target_playlist_id: None,
|
||||
handle: None,
|
||||
downloaded_bytes: 0,
|
||||
uploaded_bytes: 0,
|
||||
@@ -727,6 +736,7 @@ impl TorrentService {
|
||||
status: TorrentJobStatus::Preview,
|
||||
output_dir,
|
||||
selected_files,
|
||||
target_playlist_id: None,
|
||||
handle: None,
|
||||
downloaded_bytes: 0,
|
||||
uploaded_bytes: 0,
|
||||
@@ -794,6 +804,7 @@ impl TorrentService {
|
||||
pool: &PgPool,
|
||||
id: &str,
|
||||
selected_files: Vec<usize>,
|
||||
target_playlist_id: Option<i64>,
|
||||
inbox_dir: String,
|
||||
uploader_user_id: i64,
|
||||
proxy_url: Option<&str>,
|
||||
@@ -806,6 +817,8 @@ impl TorrentService {
|
||||
}
|
||||
let inbox_dir = validate_inbox_dir(&inbox_dir)?;
|
||||
|
||||
crate::import_playlists::validate_target(pool, uploader_user_id, target_playlist_id)
|
||||
.await?;
|
||||
self.ensure_memory_job(pool, uploader_user_id, id).await?;
|
||||
|
||||
let (torrent_bytes, output_dir, selected_size) = {
|
||||
@@ -823,7 +836,13 @@ impl TorrentService {
|
||||
bail!("torrent job is already running");
|
||||
}
|
||||
validate_selection(&job.files, &selected_files)?;
|
||||
if job.status != TorrentJobStatus::Preview
|
||||
&& job.target_playlist_id != target_playlist_id
|
||||
{
|
||||
bail!("playlist destination cannot change after a torrent has started");
|
||||
}
|
||||
job.status = TorrentJobStatus::Downloading;
|
||||
job.target_playlist_id = target_playlist_id;
|
||||
job.selected_files = selected_files.clone();
|
||||
job.downloaded_bytes = 0;
|
||||
job.uploaded_bytes = 0;
|
||||
@@ -1032,9 +1051,14 @@ impl TorrentService {
|
||||
inbox_dir: &Path,
|
||||
uploader_user_id: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
let (name, files, selected_files, output_dir) = {
|
||||
// The playlist may have been deleted while this torrent was downloading.
|
||||
let persisted_target = load_row(pool, uploader_user_id, id)
|
||||
.await?
|
||||
.target_playlist_id;
|
||||
let (name, files, selected_files, output_dir, target_playlist_id) = {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
job.target_playlist_id = persisted_target;
|
||||
job.refresh_progress();
|
||||
job.status = TorrentJobStatus::Moving;
|
||||
job.updated_at = now_string();
|
||||
@@ -1043,6 +1067,7 @@ impl TorrentService {
|
||||
job.files.clone(),
|
||||
job.selected_files.clone(),
|
||||
job.output_dir.clone(),
|
||||
job.target_playlist_id,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1052,6 +1077,7 @@ impl TorrentService {
|
||||
let destination_root = inbox_dir
|
||||
.join("user_uploads")
|
||||
.join(uploader_user_id.to_string())
|
||||
.join(format!("torrent-{id}"))
|
||||
.join(sanitize_path_component(&name));
|
||||
tokio::fs::create_dir_all(&destination_root).await?;
|
||||
|
||||
@@ -1064,6 +1090,22 @@ impl TorrentService {
|
||||
if let Some(parent) = destination.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
if let Some(playlist_id) = target_playlist_id
|
||||
&& crate::jobs::inbox_discover::is_audio_file(&file.name)
|
||||
{
|
||||
let input_path =
|
||||
crate::media_paths::path_for_root(&inbox_dir.to_string_lossy(), &destination)
|
||||
.context("torrent destination escaped inbox")?;
|
||||
let sha = crate::agent::mover::sha256_file(&source).await?;
|
||||
crate::import_playlists::register(
|
||||
pool,
|
||||
uploader_user_id,
|
||||
playlist_id,
|
||||
&input_path,
|
||||
&sha,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
move_file(&source, &destination).await?;
|
||||
}
|
||||
|
||||
@@ -1103,7 +1145,7 @@ async fn load_row(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<Torre
|
||||
r#"SELECT id, user_id, name, info_hash, source_kind, source_label, torrent_bytes,
|
||||
files_json, selected_files_json, status, total_size, selected_size,
|
||||
downloaded_bytes, uploaded_bytes, progress_percent, error,
|
||||
created_at, updated_at, completed_at
|
||||
created_at, updated_at, completed_at, target_playlist_id
|
||||
FROM furumusic__torrent_session
|
||||
WHERE id = $1 AND user_id = $2"#,
|
||||
)
|
||||
@@ -1244,13 +1286,15 @@ async fn mark_job_started(
|
||||
progress_percent = 0,
|
||||
error = NULL,
|
||||
updated_at = $4,
|
||||
completed_at = NULL
|
||||
completed_at = NULL,
|
||||
target_playlist_id = $5
|
||||
WHERE id = $1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(serde_json::to_string(selected_files)?)
|
||||
.bind(u64_to_i64(dto.selected_size))
|
||||
.bind(now_string())
|
||||
.bind(dto.target_playlist_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -1434,3 +1478,133 @@ async fn move_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod playlist_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires disposable PostgreSQL in FURU_IMPORT_TEST_DATABASE_URL"]
|
||||
async fn torrent_playlist_survives_restore_and_registers_selected_audio() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&std::env::var("FURU_IMPORT_TEST_DATABASE_URL").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(r#"
|
||||
CREATE TEMP TABLE furumusic__playlist(id bigint PRIMARY KEY,owner_id bigint);
|
||||
INSERT INTO furumusic__playlist VALUES (10,1);
|
||||
CREATE TEMP TABLE furumusic__import_playlist_target(user_id bigint,playlist_id bigint,input_path text,source_sha256 text,media_file_id bigint,fulfilled_at text,PRIMARY KEY(user_id,input_path));
|
||||
CREATE TEMP TABLE furumusic__torrent_session(
|
||||
id text PRIMARY KEY,user_id bigint,name text,info_hash text,source_kind text,source_label text,torrent_bytes bytea,
|
||||
files_json text,selected_files_json text,status text,total_size bigint DEFAULT 0,selected_size bigint DEFAULT 0,
|
||||
downloaded_bytes bigint DEFAULT 0,uploaded_bytes bigint DEFAULT 0,progress_percent float8 DEFAULT 0,
|
||||
error text,created_at text,updated_at text,completed_at text,target_playlist_id bigint);
|
||||
"#).execute(&pool).await.unwrap();
|
||||
let root = std::env::temp_dir().join(format!("furumi-torrent-test-{}", Uuid::new_v4()));
|
||||
let mut service = TorrentService::new(Arc::new(OnceCell::new()));
|
||||
service.temp_root = root.join("torrents");
|
||||
let output = service.temp_root.join("job/download");
|
||||
tokio::fs::create_dir_all(&output).await.unwrap();
|
||||
let files: Vec<_> = ["a.flac", "b.mp3", "cover.jpg"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, name)| TorrentFileDto {
|
||||
index,
|
||||
name: name.to_string(),
|
||||
components: vec![name.to_string()],
|
||||
length: 3,
|
||||
selected: index != 1,
|
||||
})
|
||||
.collect();
|
||||
for file in &files {
|
||||
tokio::fs::write(output.join(&file.name), b"abc")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let mut job = TorrentJob {
|
||||
id: "job".into(),
|
||||
user_id: 1,
|
||||
name: "Soundtrack".into(),
|
||||
info_hash: "hash".into(),
|
||||
source_kind: "torrent_file".into(),
|
||||
source_label: None,
|
||||
torrent_bytes: vec![],
|
||||
files,
|
||||
selected_files: vec![0, 2],
|
||||
target_playlist_id: None,
|
||||
output_dir: output,
|
||||
status: TorrentJobStatus::Preview,
|
||||
handle: None,
|
||||
downloaded_bytes: 0,
|
||||
uploaded_bytes: 0,
|
||||
progress_percent: 0.0,
|
||||
error: None,
|
||||
created_at: now_string(),
|
||||
updated_at: now_string(),
|
||||
completed_at: None,
|
||||
};
|
||||
sqlx::query("INSERT INTO furumusic__torrent_session (id,user_id,name,info_hash,source_kind,torrent_bytes,files_json,selected_files_json,status,created_at,updated_at) VALUES ('job',1,'Soundtrack','hash','torrent_file',$1,$2,'[0,2]','resolving',$3,$3)")
|
||||
.bind(Vec::<u8>::new()).bind(serde_json::to_string(&job.files).unwrap()).bind(now_string()).execute(&pool).await.unwrap();
|
||||
update_resolved_job(&pool, &job).await.unwrap();
|
||||
job.target_playlist_id = Some(10);
|
||||
mark_job_started(&pool, "job", &[0, 2], &job.dto())
|
||||
.await
|
||||
.unwrap();
|
||||
let restored = load_row(&pool, 1, "job")
|
||||
.await
|
||||
.unwrap()
|
||||
.into_job(&service.temp_root)
|
||||
.unwrap();
|
||||
assert_eq!(restored.target_playlist_id, Some(10));
|
||||
assert_eq!(restored.dto().target_playlist_id, Some(10));
|
||||
service.jobs.lock().await.insert("job".into(), restored);
|
||||
let inbox = root.join("inbox");
|
||||
service
|
||||
.finalize_completed(&pool, "job", &inbox, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let paths: Vec<String> =
|
||||
sqlx::query_scalar("SELECT input_path FROM furumusic__import_playlist_target")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(paths, vec!["user_uploads/1/torrent-job/Soundtrack/a.flac"]);
|
||||
assert!(inbox.join(&paths[0]).exists());
|
||||
assert!(
|
||||
!inbox
|
||||
.join("user_uploads/1/torrent-job/Soundtrack/b.mp3")
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(load_row(&pool, 1, "job").await.unwrap().status, "complete");
|
||||
// Deleting the destination while downloading clears its persisted FK;
|
||||
// an older in-memory choice must not prevent the audio from being imported.
|
||||
sqlx::query("UPDATE furumusic__torrent_session SET target_playlist_id=NULL WHERE id='job'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM furumusic__playlist WHERE id=10")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let source = service.temp_root.join("job/download");
|
||||
tokio::fs::create_dir_all(&source).await.unwrap();
|
||||
tokio::fs::write(source.join("a.flac"), b"abc")
|
||||
.await
|
||||
.unwrap();
|
||||
service
|
||||
.finalize_completed(&pool, "job", &inbox, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
service
|
||||
.memory_job_dto("job")
|
||||
.await
|
||||
.unwrap()
|
||||
.target_playlist_id,
|
||||
None
|
||||
);
|
||||
tokio::fs::remove_dir_all(root).await.unwrap();
|
||||
pool.close().await;
|
||||
}
|
||||
}
|
||||
-14
@@ -26,11 +26,6 @@ pub struct User {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl User {
|
||||
/// List all users.
|
||||
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
|
||||
Self::objects().all(db).await
|
||||
}
|
||||
|
||||
/// Get a user by primary key.
|
||||
pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result<Option<Self>> {
|
||||
Self::get_by_primary_key(db, Auto::Fixed(user_id)).await
|
||||
@@ -121,21 +116,12 @@ impl User {
|
||||
pub fn username_str(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
pub fn email_str(&self) -> String {
|
||||
self.email
|
||||
.as_ref()
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn display_name_str(&self) -> String {
|
||||
self.display_name
|
||||
.as_ref()
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn role_str(&self) -> &str {
|
||||
&self.role
|
||||
}
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active
|
||||
}
|
||||
|
||||
+6
-82
@@ -2089,91 +2089,15 @@ async fn sync_target_playlist_tracks_for_user(pool: &PgPool, user_id: i64) -> an
|
||||
.await?;
|
||||
|
||||
for (playlist_id, owner_id, track_id, _, _) in candidates {
|
||||
append_track_to_target_playlist(pool, owner_id, playlist_id, track_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_track_to_target_playlist(
|
||||
pool: &PgPool,
|
||||
user_id: i64,
|
||||
playlist_id: i64,
|
||||
track_id: i64,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let playlist: Option<i64> = sqlx::query_scalar(
|
||||
r#"SELECT id FROM furumusic__playlist
|
||||
WHERE id = $1 AND owner_id = $2
|
||||
FOR UPDATE"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
if playlist.is_none() {
|
||||
transaction.rollback().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let already_present: bool = sqlx::query_scalar(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM furumusic__playlist_track
|
||||
WHERE playlist_id = $1 AND track_id = $2
|
||||
)"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(track_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
if already_present {
|
||||
transaction.commit().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let position: i64 = sqlx::query_scalar(
|
||||
r#"SELECT COALESCE(MAX(position), -1)::bigint + 1
|
||||
FROM furumusic__playlist_track WHERE playlist_id = $1"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
let position = i32::try_from(position).context("target playlist has too many tracks")?;
|
||||
let now = now_string();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO furumusic__playlist_track
|
||||
(playlist_id, track_id, position, added_at, added_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5)"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(track_id)
|
||||
.bind(position)
|
||||
.bind(&now)
|
||||
.bind(user_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||
.bind(&now)
|
||||
.bind(playlist_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
if let Err(error) = crate::federation::devices::record_playlist_tracks_added(
|
||||
pool,
|
||||
user_id,
|
||||
playlist_id,
|
||||
&[track_id],
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
crate::import_playlists::append_track_to_target_playlist(
|
||||
pool,
|
||||
owner_id,
|
||||
playlist_id,
|
||||
track_id,
|
||||
%error,
|
||||
"federation operation for an automatic YouTube playlist addition was not recorded"
|
||||
);
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(true)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_parent(pool: &PgPool, job_id: &str) -> anyhow::Result<()> {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}</h1>
|
||||
|
||||
<form method="post" action="{% if is_edit %}/admin/artists/{{ form_artist_id }}/edit{% else %}/admin/artists/new{% endif %}">
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="name">{{ t.artists_name }}</label></td>
|
||||
<td><input name="name" id="name" value="{{ form_name }}" required style="width:100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
|
||||
{% if is_edit %}
|
||||
<hr style="margin: 2rem 0;">
|
||||
<h2>{{ t.artists_image }}</h2>
|
||||
|
||||
<div id="artist-image-section" style="margin-top: 1rem;">
|
||||
<!-- Current image -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
{% match current_image_url %}
|
||||
{% when Some with (url) %}
|
||||
<img id="current-image" src="{{ url }}" alt="" style="max-width: 200px; max-height: 200px; border-radius: 6px; border: 1px solid #ddd;">
|
||||
<br>
|
||||
<button type="button" onclick="removeImage()" style="margin-top: .5rem; padding: .3rem 1rem; cursor: pointer;">{{ t.artists_remove_image }}</button>
|
||||
{% when None %}
|
||||
<p style="color: #888;">{{ t.artists_no_image }}</p>
|
||||
{% endmatch %}
|
||||
</div>
|
||||
|
||||
<!-- Upload custom image -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_upload_image }}</h3>
|
||||
<input type="file" id="image-upload" accept="image/*" style="margin-bottom: .5rem;">
|
||||
<br>
|
||||
<button type="button" id="upload-btn" onclick="uploadImage()" style="padding: .3rem 1rem; cursor: pointer;" disabled>{{ t.artists_upload }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Pick from album covers -->
|
||||
<div>
|
||||
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_pick_cover }}</h3>
|
||||
<div id="covers-grid" style="display: flex; flex-wrap: wrap; gap: .75rem;">
|
||||
<p style="color: #888;" id="covers-loading">...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const artistId = {{ form_artist_id }};
|
||||
|
||||
// Enable upload button when file selected
|
||||
document.getElementById('image-upload').addEventListener('change', function() {
|
||||
document.getElementById('upload-btn').disabled = !this.files.length;
|
||||
});
|
||||
|
||||
// Upload custom image
|
||||
function uploadImage() {
|
||||
const fileInput = document.getElementById('image-upload');
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function() {
|
||||
const base64 = reader.result.split(',')[1];
|
||||
fetch('/admin/artists/' + artistId + '/upload-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
data: base64,
|
||||
filename: file.name,
|
||||
mime_type: file.type || 'image/jpeg'
|
||||
})
|
||||
}).then(function(r) {
|
||||
if (r.ok) location.reload();
|
||||
else r.text().then(function(t) { alert('Error: ' + t); });
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Set image from album cover
|
||||
function setImage(mediaFileId) {
|
||||
fetch('/admin/artists/' + artistId + '/set-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ media_file_id: mediaFileId })
|
||||
}).then(function(r) {
|
||||
if (r.ok) location.reload();
|
||||
else r.text().then(function(t) { alert('Error: ' + t); });
|
||||
});
|
||||
}
|
||||
|
||||
// Remove image
|
||||
function removeImage() {
|
||||
fetch('/admin/artists/' + artistId + '/set-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ media_file_id: null })
|
||||
}).then(function(r) {
|
||||
if (r.ok) location.reload();
|
||||
else r.text().then(function(t) { alert('Error: ' + t); });
|
||||
});
|
||||
}
|
||||
|
||||
// Load available covers
|
||||
fetch('/admin/artists/' + artistId + '/available-covers')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(covers) {
|
||||
const grid = document.getElementById('covers-grid');
|
||||
grid.innerHTML = '';
|
||||
if (!covers.length) {
|
||||
grid.innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
|
||||
return;
|
||||
}
|
||||
covers.forEach(function(c) {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'cursor:pointer; text-align:center;';
|
||||
div.title = c.release_title;
|
||||
div.innerHTML = '<img src="' + c.cover_url + '" alt="" style="width:100px; height:100px; object-fit:cover; border-radius:4px; border:2px solid #ddd;">'
|
||||
+ '<br><small style="font-size:.75rem; color:#666;">' + c.release_title.substring(0, 20) + '</small>';
|
||||
div.onclick = function() { setImage(c.media_file_id); };
|
||||
grid.appendChild(div);
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('covers-grid').innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -1,45 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_artists }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.artists_heading }}</h1>
|
||||
|
||||
<p style="margin-bottom: 1rem;">
|
||||
<a href="/admin/artists/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.artists_add }}</a>
|
||||
</p>
|
||||
|
||||
{% if rows.is_empty() %}
|
||||
<p>{{ t.artists_empty }}</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{{ t.artists_name }}</th>
|
||||
<th>{{ t.artists_releases }}</th>
|
||||
<th>{{ t.artists_tracks }}</th>
|
||||
<th>{{ t.artists_hidden }}</th>
|
||||
<th>{{ t.artists_actions }}</th>
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row.artist.id_val() }}</td>
|
||||
<td>{{ row.artist.name_str() }}</td>
|
||||
<td>
|
||||
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ row.release_count }}</a>
|
||||
</td>
|
||||
<td>{{ row.track_count }}</td>
|
||||
<td>{{ row.artist.is_hidden() }}</td>
|
||||
<td>
|
||||
<a href="/admin/artists/{{ row.artist.id_val() }}/edit">{{ t.artists_edit }}</a>
|
||||
|
|
||||
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ t.artists_view_releases }}</a>
|
||||
|
|
||||
<form method="post" action="/admin/artists/{{ row.artist.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.artists_delete_confirm }}')">
|
||||
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.artists_delete }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -1,82 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block admin_title %}{{ t.nav_debug }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.cfg-key { position: relative; display: inline-flex; align-items: center; gap: .35rem; }
|
||||
.cfg-info-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #ccc; color: #fff; font-size: 11px; font-weight: 700;
|
||||
cursor: help; flex-shrink: 0; line-height: 1;
|
||||
}
|
||||
.cfg-info-btn:hover { background: #888; }
|
||||
.cfg-popup {
|
||||
position: absolute; left: 100%; top: 50%;
|
||||
transform: translateY(-50%); z-index: 10;
|
||||
background: #1a1a2e; color: #eee; padding: .55rem .75rem;
|
||||
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
||||
font-size: .8rem; white-space: nowrap; min-width: 220px;
|
||||
margin-left: 8px;
|
||||
opacity: 0; visibility: hidden; pointer-events: none;
|
||||
transition: opacity .15s, visibility 0s .3s;
|
||||
user-select: text;
|
||||
}
|
||||
/* invisible bridge so cursor can travel from icon to popup */
|
||||
.cfg-popup::before {
|
||||
content: ''; position: absolute; right: 100%; top: 0; bottom: 0;
|
||||
width: 12px;
|
||||
}
|
||||
.cfg-key:hover .cfg-popup {
|
||||
opacity: 1; visibility: visible; pointer-events: auto;
|
||||
transition: opacity .15s, visibility 0s;
|
||||
}
|
||||
.cfg-popup dt { color: #999; font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; margin-top: .35rem; }
|
||||
.cfg-popup dt:first-child { margin-top: 0; }
|
||||
.cfg-popup dd { margin: .1rem 0 0; }
|
||||
.cfg-popup code { background: rgba(255,255,255,.12); color: #fff; padding: .05rem .3rem; border-radius: 3px; font-size: .8rem; }
|
||||
</style>
|
||||
|
||||
<h1>{{ t.debug_heading }}</h1>
|
||||
|
||||
<h2>{{ t.debug_build_info }}</h2>
|
||||
<table>
|
||||
<tr><th>{{ t.debug_field }}</th><th>{{ t.debug_value }}</th></tr>
|
||||
<tr><td>Package</td><td><code>{{ build.pkg_name }}</code></td></tr>
|
||||
<tr><td>Version</td><td><code>{{ build.pkg_version }}</code></td></tr>
|
||||
<tr><td>Profile</td><td><code>{{ build.profile }}</code></td></tr>
|
||||
<tr><td>Target</td><td><code>{{ build.target }}</code></td></tr>
|
||||
<tr><td>Rustc</td><td><code>{{ build.rustc_version }}</code></td></tr>
|
||||
<tr><td>{{ t.debug_db_status }}</td><td><code>{{ db_status }}</code></td></tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.debug_app_config }}</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
{% for entry in config_entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="cfg-key">
|
||||
<code>{{ entry.key }}</code>
|
||||
<span class="cfg-info-btn">i</span>
|
||||
<dl class="cfg-popup">
|
||||
<dt>env var</dt>
|
||||
<dd><code>{{ entry.env_var }}</code></dd>
|
||||
<dt>default</dt>
|
||||
<dd><code>{% if entry.default_value == "" %}(empty){% else %}{{ entry.default_value }}{% endif %}</code></dd>
|
||||
<dt>source</dt>
|
||||
<dd><code>{{ entry.source }}</code></dd>
|
||||
</dl>
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{{ entry.value }}</code></td>
|
||||
<td><span class="badge badge-{{ entry.source }}">{{ entry.source }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock content %}
|
||||
@@ -1,8 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block admin_title %}{{ t.nav_dashboard }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.admin_heading }}</h1>
|
||||
<p><a href="/admin/debug">{{ t.admin_debug_link }}</a></p>
|
||||
{% endblock content %}
|
||||
@@ -1,120 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ job.name_str() }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ job.name_str() }}</h1>
|
||||
|
||||
<table>
|
||||
<tr><th>{{ t.jobs_description }}</th><td>{{ job.description_str() }}</td></tr>
|
||||
<tr><th>{{ t.jobs_cron }}</th><td><code>{{ job.cron_expression_str() }}</code></td></tr>
|
||||
<tr><th>{{ t.jobs_enabled }}</th><td>{% if job.enabled() %}✅{% else %}❌{% endif %}</td></tr>
|
||||
<tr><th>{{ t.jobs_last_run }}</th><td>{{ job.last_run_at_str() }}</td></tr>
|
||||
<tr><th>{{ t.jobs_next_run }}</th><td>{{ job.next_run_at_str() }}</td></tr>
|
||||
</table>
|
||||
|
||||
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
|
||||
{% if job.name_str() != "metadata_backfill" && job.name_str() != "lastfm_popularity" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||
{% if job.enabled() %}
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
|
||||
{% else %}
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if job.name_str() == "lastfm_popularity" %}
|
||||
<form method="post" action="/admin/jobs/lastfm_popularity/run-options" style="margin:1rem 0;">
|
||||
<p>{{ t.jobs_lastfm_light_help }}</p>
|
||||
<label style="display:block; margin-bottom:1rem;">
|
||||
<input type="checkbox" name="refresh_existing"> {{ t.jobs_lastfm_full }}
|
||||
</label>
|
||||
<button type="submit">{{ t.jobs_run_now }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if job.name_str() == "metadata_backfill" %}
|
||||
<h2>{{ t.jobs_metadata_backfill_options }}</h2>
|
||||
<form method="post" action="/admin/jobs/metadata_backfill/run-options" style="margin:0 0 1.5rem; padding:1rem; background:#fff; border:1px solid #e0e0e0; border-radius:6px;">
|
||||
<fieldset style="border:0; margin:0 0 .75rem; padding:0;">
|
||||
<legend style="font-weight:600; margin-bottom:.5rem;">{{ t.jobs_metadata_backfill_fields }}</legend>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_bitrate" checked> audio_bitrate
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_sample_rate" checked> audio_sample_rate
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_bit_depth" checked> audio_bit_depth
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="duration_seconds" checked> duration_seconds
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="local_genres" checked> local genres from files
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="lastfm_tags" checked> Last.fm tags
|
||||
</label>
|
||||
</fieldset>
|
||||
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||
<input type="radio" name="mode" value="fill_missing" checked> {{ t.jobs_metadata_backfill_fill_missing }}
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||
<input type="radio" name="mode" value="overwrite"> {{ t.jobs_metadata_backfill_overwrite }}
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" style="padding:.45rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_metadata_backfill_run }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<h2>{{ t.jobs_cron }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
|
||||
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ t.jobs_run_history }}</h2>
|
||||
{% if runs.is_empty() %}
|
||||
<p>No runs yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{{ t.jobs_run_status }}</th>
|
||||
<th>{{ t.jobs_run_started }}</th>
|
||||
<th>{{ t.jobs_run_duration }}</th>
|
||||
<th>{{ t.jobs_run_trigger }}</th>
|
||||
<th>{{ t.jobs_actions }}</th>
|
||||
</tr>
|
||||
{% for run in runs %}
|
||||
<tr>
|
||||
<td>{{ run.id_val() }}</td>
|
||||
<td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td>
|
||||
<td>{{ run.started_at_str() }}</td>
|
||||
<td>{{ run.duration_display() }}</td>
|
||||
<td>{{ run.trigger_str() }}</td>
|
||||
<td><a href="/admin/jobs/{{ job.name_str() }}/runs/{{ run.id_val() }}">{{ t.reviews_view }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:1rem;"><a href="/admin/jobs">← {{ t.jobs_back_to_list }}</a></p>
|
||||
|
||||
<style>
|
||||
.badge-completed { background: #d4edda; color: #155724; }
|
||||
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||
.badge-processing { background: #d1ecf1; color: #0c5460; }
|
||||
</style>
|
||||
{% endblock content %}
|
||||
@@ -1,31 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.jobs_run_detail }} #{{ run.id_val() }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.jobs_run_detail }} #{{ run.id_val() }}</h1>
|
||||
|
||||
<table>
|
||||
<tr><th>{{ t.jobs_run_status }}</th><td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td></tr>
|
||||
<tr><th>{{ t.jobs_run_trigger }}</th><td>{{ run.trigger_str() }}</td></tr>
|
||||
<tr><th>{{ t.jobs_run_started }}</th><td>{{ run.started_at_str() }}</td></tr>
|
||||
<tr><th>{{ t.jobs_run_duration }}</th><td>{{ run.duration_display() }}</td></tr>
|
||||
</table>
|
||||
|
||||
{% if !run.error_message_str().is_empty() %}
|
||||
<h2>{{ t.jobs_run_error }}</h2>
|
||||
<pre style="background:#f8d7da; color:#721c24; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ run.error_message_str() }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if !run.log_output_str().is_empty() %}
|
||||
<h2>{{ t.jobs_run_log }}</h2>
|
||||
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem; max-height:40em; overflow-y:auto;">{{ run.log_output_str() }}</pre>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:1rem;"><a href="/admin/jobs/{{ job_name }}">← {{ t.jobs_back_to_job }}</a></p>
|
||||
|
||||
<style>
|
||||
.badge-completed { background: #d4edda; color: #155724; }
|
||||
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||
.badge-processing { background: #d1ecf1; color: #0c5460; }
|
||||
</style>
|
||||
{% endblock content %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_jobs }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.jobs_heading }}</h1>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.jobs_name }}</th>
|
||||
<th>{{ t.jobs_description }}</th>
|
||||
<th>{{ t.jobs_cron }}</th>
|
||||
<th>{{ t.jobs_enabled }}</th>
|
||||
<th>{{ t.jobs_last_run }}</th>
|
||||
<th>{{ t.jobs_next_run }}</th>
|
||||
<th>{{ t.jobs_actions }}</th>
|
||||
</tr>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td><a href="/admin/jobs/{{ job.name_str() }}">{{ job.name_str() }}</a></td>
|
||||
<td>{{ job.description_str() }}</td>
|
||||
<td><code>{{ job.cron_expression_str() }}</code></td>
|
||||
<td>{% if job.enabled() %}✅{% else %}❌{% endif %}</td>
|
||||
<td>{{ job.last_run_at_str() }}</td>
|
||||
<td>{{ job.next_run_at_str() }}</td>
|
||||
<td style="display:flex;gap:.3rem;">
|
||||
{% if job.name_str() == "metadata_backfill" %}
|
||||
<a href="/admin/jobs/{{ job.name_str() }}" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer; text-decoration:none;">{{ t.jobs_metadata_backfill_options }}</a>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||
{% if job.enabled() %}
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
|
||||
{% else %}
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock content %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% block admin_title %}{{ t.nav_admin }}{% endblock admin_title %} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
|
||||
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
|
||||
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
|
||||
.admin-version { display: inline-block; margin-left: .35rem; color: #999; font-size: .72rem; font-weight: 500; vertical-align: baseline; }
|
||||
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
|
||||
nav.sidebar a:hover { background: #16213e; color: #fff; }
|
||||
.main-wrap { flex: 1; display: flex; flex-direction: column; }
|
||||
.topbar { display: flex; justify-content: flex-end; align-items: center; gap: 1rem; padding: .5rem 1.5rem; background: #fff; border-bottom: 1px solid #e0e0e0; }
|
||||
.user-info { font-size: .85rem; color: #555; }
|
||||
.lang-switch { display: flex; gap: .25rem; }
|
||||
.lang-switch a { text-decoration: none; padding: .25rem .5rem; border-radius: 4px; font-size: .9rem; color: #555; }
|
||||
.lang-switch a:hover { background: #eee; color: #111; }
|
||||
.lang-switch a.active { font-weight: 700; color: #111; }
|
||||
.logout-link { text-decoration: none; font-size: .85rem; color: #555; padding: .25rem .5rem; border-radius: 4px; }
|
||||
.logout-link:hover { background: #eee; color: #111; }
|
||||
.main { flex: 1; padding: 2rem; overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 1.5rem; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid #eee; }
|
||||
th { background: #f0f0f0; font-weight: 600; }
|
||||
h1 { margin-bottom: 1rem; }
|
||||
h2 { margin: 1.5rem 0 .5rem; }
|
||||
code { background: #f4f4f4; padding: .1rem .35rem; border-radius: 3px; font-size: .85em; }
|
||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 600; letter-spacing: .02em; }
|
||||
.badge-default { background: #e9ecef; color: #555; }
|
||||
.badge-database { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-env { background: #fff3cd; color: #856404; }
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<nav class="sidebar">
|
||||
<h2>{{ t.site_name }} {{ t.nav_admin }} <span class="admin-version">v{{ t.app_version() }}</span></h2>
|
||||
<a href="/admin/">{{ t.nav_dashboard }}</a>
|
||||
<a href="/admin/artists">{{ t.nav_artists }}</a>
|
||||
<a href="/admin/releases">{{ t.nav_releases }}</a>
|
||||
<a href="/admin/media-files">{{ t.nav_media_files }}</a>
|
||||
<a href="/admin/jobs">{{ t.nav_jobs }}</a>
|
||||
<a href="/admin/reviews">{{ t.nav_reviews }}</a>
|
||||
<a href="/admin/users">{{ t.nav_users }}</a>
|
||||
<a href="/admin/settings">{{ t.nav_settings }}</a>
|
||||
<a href="/admin/debug">{{ t.nav_debug }}</a>
|
||||
</nav>
|
||||
<div class="main-wrap">
|
||||
<div class="topbar">
|
||||
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
|
||||
<div class="lang-switch">
|
||||
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
|
||||
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
|
||||
</div>
|
||||
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
@@ -1,51 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_media_files }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.media_files_heading }}</h1>
|
||||
|
||||
{% if rows.is_empty() %}
|
||||
<p>{{ t.media_files_empty }}</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{{ t.media_files_filename }}</th>
|
||||
<th>{{ t.media_files_type }}</th>
|
||||
<th>{{ t.media_files_format }}</th>
|
||||
<th>{{ t.media_files_size }}</th>
|
||||
<th>{{ t.media_files_track }}</th>
|
||||
<th>{{ t.media_files_path }}</th>
|
||||
<th>{{ t.media_files_created }}</th>
|
||||
<th>{{ t.media_files_actions }}</th>
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row.media_file.id_val() }}</td>
|
||||
<td>{{ row.media_file.original_filename_str() }}</td>
|
||||
<td>{{ row.media_file.file_type_str() }}</td>
|
||||
<td>{{ row.media_file.audio_format_str() }}</td>
|
||||
<td>{{ row.media_file.file_size_display() }}</td>
|
||||
<td>
|
||||
{% if row.track_title.is_empty() %}
|
||||
<span class="badge badge-orphan">{{ t.media_files_orphan }}</span>
|
||||
{% else %}
|
||||
{{ row.track_title }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.media_file.file_path_str() }}">{{ row.media_file.file_path_str() }}</td>
|
||||
<td>{{ row.media_file.created_at_str() }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/media-files/{{ row.media_file.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.media_files_delete_confirm }}')">
|
||||
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.media_files_delete }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.badge-orphan { background: #fff3cd; color: #856404; }
|
||||
</style>
|
||||
{% endblock content %}
|
||||
@@ -1,32 +0,0 @@
|
||||
{% if !agent_enabled %}
|
||||
<p style="color:#999;">{{ t.settings_agent_status_disabled }}</p>
|
||||
{% else if agent_llm_url.is_empty() %}
|
||||
<p style="color:#999;">{{ t.settings_agent_status_no_url }}</p>
|
||||
{% else if agent_probe.ok %}
|
||||
<div style="border:1px solid #28a745; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#f0fff0;">
|
||||
<p style="margin:0 0 .5rem; font-weight:bold; color:#28a745;">{{ t.settings_agent_status_ok }}</p>
|
||||
{% if !agent_probe.model_intro.is_empty() %}
|
||||
<blockquote style="border-left:3px solid #28a745; padding-left:.75rem; margin:.5rem 0; color:#333; font-style:italic;">{{ agent_probe.model_intro }}</blockquote>
|
||||
{% endif %}
|
||||
<table style="font-size:.85rem; margin-top:.5rem;">
|
||||
{% if !agent_probe.model_name.is_empty() %}
|
||||
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_model_name }}</td><td><code>{{ agent_probe.model_name }}</code></td></tr>
|
||||
{% endif %}
|
||||
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_latency }}</td><td>{{ agent_probe.latency_ms }} ms</td></tr>
|
||||
{% if let Some(pt) = agent_probe.prompt_tokens %}
|
||||
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_prompt_tokens }}</td><td>{{ pt }}</td></tr>
|
||||
{% endif %}
|
||||
{% if let Some(ct) = agent_probe.completion_tokens %}
|
||||
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_completion_tokens }}</td><td>{{ ct }}</td></tr>
|
||||
{% endif %}
|
||||
{% if let Some(tps) = agent_probe.tokens_per_sec %}
|
||||
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_tokens_per_sec }}</td><td>{{ format!("{:.1}", tps) }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="border:1px solid #dc3545; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#fff5f5;">
|
||||
<p style="margin:0 0 .5rem; font-weight:bold; color:#dc3545;">{{ t.settings_agent_status_error }}</p>
|
||||
<p style="margin:0; font-size:.85rem; color:#666;">{{ agent_probe.error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,130 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}</h1>
|
||||
|
||||
<form method="post" action="{% if is_edit %}/admin/releases/{{ form_release_id }}/edit{% else %}/admin/releases/new{% endif %}" id="release-form">
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="title">{{ t.releases_title }}</label></td>
|
||||
<td><input name="title" id="title" value="{{ form_title }}" required style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="release_type">{{ t.releases_type }}</label></td>
|
||||
<td>
|
||||
<select name="release_type" id="release_type" style="width:100%; padding:.4rem;">
|
||||
{% for rt in release_types %}
|
||||
<option value="{{ rt.0 }}"{% if form_release_type == rt.0 %} selected{% endif %}>{% if lang_code == "ru" %}{{ rt.2 }}{% else %}{{ rt.1 }}{% endif %} ({{ rt.0 }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="year">{{ t.releases_year }}</label></td>
|
||||
<td><input name="year" id="year" type="number" min="1900" max="2100" value="{{ form_year }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label>{{ t.releases_artists }}</label></td>
|
||||
<td>
|
||||
<div id="artist-tags" style="display:flex; flex-wrap:wrap; gap:.3rem; margin-bottom:.5rem;"></div>
|
||||
<div style="display:flex; gap:.5rem;">
|
||||
<input list="artist-list" id="artist-input" placeholder="{{ t.releases_select_artist }}" style="flex:1; padding:.4rem;">
|
||||
<datalist id="artist-list">
|
||||
{% for a in artists %}
|
||||
<option value="{{ a.name_str() }}" data-id="{{ a.id_val() }}"></option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<button type="button" onclick="addArtist()" style="padding:.4rem .8rem;">+</button>
|
||||
</div>
|
||||
<input type="hidden" name="artist_id" id="artist-ids-hidden" value="">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Artist data from server
|
||||
var allArtists = [
|
||||
{% for a in artists %}
|
||||
{ id: {{ a.id_val() }}, name: "{{ a.name_str() }}" },
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
// Currently selected artist IDs
|
||||
var selectedIds = [{% for aid in form_artist_ids %}{{ aid }},{% endfor %}];
|
||||
|
||||
var tagsContainer = document.getElementById('artist-tags');
|
||||
var hiddenInput = document.getElementById('artist-ids-hidden');
|
||||
var artistInput = document.getElementById('artist-input');
|
||||
|
||||
function findArtistByName(name) {
|
||||
var lower = name.toLowerCase().trim();
|
||||
for (var i = 0; i < allArtists.length; i++) {
|
||||
if (allArtists[i].name.toLowerCase() === lower) return allArtists[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findArtistById(id) {
|
||||
for (var i = 0; i < allArtists.length; i++) {
|
||||
if (allArtists[i].id === id) return allArtists[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncHidden() {
|
||||
hiddenInput.value = selectedIds.join(',');
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
tagsContainer.innerHTML = '';
|
||||
for (var i = 0; i < selectedIds.length; i++) {
|
||||
var artist = findArtistById(selectedIds[i]);
|
||||
if (!artist) continue;
|
||||
var tag = document.createElement('span');
|
||||
tag.style.cssText = 'display:inline-flex; align-items:center; gap:.3rem; padding:.2rem .5rem; background:#e9ecef; border-radius:4px; font-size:.85rem;';
|
||||
tag.textContent = artist.name;
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = '\u00d7';
|
||||
btn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:1rem; color:#c00; padding:0; line-height:1;';
|
||||
btn.setAttribute('data-id', artist.id);
|
||||
btn.onclick = function() {
|
||||
var rid = parseInt(this.getAttribute('data-id'));
|
||||
selectedIds = selectedIds.filter(function(x) { return x !== rid; });
|
||||
renderTags();
|
||||
syncHidden();
|
||||
};
|
||||
tag.appendChild(btn);
|
||||
tagsContainer.appendChild(tag);
|
||||
}
|
||||
}
|
||||
|
||||
window.addArtist = function() {
|
||||
var artist = findArtistByName(artistInput.value);
|
||||
if (!artist) return;
|
||||
if (selectedIds.indexOf(artist.id) === -1) {
|
||||
selectedIds.push(artist.id);
|
||||
renderTags();
|
||||
syncHidden();
|
||||
}
|
||||
artistInput.value = '';
|
||||
};
|
||||
|
||||
// Allow pressing Enter in the artist input to add
|
||||
artistInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
window.addArtist();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderTags();
|
||||
syncHidden();
|
||||
})();
|
||||
</script>
|
||||
{% endblock content %}
|
||||
@@ -1,51 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_releases }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.releases_heading }}</h1>
|
||||
|
||||
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:1rem; flex-wrap:wrap;">
|
||||
<a href="/admin/releases/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.releases_add }}</a>
|
||||
|
||||
<form method="get" action="/admin/releases" style="display:flex; gap:.5rem; align-items:center;">
|
||||
<label for="artist_id" style="font-size:.85rem; color:#555;">{{ t.releases_filter_label }}:</label>
|
||||
<select name="artist_id" id="artist_id" onchange="this.form.submit()" style="padding:.35rem .5rem; border:1px solid #ccc; border-radius:4px;">
|
||||
<option value="">{{ t.releases_filter_all }}</option>
|
||||
{% for a in artists %}
|
||||
<option value="{{ a.id_val() }}"{% match filter_artist_id %}{% when Some with (fid) %}{% if *fid == a.id_val() %} selected{% endif %}{% when None %}{% endmatch %}>{{ a.name_str() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if rows.is_empty() %}
|
||||
<p>{{ t.releases_empty }}</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{{ t.releases_title }}</th>
|
||||
<th>{{ t.releases_artists }}</th>
|
||||
<th>{{ t.releases_type }}</th>
|
||||
<th>{{ t.releases_year }}</th>
|
||||
<th>{{ t.releases_actions }}</th>
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row.release.id_val() }}</td>
|
||||
<td>{{ row.release.title_str() }}</td>
|
||||
<td>{% if row.artist_names.is_empty() %}<span style="color:#999;">{{ t.releases_no_artist }}</span>{% else %}{{ row.artist_names }}{% endif %}</td>
|
||||
<td><code>{{ row.release.release_type_str() }}</code></td>
|
||||
<td>{{ row.release.year_display() }}</td>
|
||||
<td>
|
||||
<a href="/admin/releases/{{ row.release.id_val() }}/edit">{{ t.releases_edit }}</a>
|
||||
|
|
||||
<form method="post" action="/admin/releases/{{ row.release.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.releases_delete_confirm }}')">
|
||||
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.releases_delete }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -1,111 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}Review #{{ review.id_val() }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Review #{{ review.id_val() }}</h1>
|
||||
|
||||
<table>
|
||||
<tr><th>{{ t.reviews_status }}</th><td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td></tr>
|
||||
<tr><th>{{ t.reviews_type }}</th><td>{{ review.review_type_str() }}</td></tr>
|
||||
<tr><th>{{ t.reviews_input_path }}</th><td style="word-break:break-all;">{{ review.input_path_str() }}</td></tr>
|
||||
<tr><th>{{ t.reviews_confidence }}</th><td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td></tr>
|
||||
<tr><th>{{ t.reviews_created }}</th><td>{{ review.created_at_str() }}</td></tr>
|
||||
{% match stats %}
|
||||
{% when Some with (s) %}
|
||||
<tr><th>{{ t.reviews_model }}</th><td>{{ s.model_name_str() }}</td></tr>
|
||||
<tr><th>{{ t.reviews_llm_duration }}</th><td>{{ s.duration_display() }}</td></tr>
|
||||
<tr><th>{{ t.reviews_tokens }}</th><td>{{ s.tokens_display() }}</td></tr>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
</table>
|
||||
|
||||
{% if !error_message.is_empty() %}
|
||||
<div style="margin: 1rem 0; padding: 1rem; background: #f8d7da; color: #721c24; border-radius: 6px;">
|
||||
<strong>{{ t.reviews_error }}:</strong> {{ error_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if review.status_str() == "pending" %}
|
||||
<h2>{{ t.reviews_result }}</h2>
|
||||
<form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="margin: 1rem 0;">
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="artist">Artist</label></td>
|
||||
<td><input name="artist" id="artist" value="{{ edit.artist }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="album">Album</label></td>
|
||||
<td><input name="album" id="album" value="{{ edit.album }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="title">Title</label></td>
|
||||
<td><input name="title" id="title" value="{{ edit.title }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="year">Year</label></td>
|
||||
<td><input name="year" id="year" type="number" min="0" max="3000" value="{{ edit.year }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="track_number">Track</label></td>
|
||||
<td><input name="track_number" id="track_number" type="number" min="0" value="{{ edit.track_number }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="genre">Genre</label></td>
|
||||
<td><input name="genre" id="genre" value="{{ edit.genre }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="featured_artists">Featured artists</label></td>
|
||||
<td><input name="featured_artists" id="featured_artists" value="{{ edit.featured_artists }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="release_type">{{ t.releases_type }}</label></td>
|
||||
<td>
|
||||
<select name="release_type" id="release_type" style="width:100%; padding:.4rem;">
|
||||
{% for rt in release_types %}
|
||||
<option value="{{ rt.0 }}"{% if edit.release_type == rt.0 %} selected{% endif %}>{% if lang_code == "ru" %}{{ rt.2 }}{% else %}{{ rt.1 }}{% endif %} ({{ rt.0 }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="notes">Notes</label></td>
|
||||
<td><textarea name="notes" id="notes" style="width:100%; min-height:4rem;">{{ edit.notes }}</textarea></td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top:1rem; padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button>
|
||||
</form>
|
||||
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
|
||||
<form method="post" action="/admin/reviews/{{ review.id_val() }}/reject" style="display:inline;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_reject }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
|
||||
{% if review.status_str() == "failed" || review.status_str() == "processing" %}
|
||||
<form method="post" action="/admin/reviews/{{ review.id_val() }}/requeue" style="display:inline;" onsubmit="return confirm('{{ t.reviews_requeue_confirm }}');">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if !context_pretty.is_empty() %}
|
||||
<h2>{{ t.reviews_context }}</h2>
|
||||
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if !result_pretty.is_empty() && review.status_str() != "pending" %}
|
||||
<h2>{{ t.reviews_result }}</h2>
|
||||
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:1rem;"><a href="/admin/reviews">← {{ t.reviews_back_to_list }}</a></p>
|
||||
|
||||
<style>
|
||||
.badge-completed { background: #d4edda; color: #155724; }
|
||||
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||
.badge-pending { background: #fff3cd; color: #856404; }
|
||||
.badge-queued { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-processing { background: #cce5ff; color: #004085; }
|
||||
</style>
|
||||
{% endblock content %}
|
||||
@@ -1,276 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_reviews }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.reviews_heading }}</h1>
|
||||
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;">
|
||||
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
|
||||
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
|
||||
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
|
||||
<a href="/admin/reviews?status=rejected" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "rejected" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_rejected }}</a>
|
||||
<a href="/admin/reviews?status=queued" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "queued" %} #17a2b8; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_queued }}</a>
|
||||
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
|
||||
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
|
||||
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
|
||||
{% if !rows.is_empty() %}
|
||||
<span style="flex:1;"></span>
|
||||
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
|
||||
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if rows.is_empty() %}
|
||||
<p>{{ t.reviews_empty }}</p>
|
||||
{% else %}
|
||||
<form id="reviews-bulk-form" method="post" action="/admin/reviews/bulk" style="margin:0;">
|
||||
<input type="hidden" name="selected_ids" id="selected-review-ids" value="">
|
||||
<input type="hidden" name="status_filter" value="{{ status_filter }}">
|
||||
<div class="review-bulk-toolbar">
|
||||
<button type="button" id="select-shown-reviews" class="review-toolbar-button">{{ t.reviews_select_all }}</button>
|
||||
<button type="button" id="clear-review-selection" class="review-toolbar-button">{{ t.reviews_clear_selection }}</button>
|
||||
<button type="submit" name="action" value="delete" class="review-danger-button" disabled>{{ t.reviews_delete_selected }}</button>
|
||||
<button type="submit" name="action" value="requeue" class="review-primary-button" disabled>{{ t.reviews_requeue_selected }}</button>
|
||||
<span id="review-selection-summary" class="review-selection-summary">{{ t.reviews_selected_none }}</span>
|
||||
</div>
|
||||
<table>
|
||||
<tr>
|
||||
<th class="review-select-cell"></th>
|
||||
<th>ID</th>
|
||||
<th>{{ t.reviews_status }}</th>
|
||||
<th>{{ t.reviews_type }}</th>
|
||||
<th>{{ t.reviews_input_path }}</th>
|
||||
<th>{{ t.reviews_tags }}</th>
|
||||
<th>{{ t.reviews_confidence }}</th>
|
||||
<th>{{ t.reviews_model }}</th>
|
||||
<th>{{ t.reviews_llm_duration }}</th>
|
||||
<th>{{ t.reviews_tokens }}</th>
|
||||
<th>{{ t.reviews_created }}</th>
|
||||
<th>{{ t.jobs_actions }}</th>
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="review-select-cell">
|
||||
<input type="checkbox" class="review-select" value="{{ row.review.id_val() }}" data-status="{{ row.review.status_str() }}" aria-label="Select review {{ row.review.id_val() }}">
|
||||
</td>
|
||||
<td><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
|
||||
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
|
||||
<td>{{ row.review.review_type_str() }}</td>
|
||||
<td class="review-input-path" title="{{ row.review.input_path_str() }}">{{ row.display_input_path }}</td>
|
||||
<td class="review-tag-cell">
|
||||
{% for tag in row.media_tags %}
|
||||
<span class="review-tag review-tag-{{ tag.kind }}">{{ tag.label }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>{% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
|
||||
{% match stats_map.get(&row.review.id_val()) %}
|
||||
{% when Some with (s) %}
|
||||
<td>{{ s.model_name }}</td>
|
||||
<td>{{ s.duration_display() }}</td>
|
||||
<td>{{ s.tokens_display() }}</td>
|
||||
{% when None %}
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
{% endmatch %}
|
||||
<td>{{ row.review.created_at_str() }}</td>
|
||||
<td>
|
||||
<a href="/admin/reviews/{{ row.review.id_val() }}">{{ t.reviews_view }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.review-bulk-toolbar {
|
||||
margin-bottom: .75rem;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.review-toolbar-button,
|
||||
.review-danger-button,
|
||||
.review-primary-button {
|
||||
padding: .35rem .7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ced4da;
|
||||
background: #fff;
|
||||
color: #212529;
|
||||
cursor: pointer;
|
||||
}
|
||||
.review-danger-button {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
.review-primary-button {
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
.review-danger-button:disabled,
|
||||
.review-primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .45;
|
||||
}
|
||||
.review-selection-summary {
|
||||
min-height: 1.7rem;
|
||||
padding: .35rem .6rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-size: .9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.review-select-cell {
|
||||
width: 2.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.review-select {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.review-input-path {
|
||||
max-width: 34rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.review-tag-cell {
|
||||
max-width: 18rem;
|
||||
}
|
||||
.review-tag {
|
||||
display: inline-block;
|
||||
margin: .1rem .15rem .1rem 0;
|
||||
padding: .12rem .35rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-size: .8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.review-tag-format { border-color: #9ec5fe; background: #e7f1ff; color: #084298; }
|
||||
.review-tag-bitrate { border-color: #a3cfbb; background: #d1e7dd; color: #0f5132; }
|
||||
.review-tag-sample { border-color: #ffda6a; background: #fff3cd; color: #664d03; }
|
||||
.review-tag-depth { border-color: #d0bfff; background: #f0e7ff; color: #3d246c; }
|
||||
.review-tag-size { border-color: #ced4da; background: #f8f9fa; color: #495057; }
|
||||
.badge-completed { background: #d4edda; color: #155724; }
|
||||
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||
.badge-pending { background: #fff3cd; color: #856404; }
|
||||
.badge-queued { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-processing { background: #cce5ff; color: #004085; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.getElementById("reviews-bulk-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkboxes = Array.from(form.querySelectorAll(".review-select"));
|
||||
const selectedIdsInput = document.getElementById("selected-review-ids");
|
||||
const summary = document.getElementById("review-selection-summary");
|
||||
const selectShownButton = document.getElementById("select-shown-reviews");
|
||||
const clearSelectionButton = document.getElementById("clear-review-selection");
|
||||
const submitButtons = Array.from(form.querySelectorAll("button[type='submit']"));
|
||||
const selected = new Set();
|
||||
const statusCounts = new Map();
|
||||
|
||||
const labels = {
|
||||
pending: "{{ t.reviews_filter_pending }}",
|
||||
approved: "{{ t.reviews_filter_approved }}",
|
||||
rejected: "{{ t.reviews_filter_rejected }}",
|
||||
queued: "{{ t.reviews_filter_queued }}",
|
||||
processing: "{{ t.reviews_filter_processing }}",
|
||||
auto_approved: "{{ t.reviews_filter_auto_approved }}",
|
||||
failed: "{{ t.reviews_filter_failed }}"
|
||||
};
|
||||
|
||||
function setStatusCount(status, delta) {
|
||||
const next = (statusCounts.get(status) || 0) + delta;
|
||||
if (next > 0) {
|
||||
statusCounts.set(status, next);
|
||||
} else {
|
||||
statusCounts.delete(status);
|
||||
}
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
selectedIdsInput.value = Array.from(selected).join(",");
|
||||
const total = selected.size;
|
||||
for (const button of submitButtons) {
|
||||
button.disabled = total === 0;
|
||||
}
|
||||
if (total === 0) {
|
||||
summary.textContent = "{{ t.reviews_selected_none }}";
|
||||
return;
|
||||
}
|
||||
const parts = Array.from(statusCounts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([status, count]) => `${labels[status] || status}: ${count}`);
|
||||
summary.textContent = `{{ t.reviews_selected_prefix }}: ${total} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
function setChecked(checkbox, checked) {
|
||||
const id = checkbox.value;
|
||||
const isSelected = selected.has(id);
|
||||
checkbox.checked = checked;
|
||||
if (isSelected === checked) {
|
||||
return;
|
||||
}
|
||||
const status = checkbox.dataset.status || "unknown";
|
||||
if (checked) {
|
||||
selected.add(id);
|
||||
setStatusCount(status, 1);
|
||||
} else {
|
||||
selected.delete(id);
|
||||
setStatusCount(status, -1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const checkbox of checkboxes) {
|
||||
checkbox.addEventListener("change", () => {
|
||||
setChecked(checkbox, checkbox.checked);
|
||||
syncControls();
|
||||
});
|
||||
}
|
||||
|
||||
selectShownButton.addEventListener("click", () => {
|
||||
for (const checkbox of checkboxes) {
|
||||
setChecked(checkbox, true);
|
||||
}
|
||||
syncControls();
|
||||
});
|
||||
|
||||
clearSelectionButton.addEventListener("click", () => {
|
||||
for (const checkbox of checkboxes) {
|
||||
setChecked(checkbox, false);
|
||||
}
|
||||
syncControls();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
syncControls();
|
||||
if (selected.size === 0) {
|
||||
event.preventDefault();
|
||||
alert("{{ t.reviews_none_selected_confirm }}");
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.submitter ? event.submitter.value : "";
|
||||
const message = action === "requeue"
|
||||
? "{{ t.reviews_requeue_selected_confirm }}"
|
||||
: "{{ t.reviews_delete_selected_confirm }}";
|
||||
if (!confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
syncControls();
|
||||
})();
|
||||
</script>
|
||||
{% endblock content %}
|
||||
@@ -1,166 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_settings }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.settings_heading }}</h1>
|
||||
|
||||
{% if saved %}
|
||||
<p style="color: green; font-weight: bold; margin-bottom: 1rem;">{{ t.settings_saved }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/settings">
|
||||
|
||||
<h2>{{ t.settings_auth }}</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="auth_password_enabled">{{ t.settings_password_login }}</label></td>
|
||||
<td><input type="checkbox" name="auth_password_enabled" id="auth_password_enabled" value="on"{% if auth_password_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ auth_password_enabled_source }}">{{ auth_password_enabled_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="auth_sso_enabled">{{ t.settings_sso_login }}</label></td>
|
||||
<td><input type="checkbox" name="auth_sso_enabled" id="auth_sso_enabled" value="on"{% if auth_sso_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ auth_sso_enabled_source }}">{{ auth_sso_enabled_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.settings_oidc }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_oidc_help }}</p>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:1rem;">
|
||||
<strong>{{ t.settings_oidc_callback }}:</strong>
|
||||
<code id="oidc-callback-url"></code>
|
||||
</p>
|
||||
<script>document.getElementById('oidc-callback-url').textContent=location.origin+'/auth/oidc/callback';</script>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_button_text">{{ t.settings_oidc_button }}</label></td>
|
||||
<td><input name="oidc_button_text" id="oidc_button_text" value="{{ oidc_button_text }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_button_text_source }}">{{ oidc_button_text_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_issuer">oidc_issuer</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_issuer_help }}</span></td>
|
||||
<td><input name="oidc_issuer" id="oidc_issuer" value="{{ oidc_issuer }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_issuer_source }}">{{ oidc_issuer_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_client_id">oidc_client_id</label></td>
|
||||
<td><input name="oidc_client_id" id="oidc_client_id" value="{{ oidc_client_id }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_client_id_source }}">{{ oidc_client_id_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_client_secret">oidc_client_secret</label></td>
|
||||
<td><input name="oidc_client_secret" id="oidc_client_secret" type="password" value="{{ oidc_client_secret }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_client_secret_source }}">{{ oidc_client_secret_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_admin_groups">{{ t.settings_oidc_admin_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_admin_groups_help }}</span></td>
|
||||
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_user_groups">{{ t.settings_oidc_user_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_user_groups_help }}</span></td>
|
||||
<td><input name="oidc_user_groups" id="oidc_user_groups" value="{{ oidc_user_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_user_groups_source }}">{{ oidc_user_groups_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2>{{ t.settings_api }}</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="swagger_enabled">{{ t.settings_swagger }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_swagger_help }}</span></td>
|
||||
<td><input type="checkbox" name="swagger_enabled" id="swagger_enabled" value="on"{% if swagger_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ swagger_enabled_source }}">{{ swagger_enabled_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="lastfm_api_key">{{ t.settings_lastfm_api_key }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_api_key_help }}</span></td>
|
||||
<td><input type="password" name="lastfm_api_key" id="lastfm_api_key" value="{{ lastfm_api_key }}"></td>
|
||||
<td><span class="badge badge-{{ lastfm_api_key_source }}">{{ lastfm_api_key_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="lastfm_shared_secret">{{ t.settings_lastfm_shared_secret }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_shared_secret_help }}</span></td>
|
||||
<td><input type="password" name="lastfm_shared_secret" id="lastfm_shared_secret" value="{{ lastfm_shared_secret }}"></td>
|
||||
<td><span class="badge badge-{{ lastfm_shared_secret_source }}">{{ lastfm_shared_secret_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.settings_agent }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_agent_help }}</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_enabled">{{ t.settings_agent_enabled }}</label></td>
|
||||
<td><input type="checkbox" name="agent_enabled" id="agent_enabled" value="on"{% if agent_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ agent_enabled_source }}">{{ agent_enabled_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_inbox_dir">{{ t.settings_agent_inbox }}</label></td>
|
||||
<td><input name="agent_inbox_dir" id="agent_inbox_dir" value="{{ agent_inbox_dir }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ agent_inbox_dir_source }}">{{ agent_inbox_dir_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_storage_dir">{{ t.settings_agent_storage }}</label></td>
|
||||
<td><input name="agent_storage_dir" id="agent_storage_dir" value="{{ agent_storage_dir }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ agent_storage_dir_source }}">{{ agent_storage_dir_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_llm_url">{{ t.settings_agent_llm_url }}</label></td>
|
||||
<td><input name="agent_llm_url" id="agent_llm_url" value="{{ agent_llm_url }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ agent_llm_url_source }}">{{ agent_llm_url_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_llm_model">{{ t.settings_agent_llm_model }}</label></td>
|
||||
<td><input name="agent_llm_model" id="agent_llm_model" value="{{ agent_llm_model }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ agent_llm_model_source }}">{{ agent_llm_model_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_llm_auth">{{ t.settings_agent_llm_auth }}</label></td>
|
||||
<td><input name="agent_llm_auth" id="agent_llm_auth" type="password" value="{{ agent_llm_auth }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ agent_llm_auth_source }}">{{ agent_llm_auth_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_confidence_threshold">{{ t.settings_agent_threshold }}</label></td>
|
||||
<td><input name="agent_confidence_threshold" id="agent_confidence_threshold" value="{{ agent_confidence_threshold }}" style="width:6em"></td>
|
||||
<td><span class="badge badge-{{ agent_confidence_threshold_source }}">{{ agent_confidence_threshold_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_context_limit">{{ t.settings_agent_context }}</label></td>
|
||||
<td><input name="agent_context_limit" id="agent_context_limit" value="{{ agent_context_limit }}" style="width:6em"></td>
|
||||
<td><span class="badge badge-{{ agent_context_limit_source }}">{{ agent_context_limit_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="agent_concurrency">{{ t.settings_agent_concurrency }}</label></td>
|
||||
<td><input name="agent_concurrency" id="agent_concurrency" value="{{ agent_concurrency }}" type="number" min="1" max="32" style="width:6em"></td>
|
||||
<td><span class="badge badge-{{ agent_concurrency_source }}">{{ agent_concurrency_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.settings_agent_status }}</h2>
|
||||
<div hx-get="/admin/settings/probe" hx-trigger="load" hx-swap="innerHTML">
|
||||
<p style="color:#999;">
|
||||
<span class="htmx-indicator" style="display:inline;">
|
||||
◠ {{ t.settings_agent_status_loading }}...
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -1,40 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}</h1>
|
||||
|
||||
<form method="post" action="{% if is_edit %}/admin/users/{{ form_user_id }}/edit{% else %}/admin/users/new{% endif %}">
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="username">{{ t.users_username }}</label></td>
|
||||
<td><input name="username" id="username" value="{{ form_username }}" required style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="email">{{ t.users_email }}</label></td>
|
||||
<td><input name="email" id="email" type="email" value="{{ form_email }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="display_name">{{ t.users_display_name }}</label></td>
|
||||
<td><input name="display_name" id="display_name" value="{{ form_display_name }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="password">{{ t.login_password }}</label></td>
|
||||
<td>
|
||||
<input name="password" id="password" type="password"{% if !is_edit %} required{% endif %} style="width:100%">
|
||||
{% if is_edit %}<br><small style="color:#888;">{{ t.users_password_hint }}</small>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="role">{{ t.users_role }}</label></td>
|
||||
<td>
|
||||
<select name="role" id="role" style="width:100%; padding:.4rem;">
|
||||
<option value="user"{% if form_role == "user" %} selected{% endif %}>user</option>
|
||||
<option value="admin"{% if form_role == "admin" %} selected{% endif %}>admin</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -1,37 +0,0 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_users }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.users_heading }}</h1>
|
||||
|
||||
<p style="margin-bottom: 1rem;">
|
||||
<a href="/admin/users/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.users_add }}</a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.users_username }}</th>
|
||||
<th>{{ t.users_email }}</th>
|
||||
<th>{{ t.users_display_name }}</th>
|
||||
<th>{{ t.users_role }}</th>
|
||||
<th>{{ t.users_active }}</th>
|
||||
<th>{{ t.users_actions }}</th>
|
||||
</tr>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username_str() }}</td>
|
||||
<td>{{ u.email_str() }}</td>
|
||||
<td>{{ u.display_name_str() }}</td>
|
||||
<td>{{ u.role_str() }}</td>
|
||||
<td>{{ u.is_active() }}</td>
|
||||
<td>
|
||||
<a href="/admin/users/{{ u.id_val() }}/edit">{{ t.users_edit }}</a>
|
||||
|
|
||||
<form method="post" action="/admin/users/{{ u.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.users_delete_confirm }}')">
|
||||
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.users_delete }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock content %}
|
||||
+324
-19
@@ -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; }
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-shell" x-data="adminV2()" x-init="init()" x-cloak>
|
||||
<div class="admin-shell" :class="{'statistics-view': activeView === 'statistics'}" x-data="adminV2()" x-cloak>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-title">
|
||||
@@ -2043,6 +2091,7 @@ tbody tr:hover {
|
||||
<i data-lucide="wrench"></i>
|
||||
<span>Library Tools</span>
|
||||
</button>
|
||||
<button class="nav-btn" :class="{active: activeView === 'statistics'}" @click="openStatistics()"><i data-lucide="chart-no-axes-combined"></i><span>Statistics</span></button>
|
||||
<button class="nav-btn" :class="{active: activeView === 'users'}" @click="openUsers()">
|
||||
<i data-lucide="users"></i>
|
||||
<span>Users</span>
|
||||
@@ -2086,10 +2135,6 @@ tbody tr:hover {
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<a class="btn" href="/admin/debug">
|
||||
<i data-lucide="bug"></i>
|
||||
Debug
|
||||
</a>
|
||||
<a class="btn" href="/">
|
||||
<i data-lucide="music-2"></i>
|
||||
Player
|
||||
@@ -2293,7 +2338,7 @@ tbody tr:hover {
|
||||
<div class="job-facts">
|
||||
<div class="job-fact">
|
||||
<span>Cron</span>
|
||||
<span :title="activeJob?.cron_expression || ''" x-text="cronLabel(activeJob)"></span>
|
||||
<span :title="activeJob?.cron_expression || ''" x-text="cronLabel(activeJob)"></span><button class="btn" @click="scheduleDraft = activeJob.cron_expression; scheduleEditing = true">Edit schedule</button><form x-show="scheduleEditing" @submit.prevent="saveSchedule()" class="toolbar"><input class="search" aria-label="Cron schedule" x-model="scheduleDraft" required><button class="btn" type="submit">Save schedule</button><button class="btn" type="button" @click="scheduleEditing = false">Cancel</button></form>
|
||||
</div>
|
||||
<div class="job-fact">
|
||||
<span>Next</span>
|
||||
@@ -2490,6 +2535,59 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" x-show="activeView === 'statistics'">
|
||||
<section class="panel statistics-filters">
|
||||
<div class="panel-title"><strong>Listening statistics</strong><span>Explore an artist or track to see exactly who listened. Dates and charts use UTC.</span></div>
|
||||
<div class="toolbar statistics-toolbar">
|
||||
<label>Period<select class="search" x-model="statisticsPeriod" @change="statisticsSetPeriod()"><option value="today">Today</option><option value="7">7 days</option><option value="30">30 days</option><option value="all">All time</option><option value="custom">Custom dates</option></select></label>
|
||||
<label x-show="statisticsPeriod === 'custom'">From<input class="search" type="date" x-model="statisticsFilter.from" @change="statisticsReload()"></label>
|
||||
<label x-show="statisticsPeriod === 'custom'">Through<input class="search" type="date" x-model="statisticsFilter.to" @change="statisticsReload()"></label>
|
||||
<label>Playback source<select class="search" x-model="statisticsFilter.source" @change="statisticsReload()"><option value="all">All devices</option><option value="instance">This instance</option><option value="synced">Synced devices</option></select></label>
|
||||
<label>Find a user<input class="search" placeholder="Name, username or email" x-model="statisticsUserSearch" @input.debounce.300ms="statisticsFindUsers()"></label>
|
||||
</div>
|
||||
<div class="toolbar" x-show="statisticsUserSearch"><template x-for="user in statisticsUserOptions" :key="user.id"><button class="btn" @click="statisticsSelectUser(user.id, user.display_name || user.username)" x-text="user.display_name || user.username"></button></template><span class="muted" x-show="!statisticsUserOptions.length">No matching users</span></div>
|
||||
<div class="toolbar statistics-chips">
|
||||
<button class="btn" x-show="statisticsFilter.user_id" @click="statisticsClearFilter('user_id')" x-text="'User: ' + statisticsUserLabel + ' ×'"></button>
|
||||
<button class="btn" x-show="statisticsFilter.artist" @click="statisticsClearFilter('artist')" x-text="'Artist: ' + statisticsFilter.artist + ' ×'"></button>
|
||||
<button class="btn" x-show="statisticsFilter.content_id" @click="statisticsClearFilter('content_id')" x-text="'Track: ' + statisticsTrackLabel + ' ×'"></button>
|
||||
<button class="btn" x-show="statisticsFilter.user_id || statisticsFilter.artist || statisticsFilter.content_id" @click="statisticsClearSelection()">All listeners and music</button>
|
||||
</div>
|
||||
<p class="muted statistics-note">A play counts after a natural finish, half the track, or four minutes. Short listens are recorded from five seconds. Synced device history is shown separately from this instance.</p>
|
||||
<p class="statistics-error" role="alert" x-show="statisticsError" x-text="statisticsError"></p>
|
||||
<span class="muted" role="status" x-show="statisticsLoading">Loading statistics…</span>
|
||||
</section>
|
||||
<div x-show="statisticsData && !statisticsError" :aria-busy="statisticsLoading" :style="statisticsLoading ? 'opacity: .55; pointer-events: none' : ''">
|
||||
<section class="stats-strip statistics-summary">
|
||||
<template x-for="cell in statisticsCards()" :key="cell.label"><div class="stat-cell"><div class="stat-value" x-text="cell.value"></div><div class="stat-label" x-text="cell.label"></div></div></template>
|
||||
</section>
|
||||
<section class="panel statistics-panel">
|
||||
<div class="panel-head"><div class="panel-title"><strong>Listening over time</strong><span x-text="statisticsData?.daily.unit === 'month' ? 'Monthly totals · UTC' : 'Daily totals · UTC'"></span></div></div>
|
||||
<div class="statistics-chart" role="img" aria-label="Plays over time; each bar is labelled with its date and number of plays">
|
||||
<template x-for="day in (statisticsData?.daily.items || [])" :key="day.bucket"><div class="statistics-bar-column" :title="day.bucket.slice(0,10) + ': ' + day.plays + ' plays, ' + day.listeners + ' listeners'"><span x-text="fmt(day.plays)"></span><div class="statistics-bar" :style="'height:' + Math.max(2, 120 * day.plays / statisticsChartMax()) + 'px'"></div><small x-text="day.bucket.slice(0,10)"></small></div></template>
|
||||
<div class="empty" x-show="!(statisticsData?.daily.items || []).length">No listening activity in this period</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel statistics-panel">
|
||||
<div class="panel-head statistics-ranking-head"><div class="panel-title"><strong x-text="statisticsFilter.artist || statisticsFilter.content_id ? 'Popularity and listeners for this selection' : 'Popularity and listeners'"></strong><span>Click an artist or track to see its listeners; click a listener for their history.</span></div>
|
||||
<div class="toolbar"><select class="search" x-model="statisticsKind" @change="statisticsRankPage = 1; loadStatistics()"><option value="artists">Artists</option><option value="tracks">Tracks</option><option value="releases">Releases</option><option value="users">Listeners</option></select><input class="search" placeholder="Search this ranking" x-model="statisticsSearch" @input.debounce.300ms="statisticsRankPage = 1; loadStatistics()"></div>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="statistics-table"><thead><tr><th x-text="statisticsKind === 'users' ? 'Listener' : 'Name'"></th><th>Plays</th><th>Listeners</th><th>Listened time</th><th>Last play</th></tr></thead><tbody><template x-for="item in (statisticsData?.ranking.items || [])" :key="item.key"><tr><td><button class="statistics-link" :disabled="statisticsKind === 'releases'" @click="statisticsExplore(item)" x-text="item.label"></button><div class="secondary-line" x-text="item.detail"></div></td><td x-text="fmt(item.plays)"></td><td x-text="fmt(item.listeners)"></td><td x-text="durationApprox(item.listened_seconds)"></td><td x-text="statisticsDate(item.last_play_ms)"></td></tr></template></tbody></table><div class="empty" x-show="!(statisticsData?.ranking.items || []).length">No plays match this selection</div></div>
|
||||
<div class="action-strip"><span x-text="statisticsRange('ranking')"></span><div class="toolbar"><button class="btn" @click="statisticsRankPage--; loadStatistics()" :disabled="statisticsRankPage <= 1">Previous</button><button class="btn" @click="statisticsRankPage++; loadStatistics()" :disabled="!statisticsHasNext('ranking')">Next</button></div></div>
|
||||
</section>
|
||||
<section class="panel statistics-panel">
|
||||
<div class="panel-head"><div class="panel-title"><strong>Listening history</strong><span>All matching events, including tracks no longer in the library. Artist names reflect the metadata recorded at the time.</span></div><select class="search" x-model="statisticsHistoryKind" @change="statisticsHistoryPage = 1; loadStatistics()"><option value="all">All events</option><option value="plays">Counted plays</option><option value="short">Short listens</option></select></div>
|
||||
<div class="table-wrap"><table class="statistics-table"><thead><tr><th>When</th><th>Listener</th><th>Track / artist</th><th>Listened</th><th>Result</th><th>Device</th></tr></thead><tbody><template x-for="play in (statisticsData?.history.items || [])" :key="play.user_id + ':' + play.listen_id"><tr><td x-text="statisticsDate(play.started_at_ms)"></td><td><button class="statistics-link" @click="statisticsSelectUser(play.user_id, play.user_name)" x-text="play.user_name"></button></td><td><button class="statistics-link" @click="statisticsSelectTrack(play.content_id, play.title)" x-text="play.title"></button><div class="secondary-line"><template x-for="name in play.artist_names" :key="name"><button class="statistics-link artist-stat-link" @click="statisticsSelectArtist(name)" x-text="name"></button></template></div><div class="secondary-line" x-text="play.release_title"></div></td><td x-text="durationApprox(play.listened_ms / 1000) + (play.track_duration_ms ? ' / ' + durationApprox(play.track_duration_ms / 1000) : '')"></td><td><span class="badge" :class="play.qualified ? 'ok' : 'disabled'" x-text="play.qualified ? 'play' : 'short'"></span><div class="secondary-line" x-text="play.ended_reason"></div></td><td><span x-text="play.device_name"></span><div class="secondary-line" x-text="play.source === 'instance' ? 'This instance' : 'Synced device'"></div></td></tr></template></tbody></table><div class="empty" x-show="!(statisticsData?.history.items || []).length">No listening events match this selection</div></div>
|
||||
<div class="action-strip"><span x-text="statisticsRange('history')"></span><div class="toolbar"><button class="btn" @click="statisticsHistoryPage--; loadStatistics()" :disabled="statisticsHistoryPage <= 1">Previous</button><button class="btn" @click="statisticsHistoryPage++; loadStatistics()" :disabled="!statisticsHasNext('history')">Next</button></div></div>
|
||||
</section>
|
||||
<section class="panel statistics-panel">
|
||||
<div class="panel-head"><div class="panel-title"><strong>Instance overview</strong><span>Current library and service totals, independent of the listening filters above.</span></div></div>
|
||||
<section class="stats-strip statistics-summary"><template x-for="cell in statisticsInstanceCards()" :key="cell.label"><div class="stat-cell"><div class="stat-value" x-text="cell.value"></div><div class="stat-label" x-text="cell.label"></div></div></template></section>
|
||||
<div class="statistics-service-grid"><template x-for="group in ['formats', 'reviews', 'jobs', 'scrobbles']" :key="group"><div><strong x-text="({formats:'Audio formats', reviews:'Processing queue', jobs:'Job runs', scrobbles:'Last.fm outbox'})[group]"></strong><template x-for="row in (statisticsData?.instance[group] || [])" :key="row.label"><div class="statistics-service-row"><span x-text="row.label"></span><strong x-text="fmt(row.count)"></strong></div></template><div class="muted" x-show="!(statisticsData?.instance[group] || []).length">No records</div></div></template></div>
|
||||
<div class="statistics-service-grid"><template x-for="disk in runtime.storage || []" :key="disk.label"><div><strong x-text="disk.label"></strong><p class="muted" x-text="disk.free_bytes == null ? 'Storage unavailable' : formatBytes(disk.free_bytes) + ' free of ' + formatBytes(disk.total_bytes)"></p></div></template></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" x-show="activeView === 'users'">
|
||||
<section class="stats-strip">
|
||||
<div class="stat-cell">
|
||||
@@ -2508,6 +2606,7 @@ tbody tr:hover {
|
||||
<span x-text="usersSubtitle()"></span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" @click="editUser()">Add user</button>
|
||||
<input class="search" placeholder="Search users" x-model="userSearch" @input.debounce.350ms="loadUsers()" />
|
||||
<button class="btn" @click="loadUsers(false)">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
@@ -2521,7 +2620,7 @@ tbody tr:hover {
|
||||
<tr>
|
||||
<th class="user-status-column">Status</th>
|
||||
<th>User</th>
|
||||
<th class="user-role-column">Role</th>
|
||||
<th>Plays</th><th>Listened time</th><th>Last play</th><th class="user-role-column">Role</th>
|
||||
<th class="user-active-column">Account</th>
|
||||
<th class="user-seen-column">Last activity</th>
|
||||
</tr>
|
||||
@@ -2536,7 +2635,7 @@ tbody tr:hover {
|
||||
<div class="primary-line" x-text="user.display_name || user.username"></div>
|
||||
<div class="secondary-line" x-text="user.email ? user.username + ' · ' + user.email : user.username"></div>
|
||||
</td>
|
||||
<td><span x-text="user.role"></span></td>
|
||||
<td x-text="fmt(user.plays)"></td><td x-text="durationApprox(user.listened_seconds)"></td><td x-text="user.last_play_ms ? statisticsDate(user.last_play_ms) : '—'"></td><td><span x-text="user.role"></span></td>
|
||||
<td>
|
||||
<span class="badge" :class="user.is_active ? 'ok' : 'disabled'" x-text="user.is_active ? 'active' : 'disabled'"></span>
|
||||
</td>
|
||||
@@ -3231,12 +3330,21 @@ tbody tr:hover {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="modal-backdrop" x-show="userEditorOpen" x-transition @click.self="userEditorOpen = false">
|
||||
<section class="modal user-modal"><div class="modal-head"><strong x-text="userDraft.id ? 'Edit user' : 'Add user'"></strong><button class="btn" @click="userEditorOpen = false">Close</button></div>
|
||||
<form class="modal-body" @submit.prevent="saveUser()">
|
||||
<template x-for="field in ['username', 'email', 'display_name', 'password']" :key="field"><label class="field"><span x-text="field === 'password' && userDraft.id ? 'Password (leave blank to keep)' : field.replace('_', ' ')"></span><input class="search" :type="field === 'password' ? 'password' : (field === 'email' ? 'email' : 'text')" :autocomplete="field === 'password' ? 'new-password' : 'off'" :required="field === 'username' || (field === 'password' && !userDraft.id)" maxlength="255" x-model="userDraft[field]"></label></template>
|
||||
<label class="field">Role<select class="search" x-model="userDraft.role"><option value="user">User</option><option value="admin">Administrator</option></select></label>
|
||||
<button class="btn primary" type="submit" :disabled="userSaving">Save user</button>
|
||||
</form></section>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" x-show="userModalOpen && activeUserDetail" x-transition @click.self="userModalOpen = false">
|
||||
<section class="modal user-modal">
|
||||
<div class="modal-head">
|
||||
<div class="panel-title">
|
||||
<strong x-text="activeUserDetail ? (activeUserDetail.user.display_name || activeUserDetail.user.username) : 'User'"></strong>
|
||||
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail.user) + ' · ' + activeUserDetail.user.role : ''"></span>
|
||||
<strong x-text="activeUserDetail ? (activeUserDetail?.user.display_name || activeUserDetail?.user.username) : 'User'"></strong>
|
||||
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail?.user) + ' · ' + activeUserDetail?.user.role : ''"></span>
|
||||
</div>
|
||||
<button class="icon-btn" @click="userModalOpen = false">
|
||||
<i data-lucide="x"></i>
|
||||
@@ -3252,19 +3360,19 @@ tbody tr:hover {
|
||||
<section class="user-summary-grid">
|
||||
<div class="user-summary-card">
|
||||
<span>Status</span>
|
||||
<strong x-text="userStatusLabel(activeUserDetail.user)"></strong>
|
||||
<strong x-text="userStatusLabel(activeUserDetail?.user)"></strong>
|
||||
</div>
|
||||
<div class="user-summary-card">
|
||||
<span>Last activity</span>
|
||||
<strong x-text="userLastSeenLabel(activeUserDetail.user)"></strong>
|
||||
<strong x-text="userLastSeenLabel(activeUserDetail?.user)"></strong>
|
||||
</div>
|
||||
<div class="user-summary-card">
|
||||
<span>Account</span>
|
||||
<strong x-text="activeUserDetail.user.is_active ? 'active' : 'disabled'"></strong>
|
||||
<strong x-text="activeUserDetail?.user.is_active ? 'active' : 'disabled'"></strong>
|
||||
</div>
|
||||
<div class="user-summary-card">
|
||||
<span>Last.fm</span>
|
||||
<strong x-text="activeUserDetail.stats.lastfm_connected ? 'connected' : 'not connected'"></strong>
|
||||
<strong x-text="activeUserDetail?.stats.lastfm_connected ? 'connected' : 'not connected'"></strong>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stats-strip user-stats-grid">
|
||||
@@ -3278,13 +3386,14 @@ tbody tr:hover {
|
||||
<div class="field">
|
||||
<label>Profile</label>
|
||||
<div class="user-profile-facts">
|
||||
<div><span>Username</span><strong x-text="activeUserDetail.user.username"></strong></div>
|
||||
<div><span>Email</span><strong x-text="activeUserDetail.user.email || 'not set'"></strong></div>
|
||||
<div><span>Display name</span><strong x-text="activeUserDetail.user.display_name || 'not set'"></strong></div>
|
||||
<div><span>Role</span><strong x-text="activeUserDetail.user.role"></strong></div>
|
||||
<div><span>Username</span><strong x-text="activeUserDetail?.user.username"></strong></div>
|
||||
<div><span>Email</span><strong x-text="activeUserDetail?.user.email || 'not set'"></strong></div>
|
||||
<div><span>Display name</span><strong x-text="activeUserDetail?.user.display_name || 'not set'"></strong></div>
|
||||
<div><span>Role</span><strong x-text="activeUserDetail?.user.role"></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin:16px 0"><button class="btn primary" @click="openStatistics(activeUserDetail?.user)">Full history and statistics</button><button class="btn" @click="editUser(activeUserDetail?.user)">Edit user</button><button class="btn" @click="deleteUser(activeUserDetail?.user)">Delete user</button></div>
|
||||
<div x-show="userModalTab === 'activity'">
|
||||
<div class="user-activity-list">
|
||||
<template x-for="play in (activeUserDetail?.recent_plays || [])" :key="play.history_id">
|
||||
@@ -3783,7 +3892,7 @@ tbody tr:hover {
|
||||
<label>Available release images</label>
|
||||
</div>
|
||||
<div class="cover-grid" x-show="editorDetail && editorDetail.available_covers && editorDetail.available_covers.length">
|
||||
<template x-for="cover in editorDetail.available_covers" :key="cover.media_file_id">
|
||||
<template x-for="cover in (editorDetail?.available_covers || [])" :key="cover.media_file_id">
|
||||
<button class="cover-option" type="button" :class="{active: editorDetail.current_image_url && editorDetail.current_image_url.includes('/cover/' + cover.media_file_id + '/')}" @click="setLibraryImage(cover.media_file_id)">
|
||||
<img :src="cover.cover_url" alt="" />
|
||||
<span x-text="cover.release_title"></span>
|
||||
@@ -3829,6 +3938,192 @@ function adminV2() {
|
||||
runtime: { agent: {}, storage: [], node: {} },
|
||||
libraryOverview: {},
|
||||
reviews: { items: [], total: 0, total_all: 0, limit: 80, offset: 0, status_counts: [] },
|
||||
statisticsData: null,
|
||||
statisticsLoading: false,
|
||||
statisticsError: '',
|
||||
statisticsRequest: 0,
|
||||
statisticsPeriod: '30',
|
||||
statisticsFilter: { from: '', to: '', source: 'all', user_id: '', artist: '', content_id: '' },
|
||||
statisticsKind: 'artists',
|
||||
statisticsSearch: '',
|
||||
statisticsRankPage: 1,
|
||||
statisticsHistoryPage: 1,
|
||||
statisticsHistoryKind: 'all',
|
||||
statisticsUserSearch: '',
|
||||
statisticsUserOptions: [],
|
||||
statisticsUserLabel: '',
|
||||
statisticsTrackLabel: '',
|
||||
|
||||
openStatistics(user = null) {
|
||||
this.activeView = 'statistics';
|
||||
this.userModalOpen = false;
|
||||
this.setRoute('#statistics');
|
||||
if (user) {
|
||||
this.statisticsFilter.user_id = user.id;
|
||||
this.statisticsUserLabel = user.display_name || user.username;
|
||||
this.statisticsFilter.artist = '';
|
||||
this.statisticsFilter.content_id = '';
|
||||
this.statisticsPeriod = 'all';
|
||||
this.statisticsKind = 'artists';
|
||||
this.statisticsFilter.source = 'all';
|
||||
this.statisticsSearch = '';
|
||||
this.statisticsHistoryKind = 'all';
|
||||
this.statisticsUserSearch = '';
|
||||
}
|
||||
this.statisticsSetPeriod();
|
||||
},
|
||||
statisticsSetPeriod() {
|
||||
if (this.statisticsPeriod === 'all') {
|
||||
this.statisticsFilter.from = 'all';
|
||||
this.statisticsFilter.to = '';
|
||||
} else if (this.statisticsPeriod !== 'custom') {
|
||||
const now = new Date();
|
||||
this.statisticsFilter.to = now.toISOString().slice(0,10);
|
||||
now.setUTCDate(now.getUTCDate() - (this.statisticsPeriod === 'today' ? 0 : Number(this.statisticsPeriod) - 1));
|
||||
this.statisticsFilter.from = now.toISOString().slice(0,10);
|
||||
} else if (this.statisticsFilter.from === 'all') {
|
||||
this.statisticsFilter.from = '';
|
||||
}
|
||||
this.statisticsReload();
|
||||
},
|
||||
statisticsReload() {
|
||||
this.statisticsRankPage = 1;
|
||||
this.statisticsHistoryPage = 1;
|
||||
return this.loadStatistics();
|
||||
},
|
||||
async loadStatistics() {
|
||||
const requestId = ++this.statisticsRequest;
|
||||
this.statisticsLoading = true;
|
||||
this.statisticsError = '';
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(this.statisticsFilter)) {
|
||||
if (value !== '' && value != null) params.set(key, value);
|
||||
}
|
||||
params.set('kind', this.statisticsKind);
|
||||
params.set('search', this.statisticsSearch);
|
||||
params.set('rank_page', this.statisticsRankPage);
|
||||
params.set('history_page', this.statisticsHistoryPage);
|
||||
params.set('history_kind', this.statisticsHistoryKind);
|
||||
try {
|
||||
const data = await this.request(`${this.apiBase}/statistics?${params}`);
|
||||
if (requestId === this.statisticsRequest) this.statisticsData = data;
|
||||
} catch (error) {
|
||||
if (requestId === this.statisticsRequest) this.statisticsError = error.message;
|
||||
} finally {
|
||||
if (requestId === this.statisticsRequest) this.statisticsLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
async statisticsFindUsers() {
|
||||
const search = this.statisticsUserSearch.trim();
|
||||
if (!search) { this.statisticsUserOptions = []; return; }
|
||||
try {
|
||||
const data = await this.request(`${this.apiBase}/users?limit=20&search=${encodeURIComponent(search)}`);
|
||||
if (search === this.statisticsUserSearch.trim()) this.statisticsUserOptions = data.items;
|
||||
} catch (error) { this.showToast(error.message); }
|
||||
},
|
||||
statisticsSelectUser(id, name) {
|
||||
this.statisticsFilter.user_id = id;
|
||||
this.statisticsUserLabel = name;
|
||||
this.statisticsUserSearch = '';
|
||||
this.statisticsUserOptions = [];
|
||||
this.statisticsSearch = '';
|
||||
this.statisticsKind = 'artists';
|
||||
return this.statisticsReload();
|
||||
},
|
||||
statisticsSelectArtist(name) {
|
||||
this.statisticsFilter.artist = name;
|
||||
this.statisticsKind = 'users';
|
||||
this.statisticsSearch = '';
|
||||
return this.statisticsReload();
|
||||
},
|
||||
statisticsSelectTrack(id, name) {
|
||||
this.statisticsFilter.content_id = id;
|
||||
this.statisticsTrackLabel = name;
|
||||
this.statisticsKind = 'users';
|
||||
this.statisticsSearch = '';
|
||||
return this.statisticsReload();
|
||||
},
|
||||
statisticsExplore(item) {
|
||||
if (this.statisticsKind === 'users') return this.statisticsSelectUser(Number(item.key), item.label);
|
||||
if (this.statisticsKind === 'artists') return this.statisticsSelectArtist(item.label);
|
||||
if (this.statisticsKind === 'tracks') return this.statisticsSelectTrack(item.key, item.label);
|
||||
},
|
||||
statisticsClearFilter(key) {
|
||||
this.statisticsFilter[key] = '';
|
||||
this.statisticsSearch = '';
|
||||
return this.statisticsReload();
|
||||
},
|
||||
statisticsClearSelection() {
|
||||
for (const key of ['user_id', 'artist', 'content_id']) this.statisticsFilter[key] = '';
|
||||
this.statisticsSearch = '';
|
||||
return this.statisticsReload();
|
||||
},
|
||||
statisticsCards() {
|
||||
const s = this.statisticsData?.summary || {};
|
||||
return [
|
||||
{ label: 'Counted plays', value: this.fmt(s.plays || 0) },
|
||||
{ label: 'Unique listeners', value: this.fmt(s.listeners || 0) },
|
||||
{ label: 'Listened time · all events', value: this.durationApprox(s.listened_seconds || 0) },
|
||||
{ label: 'Unique tracks played', value: this.fmt(s.tracks || 0) },
|
||||
{ label: 'Finished tracks', value: this.fmt(s.completed || 0) },
|
||||
{ label: 'Short listens', value: this.fmt(s.short_listens || 0) },
|
||||
{ label: 'Plays on this instance', value: this.fmt(s.instance_plays || 0) },
|
||||
{ label: 'Plays on synced devices', value: this.fmt(s.synced_plays || 0) }
|
||||
];
|
||||
},
|
||||
statisticsInstanceCards() {
|
||||
const s = this.statisticsData?.instance || {};
|
||||
return ['users', 'tracks', 'releases', 'artists', 'playlists'].map(key => ({ label: key, value: this.fmt(s[key] || 0) }))
|
||||
.concat([{ label: 'Media files', value: this.formatBytes(s.media_bytes || 0) }]);
|
||||
},
|
||||
statisticsChartMax() { return Math.max(1, ...(this.statisticsData?.daily.items || []).map(d => Number(d.plays))); },
|
||||
statisticsDate(ms) { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; },
|
||||
statisticsRange(key) {
|
||||
const page = this.statisticsData?.[key];
|
||||
if (!page?.total) return '0 results';
|
||||
return `${(page.page - 1) * page.limit + 1}–${Math.min(page.page * page.limit, page.total)} of ${this.fmt(page.total)}`;
|
||||
},
|
||||
statisticsHasNext(key) { const p = this.statisticsData?.[key]; return p && p.page * p.limit < p.total; },
|
||||
|
||||
userEditorOpen: false,
|
||||
userSaving: false,
|
||||
userDraft: {id:null, username:'', email:'', display_name:'', password:'', role:'user'},
|
||||
scheduleEditing: false,
|
||||
scheduleDraft: '',
|
||||
editUser(user = null) {
|
||||
this.userModalOpen = false;
|
||||
this.userDraft = {id:user?.id || null, username:user?.username || '', email:user?.email || '', display_name:user?.display_name || '', password:'', role:user?.role || 'user'};
|
||||
this.userEditorOpen = true;
|
||||
},
|
||||
async saveUser() {
|
||||
if (this.userSaving) return;
|
||||
this.userSaving = true;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/users/manage`, {method:'POST', body:JSON.stringify({...this.userDraft, action:this.userDraft.id ? 'update' : 'create'})});
|
||||
this.userDraft.password = '';
|
||||
this.userEditorOpen = false;
|
||||
await this.loadUsers(false);
|
||||
this.showToast('User saved');
|
||||
} catch (error) { this.showToast(error.message); }
|
||||
finally { this.userSaving = false; }
|
||||
},
|
||||
async deleteUser(user) {
|
||||
if (!confirm(`Delete user ${user.username}?`)) return;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/users/manage`, {method:'POST', body:JSON.stringify({action:'delete', id:user.id})});
|
||||
this.userModalOpen = false;
|
||||
await this.loadUsers(false);
|
||||
} catch (error) { this.showToast(error.message); }
|
||||
},
|
||||
async saveSchedule() {
|
||||
try {
|
||||
await this.request(`${this.apiBase}/jobs/${encodeURIComponent(this.activeJobName)}/schedule`, {method:'POST', body:JSON.stringify({cron:this.scheduleDraft})});
|
||||
this.scheduleEditing = false;
|
||||
await this.loadJobs();
|
||||
} catch (error) { this.showToast(error.message); }
|
||||
},
|
||||
|
||||
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
|
||||
usersLoading: false,
|
||||
userSearch: '',
|
||||
@@ -4009,6 +4304,7 @@ function adminV2() {
|
||||
},
|
||||
|
||||
async refreshAll() {
|
||||
if (this.routeReady && this.activeView === 'statistics') await this.loadStatistics();
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await this.request(`${this.apiBase}/dashboard`);
|
||||
@@ -4039,6 +4335,8 @@ function adminV2() {
|
||||
]);
|
||||
} else if (this.activeView === 'users') {
|
||||
await Promise.allSettled([this.loadUsers(false)]);
|
||||
} else if (this.activeView === 'statistics') {
|
||||
return;
|
||||
} else if (this.activeView === 'settings') {
|
||||
await Promise.allSettled([
|
||||
this.loadFederation(false),
|
||||
@@ -4076,6 +4374,8 @@ function adminV2() {
|
||||
this.libraryKind = nextKind;
|
||||
} else if (view === 'settings') {
|
||||
this.activeView = 'settings';
|
||||
} else if (view === 'statistics') {
|
||||
this.activeView = 'statistics';
|
||||
} else if (view === 'users') {
|
||||
this.activeView = 'users';
|
||||
} else if (view === 'tools') {
|
||||
@@ -4113,6 +4413,9 @@ function adminV2() {
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
} else if (this.activeView === 'statistics') {
|
||||
if (updateRoute) this.setRoute('#statistics');
|
||||
this.statisticsSetPeriod();
|
||||
} else if (this.activeView === 'users') {
|
||||
if (updateRoute) this.setRoute('#users');
|
||||
await this.loadUsers(false);
|
||||
@@ -6037,6 +6340,7 @@ function adminV2() {
|
||||
if (this.activeView === 'jobs') return 'Tasks';
|
||||
if (this.activeView === 'tools') return 'Library Tools';
|
||||
if (this.activeView === 'settings') return 'Settings';
|
||||
if (this.activeView === 'statistics') return 'Statistics';
|
||||
if (this.activeView === 'users') return 'Users';
|
||||
return 'Review Queue';
|
||||
},
|
||||
@@ -6046,6 +6350,7 @@ function adminV2() {
|
||||
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
||||
if (this.activeView === 'tools') return 'Guided metadata repair and maintenance history';
|
||||
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
||||
if (this.activeView === 'statistics') return 'Listening, popularity, individual histories and instance health';
|
||||
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
|
||||
return 'Full-screen review triage with filter-aware bulk actions';
|
||||
},
|
||||
|
||||
@@ -495,6 +495,18 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="youtube-preview-destination">
|
||||
<label for="torrent-target-playlist">{{ t.player_import_destination }}</label>
|
||||
<div class="youtube-preview-destination-fields" :class="{ creating: $store.torrents.torrentPlaylistChoice === '__new__' }">
|
||||
<select id="torrent-target-playlist" x-model="$store.torrents.torrentPlaylistChoice" @change="$store.torrents.importPlaylistChoiceChanged('torrent')" :disabled="$store.torrents.torrentDestinationLocked()">
|
||||
<option value="">{{ t.player_youtube_no_destination }}</option>
|
||||
<template x-for="playlist in $store.torrents.youtubeOwnedPlaylists()" :key="playlist.id"><option :value="String(playlist.id)" x-text="playlist.title"></option></template>
|
||||
<option value="__new__">{{ t.player_youtube_create_playlist }}</option>
|
||||
</select>
|
||||
<template x-if="$store.torrents.torrentPlaylistChoice === '__new__'"><input type="text" maxlength="255" autocomplete="off" x-model="$store.torrents.torrentNewPlaylistTitle" placeholder="{{ t.player_youtube_new_playlist_name }}" :disabled="$store.torrents.torrentDestinationLocked()"></template>
|
||||
</div>
|
||||
<p>{{ t.player_import_destination_hint }}</p>
|
||||
</div>
|
||||
<div class="torrent-tree-toolbar">
|
||||
<div class="torrent-selected-summary"
|
||||
x-text="$store.torrents.selected.size + ' {{ t.player_selected }} - ' + $store.torrents.bytes($store.torrents.selectedBytes())"></div>
|
||||
@@ -594,6 +606,18 @@
|
||||
<small>{{ t.player_audio_folder_hint }}</small>
|
||||
</div>
|
||||
|
||||
<div class="youtube-preview-destination" x-show="$store.torrents.localFiles.length > 0">
|
||||
<label for="local-target-playlist">{{ t.player_import_destination }}</label>
|
||||
<div class="youtube-preview-destination-fields" :class="{ creating: $store.torrents.localPlaylistChoice === '__new__' }">
|
||||
<select id="local-target-playlist" x-model="$store.torrents.localPlaylistChoice" @change="$store.torrents.importPlaylistChoiceChanged('local')" :disabled="$store.torrents.localFilesUploading || $store.torrents.localPlaylistLocked">
|
||||
<option value="">{{ t.player_youtube_no_destination }}</option>
|
||||
<template x-for="playlist in $store.torrents.youtubeOwnedPlaylists()" :key="playlist.id"><option :value="String(playlist.id)" x-text="playlist.title"></option></template>
|
||||
<option value="__new__">{{ t.player_youtube_create_playlist }}</option>
|
||||
</select>
|
||||
<template x-if="$store.torrents.localPlaylistChoice === '__new__'"><input type="text" maxlength="255" autocomplete="off" x-model="$store.torrents.localNewPlaylistTitle" placeholder="{{ t.player_youtube_new_playlist_name }}" :disabled="$store.torrents.localFilesUploading || $store.torrents.localPlaylistLocked"></template>
|
||||
</div>
|
||||
<p>{{ t.player_import_destination_hint }}</p>
|
||||
</div>
|
||||
<div class="file-upload-selection" x-show="$store.torrents.localFiles.length > 0">
|
||||
<span x-text="$store.torrents.localUploadSummary()"></span>
|
||||
<div>
|
||||
@@ -602,7 +626,7 @@
|
||||
:disabled="$store.torrents.localFilesUploading">{{ t.player_cancel }}</button>
|
||||
<button type="button" class="modal-btn modal-btn-primary"
|
||||
@click="$store.torrents.uploadLocalFiles()"
|
||||
:disabled="$store.torrents.localFilesUploading">{{ t.player_upload_selected_files }}</button>
|
||||
:disabled="$store.torrents.localFilesUploading || !$store.torrents.importDestinationValid('local')">{{ t.player_upload_selected_files }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -635,6 +659,7 @@
|
||||
<div class="file-upload-history-title" x-text="item.filename"></div>
|
||||
<div class="file-upload-history-meta"
|
||||
x-text="$store.torrents.bytes(item.size_bytes) + ' · ' + $store.torrents.formatUploadDate(item.created_at)"></div>
|
||||
<div class="file-upload-history-meta" x-show="item.target_playlist_id" x-text="T.youtubePlaylist + ': ' + ($store.torrents.youtubePlaylistTitle(item.target_playlist_id) || '#' + item.target_playlist_id)"></div>
|
||||
<p class="youtube-item-error" x-show="item.error" x-text="item.error"></p>
|
||||
</div>
|
||||
<span class="torrent-status-badge"
|
||||
|
||||
+114
-67
@@ -785,6 +785,7 @@ document.addEventListener('alpine:init', () => {
|
||||
progress: 0,
|
||||
_saveTimer: null,
|
||||
_historyRecorded: false,
|
||||
_historyTrack: null,
|
||||
_nowPlayingSent: false,
|
||||
_playbackStartedAt: null,
|
||||
_listenId: null,
|
||||
@@ -836,6 +837,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this._sendNowPlaying();
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
this._trackListenedDelta();
|
||||
this.isPlaying = false;
|
||||
this._lastAudioTime = audio.currentTime || 0;
|
||||
});
|
||||
@@ -858,8 +860,19 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
|
||||
// Save state on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (this._isLocalPlaybackDevice()) this._saveStateSync();
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (this._isLocalPlaybackDevice()) {
|
||||
this._saveStateSync();
|
||||
this._recordHistory('stopped', true);
|
||||
}
|
||||
});
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted && this._historyRecorded) {
|
||||
this._historyRecorded = false;
|
||||
this._resetPlaybackTracking();
|
||||
this._lastAudioTime = audio.currentTime || 0;
|
||||
if (this.isPlaying) this._playbackStartedAt = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -928,7 +941,9 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
_playLocal(track, options = {}) {
|
||||
this._recordHistory('replaced');
|
||||
this.currentTrack = track;
|
||||
this._historyTrack = track;
|
||||
this._prefetchedQueueKey = null;
|
||||
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
|
||||
this._localSourceTrackId = track.id;
|
||||
@@ -1060,7 +1075,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const queue = Alpine.store('queue');
|
||||
if (queue.tracks.length === 0) return;
|
||||
|
||||
this._recordHistoryIfListenThresholdReached();
|
||||
this._recordHistory('skipped');
|
||||
|
||||
let nextIdx;
|
||||
if (this.repeatMode === 'one') {
|
||||
@@ -1481,6 +1496,7 @@ document.addEventListener('alpine:init', () => {
|
||||
: tracks[idx];
|
||||
if (currentTrack) {
|
||||
this.currentTrack = currentTrack;
|
||||
this._historyTrack = currentTrack;
|
||||
queue.syncCurrentIndexToTrack(currentTrack);
|
||||
this._localSourceTrackId = currentTrack.id;
|
||||
this._historyRecorded = false;
|
||||
@@ -1504,38 +1520,40 @@ document.addEventListener('alpine:init', () => {
|
||||
} catch {}
|
||||
},
|
||||
|
||||
_recordHistory(endedReason) {
|
||||
if (this._historyRecorded || !this.currentTrack) return;
|
||||
if (!Number.isInteger(Number(this.currentTrack.id)) || this.currentTrack.federated_cache) {
|
||||
return;
|
||||
}
|
||||
this._historyRecorded = true;
|
||||
_recordHistory(endedReason, unloading = false) {
|
||||
const track = this._historyTrack;
|
||||
if (this._historyRecorded || !track || !this._playbackStartedAt) return;
|
||||
if (!Number.isInteger(Number(track.id)) || track.federated_cache) return;
|
||||
this._trackListenedDelta();
|
||||
const completed = endedReason === 'finished';
|
||||
const listenedSeconds = this._historyListenedSeconds(completed);
|
||||
fetch('/api/player/history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
track_id: this.currentTrack.id,
|
||||
listen_id: this._listenId,
|
||||
started_at: this._playbackStartedAt,
|
||||
duration_listened: listenedSeconds,
|
||||
completed: completed,
|
||||
ended_reason: endedReason,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
_recordHistoryIfListenThresholdReached() {
|
||||
if (this._historyRecorded || !this.currentTrack) return false;
|
||||
this._trackListenedDelta();
|
||||
const duration = this._trackDuration();
|
||||
if (duration <= 0) return false;
|
||||
const listened = Math.floor(Number(this._listenedSeconds || 0));
|
||||
const threshold = Math.ceil(duration / 2);
|
||||
if (threshold <= 0 || listened < threshold) return false;
|
||||
this._recordHistory('unknown');
|
||||
return true;
|
||||
if (!completed && listenedSeconds < 5) return;
|
||||
const listenId = this._listenId;
|
||||
const body = JSON.stringify({
|
||||
track_id: track.id,
|
||||
listen_id: listenId,
|
||||
started_at: this._playbackStartedAt,
|
||||
duration_listened: listenedSeconds,
|
||||
completed,
|
||||
ended_reason: endedReason,
|
||||
});
|
||||
this._historyRecorded = true;
|
||||
if (unloading && navigator.sendBeacon('/api/player/history', new Blob([body], {type:'application/json'}))) return;
|
||||
// Retry the captured event, never the next track. The server deduplicates by listen_id.
|
||||
const send = async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const response = await fetch('/api/player/history', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, body, keepalive: true,
|
||||
});
|
||||
if (response.ok) return;
|
||||
if (response.status >= 400 && response.status < 500) break;
|
||||
} catch (_) {}
|
||||
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)));
|
||||
}
|
||||
if (this._listenId === listenId) this._historyRecorded = false;
|
||||
};
|
||||
send();
|
||||
},
|
||||
|
||||
_resetPlaybackTracking() {
|
||||
@@ -1580,16 +1598,8 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
_historyListenedSeconds(completed) {
|
||||
const duration = this._trackDuration();
|
||||
const listened = Number(this._listenedSeconds || 0);
|
||||
const finalGrace = completed ? 1 : 0;
|
||||
const precise = Math.floor(listened + finalGrace);
|
||||
if (precise > 0) return precise;
|
||||
const current = Number(audio.currentTime || this.currentTime || 0);
|
||||
if (duration > 0 && Number.isFinite(current)) {
|
||||
return Math.floor(Math.min(current, duration));
|
||||
}
|
||||
return Math.floor(current || 0);
|
||||
// Seeking is not listening time. Allow rounding grace only at a natural finish.
|
||||
return Math.max(0, Math.floor(Number(this._listenedSeconds || 0) + (completed ? 1 : 0)));
|
||||
},
|
||||
|
||||
_sendNowPlaying() {
|
||||
@@ -4632,6 +4642,12 @@ document.addEventListener('alpine:init', () => {
|
||||
youtubeUrl: '',
|
||||
youtubePreview: null,
|
||||
youtubePreviewSelected: new Set(),
|
||||
torrentPlaylistChoice: '',
|
||||
torrentNewPlaylistTitle: '',
|
||||
localPlaylistChoice: '',
|
||||
localNewPlaylistTitle: '',
|
||||
localPlaylistLocked: false,
|
||||
localTargetPlaylistId: null,
|
||||
youtubePlaylistChoice: '',
|
||||
youtubeNewPlaylistTitle: '',
|
||||
youtubePlaylistSyncKey: '',
|
||||
@@ -4704,6 +4720,7 @@ document.addEventListener('alpine:init', () => {
|
||||
open() {
|
||||
if (!this.downloadsEnabled) return;
|
||||
this.modal = true;
|
||||
Alpine.store('playlists')?.reload?.();
|
||||
this.message = '';
|
||||
this.error = false;
|
||||
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
|
||||
@@ -4874,34 +4891,41 @@ document.addEventListener('alpine:init', () => {
|
||||
return this.youtubeOwnedPlaylists().find(playlist => Number(playlist.id) === wanted)?.title || '';
|
||||
},
|
||||
|
||||
async resolveYoutubeTargetPlaylist() {
|
||||
if (this.youtubePlaylistChoice !== '__new__') {
|
||||
const playlistId = Number(this.youtubePlaylistChoice || 0);
|
||||
importPlaylistChoiceChanged(kind) {
|
||||
const choice = kind + 'PlaylistChoice';
|
||||
const title = kind + 'NewPlaylistTitle';
|
||||
if (this[choice] !== '__new__' || String(this[title] || '').trim()) return;
|
||||
const file = this.localFiles[0];
|
||||
const suggestion = kind === 'torrent' ? this.previewData?.name
|
||||
: (file?.webkitRelativePath?.split('/').slice(0, -1).join('/') || (this.localFiles.length === 1 ? file?.name?.replace(/\.[^.]+$/, '') : ''));
|
||||
this[title] = String(suggestion || '').slice(0,255);
|
||||
},
|
||||
importDestinationValid(kind) {
|
||||
return this[kind + 'PlaylistChoice'] !== '__new__' || String(this[kind + 'NewPlaylistTitle'] || '').trim().length > 0;
|
||||
},
|
||||
torrentDestinationLocked() {
|
||||
return this.loading || (this.currentJob && !['preview', 'resolving'].includes(this.currentJob.status));
|
||||
},
|
||||
async resolveYoutubeTargetPlaylist() { return this.resolveImportTargetPlaylist('youtube'); },
|
||||
async resolveImportTargetPlaylist(kind) {
|
||||
const choice = kind + 'PlaylistChoice';
|
||||
const titleKey = kind + 'NewPlaylistTitle';
|
||||
if (this[choice] !== '__new__') {
|
||||
const playlistId = Number(this[choice] || 0);
|
||||
return playlistId > 0 ? playlistId : null;
|
||||
}
|
||||
|
||||
const title = String(this.youtubeNewPlaylistTitle || '').trim();
|
||||
const title = String(this[titleKey] || '').trim();
|
||||
if (!title) throw new Error(T.youtubePlaylistCreateFailed);
|
||||
const res = await fetch('/api/player/playlists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({title}),
|
||||
});
|
||||
const playlist = await res.json().catch(() => null);
|
||||
if (!res.ok || Number(playlist?.id || 0) <= 0) {
|
||||
throw new Error(playlist?.error || T.youtubePlaylistCreateFailed);
|
||||
}
|
||||
|
||||
// Switch to the created playlist before starting the job. If the
|
||||
// YouTube request fails, retrying will reuse it instead of creating
|
||||
// another playlist with the same name.
|
||||
this.youtubePlaylistChoice = String(playlist.id);
|
||||
if (!res.ok || Number(playlist?.id || 0) <= 0) throw new Error(playlist?.error || T.youtubePlaylistCreateFailed);
|
||||
// Retrying an upload reuses this playlist, including after a failed start request.
|
||||
this[choice] = String(playlist.id);
|
||||
const playlists = Alpine.store('playlists');
|
||||
if (playlists) {
|
||||
playlists.list = [
|
||||
...(playlists.list || []).filter(item => Number(item.id) !== Number(playlist.id)),
|
||||
playlist,
|
||||
];
|
||||
playlists.list = [...(playlists.list || []).filter(item => Number(item.id) !== Number(playlist.id)), playlist];
|
||||
await playlists.reload();
|
||||
}
|
||||
return Number(playlist.id);
|
||||
@@ -5325,6 +5349,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
actionButtonDisabled() {
|
||||
return this.loading
|
||||
|| (!this.isCurrentDownloading() && !this.importDestinationValid('torrent'))
|
||||
|| this.isCurrentCompletedLocked()
|
||||
|| this.normalizedStatus(this.currentJob) === 'resolving'
|
||||
|| !this.previewData
|
||||
@@ -5341,7 +5366,8 @@ document.addEventListener('alpine:init', () => {
|
||||
sessionMeta(job) {
|
||||
if (!job) return '';
|
||||
const size = this.bytes(job.selected_size || job.total_size);
|
||||
return this.progressValue(job).toFixed(1) + '% - ' + size;
|
||||
const title = this.youtubePlaylistTitle(job.target_playlist_id);
|
||||
return this.progressValue(job).toFixed(1) + '% - ' + size + (title ? ' · ' + T.youtubePlaylist + ': ' + title : '');
|
||||
},
|
||||
|
||||
async loadAgentStatus() {
|
||||
@@ -5878,6 +5904,8 @@ document.addEventListener('alpine:init', () => {
|
||||
this.uploadProgressText = '';
|
||||
this.previewData = preview;
|
||||
this.currentJob = job;
|
||||
this.torrentPlaylistChoice = job?.target_playlist_id ? String(job.target_playlist_id) : '';
|
||||
this.torrentNewPlaylistTitle = '';
|
||||
const selected = Array.isArray(data.selected_files) && data.selected_files.length
|
||||
? data.selected_files
|
||||
: (preview.files || []).filter(f => f.selected).map(f => f.index);
|
||||
@@ -5893,6 +5921,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
|
||||
this.sessions = Array.isArray(data) ? data : [];
|
||||
if (this.sessions.some(job => job.target_playlist_id)) Alpine.store('playlists')?.reload?.();
|
||||
this._syncCurrentJobFromSessions();
|
||||
await this._refreshResolvedSelection();
|
||||
} catch (err) {
|
||||
@@ -5982,6 +6011,10 @@ document.addEventListener('alpine:init', () => {
|
||||
this.localFiles = selected;
|
||||
this.localFilesBatchId = selected.length ? crypto.randomUUID() : null;
|
||||
this.localFileUploadIds = new WeakMap();
|
||||
this.localPlaylistLocked = false;
|
||||
this.localTargetPlaylistId = null;
|
||||
this.localPlaylistChoice = '';
|
||||
this.localNewPlaylistTitle = '';
|
||||
this.localFilesDragging = false;
|
||||
this.uploadProgress = 0;
|
||||
this.uploadProgressText = '';
|
||||
@@ -5997,6 +6030,10 @@ document.addEventListener('alpine:init', () => {
|
||||
this.localFiles = [];
|
||||
this.localFilesBatchId = null;
|
||||
this.localFileUploadIds = new WeakMap();
|
||||
this.localPlaylistLocked = false;
|
||||
this.localTargetPlaylistId = null;
|
||||
this.localPlaylistChoice = '';
|
||||
this.localNewPlaylistTitle = '';
|
||||
this.uploadProgress = 0;
|
||||
this.uploadProgressText = '';
|
||||
},
|
||||
@@ -6033,6 +6070,7 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
xhr.setRequestHeader('X-Furumusic-Upload-Id', uploadId);
|
||||
xhr.setRequestHeader('X-Furumusic-Upload-Batch-Id', this.localFilesBatchId);
|
||||
if (this.localTargetPlaylistId) xhr.setRequestHeader('X-Furumusic-Target-Playlist-Id', String(this.localTargetPlaylistId));
|
||||
if (file.webkitRelativePath) {
|
||||
xhr.setRequestHeader('X-Furumusic-Relative-Path', encodeURIComponent(file.webkitRelativePath));
|
||||
}
|
||||
@@ -6055,7 +6093,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async uploadLocalFiles() {
|
||||
if (this.localFilesUploading || this.localFiles.length === 0) return;
|
||||
if (this.localFilesUploading || this.localFiles.length === 0 || !this.importDestinationValid('local')) return;
|
||||
const batchId = this.localFilesBatchId;
|
||||
this.localFilesUploading = true;
|
||||
this.uploadProgress = 0;
|
||||
@@ -6063,7 +6101,13 @@ document.addEventListener('alpine:init', () => {
|
||||
this._setMessage(T.uploadingFiles);
|
||||
const totalBytes = this.localUploadBytes();
|
||||
let loadedBefore = 0;
|
||||
let batchStarted = false;
|
||||
try {
|
||||
if (!this.localPlaylistLocked) {
|
||||
this.localTargetPlaylistId = await this.resolveImportTargetPlaylist('local');
|
||||
this.localPlaylistLocked = true;
|
||||
}
|
||||
batchStarted = true;
|
||||
for (const file of [...this.localFiles]) {
|
||||
const data = await this.uploadLocalFile(file, loadedBefore, totalBytes);
|
||||
if (data?.upload) {
|
||||
@@ -6085,7 +6129,7 @@ document.addEventListener('alpine:init', () => {
|
||||
} catch (err) {
|
||||
this._setMessage(err.message || String(err), true);
|
||||
} finally {
|
||||
try {
|
||||
if (batchStarted) try {
|
||||
const response = await fetch('/api/player/uploads/local', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -6111,6 +6155,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.error || T.fileUploadLoadFailed);
|
||||
this.localUploadHistory = Array.isArray(data) ? data : [];
|
||||
if (this.localUploadHistory.some(item => item.target_playlist_id)) Alpine.store('playlists')?.reload?.();
|
||||
} catch (err) {
|
||||
if (!silent) this._setMessage(err.message || T.fileUploadLoadFailed, true);
|
||||
} finally {
|
||||
@@ -6361,6 +6406,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async start() {
|
||||
if (!this.importDestinationValid('torrent')) return;
|
||||
if (!this.previewData || this.loading) return;
|
||||
const selected = this.selectedArray();
|
||||
if (selected.length === 0) {
|
||||
@@ -6371,10 +6417,11 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
this._setMessage(T.startingDownload);
|
||||
try {
|
||||
const targetPlaylistId = await this.resolveImportTargetPlaylist('torrent');
|
||||
const res = await fetch(`/api/player/torrents/${this.previewData.id}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ selected_files: selected }),
|
||||
body: JSON.stringify({ selected_files: selected, target_playlist_id: targetPlaylistId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || T.startFailed);
|
||||
|
||||
@@ -6666,7 +6666,8 @@ button.user-stat:hover {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.youtube-preview-destination-fields {
|
||||
.youtube-preview-destination-fields,
|
||||
.youtube-preview-destination-fields.creating {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const {readFileSync} = require('node:fs');
|
||||
const {test} = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const template = readFileSync(`${__dirname}/../templates/admin/v2.html`, 'utf8');
|
||||
const script = template.match(/<script>\s*(function adminV2\(\)[\s\S]*?)<\/script>/)[1];
|
||||
function app() {
|
||||
const context = vm.createContext({URLSearchParams, Date}); vm.runInContext(script, context);
|
||||
const a = context.adminV2(); a.icons = () => {}; a.setRoute = () => {}; return a;
|
||||
}
|
||||
test('artist → listeners → user retains artist and period filters', async () => {
|
||||
const a = app(); const urls=[];
|
||||
a.request = async url => { urls.push(new URL(url, 'http://localhost').searchParams); return {}; };
|
||||
a.statisticsFilter.from = '2026-11-01'; a.statisticsHistoryPage = 5;
|
||||
await a.statisticsSelectArtist('Artist & Friends');
|
||||
assert.equal(urls[0].get('kind'), 'users'); assert.equal(urls[0].get('artist'), 'Artist & Friends');
|
||||
assert.equal(urls[0].get('history_page'), '1');
|
||||
await a.statisticsExplore({key:'23', label:'Alice'});
|
||||
assert.equal(urls[1].get('user_id'), '23'); assert.equal(urls[1].get('artist'), 'Artist & Friends');
|
||||
assert.equal(urls[1].get('from'), '2026-11-01');
|
||||
await a.statisticsClearFilter('user_id'); assert.equal(urls[2].get('user_id'), null);
|
||||
assert.equal(urls[2].get('artist'), 'Artist & Friends');
|
||||
});
|
||||
test('latest filters win when earlier requests arrive late', async () => {
|
||||
const a = app(); const responses=[];
|
||||
a.request = () => new Promise(resolve => responses.push(resolve));
|
||||
const first = a.loadStatistics(); const second = a.statisticsSelectArtist('Second');
|
||||
responses[1]({marker:2}); await second; responses[0]({marker:1}); await first;
|
||||
assert.equal(a.statisticsData.marker, 2); assert.equal(a.statisticsLoading, false);
|
||||
});
|
||||
test('errors are shown separately from an empty result', async () => {
|
||||
const a = app(); a.request = async () => {throw new Error('Database unavailable')};
|
||||
await a.loadStatistics(); assert.equal(a.statisticsError, 'Database unavailable'); assert.equal(a.statisticsLoading, false);
|
||||
});
|
||||
test('opening a user starts full history without an earlier artist selection', () => {
|
||||
const a = app(); a.statisticsFilter.artist = 'Previous'; a.statisticsSearch = 'old search'; a.statisticsHistoryKind = 'short'; a.statisticsFilter.source = 'synced'; a.statisticsSetPeriod = () => {};
|
||||
a.openStatistics({id:4, username:'user4'});
|
||||
assert.equal(a.statisticsFilter.user_id, 4); assert.equal(a.statisticsFilter.artist, '');
|
||||
assert.equal(a.statisticsSearch, ''); assert.equal(a.statisticsHistoryKind, 'all'); assert.equal(a.statisticsFilter.source, 'all');
|
||||
assert.equal(a.statisticsPeriod, 'all'); assert.equal(a.activeView, 'statistics'); assert.equal(a.userModalOpen, false);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Run only against a fresh disposable instance, with the import agent disabled.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert=require('node:assert/strict');
|
||||
const {spawnSync}=require('node:child_process');
|
||||
const base=process.env.FURU_TEST_URL || 'http://127.0.0.1:3019';
|
||||
const container=process.env.FURU_TEST_PG_CONTAINER || 'furumi-import-playlist-test-pg';
|
||||
function sql(statement) {
|
||||
const result=spawnSync('docker',['exec','-i',container,'psql','-U','postgres','-d','furumi_import_test','-At','-v','ON_ERROR_STOP=1'],{input:statement,encoding:'utf8'});
|
||||
assert.equal(result.status,0,result.stderr);return result.stdout.trim();
|
||||
}
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try {
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000}});
|
||||
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto(base+'/admin/setup');
|
||||
await page.locator('[name=username]').fill('importer');
|
||||
await page.locator('[name=password]').fill('import-test-password');
|
||||
await page.locator('[name=confirm_password]').fill('import-test-password');
|
||||
await page.locator('form button[type=submit]').click();await page.waitForURL('**/admin/v2');
|
||||
await page.goto(base+'/');
|
||||
await page.evaluate(()=>{Alpine.store('torrents').open();Alpine.store('torrents').showSourceTab('files');});
|
||||
await page.locator('#direct-audio-file-input').setInputFiles([
|
||||
{name:'theme.wav',mimeType:'audio/wav',buffer:Buffer.from('test-audio-one')},
|
||||
{name:'credits.wav',mimeType:'audio/wav',buffer:Buffer.from('test-audio-two')},
|
||||
]);
|
||||
await page.locator('#local-target-playlist').selectOption('__new__');
|
||||
await page.getByPlaceholder('New playlist name',{exact:true}).fill('Game Soundtrack');
|
||||
await page.screenshot({path:'/tmp/furumi-import-desktop.png'});
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.screenshot({path:'/tmp/furumi-import-mobile.png'});
|
||||
await page.getByRole('button',{name:'Upload selected files',exact:true}).click();
|
||||
await page.waitForFunction(()=>!Alpine.store('torrents').localFilesUploading && Alpine.store('torrents').localFiles.length===0);
|
||||
const history=await (await context.request.get(base+'/api/player/uploads/local/history')).json();
|
||||
assert.equal(history.length,2,JSON.stringify(history));
|
||||
const target=history[0].target_playlist_id;assert.ok(target>0);assert.equal(history[1].target_playlist_id,target);
|
||||
assert.equal(sql('SELECT count(*) FROM furumusic__import_playlist_target;'),'2');
|
||||
// Simulate the importer resolving both uploaded files to library tracks.
|
||||
sql(`INSERT INTO furumusic__media_file (file_type,file_path,original_filename,mime_type,file_size_bytes,sha256_hash,created_at)
|
||||
SELECT 'audio',input_path,input_path,'audio/wav',14,source_sha256,'2026-11-12T00:00:00Z' FROM furumusic__import_playlist_target;
|
||||
INSERT INTO furumusic__track (title,title_sort,release_id,duration_seconds,audio_file_id,is_hidden,created_at,updated_at)
|
||||
SELECT original_filename,original_filename,0,120,id,false,'2026-11-12T00:00:00Z','2026-11-12T00:00:00Z' FROM furumusic__media_file;`);
|
||||
assert.equal((await context.request.get(base+'/api/player/uploads/local/history')).status(),200);
|
||||
assert.equal(sql(`SELECT count(*) FROM furumusic__playlist_track WHERE playlist_id=${target};`),'2');
|
||||
await context.request.get(base+'/api/player/uploads/local/history');
|
||||
assert.equal(sql(`SELECT count(*) FROM furumusic__playlist_track WHERE playlist_id=${target};`),'2');
|
||||
const invalid=await context.request.post(base+'/api/player/uploads/local',{headers:{'X-Furumusic-Target-Playlist-Id':'99999','X-Furumusic-Filename':'bad.wav'},data:Buffer.from('bad')});
|
||||
assert.equal(invalid.status(),400);
|
||||
// Render the torrent picker with a local preview fixture; no external torrent network.
|
||||
await page.setViewportSize({width:1440,height:1000});
|
||||
await page.evaluate(()=>{
|
||||
const s=Alpine.store('torrents');s.showSourceTab('torrents');s._stopRefresh();s._stopPoll();
|
||||
s._applySession({job:{id:'preview-test',name:'Movie OST',status:'preview'},preview:{id:'preview-test',name:'Movie OST',total_size:20,files:[{index:0,name:'theme.flac',components:['theme.flac'],length:20,selected:true}]},selected_files:[0]});
|
||||
});
|
||||
await page.locator('#torrent-target-playlist').selectOption(String(target));
|
||||
assert.equal(await page.evaluate(()=>Alpine.store('torrents').torrentPlaylistChoice),String(target));
|
||||
await page.screenshot({path:'/tmp/furumi-torrent-playlist.png'});
|
||||
await page.evaluate(()=>{Alpine.store('torrents').currentJob.status='paused';});
|
||||
assert.equal(await page.locator('#torrent-target-playlist').isDisabled(),true);
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: file batch → new playlist, persistent destinations, imported tracks, idempotency, invalid destination, torrent picker, desktop/mobile');
|
||||
} finally {await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Run against a fresh disposable instance and the named disposable PostgreSQL container.
|
||||
// PLAYWRIGHT_MODULE may point to an existing Playwright installation.
|
||||
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const {spawnSync} = require('node:child_process');
|
||||
const base = process.env.FURU_TEST_URL || 'http://127.0.0.1:3018';
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true, executablePath:process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try {
|
||||
const context = await browser.newContext({viewport:{width:1440,height:1100}, ignoreHTTPSErrors:true});
|
||||
const page = await context.newPage(); const errors=[];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
const anonymous = await context.request.get(`${base}/admin/v2/api/statistics`);
|
||||
assert.equal(anonymous.status(),401);
|
||||
await page.goto(`${base}/admin/setup`);
|
||||
await page.getByLabel('Username', {exact:true}).fill('alice');
|
||||
await page.getByLabel('Password', {exact:true}).fill('statistics-test-password');
|
||||
await page.locator('[name=confirm_password]').fill('statistics-test-password');
|
||||
await page.locator('form button[type=submit]').click();
|
||||
await page.waitForURL('**/admin/v2');
|
||||
await page.getByRole('button', {name:'Users',exact:true}).click();
|
||||
await page.getByRole('button', {name:'Add user',exact:true}).click();
|
||||
const editor = page.locator('.modal-backdrop').filter({has:page.getByRole('button',{name:'Save user',exact:true})});
|
||||
await editor.getByLabel('username', {exact:true}).fill('bob');
|
||||
await editor.getByLabel('password', {exact:true}).fill('statistics-test-password');
|
||||
await editor.getByRole('button',{name:'Save user',exact:true}).click();
|
||||
await editor.waitFor({state:'hidden'});
|
||||
const sql = `INSERT INTO furumusic__listen_event
|
||||
(user_id,listen_id,content_id,local_track_id,origin_device_id,started_at_ms,listened_ms,track_duration_ms,ended_reason,qualified,metadata_json,created_at)
|
||||
SELECT 1,'ui-'||n,'content-one',NULL,'web-a',1793491200000+n*1000,180000,180000,'"finished"',true,
|
||||
'{"title":"First song","artist_names":["Artist A"],"release_title":"Album"}'::jsonb,'2026-11-01T00:00:00Z' FROM generate_series(1,30) n;
|
||||
INSERT INTO furumusic__listen_event
|
||||
(user_id,listen_id,content_id,local_track_id,origin_device_id,started_at_ms,listened_ms,track_duration_ms,ended_reason,qualified,metadata_json,created_at)
|
||||
VALUES (2,'ui-bob','content-one',NULL,'web-b',1793664000000,90000,180000,'"skipped"',true,
|
||||
'{"title":"First song","artist_names":["Artist A"],"release_title":"Album"}','2026-11-03T00:00:00Z');`;
|
||||
const seeded = spawnSync('docker',['exec','-i','furumi-statistics-test-pg','psql','-U','postgres','-d','furumi_statistics_test','-v','ON_ERROR_STOP=1'],{input:sql, encoding:'utf8'});
|
||||
assert.equal(seeded.status,0,seeded.stderr);
|
||||
await page.getByRole('button',{name:'Statistics',exact:true}).click();
|
||||
const area = page.locator('.content').filter({has:page.getByText('Listening statistics',{exact:true})});
|
||||
await area.getByRole('button',{name:'Artist A',exact:true}).first().waitFor();
|
||||
const artistResponse = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('artist=Artist+A'));
|
||||
await area.getByRole('button',{name:'Artist A',exact:true}).first().click();
|
||||
assert.equal((await artistResponse).status(),200);
|
||||
await area.getByRole('button',{name:'bob',exact:true}).first().waitFor();
|
||||
const bobResponse = page.waitForResponse(r => r.url().includes('/api/statistics?') && r.url().includes('user_id=2'));
|
||||
await area.getByRole('button',{name:'bob',exact:true}).first().click();
|
||||
const bob = await (await bobResponse).json();
|
||||
assert.equal(bob.summary.plays,1); assert.equal(bob.history.items[0].user_name,'bob');
|
||||
assert.equal(bob.history.items[0].title,'First song');
|
||||
const cleared = page.waitForResponse(r => r.url().includes('/api/statistics?') && !r.url().includes('user_id=') && !r.url().includes('artist='));
|
||||
await area.getByRole('button',{name:'All listeners and music'}).click();
|
||||
await cleared;
|
||||
await area.getByText('Loading statistics…',{exact:true}).waitFor({state:'hidden'});
|
||||
await page.screenshot({path:'/tmp/furumi-statistics-desktop.png',fullPage:false});
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.screenshot({path:'/tmp/furumi-statistics-mobile.png',fullPage:false});
|
||||
await page.setViewportSize({width:1440,height:1100});
|
||||
const usersResponse = await context.request.get(`${base}/admin/v2/api/users`); const users = await usersResponse.json();
|
||||
assert.equal(users.items.find(u=>u.username==='bob').plays,1);
|
||||
const detailResponse = await context.request.get(`${base}/admin/v2/api/users/2`); const detail = await detailResponse.json();
|
||||
assert.equal(detail.stats.plays,1); assert.equal(detail.recent_plays[0].title,'First song');
|
||||
const next = await context.request.get(`${base}/admin/v2/api/statistics?from=all&history_page=2`);
|
||||
assert.equal((await next.json()).history.items.length,6);
|
||||
for (const path of ['/admin/debug','/admin/users','/admin/settings','/admin/artists','/admin/reviews','/admin/jobs']) {
|
||||
assert.equal((await context.request.get(base+path)).status(),404,path);
|
||||
}
|
||||
assert.equal((await context.request.get(`${base}/admin/v2/api/statistics?from=invalid`)).status(),400);
|
||||
const jobs = await (await context.request.get(`${base}/admin/v2/api/jobs`)).json();
|
||||
const job = jobs.find(j => j.name === 'metadata_backfill');
|
||||
if (job.enabled) assert.equal((await context.request.post(`${base}/admin/v2/api/jobs/${job.name}/toggle`)).status(),200);
|
||||
assert.equal((await context.request.post(`${base}/admin/v2/api/jobs/${job.name}/schedule`,{data:{cron:'0 17 3 * * *'}})).status(),200);
|
||||
await context.request.get(`${base}/admin/v2/api/dashboard`);
|
||||
const changedJob = (await (await context.request.get(`${base}/admin/v2/api/jobs`)).json()).find(j=>j.name===job.name);
|
||||
assert.equal(changedJob.enabled,false); assert.equal(changedJob.cron_expression,'0 17 3 * * *');
|
||||
const ownDelete = await context.request.post(`${base}/admin/v2/api/users/manage`, {data:{action:'delete',id:1}});
|
||||
assert.equal(ownDelete.status(),400);
|
||||
const listenerContext = await browser.newContext({ignoreHTTPSErrors:true});
|
||||
const login = await listenerContext.newPage();
|
||||
await login.goto(`${base}/login`);
|
||||
await login.locator('[name=username]').fill('bob');
|
||||
await login.locator('[name=password]').fill('statistics-test-password');
|
||||
await login.locator('form button[type=submit]').click();
|
||||
await login.waitForURL(base + '/');
|
||||
assert.equal((await listenerContext.request.get(`${base}/admin/v2/api/statistics`)).status(),403);
|
||||
await listenerContext.close();
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: setup, user creation, artist → listeners → user history, counts, pagination, invalid filters, v1 removal, desktop/mobile rendering');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => {console.error(error); process.exitCode=1});
|
||||
@@ -0,0 +1,75 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const {readFileSync} = require('node:fs');
|
||||
const {test} = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const {randomUUID} = require('node:crypto');
|
||||
const template = readFileSync(`${__dirname}/../templates/player/scripts.html`, 'utf8');
|
||||
function downloads(fetchImpl) {
|
||||
let code = template.slice(template.indexOf(" Alpine.store('torrents', {"), template.indexOf(" Alpine.store('playlists', {"));
|
||||
code = code.replace(/\{\{[^}]+\}\}/g,'true').replace(/sourceTab: .*?,\n/,"sourceTab: 'files',\n");
|
||||
const stores={playlists:{list:[{id:10,title:'Mine',kind:'user',is_own:true},{id:20,title:'Other',kind:'user',is_own:false}],reload:async()=>{}}};
|
||||
const requests=[];
|
||||
class XHR {
|
||||
headers={}; upload={}; status=200; responseText='{}';
|
||||
open(method,url) {this.url=url;}
|
||||
setRequestHeader(key,value) {this.headers[key]=value;}
|
||||
send(file) {requests.push({file,headers:this.headers});this.onload();}
|
||||
}
|
||||
const context=vm.createContext({Alpine:{store:(name,value)=>value?(stores[name]=value):stores[name]},fetch:fetchImpl,
|
||||
T:new Proxy({}, {get:(_,key)=>key}),crypto:{randomUUID},XMLHttpRequest:XHR,redirectOnUnauthorized:()=>{},console,setInterval:()=>0,clearInterval:()=>{}});
|
||||
vm.runInContext(code,context);
|
||||
const store=stores.torrents;
|
||||
store._poll=()=>{};store.loadSessions=async()=>{};store.loadAgentStatus=async()=>{};store.loadLocalUploadHistory=async()=>{};
|
||||
return {store,requests};
|
||||
}
|
||||
test('destinations are independent, owned playlists only, new titles required',()=>{
|
||||
const {store:s}=downloads();
|
||||
assert.deepEqual(Array.from(s.youtubeOwnedPlaylists(),p=>p.id),[10]);
|
||||
s.localPlaylistChoice='__new__';assert.equal(s.importDestinationValid('local'),false);
|
||||
assert.equal(s.importDestinationValid('torrent'),true);
|
||||
s.localFiles=[{name:'song.flac',webkitRelativePath:'Soundtrack/song.flac'}];s.importPlaylistChoiceChanged('local');
|
||||
assert.equal(s.localNewPlaylistTitle,'Soundtrack');assert.equal(s.importDestinationValid('local'),true);
|
||||
});
|
||||
test('failed torrent starts reuse the newly created playlist and send only selected files',async()=>{
|
||||
const calls=[];
|
||||
const {store:s}=downloads(async(url,opts)=>{
|
||||
calls.push({url,data:JSON.parse(opts.body)});
|
||||
return url==='/api/player/playlists'?{ok:true,json:async()=>({id:33,title:'Soundtrack',kind:'user',is_own:true})}:{ok:false,json:async()=>({error:'retry'})};
|
||||
});
|
||||
s.previewData={id:'torrent-1'};s.selected=new Set([1,3]);s.torrentPlaylistChoice='__new__';s.torrentNewPlaylistTitle='Soundtrack';
|
||||
await s.start();await s.start();
|
||||
assert.equal(calls.filter(c=>c.url==='/api/player/playlists').length,1);
|
||||
assert.deepEqual(calls[1].data,{selected_files:[1,3],target_playlist_id:33});
|
||||
assert.deepEqual(calls[2].data,calls[1].data);assert.equal(s.loading,false);
|
||||
});
|
||||
test('local files carry the same playlist destination and retries retain upload IDs',async()=>{
|
||||
const {store:s,requests}=downloads();const f={name:'a.flac',size:10};
|
||||
s.setLocalFiles([f]);s.localTargetPlaylistId=10;
|
||||
await s.uploadLocalFile(f,0,10);await s.uploadLocalFile(f,0,10);
|
||||
assert.equal(requests[0].headers['X-Furumusic-Target-Playlist-Id'],'10');
|
||||
assert.equal(requests[0].headers['X-Furumusic-Upload-Id'],requests[1].headers['X-Furumusic-Upload-Id']);
|
||||
s.clearLocalFiles();s.setLocalFiles([f]);await s.uploadLocalFile(f,0,10);
|
||||
assert.equal(requests[2].headers['X-Furumusic-Target-Playlist-Id'],undefined);
|
||||
});
|
||||
test('partially failed batches keep their destination, create one playlist and retry remaining files',async()=>{
|
||||
let creates=0;let attempts=0;
|
||||
const {store:s}=downloads(async(url)=>{
|
||||
if(url==='/api/player/playlists') {creates++;return {ok:true,json:async()=>({id:44})};}
|
||||
return {ok:true};
|
||||
});
|
||||
s.setLocalFiles([{name:'a.mp3',size:1},{name:'b.mp3',size:1}]);s.localPlaylistChoice='__new__';s.localNewPlaylistTitle='OST';
|
||||
s.uploadLocalFile=async()=>{attempts++;if(attempts===2)throw Error('offline');return {};};
|
||||
await s.uploadLocalFiles();assert.equal(s.localFiles.length,1);assert.equal(s.localPlaylistLocked,true);
|
||||
assert.equal(s.localTargetPlaylistId,44);await s.uploadLocalFiles();
|
||||
assert.equal(creates,1);assert.equal(s.localFiles.length,0);assert.equal(s.localFilesUploading,false);
|
||||
});
|
||||
test('playlist creation failure leaves the batch editable and does not signal upload completion',async()=>{
|
||||
const calls=[];const {store:s}=downloads(async(url)=>{calls.push(url);return {ok:false,json:async()=>({error:'failed'})};});
|
||||
s.setLocalFiles([{name:'a.mp3',size:1}]);s.localPlaylistChoice='__new__';s.localNewPlaylistTitle='OST';
|
||||
await s.uploadLocalFiles();assert.equal(s.localPlaylistLocked,false);assert.equal(s.localFilesUploading,false);
|
||||
assert.deepEqual(calls,['/api/player/playlists']);assert.equal(s.localFiles.length,1);
|
||||
});
|
||||
test('torrent destination locks after start',()=>{
|
||||
const {store:s}=downloads();s.currentJob={status:'preview'};assert.equal(s.torrentDestinationLocked(),false);
|
||||
s.currentJob.status='paused';assert.equal(s.torrentDestinationLocked(),true);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const {readFileSync} = require('node:fs');
|
||||
const {test} = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const template = readFileSync(`${__dirname}/../templates/player/scripts.html`, 'utf8');
|
||||
function player(fetchImpl = async () => ({ok:true}), beacon = () => true) {
|
||||
// Exercise the production methods with audio and network stubs.
|
||||
const methods = template.slice(template.indexOf(' _recordHistory(endedReason'), template.indexOf(' _sendNowPlaying()'));
|
||||
const audio = {currentTime:60};
|
||||
const context = vm.createContext({audio, fetch:fetchImpl, navigator:{sendBeacon:beacon}, Blob,
|
||||
globalThis:{crypto:{randomUUID:()=> 'uuid'}}, setTimeout: fn => { fn(); }, console});
|
||||
vm.runInContext('this.methods = {' + methods + '};', context);
|
||||
return Object.assign(context.methods, {
|
||||
currentTrack:{id:8,duration_seconds:180}, _historyTrack:{id:8,duration_seconds:180},
|
||||
_localSourceTrackId:8, _listenId:'event1', _playbackStartedAt:123456,
|
||||
_historyRecorded:false, _listenedSeconds:60, _lastAudioTime:60, isPlaying:true,
|
||||
});
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
test('switching tracks records short sessions, and completion is not counted twice', async () => {
|
||||
const calls=[]; const p=player(async (_url, options)=> {calls.push(JSON.parse(options.body)); return {ok:true};});
|
||||
p._listenedSeconds=8; p._recordHistory('replaced'); await flush();
|
||||
assert.equal(calls[0].duration_listened,8); assert.equal(calls[0].ended_reason,'replaced');
|
||||
p._recordHistory('skipped'); await flush(); assert.equal(calls.length,1);
|
||||
});
|
||||
test('natural finish records final duration; seeking does not create listened time', async () => {
|
||||
const calls=[]; const p=player(async (_url, options)=> {calls.push(JSON.parse(options.body)); return {ok:true};});
|
||||
p._listenedSeconds=179.4; p._recordHistory('finished'); await flush();
|
||||
assert.equal(calls[0].duration_listened,180); assert.equal(calls[0].completed,true);
|
||||
p._listenedSeconds=0; assert.equal(p._historyListenedSeconds(false),0);
|
||||
});
|
||||
test('failed submission retries the original event even after track changes', async () => {
|
||||
const calls=[]; const p=player(async (_url,options)=> {calls.push(JSON.parse(options.body)); return {ok:calls.length>1,status:503};});
|
||||
p._recordHistory('skipped'); p.currentTrack={id:9}; p._listenId='event2';
|
||||
await flush(); assert.equal(calls.length,2); assert.deepEqual(calls[0], calls[1]);
|
||||
assert.equal(calls[0].track_id,8);
|
||||
});
|
||||
test('page exit uses beacon and does not duplicate a recorded event', () => {
|
||||
const calls=[]; const p=player(()=>assert.fail('unexpected fetch'), (url,blob)=>{calls.push({url,blob});return true;});
|
||||
p._recordHistory('stopped',true); p._recordHistory('stopped',true);
|
||||
assert.equal(calls.length,1); assert.equal(calls[0].url,'/api/player/history');
|
||||
});
|
||||
test('remote UI updates cannot attribute local audio to the wrong track', async () => {
|
||||
let body; const p=player(async (_url,options)=> {body=JSON.parse(options.body);return {ok:true}});
|
||||
p.currentTrack={id:999}; p._recordHistory('replaced'); await flush(); assert.equal(body.track_id,8);
|
||||
});
|
||||
test('unstarted tracks and sub-five-second interrupted sessions are not recorded', async () => {
|
||||
const p=player(()=>assert.fail('unexpected fetch'));
|
||||
p._playbackStartedAt=null; p._recordHistory('finished');
|
||||
p._playbackStartedAt=123; p._listenedSeconds=4; p._recordHistory('skipped'); await flush();
|
||||
assert.equal(p._historyRecorded,false);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TEMP TABLE furumusic__user (id bigint PRIMARY KEY, username text, display_name text);
|
||||
CREATE TEMP TABLE furumusic__fed_device_identity (user_id bigint PRIMARY KEY, device_id text);
|
||||
CREATE TEMP TABLE furumusic__fed_device (user_id bigint, device_id text, name text, PRIMARY KEY(user_id, device_id));
|
||||
CREATE TEMP TABLE furumusic__listen_event (
|
||||
user_id bigint, listen_id text, content_id text, local_track_id bigint, origin_device_id text,
|
||||
started_at_ms bigint, listened_ms bigint, track_duration_ms bigint, ended_reason text,
|
||||
qualified boolean, metadata_json jsonb, PRIMARY KEY(user_id, listen_id)
|
||||
);
|
||||
CREATE TEMP TABLE furumusic__track (id bigint);
|
||||
CREATE TEMP TABLE furumusic__release (id bigint);
|
||||
CREATE TEMP TABLE furumusic__artist (id bigint);
|
||||
CREATE TEMP TABLE furumusic__playlist (id bigint);
|
||||
CREATE TEMP TABLE furumusic__media_file (file_size_bytes bigint, audio_format text, file_type text);
|
||||
CREATE TEMP TABLE furumusic__pending_review (status text);
|
||||
CREATE TEMP TABLE furumusic__job_run (status text);
|
||||
CREATE TEMP TABLE furumusic__lastfm_scrobble_outbox (status text);
|
||||
INSERT INTO furumusic__user VALUES (1, 'alice', 'Alice'), (2, 'bob', 'Bob'), (3, 'silent', NULL);
|
||||
INSERT INTO furumusic__fed_device_identity VALUES (1, 'web-a'), (2, 'web-b');
|
||||
INSERT INTO furumusic__fed_device VALUES (1, 'phone', 'Alice phone');
|
||||
INSERT INTO furumusic__listen_event
|
||||
SELECT 1, 'listen-' || n, 'content-one', NULL, 'web-a',
|
||||
1793491200000 + n * 1000, 180000, 180000, '"finished"', true,
|
||||
'{"title":"First song", "artist_names":["Artist A"], "featured_artist_names":["Artist A", "Guest"], "release_title":"Album"}'::jsonb
|
||||
FROM generate_series(1, 30) n;
|
||||
INSERT INTO furumusic__listen_event VALUES
|
||||
(2, 'listen-1', 'content-one', NULL, 'web-b', 1793664000000, 90000, 180000, '"skipped"', true,
|
||||
'{"title":"First song", "artist_names":["Artist A"], "release_title":"Album"}'),
|
||||
(1, 'phone-1', 'content-two', NULL, 'phone', 1793664001000, 120000, 240000, '"replaced"', true,
|
||||
'{"title":"Remote song", "artist_names":["Artist B"], "release_title":"Remote album"}'),
|
||||
(2, 'short-1', 'content-one', NULL, 'web-b', 1793664002000, 8000, 180000, '"skipped"', false,
|
||||
'{"title":"First song", "artist_names":["Artist A"]}');
|
||||
Reference in new issue
Block a user