Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4141562e33 | ||
|
|
bcb5d378c8 | ||
|
|
59345a8d22 | ||
|
|
0975f3851a | ||
|
|
bded5a79cc | ||
|
|
2aede192fe | ||
|
|
2199fda240 | ||
|
|
8f7c0d231f | ||
|
|
9ab27c921c | ||
|
|
bd1d3a4ebc |
No files matched your search
@@ -5,3 +5,4 @@
|
||||
/federation
|
||||
/similarity-models
|
||||
/federation-cache
|
||||
*.DS_Store
|
||||
Generated
+1
-1
@@ -1988,7 +1988,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.10.8"
|
||||
version = "0.12.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
+6
-1
@@ -1,9 +1,14 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.11.0"
|
||||
version = "0.12.2"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
# macOS 27 rejects some stripped proc-macro dylibs with a misaligned LINKEDIT
|
||||
# string pool. Build scripts and proc macros are compile-time artifacts only.
|
||||
[profile.release.build-override]
|
||||
strip = false
|
||||
|
||||
[dependencies]
|
||||
# default-features off: cot's defaults include the sqlite backend, whose old
|
||||
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
|
||||
|
||||
@@ -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
|
||||
@@ -131,6 +195,22 @@ The browser interface and JSON API are served by the same application. Import,
|
||||
artwork, metadata enrichment, similarity indexing, and maintenance run as
|
||||
durable background jobs rather than blocking playback requests.
|
||||
|
||||
The `lastfm_popularity` job defaults to **light** mode: it requests popularity
|
||||
only for tracks without a completed lookup or with a changed title or primary
|
||||
artist. Both matches (including zero counts) and explicit Last.fm “Track not
|
||||
found” results are skipped on subsequent light runs. HTTP errors, timeouts,
|
||||
invalid responses, and other API errors remain eligible for retry. A failed
|
||||
full refresh also makes that track eligible for the next light run.
|
||||
|
||||
Choose **Full: refresh all visible tracks** in the task's admin parameters to
|
||||
refresh existing statistics and retry previous misses. The options endpoint
|
||||
`POST /admin/v2/api/jobs/lastfm_popularity/run-options` accepts
|
||||
`{"refresh_existing": true}`; an empty object, scheduled runs, and the ordinary
|
||||
run endpoint use light mode. Neither mode limits the number of tracks processed.
|
||||
An authoritative miss clears current statistics while preserving their history.
|
||||
The migration preserves existing popularity; legacy misses need one more lookup
|
||||
because older versions did not persist them.
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, design discussions, and patches are welcome. Before submitting a
|
||||
@@ -141,3 +221,10 @@ cargo fmt --all -- --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Last.fm database tests use temporary tables on a disposable PostgreSQL database:
|
||||
|
||||
```bash
|
||||
FURUMUSIC_LASTFM_TEST_DATABASE_URL=postgres://user:pass@localhost/test_db \
|
||||
cargo test lastfm_popularity -- --include-ignored
|
||||
```
|
||||
@@ -0,0 +1,167 @@
|
||||
use super::v2::{json_error, require_admin_json};
|
||||
use crate::{auth::Role, user::User};
|
||||
use cot::{db::Database, http::StatusCode, json::Json, response::IntoResponse, session::Session};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct UserRequest {
|
||||
action: String,
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
email: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
role: String,
|
||||
}
|
||||
|
||||
pub(super) async fn manage_user(
|
||||
session: Session,
|
||||
db: Database,
|
||||
Json(body): Json<UserRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let admin = match require_admin_json(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
if body.action == "delete" {
|
||||
let Some(id) = body.id.filter(|id| *id > 0) else {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "Invalid user"));
|
||||
};
|
||||
if id == admin.id {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"You cannot delete your own account",
|
||||
));
|
||||
}
|
||||
User::delete_by_id(&db, id).await?;
|
||||
} else {
|
||||
if !["create", "update"].contains(&body.action.as_str())
|
||||
|| body.username.trim().is_empty()
|
||||
|| body.username.chars().count() > 255
|
||||
|| Role::from_code(&body.role).is_none()
|
||||
|| (body.action == "create" && body.password.is_empty())
|
||||
{
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Provide a username, valid role, and a password for new accounts",
|
||||
));
|
||||
}
|
||||
let optional = |s: String| if s.trim().is_empty() { None } else { Some(s) };
|
||||
let email = optional(body.email);
|
||||
let display_name = optional(body.display_name);
|
||||
if body.action == "create" {
|
||||
if User::get_by_username(&db, body.username.trim())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(json_error(StatusCode::CONFLICT, "Username already exists"));
|
||||
}
|
||||
User::create(
|
||||
&db,
|
||||
body.username.trim(),
|
||||
email.as_deref(),
|
||||
display_name.as_deref(),
|
||||
&body.password,
|
||||
&body.role,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let Some(id) = body.id.filter(|id| *id > 0) else {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "Invalid user"));
|
||||
};
|
||||
if id == admin.id && body.role != "admin" {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"You cannot remove your own administrator role",
|
||||
));
|
||||
}
|
||||
let Some(mut user) = User::get_by_id(&db, id).await? else {
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "User not found"));
|
||||
};
|
||||
if User::get_by_username(&db, body.username.trim())
|
||||
.await?
|
||||
.is_some_and(|u| u.id_val() != id)
|
||||
{
|
||||
return Ok(json_error(StatusCode::CONFLICT, "Username already exists"));
|
||||
}
|
||||
user.update_fields(
|
||||
&db,
|
||||
body.username.trim(),
|
||||
email.as_deref(),
|
||||
display_name.as_deref(),
|
||||
(!body.password.is_empty()).then_some(body.password.as_str()),
|
||||
&body.role,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ScheduleRequest {
|
||||
cron: String,
|
||||
}
|
||||
|
||||
pub(super) async fn schedule(
|
||||
session: Session,
|
||||
db: Database,
|
||||
handle: &std::sync::Arc<
|
||||
tokio::sync::OnceCell<std::sync::Arc<crate::scheduler::SchedulerHandle>>,
|
||||
>,
|
||||
name: &str,
|
||||
Json(body): Json<ScheduleRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let cron = body.cron.trim();
|
||||
if !valid_schedule(cron) {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid cron schedule: include the seconds field",
|
||||
));
|
||||
}
|
||||
if crate::scheduler::ScheduledJob::get_by_name(&db, name)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "Job not found"));
|
||||
}
|
||||
let Some(handle) = handle.get() else {
|
||||
return Ok(json_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Scheduler is not ready",
|
||||
));
|
||||
};
|
||||
handle
|
||||
.reschedule_job(name, cron)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
fn valid_schedule(cron: &str) -> bool {
|
||||
cron.len() <= 100
|
||||
&& croner::parser::CronParser::builder()
|
||||
.seconds(croner::parser::Seconds::Required)
|
||||
.build()
|
||||
.parse(cron)
|
||||
.is_ok()
|
||||
&& crate::scheduler::compute_next_run(cron).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn schedules_use_the_same_seconds_field_as_the_scheduler() {
|
||||
assert!(super::valid_schedule("0 17 3 * * *"));
|
||||
assert!(!super::valid_schedule("17 3 * * *"));
|
||||
assert!(!super::valid_schedule("invalid"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+196
-689
@@ -1,5 +1,8 @@
|
||||
mod management;
|
||||
mod metadata_fix;
|
||||
mod setup;
|
||||
mod statistics;
|
||||
mod v2;
|
||||
pub mod views;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -19,15 +22,7 @@ use crate::config::AppConfig;
|
||||
use crate::i18n::I18n;
|
||||
use crate::scheduler::{JobRegistry, SchedulerHandle};
|
||||
use crate::user::User;
|
||||
use views::{
|
||||
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewApproveForm,
|
||||
ReviewsBulkForm, SetImageBody, SetupForm, UploadImageBody, UserForm,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReviewsQuery {
|
||||
status: Option<String>,
|
||||
}
|
||||
use setup::SetupForm;
|
||||
|
||||
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
|
||||
#[derive(Debug)]
|
||||
@@ -71,11 +66,6 @@ impl AdminApp {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SettingsQuery {
|
||||
saved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PathId {
|
||||
id: i64,
|
||||
@@ -97,11 +87,6 @@ struct PathNameRunId {
|
||||
run_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleasesQuery {
|
||||
artist_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl App for AdminApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"admin"
|
||||
@@ -121,7 +106,7 @@ impl App for AdminApp {
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_page(i18n, String::new())
|
||||
setup::setup_page(i18n, String::new())
|
||||
.await?
|
||||
.into_response()
|
||||
})
|
||||
@@ -132,7 +117,7 @@ impl App for AdminApp {
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_submit(i18n, &db, &session, form).await
|
||||
setup::setup_submit(i18n, &db, &session, form).await
|
||||
},
|
||||
),
|
||||
"admin_setup",
|
||||
@@ -179,6 +164,26 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_v2_dashboard",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/statistics",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
get(move |session: Session, db: Database,
|
||||
query: UrlQuery<statistics::StatisticsQuery>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new().max_connections(5)
|
||||
.connect(&pool_config.database_url).await.expect("admin pool")
|
||||
}).await;
|
||||
statistics::statistics(session, db, pg_pool, query.0).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_statistics",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/reviews",
|
||||
{
|
||||
@@ -232,6 +237,25 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_v2_reviews_bulk",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/users/manage",
|
||||
cot::router::method::post(management::manage_user),
|
||||
"admin_manage_user",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/jobs/{name}/schedule",
|
||||
cot::router::method::post({
|
||||
let handle = Arc::clone(&self.scheduler_handle);
|
||||
move |session: Session, db: Database, path: Path<PathName>,
|
||||
json: Json<management::ScheduleRequest>| {
|
||||
let handle = Arc::clone(&handle);
|
||||
async move {
|
||||
management::schedule(session, db, &handle, &path.0.name, json).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_job_schedule",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/users",
|
||||
{
|
||||
@@ -388,6 +412,32 @@ impl App for AdminApp {
|
||||
}),
|
||||
"admin_v2_artwork_backfill_run_options",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/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,
|
||||
json: Json<v2::LastfmPopularityRunRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
v2::run_lastfm_popularity(session, db, pg_pool, json).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_v2_lastfm_popularity_run_options",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/jobs/{name}/run",
|
||||
cot::router::method::post({
|
||||
@@ -557,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
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -781,6 +831,86 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_v2_library_releases_merge",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/metadata-fix/expand",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::ExpandRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
|
||||
metadata_fix::expand(session, db, pg_pool, json).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_metadata_fix_expand",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/metadata-fix/plan",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::PlanRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
|
||||
metadata_fix::propose(session, db, pg_pool, json).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_metadata_fix_plan",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/metadata-fix/apply",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::ApplyRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
|
||||
metadata_fix::apply(session, db, pg_pool, json).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_metadata_fix_apply",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/metadata-fix/history",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
get(move |session: Session, db: Database| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
|
||||
metadata_fix::history(session, db, pg_pool).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_metadata_fix_history",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/metadata-fix/undo",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::UndoRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
|
||||
metadata_fix::undo(session, db, pg_pool, json).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_metadata_fix_undo",
|
||||
),
|
||||
// -- Dashboard ----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/",
|
||||
@@ -800,672 +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/{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",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1490,3 +954,46 @@ impl App for AdminApp {
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pool: &sqlx::PgPool,
|
||||
options: crate::jobs::lastfm_popularity::LastfmPopularityOptions,
|
||||
) -> cot::Result<i64> {
|
||||
use crate::scheduler::{JobContext, JobLog, JobRun};
|
||||
|
||||
let mut run = JobRun::create_running(db, "lastfm_popularity", "manual")
|
||||
.await
|
||||
.map_err(|err| cot::Error::internal(format!("failed to create job run: {err}")))?;
|
||||
let run_id = run.id_val();
|
||||
let (live_config, _) = AppConfig::load_with_db(db).await;
|
||||
let db = db.clone();
|
||||
let pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let start = std::time::Instant::now();
|
||||
let ctx = JobContext {
|
||||
config: Arc::new(live_config),
|
||||
db: db.clone(),
|
||||
pool: pool.clone(),
|
||||
run_id,
|
||||
registry: Arc::new(JobRegistry::new()),
|
||||
};
|
||||
let mut log = JobLog::with_live_flush(pool, run_id);
|
||||
let result =
|
||||
crate::jobs::lastfm_popularity::run_with_options(&ctx, &mut log, options).await;
|
||||
let duration_ms = start.elapsed().as_millis() as i64;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = run.set_completed(&db, duration_ms, &log.output()).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = run
|
||||
.set_failed(&db, duration_ms, &log.output(), &err.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(run_id)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+203
-70
@@ -110,6 +110,8 @@ pub struct ArtworkBackfillRunRequest {
|
||||
overwrite_existing: bool,
|
||||
}
|
||||
|
||||
pub type LastfmPopularityRunRequest = crate::jobs::lastfm_popularity::LastfmPopularityOptions;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct UpdateLibraryItemRequest {
|
||||
kind: String,
|
||||
@@ -230,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)]
|
||||
@@ -255,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,
|
||||
@@ -430,6 +435,8 @@ struct JobRunDetailDto {
|
||||
struct BulkReviewsResponse {
|
||||
ok: bool,
|
||||
affected: u64,
|
||||
failed: u64,
|
||||
skipped: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -987,10 +994,14 @@ pub async fn bulk_reviews(
|
||||
}
|
||||
|
||||
let action = body.action.trim();
|
||||
if action != "delete" && action != "requeue" {
|
||||
if action != "delete" && action != "requeue" && action != "approve" {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown bulk action"));
|
||||
}
|
||||
|
||||
if action == "approve" {
|
||||
return Json(approve_selected_reviews(&db, pool, &body).await?).into_response();
|
||||
}
|
||||
|
||||
let mode = body.mode.as_deref().unwrap_or("ids");
|
||||
let affected = if mode == "filter" {
|
||||
apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await?
|
||||
@@ -1014,7 +1025,99 @@ pub async fn bulk_reviews(
|
||||
}
|
||||
};
|
||||
|
||||
Json(BulkReviewsResponse { ok: true, affected }).into_response()
|
||||
Json(BulkReviewsResponse {
|
||||
ok: true,
|
||||
affected,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn selected_reviews_query(body: &BulkReviewsRequest) -> QueryBuilder<'static, Postgres> {
|
||||
let mut qb = QueryBuilder::new("SELECT id FROM furumusic__pending_review WHERE 1=1");
|
||||
if body.mode.as_deref() == Some("filter") {
|
||||
let filter = body.filter.clone().unwrap_or_default();
|
||||
push_review_filters(
|
||||
&mut qb,
|
||||
normalize_status(filter.status.as_deref()),
|
||||
clean_search(filter.search.as_deref()).map(|s| format!("%{s}%")),
|
||||
);
|
||||
} else {
|
||||
qb.push(" AND id = ANY(");
|
||||
qb.push_bind(body.ids.clone().unwrap_or_default());
|
||||
qb.push(")");
|
||||
}
|
||||
qb.push(" ORDER BY id");
|
||||
qb
|
||||
}
|
||||
|
||||
async fn approve_selected_reviews(
|
||||
db: &Database,
|
||||
pool: &PgPool,
|
||||
body: &BulkReviewsRequest,
|
||||
) -> cot::Result<BulkReviewsResponse> {
|
||||
let ids = selected_reviews_query(body)
|
||||
.build_query_as::<IdRow>()
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let (config, _) = AppConfig::load_with_db(db).await;
|
||||
let mut result = BulkReviewsResponse {
|
||||
ok: true,
|
||||
affected: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
for row in ids {
|
||||
let Some(mut review) = crate::scheduler::PendingReview::get_by_id(db, row.id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if review.status_str() != "pending" {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claim one row at a time so overlapping bulk requests cannot import it twice.
|
||||
let claimed = sqlx::query(
|
||||
"UPDATE furumusic__pending_review SET status = 'processing', updated_at = $2 \
|
||||
WHERE id = $1 AND status = 'pending'",
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
if claimed.rows_affected() == 0 {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let approval = async {
|
||||
let normalized = serde_json::from_str::<crate::agent::dto::NormalizedFields>(
|
||||
review.result_json_str(),
|
||||
)?;
|
||||
finalize_review_approval(db, pool, &config, &mut review, &normalized).await
|
||||
}
|
||||
.await;
|
||||
match approval {
|
||||
Ok(()) => result.affected += 1,
|
||||
Err(error) => {
|
||||
tracing::error!(review_id = row.id, ?error, "bulk review approval failed");
|
||||
review
|
||||
.set_failed(db, &format!("Review approval failed: {error:#}"))
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
result.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.ok = result.failed == 0;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn approve_review(
|
||||
@@ -1040,31 +1143,9 @@ pub async fn approve_review(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let context: serde_json::Value =
|
||||
serde_json::from_str(review.context_json_str()).unwrap_or_default();
|
||||
let input_path = review.input_path_str().to_owned();
|
||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||
let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
|
||||
|
||||
match crate::jobs::inbox_process::finalize_approved(
|
||||
&db,
|
||||
pool,
|
||||
&live_config,
|
||||
&input_path,
|
||||
&normalized,
|
||||
&context,
|
||||
&live_config.agent_storage_dir,
|
||||
model_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let _ = review.set_approved(&db).await;
|
||||
Json(serde_json::json!({ "ok": true })).into_response()
|
||||
}
|
||||
match finalize_review_approval(&db, pool, &live_config, &mut review, &normalized).await {
|
||||
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Err(error) => {
|
||||
tracing::error!(?error, "review approval failed");
|
||||
let _ = review.set_rejected(&db).await;
|
||||
@@ -1076,6 +1157,36 @@ pub async fn approve_review(
|
||||
}
|
||||
}
|
||||
|
||||
async fn finalize_review_approval(
|
||||
db: &Database,
|
||||
pool: &PgPool,
|
||||
config: &AppConfig,
|
||||
review: &mut crate::scheduler::PendingReview,
|
||||
normalized: &crate::agent::dto::NormalizedFields,
|
||||
) -> anyhow::Result<()> {
|
||||
let context: serde_json::Value =
|
||||
serde_json::from_str(review.context_json_str()).unwrap_or_default();
|
||||
let input_path = review.input_path_str().to_owned();
|
||||
let stats = crate::scheduler::ProcessingStats::get_by_review_id(db, review.id_val())
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
|
||||
|
||||
crate::jobs::inbox_process::finalize_approved(
|
||||
db,
|
||||
pool,
|
||||
config,
|
||||
&input_path,
|
||||
normalized,
|
||||
&context,
|
||||
&config.agent_storage_dir,
|
||||
model_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
review.set_approved(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn jobs(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1734,6 +1845,19 @@ pub async fn run_metadata_backfill(
|
||||
Json(JobRunStartedDto { ok: true, run_id }).into_response()
|
||||
}
|
||||
|
||||
pub async fn run_lastfm_popularity(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &PgPool,
|
||||
Json(options): Json<LastfmPopularityRunRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let run_id = super::start_lastfm_popularity(&db, pool, options).await?;
|
||||
Json(JobRunStartedDto { ok: true, run_id }).into_response()
|
||||
}
|
||||
|
||||
pub async fn run_artwork_backfill(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1837,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 {
|
||||
@@ -1846,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()))?;
|
||||
@@ -2446,7 +2572,7 @@ pub async fn merge_releases(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn require_admin_json(
|
||||
pub(super) async fn require_admin_json(
|
||||
session: &Session,
|
||||
db: &Database,
|
||||
) -> Result<AuthenticatedUser, cot::response::Response> {
|
||||
@@ -2459,7 +2585,7 @@ async fn require_admin_json(
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
|
||||
pub(super) fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
|
||||
let body = serde_json::json!({ "error": message });
|
||||
cot::http::Response::builder()
|
||||
.status(status)
|
||||
@@ -2541,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(
|
||||
@@ -2571,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 ");
|
||||
@@ -2614,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)
|
||||
@@ -2638,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),
|
||||
@@ -2714,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)
|
||||
@@ -2772,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,
|
||||
@@ -2809,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,
|
||||
}
|
||||
@@ -3428,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",
|
||||
)
|
||||
@@ -3464,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",
|
||||
@@ -4241,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 {
|
||||
@@ -4277,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",
|
||||
);
|
||||
|
||||
-2146
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()
|
||||
|
||||
@@ -194,6 +194,8 @@ translations! {
|
||||
jobs_back_to_list: "Back to jobs" , "Назад к заданиям";
|
||||
jobs_run_detail: "Run detail" , "Детали запуска";
|
||||
jobs_back_to_job: "Back to job" , "Назад к заданию";
|
||||
jobs_lastfm_light_help: "Light mode checks only unchecked tracks, changed titles or artists, and failed lookups. Previous matches and confirmed misses are skipped." , "Лайт-режим проверяет только непроверенные треки, изменённые названия или исполнителей и ошибки прошлых запросов. Найденные и подтверждённо отсутствующие треки пропускаются.";
|
||||
jobs_lastfm_full: "Full refresh: check all visible tracks, including previous matches and misses" , "Полное обновление: проверить все видимые треки, включая ранее найденные и отсутствующие";
|
||||
jobs_metadata_backfill_options: "Metadata backfill options" , "Параметры обновления метадаты";
|
||||
jobs_metadata_backfill_fields: "Fields to update" , "Поля для обновления";
|
||||
jobs_metadata_backfill_fill_missing: "Fill missing only" , "Заполнить только пустые";
|
||||
@@ -406,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
|
||||
{
|
||||
|
||||
+371
-182
@@ -1,19 +1,54 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgConnection;
|
||||
|
||||
use crate::scheduler::{Job, JobContext, JobLog};
|
||||
|
||||
pub struct LastfmPopularityJob;
|
||||
|
||||
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
|
||||
const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||
const LASTFM_REQUEST_DELAY: Duration = Duration::from_millis(1200);
|
||||
const TRACK_PAGE_SIZE: i64 = 500;
|
||||
const ADVISORY_LOCK_ID: i64 = 0x4c41_5354_464d_504f; // "LASTFMPO"
|
||||
|
||||
/// Scheduled and ordinary manual runs use light mode. Full mode is opt-in.
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct LastfmPopularityOptions {
|
||||
pub refresh_existing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct TrackLookupRow {
|
||||
id: i64,
|
||||
title: String,
|
||||
artist_name: Option<String>,
|
||||
lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
// Store the lookup identity alongside the result so every metadata editing path
|
||||
// (including artist renames and link changes) automatically invalidates it.
|
||||
const TRACK_CANDIDATES_SQL: &str = r#"
|
||||
SELECT t.id, t.title::text AS title, primary_artist.name AS artist_name
|
||||
FROM furumusic__track t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT a.name::text AS name
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||
ORDER BY ta.position, ta.artist_id
|
||||
LIMIT 1
|
||||
) primary_artist ON true
|
||||
WHERE t.is_hidden = false AND t.id > $1 AND t.id <= $2
|
||||
AND ($3 OR t.lastfm_checked_at IS NULL
|
||||
OR t.lastfm_lookup_title IS DISTINCT FROM t.title::text
|
||||
OR t.lastfm_lookup_artist IS DISTINCT FROM primary_artist.name)
|
||||
ORDER BY t.id
|
||||
LIMIT $4
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LastfmTrackInfoResponse {
|
||||
track: Option<LastfmTrack>,
|
||||
@@ -23,8 +58,69 @@ struct LastfmTrackInfoResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LastfmTrack {
|
||||
listeners: Option<String>,
|
||||
playcount: Option<String>,
|
||||
listeners: String,
|
||||
playcount: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum LookupOutcome {
|
||||
Found { listeners: i64, playcount: i64 },
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum LookupError {
|
||||
Http(StatusCode),
|
||||
Api { code: i32, message: String },
|
||||
Transport(reqwest::Error),
|
||||
InvalidResponse(String),
|
||||
}
|
||||
|
||||
impl LookupError {
|
||||
fn should_stop_run(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(status) => matches!(status.as_u16(), 401 | 403 | 429),
|
||||
Self::Api { code, .. } => matches!(code, 2 | 3 | 4 | 5 | 9 | 10 | 11 | 13 | 26 | 29),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LookupError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Http(status) => write!(f, "Last.fm HTTP {status}"),
|
||||
Self::Api { code, message } => write!(f, "Last.fm API error {code}: {message}"),
|
||||
Self::Transport(err) => write!(f, "Last.fm request failed: {err}"),
|
||||
Self::InvalidResponse(message) => write!(f, "Invalid Last.fm response: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LookupError {}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PopularityStats {
|
||||
considered: u64,
|
||||
requested: u64,
|
||||
updated: u64,
|
||||
not_found: u64,
|
||||
skipped: u64,
|
||||
failed: u64,
|
||||
}
|
||||
|
||||
impl PopularityStats {
|
||||
fn summary(&self) -> String {
|
||||
format!(
|
||||
"{} considered, {} requested, {} updated, {} not found, {} skipped, {} failed",
|
||||
self.considered,
|
||||
self.requested,
|
||||
self.updated,
|
||||
self.not_found,
|
||||
self.skipped,
|
||||
self.failed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -34,7 +130,7 @@ impl Job for LastfmPopularityJob {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Update Last.fm playcount/listener popularity for library tracks"
|
||||
"Fetch Last.fm popularity for unchecked tracks; optionally refresh all tracks"
|
||||
}
|
||||
|
||||
fn default_cron(&self) -> &'static str {
|
||||
@@ -43,159 +139,236 @@ impl Job for LastfmPopularityJob {
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||
let api_key = ctx.config.lastfm_api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
log.warn("lastfm_api_key is not configured, skipping Last.fm popularity update");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tracks = sqlx::query_as::<_, TrackLookupRow>(
|
||||
r#"SELECT t.id,
|
||||
t.title::text AS title,
|
||||
t.lastfm_updated_at::text AS lastfm_updated_at,
|
||||
(
|
||||
SELECT a.name::text
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||
ORDER BY ta.position
|
||||
LIMIT 1
|
||||
) AS artist_name
|
||||
FROM furumusic__track t
|
||||
WHERE t.is_hidden = false
|
||||
ORDER BY t.lastfm_updated_at IS NOT NULL, t.lastfm_updated_at ASC, t.id ASC"#,
|
||||
)
|
||||
.fetch_all(&ctx.pool)
|
||||
.await?;
|
||||
|
||||
if tracks.is_empty() {
|
||||
log.info("No visible tracks found for Last.fm popularity update");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log.info(&format!(
|
||||
"Starting Last.fm popularity update for {} visible tracks; oldest or missing ratings are processed first; request delay is {} ms; rating formula is ln(playcount + 1) * ln(listeners + 1)",
|
||||
tracks.len(),
|
||||
LASTFM_REQUEST_DELAY.as_millis()
|
||||
));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("furumusic-lastfm-popularity/0.1")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?;
|
||||
let mut updated = 0u64;
|
||||
let mut skipped = 0u64;
|
||||
let mut failed = 0u64;
|
||||
|
||||
for (index, track) in tracks.iter().enumerate() {
|
||||
let Some(artist) = track
|
||||
.artist_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
else {
|
||||
skipped += 1;
|
||||
log.warn(&format!(
|
||||
"Skipping track {} \"{}\": no primary artist",
|
||||
track.id, track.title
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
log.info(&format!(
|
||||
"Last.fm lookup {}/{}: track {} \"{}\" by \"{}\" (previous update: {})",
|
||||
index + 1,
|
||||
tracks.len(),
|
||||
track.id,
|
||||
track.title,
|
||||
artist,
|
||||
track.lastfm_updated_at.as_deref().unwrap_or("never")
|
||||
));
|
||||
let result = fetch_track_info(&client, api_key, artist, &track.title).await;
|
||||
match result {
|
||||
Ok(Some((listeners, playcount))) => {
|
||||
let rating = popularity_rating(listeners, playcount);
|
||||
let fetched_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
sqlx::query(
|
||||
r#"UPDATE furumusic__track
|
||||
SET lastfm_listeners = $2,
|
||||
lastfm_playcount = $3,
|
||||
lastfm_rating = $4,
|
||||
lastfm_updated_at = $5
|
||||
WHERE id = $1"#,
|
||||
)
|
||||
.bind(track.id)
|
||||
.bind(listeners)
|
||||
.bind(playcount)
|
||||
.bind(rating)
|
||||
.bind(&fetched_at)
|
||||
.execute(&ctx.pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"INSERT INTO furumusic__track_popularity_history
|
||||
(track_id, source, listeners, playcount, rating, fetched_at)
|
||||
VALUES ($1, 'lastfm', $2, $3, $4, $5)"#,
|
||||
)
|
||||
.bind(track.id)
|
||||
.bind(listeners)
|
||||
.bind(playcount)
|
||||
.bind(rating)
|
||||
.bind(&fetched_at)
|
||||
.execute(&ctx.pool)
|
||||
.await?;
|
||||
updated += 1;
|
||||
log.info(&format!(
|
||||
"Updated track {} \"{}\" by \"{}\": listeners={listeners}, playcount={playcount}, rating={rating:.4}",
|
||||
track.id, track.title, artist
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
skipped += 1;
|
||||
log.warn(&format!(
|
||||
"Last.fm has no usable match for track {} \"{}\" by \"{}\"",
|
||||
track.id, track.title, artist
|
||||
));
|
||||
}
|
||||
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
|
||||
failed += 1;
|
||||
log.error("Last.fm rate limit exceeded; stopping this run early");
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
log.warn(&format!(
|
||||
"Last.fm lookup failed for track {} \"{}\" / \"{}\": {err}",
|
||||
track.id, artist, track.title
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (index + 1) % 50 == 0 {
|
||||
log.info(&format!(
|
||||
"Last.fm progress: {}/{} tracks, {updated} updated, {skipped} skipped, {failed} failed",
|
||||
index + 1,
|
||||
tracks.len()
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(LASTFM_REQUEST_DELAY).await;
|
||||
}
|
||||
|
||||
log.info(&format!(
|
||||
"Last.fm popularity update finished: {updated} updated, {skipped} skipped, {failed} failed, {} considered",
|
||||
tracks.len()
|
||||
));
|
||||
Ok(())
|
||||
run_with_options(ctx, log, LastfmPopularityOptions::default()).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_with_options(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
options: LastfmPopularityOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
let api_key = ctx.config.lastfm_api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
log.warn("lastfm_api_key is not configured, skipping Last.fm popularity update");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut conn = ctx.pool.acquire().await?;
|
||||
// A session lock must never return to the pool, including on cancellation.
|
||||
// All database work below uses this connection; no transaction spans HTTP.
|
||||
conn.close_on_drop();
|
||||
if !try_lock(&mut conn).await? {
|
||||
log.info("Last.fm popularity update is already running; skipping this run");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(format!(
|
||||
"furumusic-lastfm-popularity/{}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()?;
|
||||
log.info(&format!(
|
||||
"Last.fm popularity options: mode={}; request delay={} ms",
|
||||
if options.refresh_existing {
|
||||
"full"
|
||||
} else {
|
||||
"light"
|
||||
},
|
||||
LASTFM_REQUEST_DELAY.as_millis()
|
||||
));
|
||||
let result = process_tracks(&mut conn, &client, api_key, options, log).await;
|
||||
// Release promptly on normal completion; close_on_drop also covers errors
|
||||
// and task cancellation without leaking the lock into another run.
|
||||
conn.close().await?;
|
||||
result
|
||||
}
|
||||
|
||||
async fn try_lock(conn: &mut PgConnection) -> anyhow::Result<bool> {
|
||||
Ok(sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(ADVISORY_LOCK_ID)
|
||||
.fetch_one(conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn fetch_candidates(
|
||||
conn: &mut PgConnection,
|
||||
after_id: i64,
|
||||
max_id: i64,
|
||||
options: LastfmPopularityOptions,
|
||||
) -> anyhow::Result<Vec<TrackLookupRow>> {
|
||||
Ok(sqlx::query_as(TRACK_CANDIDATES_SQL)
|
||||
.bind(after_id)
|
||||
.bind(max_id)
|
||||
.bind(options.refresh_existing)
|
||||
.bind(TRACK_PAGE_SIZE)
|
||||
.fetch_all(conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn process_tracks(
|
||||
conn: &mut PgConnection,
|
||||
client: &Client,
|
||||
api_key: &str,
|
||||
options: LastfmPopularityOptions,
|
||||
log: &mut JobLog,
|
||||
) -> anyhow::Result<()> {
|
||||
// Bound the snapshot by id, not by count: every eligible track at the start
|
||||
// can be processed, while concurrent imports wait for the next run.
|
||||
let max_id: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(id), 0) FROM furumusic__track")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let mut after_id = 0;
|
||||
let mut stats = PopularityStats::default();
|
||||
let mut next_request_at = tokio::time::Instant::now();
|
||||
let mut last_progress = std::time::Instant::now();
|
||||
loop {
|
||||
let tracks = fetch_candidates(conn, after_id, max_id, options).await?;
|
||||
if tracks.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Invalidate the page before HTTP, preserving its statistics. A failed
|
||||
// write or cancellation during a full refresh then also leaves work for
|
||||
// the next light run. This costs one update per page, not per request.
|
||||
let ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
|
||||
mark_pending(conn, &ids).await?;
|
||||
for track in tracks {
|
||||
// Advance even after an error so a failing track is tried only once
|
||||
// per run and cannot starve the remaining candidates.
|
||||
after_id = track.id;
|
||||
stats.considered += 1;
|
||||
let artist = track.artist_name.as_deref().unwrap_or_default();
|
||||
if artist.trim().is_empty() || track.title.trim().is_empty() {
|
||||
stats.skipped += 1;
|
||||
// Missing local metadata says nothing about the Last.fm catalog.
|
||||
continue;
|
||||
}
|
||||
tokio::time::sleep_until(next_request_at).await;
|
||||
stats.requested += 1;
|
||||
let outcome = fetch_track_info(client, api_key, artist, &track.title).await;
|
||||
next_request_at = tokio::time::Instant::now() + LASTFM_REQUEST_DELAY;
|
||||
match outcome {
|
||||
Ok(outcome) => {
|
||||
if save_outcome(conn, &track, &outcome).await? {
|
||||
match outcome {
|
||||
LookupOutcome::Found { .. } => stats.updated += 1,
|
||||
LookupOutcome::NotFound => stats.not_found += 1,
|
||||
}
|
||||
} else {
|
||||
// Metadata changed or the track was removed during HTTP.
|
||||
stats.skipped += 1;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
stats.failed += 1;
|
||||
tracing::warn!(track_id = track.id, error = %err, "Last.fm popularity lookup failed");
|
||||
if stats.failed <= 5 || err.should_stop_run() {
|
||||
log.warn(&format!(
|
||||
"Last.fm lookup failed for track {}: {err}",
|
||||
track.id
|
||||
));
|
||||
}
|
||||
if err.should_stop_run() {
|
||||
log.error(&format!("Last.fm popularity stopped: {}", stats.summary()));
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep live logs bounded: the shared logger rewrites its entire
|
||||
// accumulated output whenever a new line is added.
|
||||
if last_progress.elapsed() >= Duration::from_secs(30) {
|
||||
log.info(&format!("Last.fm progress: {}", stats.summary()));
|
||||
last_progress = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info(&format!("Last.fm popularity finished: {}", stats.summary()));
|
||||
anyhow::ensure!(
|
||||
stats.failed == 0,
|
||||
"{} Last.fm lookups failed; they will be retried in light mode",
|
||||
stats.failed
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_pending(conn: &mut PgConnection, track_ids: &[i64]) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__track SET lastfm_checked_at = NULL \
|
||||
WHERE id = ANY($1) AND lastfm_checked_at IS NOT NULL",
|
||||
)
|
||||
.bind(track_ids)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_outcome(
|
||||
conn: &mut PgConnection,
|
||||
track: &TrackLookupRow,
|
||||
outcome: &LookupOutcome,
|
||||
) -> anyhow::Result<bool> {
|
||||
let (listeners, playcount, rating, updated_at) = match outcome {
|
||||
LookupOutcome::Found {
|
||||
listeners,
|
||||
playcount,
|
||||
} => (
|
||||
Some(*listeners),
|
||||
Some(*playcount),
|
||||
Some(popularity_rating(*listeners, *playcount)),
|
||||
Some(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()),
|
||||
),
|
||||
// An authoritative miss replaces the current result, but preserves
|
||||
// historical observations. Unknown popularity is not a zero rating.
|
||||
LookupOutcome::NotFound => (None, None, None, None),
|
||||
};
|
||||
// The data-modifying CTE atomically saves the result and its history in one
|
||||
// round trip. Ignore responses for a lookup identity that is already stale.
|
||||
let saved: bool = sqlx::query_scalar(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE furumusic__track t
|
||||
SET lastfm_listeners = $2, lastfm_playcount = $3,
|
||||
lastfm_rating = $4, lastfm_updated_at = $5,
|
||||
lastfm_checked_at = CURRENT_TIMESTAMP,
|
||||
lastfm_lookup_title = $6, lastfm_lookup_artist = $7
|
||||
WHERE t.id = $1 AND t.is_hidden = false AND t.title::text = $6
|
||||
AND (SELECT a.name::text
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||
ORDER BY ta.position, ta.artist_id LIMIT 1) IS NOT DISTINCT FROM $7
|
||||
RETURNING id
|
||||
), history AS (
|
||||
INSERT INTO furumusic__track_popularity_history
|
||||
(track_id, source, listeners, playcount, rating, fetched_at)
|
||||
SELECT id, 'lastfm', $2::bigint, $3::bigint, $4::double precision, $5::varchar
|
||||
FROM updated WHERE $2::bigint IS NOT NULL
|
||||
)
|
||||
SELECT EXISTS (SELECT 1 FROM updated)
|
||||
"#,
|
||||
)
|
||||
.bind(track.id)
|
||||
.bind(listeners)
|
||||
.bind(playcount)
|
||||
.bind(rating)
|
||||
.bind(updated_at)
|
||||
.bind(&track.title)
|
||||
.bind(&track.artist_name)
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
async fn fetch_track_info(
|
||||
client: &reqwest::Client,
|
||||
client: &Client,
|
||||
api_key: &str,
|
||||
artist: &str,
|
||||
track: &str,
|
||||
) -> anyhow::Result<Option<(i64, i64)>> {
|
||||
) -> Result<LookupOutcome, LookupError> {
|
||||
let response = client
|
||||
.get("https://ws.audioscrobbler.com/2.0/")
|
||||
.get(LASTFM_API_URL)
|
||||
.query(&[
|
||||
("method", "track.getInfo"),
|
||||
("api_key", api_key),
|
||||
@@ -205,45 +378,61 @@ async fn fetch_track_info(
|
||||
("format", "json"),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|err| LookupError::Transport(err.without_url()))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(LookupError::Http(status));
|
||||
}
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| LookupError::Transport(err.without_url()))?;
|
||||
interpret_response(status, &body)
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
fn interpret_response(status: StatusCode, body: &[u8]) -> Result<LookupOutcome, LookupError> {
|
||||
// A bare HTTP 404 (or any other HTTP failure) never proves catalog absence.
|
||||
if !status.is_success() {
|
||||
return Err(LookupError::Http(status));
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let body: LastfmTrackInfoResponse = response.json().await?;
|
||||
let body: LastfmTrackInfoResponse = serde_json::from_slice(body)
|
||||
.map_err(|err| LookupError::InvalidResponse(err.to_string()))?;
|
||||
if let Some(code) = body.error {
|
||||
if code == 29 {
|
||||
anyhow::bail!("Last.fm rate limit exceeded");
|
||||
let message = body.message.unwrap_or_else(|| "unknown error".to_owned());
|
||||
// Last.fm uses error 6 for both missing tracks and invalid parameters.
|
||||
// Only an explicit track-not-found message is a cacheable miss.
|
||||
if matches!(code, 6 | 7)
|
||||
&& message
|
||||
.trim()
|
||||
.trim_end_matches('.')
|
||||
.eq_ignore_ascii_case("track not found")
|
||||
{
|
||||
return Ok(LookupOutcome::NotFound);
|
||||
}
|
||||
if code == 6 || code == 7 {
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"Last.fm API error {code}: {}",
|
||||
body.message.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
return Err(LookupError::Api { code, message });
|
||||
}
|
||||
let Some(info) = body.track else {
|
||||
return Ok(None);
|
||||
let info = body
|
||||
.track
|
||||
.ok_or_else(|| LookupError::InvalidResponse("missing track".to_owned()))?;
|
||||
let parse_count = |value: &str, field: &str| {
|
||||
value
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.filter(|value| *value >= 0)
|
||||
.with_context(|| format!("invalid {field}"))
|
||||
.map_err(|err| LookupError::InvalidResponse(err.to_string()))
|
||||
};
|
||||
let listeners = info
|
||||
.listeners
|
||||
.as_deref()
|
||||
.unwrap_or("0")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let playcount = info
|
||||
.playcount
|
||||
.as_deref()
|
||||
.unwrap_or("0")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
Ok(Some((listeners.max(0), playcount.max(0))))
|
||||
Ok(LookupOutcome::Found {
|
||||
listeners: parse_count(&info.listeners, "listeners")?,
|
||||
playcount: parse_count(&info.playcount, "playcount")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn popularity_rating(listeners: i64, playcount: i64) -> f64 {
|
||||
let listeners = listeners.max(0) as f64;
|
||||
let playcount = playcount.max(0) as f64;
|
||||
playcount.ln_1p() * listeners.ln_1p()
|
||||
(playcount as f64).ln_1p() * (listeners as f64).ln_1p()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lastfm_popularity_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,337 @@
|
||||
use super::*;
|
||||
use sqlx::Connection;
|
||||
|
||||
#[test]
|
||||
fn run_options_default_to_light_and_require_explicit_full_mode() {
|
||||
assert!(!LastfmPopularityOptions::default().refresh_existing);
|
||||
let light: LastfmPopularityOptions = serde_json::from_str("{}").unwrap();
|
||||
assert!(!light.refresh_existing);
|
||||
let full: LastfmPopularityOptions =
|
||||
serde_json::from_str(r#"{"refresh_existing":true}"#).unwrap();
|
||||
assert!(full.refresh_existing);
|
||||
assert!(
|
||||
serde_json::from_str::<LastfmPopularityOptions>(r#"{"refresh_existing":"true"}"#).is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<LastfmPopularityOptions>(r#"{"refresh_exising":true}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_explicit_catalog_misses_are_cacheable() {
|
||||
for code in [6, 7] {
|
||||
for message in ["Track not found", "Track not found.", " track NOT found "] {
|
||||
let body = serde_json::json!({"error": code, "message": message}).to_string();
|
||||
assert_eq!(
|
||||
interpret_response(StatusCode::OK, body.as_bytes()).unwrap(),
|
||||
LookupOutcome::NotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
for (code, message) in [
|
||||
(6, "Invalid parameters"),
|
||||
(6, "Track parameter is required"),
|
||||
(7, "Invalid resource specified"),
|
||||
(8, "Operation failed"),
|
||||
(11, "Service Offline"),
|
||||
(16, "Temporary error"),
|
||||
(29, "Track not found"),
|
||||
] {
|
||||
let body = serde_json::json!({"error": code, "message": message}).to_string();
|
||||
assert!(interpret_response(StatusCode::OK, body.as_bytes()).is_err());
|
||||
}
|
||||
for status in [
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
] {
|
||||
// Even a JSON not-found message cannot turn an HTTP error into a miss.
|
||||
assert!(interpret_response(status, br#"{"error":6,"message":"Track not found"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_incomplete_popularity_is_retryable_and_zero_is_valid() {
|
||||
for body in [
|
||||
"not json",
|
||||
"{}",
|
||||
r#"{"track":null}"#,
|
||||
r#"{"track":{}}"#,
|
||||
r#"{"track":{"listeners":"1"}}"#,
|
||||
r#"{"track":{"listeners":"bad","playcount":"2"}}"#,
|
||||
r#"{"track":{"listeners":"1","playcount":"-1"}}"#,
|
||||
r#"{"track":{"listeners":"1","playcount":"9223372036854775808"}}"#,
|
||||
r#"{"track":{"listeners":null,"playcount":"2"}}"#,
|
||||
] {
|
||||
assert!(
|
||||
interpret_response(StatusCode::OK, body.as_bytes()).is_err(),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
interpret_response(
|
||||
StatusCode::OK,
|
||||
br#"{"track":{"listeners":"0","playcount":"0"}}"#
|
||||
)
|
||||
.unwrap(),
|
||||
LookupOutcome::Found {
|
||||
listeners: 0,
|
||||
playcount: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(popularity_rating(0, 0), 0.0);
|
||||
assert!(popularity_rating(10, 100) > popularity_rating(1, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_errors_stop_the_run_without_caching_absence() {
|
||||
assert!(LookupError::Http(StatusCode::TOO_MANY_REQUESTS).should_stop_run());
|
||||
for code in [10, 11, 26, 29] {
|
||||
assert!(
|
||||
LookupError::Api {
|
||||
code,
|
||||
message: String::new()
|
||||
}
|
||||
.should_stop_run()
|
||||
);
|
||||
}
|
||||
assert!(!LookupError::Http(StatusCode::INTERNAL_SERVER_ERROR).should_stop_run());
|
||||
assert!(!LookupError::InvalidResponse("missing track".into()).should_stop_run());
|
||||
}
|
||||
|
||||
// These tests use temporary tables on isolated connections, never application
|
||||
// tables. Run explicitly against a disposable PostgreSQL instance:
|
||||
// FURUMUSIC_LASTFM_TEST_DATABASE_URL=... cargo test lastfm_popularity -- --include-ignored
|
||||
async fn database_fixture() -> PgConnection {
|
||||
let url = std::env::var("FURUMUSIC_LASTFM_TEST_DATABASE_URL")
|
||||
.expect("set FURUMUSIC_LASTFM_TEST_DATABASE_URL to a disposable PostgreSQL database");
|
||||
let mut conn = PgConnection::connect(&url).await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
r#"
|
||||
CREATE TEMP TABLE furumusic__track (
|
||||
id BIGINT PRIMARY KEY, title TEXT NOT NULL,
|
||||
is_hidden BOOLEAN NOT NULL DEFAULT false,
|
||||
lastfm_listeners BIGINT, lastfm_playcount BIGINT,
|
||||
lastfm_rating DOUBLE PRECISION, lastfm_updated_at VARCHAR(32)
|
||||
);
|
||||
CREATE TEMP TABLE furumusic__artist (id BIGINT PRIMARY KEY, name TEXT NOT NULL);
|
||||
CREATE TEMP TABLE furumusic__track_artist (
|
||||
track_id BIGINT, artist_id BIGINT, role TEXT, position INTEGER
|
||||
);
|
||||
CREATE TEMP TABLE furumusic__track_popularity_history (
|
||||
id BIGSERIAL PRIMARY KEY, track_id BIGINT NOT NULL, source VARCHAR(32) NOT NULL,
|
||||
listeners BIGINT NOT NULL, playcount BIGINT NOT NULL,
|
||||
rating DOUBLE PRECISION NOT NULL, fetched_at VARCHAR(32) NOT NULL
|
||||
);
|
||||
INSERT INTO furumusic__artist VALUES (1, 'Artist'), (2, 'Guest'), (3, 'Other');
|
||||
INSERT INTO furumusic__track (id, title, lastfm_rating, lastfm_listeners, lastfm_playcount)
|
||||
VALUES (1, 'Legacy zero', 0, 0, 0), (2, 'New', NULL, NULL, NULL),
|
||||
(3, 'Hidden', NULL, NULL, NULL), (4, 'No artist', NULL, NULL, NULL);
|
||||
UPDATE furumusic__track SET is_hidden = true WHERE id = 3;
|
||||
INSERT INTO furumusic__track_artist VALUES
|
||||
(1, 1, 'main', 0), (2, 1, 'main', 0), (3, 1, 'main', 0), (2, 2, 'featuring', -1);
|
||||
"#,
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
// Exercise precisely the same prepared statements as the real migration.
|
||||
for statement in include_str!("../music/lastfm_popularity_lookup.sql").split(';') {
|
||||
if !statement.trim().is_empty() {
|
||||
sqlx::query(statement).execute(&mut conn).await.unwrap();
|
||||
}
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
async fn candidates(conn: &mut PgConnection, full: bool) -> Vec<TrackLookupRow> {
|
||||
fetch_candidates(
|
||||
conn,
|
||||
0,
|
||||
i64::MAX,
|
||||
LastfmPopularityOptions {
|
||||
refresh_existing: full,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn ids(rows: &[TrackLookupRow]) -> Vec<i64> {
|
||||
rows.iter().map(|row| row.id).collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FURUMUSIC_LASTFM_TEST_DATABASE_URL"]
|
||||
async fn database_modes_misses_retries_and_metadata_changes() {
|
||||
let mut conn = database_fixture().await;
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), [2, 4]);
|
||||
assert_eq!(ids(&candidates(&mut conn, true).await), [1, 2, 4]);
|
||||
let new = candidates(&mut conn, false).await.remove(0);
|
||||
assert_eq!(new.artist_name.as_deref(), Some("Artist"));
|
||||
assert!(
|
||||
save_outcome(&mut conn, &new, &LookupOutcome::NotFound)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), [4]);
|
||||
assert_eq!(ids(&candidates(&mut conn, true).await), [1, 2, 4]);
|
||||
// A failed full refresh must become eligible for light mode again.
|
||||
mark_pending(&mut conn, &[new.id]).await.unwrap();
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), [2, 4]);
|
||||
let found = LookupOutcome::Found {
|
||||
listeners: 5,
|
||||
playcount: 10,
|
||||
};
|
||||
assert!(save_outcome(&mut conn, &new, &found).await.unwrap());
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), [4]);
|
||||
// Unlike a network error, a confirmed miss replaces old current statistics.
|
||||
assert!(
|
||||
save_outcome(&mut conn, &new, &LookupOutcome::NotFound)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let rating: Option<f64> =
|
||||
sqlx::query_scalar("SELECT lastfm_rating FROM furumusic__track WHERE id=2")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rating, None);
|
||||
let history: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__track_popularity_history")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history, 1);
|
||||
sqlx::query("UPDATE furumusic__artist SET name='Renamed' WHERE id=1")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), [1, 2, 4]);
|
||||
// A response fetched before a concurrent rename cannot mark the new identity checked.
|
||||
assert!(!save_outcome(&mut conn, &new, &found).await.unwrap());
|
||||
let renamed = candidates(&mut conn, false).await.remove(1);
|
||||
assert!(save_outcome(&mut conn, &renamed, &found).await.unwrap());
|
||||
sqlx::query("UPDATE furumusic__track SET title='New title' WHERE id=2")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), [1, 2, 4]);
|
||||
assert!(!save_outcome(&mut conn, &renamed, &found).await.unwrap());
|
||||
let retitled = candidates(&mut conn, false).await.remove(1);
|
||||
assert!(save_outcome(&mut conn, &retitled, &found).await.unwrap());
|
||||
sqlx::query("UPDATE furumusic__track_artist SET artist_id=3 WHERE track_id=2 AND role='main'")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), [1, 2, 4]);
|
||||
assert!(!save_outcome(&mut conn, &retitled, &found).await.unwrap());
|
||||
let relinked = candidates(&mut conn, false).await.remove(1);
|
||||
assert_eq!(relinked.artist_name.as_deref(), Some("Other"));
|
||||
assert!(save_outcome(&mut conn, &relinked, &found).await.unwrap());
|
||||
sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id=2 AND role='main'")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(candidates(&mut conn, false).await[1].artist_name, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FURUMUSIC_LASTFM_TEST_DATABASE_URL"]
|
||||
async fn database_pagination_does_not_limit_runs_or_repeat_failures() {
|
||||
let mut conn = database_fixture().await;
|
||||
sqlx::query("INSERT INTO furumusic__track (id, title) SELECT n, 'Track ' || n FROM generate_series(5, 1105) n")
|
||||
.execute(&mut conn).await.unwrap();
|
||||
sqlx::query("INSERT INTO furumusic__track_artist SELECT id, 1, 'main', 0 FROM furumusic__track WHERE id>=5")
|
||||
.execute(&mut conn).await.unwrap();
|
||||
let mut after_id = 0;
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let page = fetch_candidates(
|
||||
&mut conn,
|
||||
after_id,
|
||||
1105,
|
||||
LastfmPopularityOptions::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
assert!(page.len() <= TRACK_PAGE_SIZE as usize);
|
||||
for row in page {
|
||||
after_id = row.id;
|
||||
// Simulate failed requests: all candidates remain pending in the DB.
|
||||
mark_pending(&mut conn, &[row.id]).await.unwrap();
|
||||
seen.push(row.id);
|
||||
}
|
||||
}
|
||||
assert_eq!(seen.len(), 1103);
|
||||
assert!(seen.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
assert_eq!(seen.last(), Some(&1105));
|
||||
let bounded = fetch_candidates(&mut conn, 1000, 1010, LastfmPopularityOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids(&bounded), (1001..=1010).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FURUMUSIC_LASTFM_TEST_DATABASE_URL"]
|
||||
async fn database_result_and_history_are_atomic() {
|
||||
let mut conn = database_fixture().await;
|
||||
let track = candidates(&mut conn, false).await.remove(0);
|
||||
sqlx::query("ALTER TABLE furumusic__track_popularity_history ADD CHECK (playcount > 100)")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
save_outcome(
|
||||
&mut conn,
|
||||
&track,
|
||||
&LookupOutcome::Found {
|
||||
listeners: 5,
|
||||
playcount: 10
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let state: (bool, Option<f64>) = sqlx::query_as(
|
||||
"SELECT lastfm_checked_at IS NULL, lastfm_rating FROM furumusic__track WHERE id=2",
|
||||
)
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state, (true, None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FURUMUSIC_LASTFM_TEST_DATABASE_URL"]
|
||||
async fn database_interrupted_full_refresh_remains_pending_without_losing_statistics() {
|
||||
let mut conn = database_fixture().await;
|
||||
let page = candidates(&mut conn, true).await;
|
||||
let track_ids = ids(&page);
|
||||
mark_pending(&mut conn, &track_ids).await.unwrap();
|
||||
assert_eq!(track_ids, vec![1, 2, 4]);
|
||||
assert_eq!(ids(&candidates(&mut conn, false).await), track_ids);
|
||||
let state: (bool, Option<f64>) = sqlx::query_as(
|
||||
"SELECT lastfm_checked_at IS NULL, lastfm_rating FROM furumusic__track WHERE id=1",
|
||||
)
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state, (true, Some(0.0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FURUMUSIC_LASTFM_TEST_DATABASE_URL"]
|
||||
async fn database_lock_is_shared_by_light_and_full_runs() {
|
||||
let url = std::env::var("FURUMUSIC_LASTFM_TEST_DATABASE_URL").unwrap();
|
||||
let mut first = PgConnection::connect(&url).await.unwrap();
|
||||
let mut second = PgConnection::connect(&url).await.unwrap();
|
||||
assert!(try_lock(&mut first).await.unwrap());
|
||||
assert!(!try_lock(&mut second).await.unwrap());
|
||||
first.close().await.unwrap();
|
||||
assert!(try_lock(&mut second).await.unwrap());
|
||||
}
|
||||
+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"),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE furumusic__track
|
||||
ADD COLUMN IF NOT EXISTS lastfm_checked_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS lastfm_lookup_title TEXT,
|
||||
ADD COLUMN IF NOT EXISTS lastfm_lookup_artist TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_artist_lastfm_lookup
|
||||
ON furumusic__track_artist (track_id, position, artist_id)
|
||||
WHERE role <> 'featuring';
|
||||
|
||||
-- Preserve existing observations, including valid zero-valued popularity.
|
||||
-- Legacy misses cannot be backfilled because they were only written to logs.
|
||||
UPDATE furumusic__track t
|
||||
SET lastfm_checked_at = CURRENT_TIMESTAMP,
|
||||
lastfm_lookup_title = t.title::text,
|
||||
lastfm_lookup_artist = (
|
||||
SELECT a.name::text
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||
ORDER BY ta.position, ta.artist_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE t.lastfm_checked_at IS NULL
|
||||
AND (t.lastfm_updated_at IS NOT NULL
|
||||
OR t.lastfm_rating IS NOT NULL
|
||||
OR t.lastfm_listeners IS NOT NULL
|
||||
OR t.lastfm_playcount IS NOT NULL);
|
||||
+103
-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,
|
||||
@@ -2823,6 +2739,105 @@ pub mod db_migrations {
|
||||
&[Operation::custom(add_playback_coordination_columns).build()];
|
||||
}
|
||||
|
||||
// -- M0049: persist Last.fm lookup results and their metadata identity --
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn add_lastfm_lookup_state(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
// Cot prepares raw queries, so execute these static DDL statements
|
||||
// separately rather than sending a multi-statement prepared query.
|
||||
for statement in include_str!("lastfm_popularity_lookup.sql").split(';') {
|
||||
if !statement.trim().is_empty() {
|
||||
ctx.db.raw(statement).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0049AddLastfmLookupState;
|
||||
|
||||
impl migrations::Migration for M0049AddLastfmLookupState {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0049_add_lastfm_lookup_state";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0048_add_playback_coordination_columns",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_lastfm_lookup_state).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_metadata_fix_history(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db.raw("CREATE TABLE furumusic__metadata_fix_history (id BIGSERIAL PRIMARY KEY, instruction TEXT NOT NULL, before_json TEXT NOT NULL, after_json TEXT NOT NULL, created_release_ids BIGINT[] NOT NULL DEFAULT '{}', created_by BIGINT NOT NULL, created_at VARCHAR(32) NOT NULL, undone_by BIGINT, undone_at VARCHAR(32))").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0050CreateMetadataFixHistory;
|
||||
|
||||
impl migrations::Migration for M0050CreateMetadataFixHistory {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0050_create_metadata_fix_history";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0049_add_lastfm_lookup_state",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_metadata_fix_history).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn add_listen_statistics_indexes(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db.raw("CREATE INDEX IF NOT EXISTS idx_listen_event_time ON furumusic__listen_event (started_at_ms DESC)").await?;
|
||||
ctx.db.raw("CREATE INDEX IF NOT EXISTS idx_listen_event_content_plays ON furumusic__listen_event (content_id) WHERE qualified").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0051AddListenStatisticsIndexes;
|
||||
impl migrations::Migration for M0051AddListenStatisticsIndexes {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0051_add_listen_statistics_indexes";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0050_create_metadata_fix_history",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_listen_statistics_indexes).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn add_import_playlist_destinations(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db.raw("ALTER TABLE furumusic__torrent_session ADD COLUMN target_playlist_id BIGINT REFERENCES furumusic__playlist(id) ON DELETE SET NULL").await?;
|
||||
ctx.db.raw("ALTER TABLE furumusic__local_upload ADD COLUMN target_playlist_id BIGINT REFERENCES furumusic__playlist(id) ON DELETE SET NULL").await?;
|
||||
ctx.db.raw("CREATE TABLE furumusic__import_playlist_target (user_id BIGINT NOT NULL, playlist_id BIGINT NOT NULL REFERENCES furumusic__playlist(id) ON DELETE CASCADE, input_path TEXT NOT NULL, source_sha256 TEXT NOT NULL, media_file_id BIGINT REFERENCES furumusic__media_file(id) ON DELETE SET NULL, fulfilled_at TEXT, PRIMARY KEY(user_id,input_path))").await?;
|
||||
ctx.db.raw("CREATE INDEX idx_import_playlist_pending_hash ON furumusic__import_playlist_target(source_sha256) WHERE fulfilled_at IS NULL").await?;
|
||||
Ok(())
|
||||
}
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0052AddImportPlaylistDestinations;
|
||||
impl migrations::Migration for M0052AddImportPlaylistDestinations {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0052_add_import_playlist_destinations";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0051_add_listen_statistics_indexes",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_import_playlist_destinations).build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -2862,5 +2877,9 @@ pub mod db_migrations {
|
||||
&M0046CreateLocalUploadHistory,
|
||||
&M0047CreateYouTubeImportMediaLinks,
|
||||
&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,110 +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" %}
|
||||
<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() == "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 %}
|
||||
+681
-58
File diff suppressed because it is too large.
Load diff
@@ -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,104 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const template = readFileSync(`${__dirname}/../templates/admin/v2.html`, 'utf8');
|
||||
const script = template.match(/<script>\s*(function adminV2\(\)[\s\S]*?)<\/script>/)[1];
|
||||
|
||||
function createApp() {
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(script, context);
|
||||
const app = context.adminV2();
|
||||
app.showToast = message => { app.toastMessage = message; };
|
||||
app.loadReviews = async () => { app.reviewsReloaded = true; };
|
||||
app.loadLibrary = async () => { app.libraryReloaded = true; };
|
||||
return app;
|
||||
}
|
||||
|
||||
test('approves individually selected reviews and reports partial failures', async () => {
|
||||
const app = createApp();
|
||||
app.toggleReview({ id: 12, status: 'pending' });
|
||||
app.toggleReview({ id: 34, status: 'pending' });
|
||||
app.toggleReview({ id: 56, status: 'failed' });
|
||||
assert.equal(app.selectedPendingReviewCount(), 2);
|
||||
app.request = async (url, options) => {
|
||||
assert.equal(url, '/admin/v2/api/reviews/bulk');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(options.body), {
|
||||
action: 'approve', mode: 'ids', ids: [12, 34, 56],
|
||||
filter: { status: null, search: null }
|
||||
});
|
||||
assert.equal(app.reviewBulkBusy, true);
|
||||
return { ok: false, affected: 1, failed: 1, skipped: 1 };
|
||||
};
|
||||
await app.bulkReviews('approve');
|
||||
assert.equal(app.toastMessage, '1 review(s) approved · 1 failed · 1 skipped');
|
||||
assert.equal(app.selectedReviewCount(), 0);
|
||||
assert.equal(app.reviewsReloaded, true);
|
||||
assert.equal(app.libraryReloaded, true);
|
||||
assert.equal(app.reviewBulkBusy, false);
|
||||
});
|
||||
|
||||
test('select all approves the whole searched filter across pages', async () => {
|
||||
const app = createApp();
|
||||
app.reviewFilter = { status: 'pending', search: 'Album' };
|
||||
app.reviews = { total: 320, items: [{ id: 1, status: 'pending' }] };
|
||||
app.selectReviewFilter();
|
||||
assert.equal(app.selectedPendingReviewCount(), 320);
|
||||
app.request = async (_url, options) => {
|
||||
assert.deepEqual(JSON.parse(options.body), {
|
||||
action: 'approve', mode: 'filter', ids: [],
|
||||
filter: { status: 'pending', search: 'Album' }
|
||||
});
|
||||
return { affected: 320, failed: 0, skipped: 0 };
|
||||
};
|
||||
await app.bulkReviews('approve');
|
||||
assert.equal(app.toastMessage, '320 review(s) approved · 0 failed · 0 skipped');
|
||||
});
|
||||
|
||||
test('mixed status filter enables approval only when pending reviews exist', () => {
|
||||
const app = createApp();
|
||||
app.reviews = { total: 10, status_counts: [{ status: 'pending', count: 3 }, { status: 'queued', count: 7 }] };
|
||||
app.selectReviewFilter();
|
||||
assert.equal(app.selectedPendingReviewCount(), 3);
|
||||
app.reviewFilter.status = 'queued';
|
||||
assert.equal(app.selectedPendingReviewCount(), 0);
|
||||
});
|
||||
|
||||
test('empty and non-pending selections do not submit approval', async () => {
|
||||
const app = createApp();
|
||||
app.request = async () => { assert.fail('unexpected request'); };
|
||||
await app.bulkReviews('approve');
|
||||
app.toggleReview({ id: 1, status: 'queued' });
|
||||
await app.bulkReviews('approve');
|
||||
});
|
||||
|
||||
test('blocks duplicate bulk submissions while approval is running', async () => {
|
||||
const app = createApp();
|
||||
app.toggleReview({ id: 1, status: 'pending' });
|
||||
let finish;
|
||||
let calls = 0;
|
||||
app.request = async () => {
|
||||
calls += 1;
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
};
|
||||
const approval = app.bulkReviews('approve');
|
||||
await app.bulkReviews('approve');
|
||||
await app.bulkReviews('requeue');
|
||||
assert.equal(calls, 1);
|
||||
finish({ affected: 1 });
|
||||
await approval;
|
||||
assert.equal(app.reviewBulkBusy, false);
|
||||
});
|
||||
|
||||
test('request failure retains selection and restores bulk controls', async () => {
|
||||
const app = createApp();
|
||||
app.toggleReview({ id: 1, status: 'pending' });
|
||||
app.request = async () => { throw new Error('Connection lost'); };
|
||||
await app.bulkReviews('approve');
|
||||
assert.equal(app.selectedReviewCount(), 1);
|
||||
assert.equal(app.toastMessage, 'Connection lost');
|
||||
assert.equal(app.reviewBulkBusy, false);
|
||||
assert.equal(app.reviewBulkAction, null);
|
||||
});
|
||||
@@ -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