Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65da460c0c | |||
| 538a6f6abf | |||
| 04c30bc4b8 | |||
| c0342ed987 | |||
| 4b8797bb2e | |||
| d425bf3087 | |||
| 82923c871e | |||
| 3878d746d2 | |||
| 31ae57a5a3 | |||
| 16de1fb711 |
Generated
+117
-1
@@ -501,6 +501,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -599,6 +605,12 @@ version = "0.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24"
|
checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -1304,6 +1316,15 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ff"
|
name = "ff"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -1397,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.10"
|
version = "0.1.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1407,6 +1428,7 @@ dependencies = [
|
|||||||
"croner",
|
"croner",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"id3",
|
"id3",
|
||||||
|
"image",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"openidconnect",
|
"openidconnect",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1588,6 +1610,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.32.3"
|
version = "0.32.3"
|
||||||
@@ -2026,6 +2058,34 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png",
|
||||||
|
"zune-core",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -2522,6 +2582,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multer"
|
name = "multer"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@@ -3000,6 +3070,19 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -3067,6 +3150,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quanta"
|
name = "quanta"
|
||||||
version = "0.12.6"
|
version = "0.12.6"
|
||||||
@@ -3082,6 +3171,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.37.5"
|
version = "0.37.5"
|
||||||
@@ -5142,6 +5237,12 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -5690,3 +5791,18 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core",
|
||||||
|
]
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.10"
|
version = "0.1.19"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ symphonia = { version = "0.5", default-features = false, features = ["mp3","aac"
|
|||||||
id3 = "1"
|
id3 = "1"
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
tokio-cron-scheduler = "0.15"
|
tokio-cron-scheduler = "0.15"
|
||||||
|
|||||||
+55
-4
@@ -20,8 +20,8 @@ use crate::i18n::I18n;
|
|||||||
use crate::scheduler::{JobRegistry, SchedulerHandle};
|
use crate::scheduler::{JobRegistry, SchedulerHandle};
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
use views::{
|
use views::{
|
||||||
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewsBulkForm,
|
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewApproveForm,
|
||||||
SetImageBody, SetupForm, UploadImageBody, UserForm,
|
ReviewsBulkForm, SetImageBody, SetupForm, UploadImageBody, UserForm,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -227,6 +227,35 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_reviews_bulk",
|
"admin_v2_reviews_bulk",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/reviews/{id}/approve",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathId>,
|
||||||
|
json: Json<v2::ReviewEditDto>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
v2::approve_review(session, db, pg_pool, path.0.id, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_review_approve",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/jobs",
|
"/v2/api/jobs",
|
||||||
{
|
{
|
||||||
@@ -264,6 +293,27 @@ impl App for AdminApp {
|
|||||||
}),
|
}),
|
||||||
"admin_v2_job_run",
|
"admin_v2_job_run",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/settings",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::settings(session, db).await
|
||||||
|
})
|
||||||
|
.post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::UpdateSettingsRequest>| async move {
|
||||||
|
v2::update_settings(session, db, json).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"admin_v2_settings",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/settings/probe",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::settings_probe(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_settings_probe",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/jobs/{name}/toggle",
|
"/v2/api/jobs/{name}/toggle",
|
||||||
cot::router::method::post({
|
cot::router::method::post({
|
||||||
@@ -1027,7 +1077,8 @@ impl App for AdminApp {
|
|||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
move |session: Session, db: Database, path: Path<PathId>| {
|
move |session: Session, db: Database, path: Path<PathId>,
|
||||||
|
form: RequestForm<ReviewApproveForm>| {
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
@@ -1043,7 +1094,7 @@ impl App for AdminApp {
|
|||||||
.await
|
.await
|
||||||
.expect("admin pool")
|
.expect("admin pool")
|
||||||
}).await;
|
}).await;
|
||||||
views::review_approve(admin, &config, &db, pg_pool, path.0.id).await
|
views::review_approve(admin, &config, &db, pg_pool, path.0.id, form).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
+388
-1
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::{Database, Model};
|
||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::http::StatusCode;
|
use cot::http::StatusCode;
|
||||||
use cot::http::header::CONTENT_TYPE;
|
use cot::http::header::CONTENT_TYPE;
|
||||||
@@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sqlx::{PgPool, Postgres, QueryBuilder};
|
use sqlx::{PgPool, Postgres, QueryBuilder};
|
||||||
|
|
||||||
use super::BUILD_INFO;
|
use super::BUILD_INFO;
|
||||||
|
use crate::agent;
|
||||||
use crate::auth::{self, AuthenticatedUser, Role};
|
use crate::auth::{self, AuthenticatedUser, Role};
|
||||||
|
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
||||||
use crate::i18n::{I18n, Translations};
|
use crate::i18n::{I18n, Translations};
|
||||||
use crate::scheduler::{JobRegistry, ScheduledJob};
|
use crate::scheduler::{JobRegistry, ScheduledJob};
|
||||||
|
|
||||||
@@ -154,10 +156,24 @@ struct ReviewDto {
|
|||||||
token_count: Option<i64>,
|
token_count: Option<i64>,
|
||||||
tags: Vec<TagDto>,
|
tags: Vec<TagDto>,
|
||||||
error_message: Option<String>,
|
error_message: Option<String>,
|
||||||
|
normalized: ReviewEditDto,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct ReviewEditDto {
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
year: String,
|
||||||
|
track_number: String,
|
||||||
|
genre: String,
|
||||||
|
featured_artists: String,
|
||||||
|
release_type: String,
|
||||||
|
notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct JobDto {
|
struct JobDto {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -214,6 +230,95 @@ struct MutationResponse {
|
|||||||
affected: u64,
|
affected: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AdminSettingsDto {
|
||||||
|
values: AdminSettingsValues,
|
||||||
|
sources: AdminSettingsSources,
|
||||||
|
lastfm_api_key_configured: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
struct AdminSettingsValues {
|
||||||
|
auth_password_enabled: bool,
|
||||||
|
auth_sso_enabled: bool,
|
||||||
|
oidc_button_text: String,
|
||||||
|
oidc_issuer: String,
|
||||||
|
oidc_client_id: String,
|
||||||
|
oidc_client_secret: String,
|
||||||
|
oidc_admin_groups: String,
|
||||||
|
oidc_user_groups: String,
|
||||||
|
swagger_enabled: bool,
|
||||||
|
lastfm_api_key: String,
|
||||||
|
agent_enabled: bool,
|
||||||
|
agent_inbox_dir: String,
|
||||||
|
agent_storage_dir: String,
|
||||||
|
agent_llm_url: String,
|
||||||
|
agent_llm_model: String,
|
||||||
|
agent_llm_auth: String,
|
||||||
|
agent_confidence_threshold: String,
|
||||||
|
agent_context_limit: String,
|
||||||
|
agent_concurrency: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
struct AdminSettingsSources {
|
||||||
|
auth_password_enabled: &'static str,
|
||||||
|
auth_sso_enabled: &'static str,
|
||||||
|
oidc_button_text: &'static str,
|
||||||
|
oidc_issuer: &'static str,
|
||||||
|
oidc_client_id: &'static str,
|
||||||
|
oidc_client_secret: &'static str,
|
||||||
|
oidc_admin_groups: &'static str,
|
||||||
|
oidc_user_groups: &'static str,
|
||||||
|
swagger_enabled: &'static str,
|
||||||
|
lastfm_api_key: &'static str,
|
||||||
|
agent_enabled: &'static str,
|
||||||
|
agent_inbox_dir: &'static str,
|
||||||
|
agent_storage_dir: &'static str,
|
||||||
|
agent_llm_url: &'static str,
|
||||||
|
agent_llm_model: &'static str,
|
||||||
|
agent_llm_auth: &'static str,
|
||||||
|
agent_confidence_threshold: &'static str,
|
||||||
|
agent_context_limit: &'static str,
|
||||||
|
agent_concurrency: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UpdateSettingsRequest {
|
||||||
|
auth_password_enabled: bool,
|
||||||
|
auth_sso_enabled: bool,
|
||||||
|
oidc_button_text: String,
|
||||||
|
oidc_issuer: String,
|
||||||
|
oidc_client_id: String,
|
||||||
|
oidc_client_secret: String,
|
||||||
|
oidc_admin_groups: String,
|
||||||
|
oidc_user_groups: String,
|
||||||
|
swagger_enabled: bool,
|
||||||
|
lastfm_api_key: String,
|
||||||
|
agent_enabled: bool,
|
||||||
|
agent_inbox_dir: String,
|
||||||
|
agent_storage_dir: String,
|
||||||
|
agent_llm_url: String,
|
||||||
|
agent_llm_model: String,
|
||||||
|
agent_llm_auth: String,
|
||||||
|
agent_confidence_threshold: String,
|
||||||
|
agent_context_limit: String,
|
||||||
|
agent_concurrency: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AgentProbeDto {
|
||||||
|
status: String,
|
||||||
|
ok: bool,
|
||||||
|
model_intro: String,
|
||||||
|
model_name: String,
|
||||||
|
prompt_tokens: Option<u32>,
|
||||||
|
completion_tokens: Option<u32>,
|
||||||
|
tokens_per_sec: Option<f64>,
|
||||||
|
latency_ms: u64,
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct LibraryOverviewDto {
|
struct LibraryOverviewDto {
|
||||||
artists: i64,
|
artists: i64,
|
||||||
@@ -442,6 +547,65 @@ pub async fn bulk_reviews(
|
|||||||
Json(BulkReviewsResponse { ok: true, affected }).into_response()
|
Json(BulkReviewsResponse { ok: true, affected }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn approve_review(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &PgPool,
|
||||||
|
review_id: i64,
|
||||||
|
Json(body): Json<ReviewEditDto>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut review = crate::scheduler::PendingReview::get_by_id(&db, review_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| cot::Error::internal("review not found"))?;
|
||||||
|
let normalized = normalized_from_review_edit(&body);
|
||||||
|
let result_json = serde_json::to_string(&normalized)
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?;
|
||||||
|
review
|
||||||
|
.set_result_json(&db, result_json)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let context: serde_json::Value =
|
||||||
|
serde_json::from_str(review.context_json_str()).unwrap_or_default();
|
||||||
|
let input_path = review.input_path_str().to_owned();
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
|
||||||
|
|
||||||
|
match crate::jobs::inbox_process::finalize_approved(
|
||||||
|
&db,
|
||||||
|
pool,
|
||||||
|
&live_config,
|
||||||
|
&input_path,
|
||||||
|
&normalized,
|
||||||
|
&context,
|
||||||
|
&live_config.agent_storage_dir,
|
||||||
|
model_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = review.set_approved(&db).await;
|
||||||
|
Json(serde_json::json!({ "ok": true })).into_response()
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(?error, "review approval failed");
|
||||||
|
let _ = review.set_rejected(&db).await;
|
||||||
|
Ok(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"review approval failed",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn jobs(
|
pub async fn jobs(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -458,6 +622,163 @@ pub async fn jobs(
|
|||||||
Json(jobs).into_response()
|
Json(jobs).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn settings(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let (config, sources) = AppConfig::load_with_db(&db).await;
|
||||||
|
Json(settings_dto(config, sources)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_settings(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Json(body): Json<UpdateSettingsRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let fields = [
|
||||||
|
(
|
||||||
|
"auth_password_enabled",
|
||||||
|
body.auth_password_enabled.to_string(),
|
||||||
|
),
|
||||||
|
("auth_sso_enabled", body.auth_sso_enabled.to_string()),
|
||||||
|
("oidc_button_text", body.oidc_button_text.trim().to_string()),
|
||||||
|
("oidc_issuer", body.oidc_issuer.trim().to_string()),
|
||||||
|
("oidc_client_id", body.oidc_client_id.trim().to_string()),
|
||||||
|
(
|
||||||
|
"oidc_client_secret",
|
||||||
|
body.oidc_client_secret.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oidc_admin_groups",
|
||||||
|
body.oidc_admin_groups.trim().to_string(),
|
||||||
|
),
|
||||||
|
("oidc_user_groups", body.oidc_user_groups.trim().to_string()),
|
||||||
|
("swagger_enabled", body.swagger_enabled.to_string()),
|
||||||
|
("lastfm_api_key", body.lastfm_api_key.trim().to_string()),
|
||||||
|
("agent_enabled", body.agent_enabled.to_string()),
|
||||||
|
("agent_inbox_dir", body.agent_inbox_dir.trim().to_string()),
|
||||||
|
(
|
||||||
|
"agent_storage_dir",
|
||||||
|
body.agent_storage_dir.trim().to_string(),
|
||||||
|
),
|
||||||
|
("agent_llm_url", body.agent_llm_url.trim().to_string()),
|
||||||
|
("agent_llm_model", body.agent_llm_model.trim().to_string()),
|
||||||
|
("agent_llm_auth", body.agent_llm_auth.trim().to_string()),
|
||||||
|
(
|
||||||
|
"agent_confidence_threshold",
|
||||||
|
body.agent_confidence_threshold.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"agent_context_limit",
|
||||||
|
body.agent_context_limit.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"agent_concurrency",
|
||||||
|
body.agent_concurrency.trim().to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (key, value) in fields {
|
||||||
|
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||||
|
entry
|
||||||
|
.save(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Json(serde_json::json!({ "ok": true })).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn settings_probe(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let probe = if config.agent_enabled && !config.agent_llm_url.is_empty() {
|
||||||
|
agent::probe_llm(
|
||||||
|
&config.agent_llm_url,
|
||||||
|
&config.agent_llm_model,
|
||||||
|
&config.agent_llm_auth,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
agent::AgentProbeResult::default()
|
||||||
|
};
|
||||||
|
let status = if !config.agent_enabled {
|
||||||
|
"disabled"
|
||||||
|
} else if config.agent_llm_url.is_empty() {
|
||||||
|
"not_configured"
|
||||||
|
} else if probe.ok {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"error"
|
||||||
|
};
|
||||||
|
Json(AgentProbeDto {
|
||||||
|
status: status.to_string(),
|
||||||
|
ok: probe.ok,
|
||||||
|
model_intro: probe.model_intro,
|
||||||
|
model_name: probe.model_name,
|
||||||
|
prompt_tokens: probe.prompt_tokens,
|
||||||
|
completion_tokens: probe.completion_tokens,
|
||||||
|
tokens_per_sec: probe.tokens_per_sec,
|
||||||
|
latency_ms: probe.latency_ms,
|
||||||
|
error: probe.error,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||||
|
AdminSettingsDto {
|
||||||
|
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
|
||||||
|
values: AdminSettingsValues {
|
||||||
|
auth_password_enabled: config.auth_password_enabled,
|
||||||
|
auth_sso_enabled: config.auth_sso_enabled,
|
||||||
|
oidc_button_text: config.oidc_button_text,
|
||||||
|
oidc_issuer: config.oidc_issuer,
|
||||||
|
oidc_client_id: config.oidc_client_id,
|
||||||
|
oidc_client_secret: config.oidc_client_secret,
|
||||||
|
oidc_admin_groups: config.oidc_admin_groups,
|
||||||
|
oidc_user_groups: config.oidc_user_groups,
|
||||||
|
swagger_enabled: config.swagger_enabled,
|
||||||
|
lastfm_api_key: config.lastfm_api_key,
|
||||||
|
agent_enabled: config.agent_enabled,
|
||||||
|
agent_inbox_dir: config.agent_inbox_dir,
|
||||||
|
agent_storage_dir: config.agent_storage_dir,
|
||||||
|
agent_llm_url: config.agent_llm_url,
|
||||||
|
agent_llm_model: config.agent_llm_model,
|
||||||
|
agent_llm_auth: config.agent_llm_auth,
|
||||||
|
agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
|
||||||
|
agent_context_limit: config.agent_context_limit.to_string(),
|
||||||
|
agent_concurrency: config.agent_concurrency.to_string(),
|
||||||
|
},
|
||||||
|
sources: AdminSettingsSources {
|
||||||
|
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||||
|
auth_sso_enabled: sources.auth_sso_enabled.code(),
|
||||||
|
oidc_button_text: sources.oidc_button_text.code(),
|
||||||
|
oidc_issuer: sources.oidc_issuer.code(),
|
||||||
|
oidc_client_id: sources.oidc_client_id.code(),
|
||||||
|
oidc_client_secret: sources.oidc_client_secret.code(),
|
||||||
|
oidc_admin_groups: sources.oidc_admin_groups.code(),
|
||||||
|
oidc_user_groups: sources.oidc_user_groups.code(),
|
||||||
|
swagger_enabled: sources.swagger_enabled.code(),
|
||||||
|
lastfm_api_key: sources.lastfm_api_key.code(),
|
||||||
|
agent_enabled: sources.agent_enabled.code(),
|
||||||
|
agent_inbox_dir: sources.agent_inbox_dir.code(),
|
||||||
|
agent_storage_dir: sources.agent_storage_dir.code(),
|
||||||
|
agent_llm_url: sources.agent_llm_url.code(),
|
||||||
|
agent_llm_model: sources.agent_llm_model.code(),
|
||||||
|
agent_llm_auth: sources.agent_llm_auth.code(),
|
||||||
|
agent_confidence_threshold: sources.agent_confidence_threshold.code(),
|
||||||
|
agent_context_limit: sources.agent_context_limit.code(),
|
||||||
|
agent_concurrency: sources.agent_concurrency.code(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_job(
|
pub async fn run_job(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -961,6 +1282,11 @@ fn review_dto(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||||
.and_then(|value| value.get("confidence").and_then(|v| v.as_f64()));
|
.and_then(|value| value.get("confidence").and_then(|v| v.as_f64()));
|
||||||
|
let normalized = row
|
||||||
|
.result_json
|
||||||
|
.as_deref()
|
||||||
|
.map(review_edit_dto_from_json)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
ReviewDto {
|
ReviewDto {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -976,11 +1302,72 @@ fn review_dto(
|
|||||||
token_count: stat.map(|s| s.prompt_tokens + s.completion_tokens),
|
token_count: stat.map(|s| s.prompt_tokens + s.completion_tokens),
|
||||||
tags,
|
tags,
|
||||||
error_message: row.error_message,
|
error_message: row.error_message,
|
||||||
|
normalized,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn review_edit_dto_from_json(result_json: &str) -> ReviewEditDto {
|
||||||
|
let Ok(normalized) = serde_json::from_str::<crate::agent::dto::NormalizedFields>(result_json)
|
||||||
|
else {
|
||||||
|
return ReviewEditDto::default();
|
||||||
|
};
|
||||||
|
ReviewEditDto {
|
||||||
|
title: normalized.title.unwrap_or_default(),
|
||||||
|
artist: normalized.artist.unwrap_or_default(),
|
||||||
|
album: normalized.album.unwrap_or_default(),
|
||||||
|
year: normalized.year.map(|v| v.to_string()).unwrap_or_default(),
|
||||||
|
track_number: normalized
|
||||||
|
.track_number
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
genre: normalized.genre.unwrap_or_default(),
|
||||||
|
featured_artists: normalized.featured_artists.join(", "),
|
||||||
|
release_type: normalized
|
||||||
|
.release_type
|
||||||
|
.unwrap_or_else(|| "album".to_owned()),
|
||||||
|
notes: normalized.notes.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_trimmed(value: &str) -> Option<String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_optional_i32(value: &str) -> Option<i32> {
|
||||||
|
value.trim().parse::<i32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_featured_artists(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_from_review_edit(edit: &ReviewEditDto) -> crate::agent::dto::NormalizedFields {
|
||||||
|
crate::agent::dto::NormalizedFields {
|
||||||
|
title: optional_trimmed(&edit.title),
|
||||||
|
artist: optional_trimmed(&edit.artist),
|
||||||
|
album: optional_trimmed(&edit.album),
|
||||||
|
year: parse_optional_i32(&edit.year),
|
||||||
|
track_number: parse_optional_i32(&edit.track_number),
|
||||||
|
genre: optional_trimmed(&edit.genre),
|
||||||
|
featured_artists: parse_featured_artists(&edit.featured_artists),
|
||||||
|
release_type: optional_trimmed(&edit.release_type).or_else(|| Some("album".to_owned())),
|
||||||
|
confidence: Some(1.0),
|
||||||
|
notes: optional_trimmed(&edit.notes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn media_tags(row: &ReviewMediaRow) -> Vec<TagDto> {
|
fn media_tags(row: &ReviewMediaRow) -> Vec<TagDto> {
|
||||||
let mut tags = Vec::new();
|
let mut tags = Vec::new();
|
||||||
if let Some(format) = row.audio_format.as_deref().filter(|s| !s.trim().is_empty()) {
|
if let Some(format) = row.audio_format.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||||
|
|||||||
+113
-11
@@ -799,7 +799,7 @@ pub async fn artists_edit(
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|mf| format!("/api/player/cover/{}", mf.id_val())),
|
.map(|mf| format!("/api/player/cover/{}/large", mf.id_val())),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -879,7 +879,7 @@ pub async fn artists_available_covers(
|
|||||||
covers.push(AvailableCover {
|
covers.push(AvailableCover {
|
||||||
media_file_id: cover_fid,
|
media_file_id: cover_fid,
|
||||||
release_title: release.title_str().to_owned(),
|
release_title: release.title_str().to_owned(),
|
||||||
cover_url: format!("/api/player/cover/{cover_fid}"),
|
cover_url: format!("/api/player/cover/{cover_fid}/medium"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1794,12 +1794,104 @@ struct ReviewDetailTemplate {
|
|||||||
user_name: String,
|
user_name: String,
|
||||||
user_role: String,
|
user_role: String,
|
||||||
review: PendingReview,
|
review: PendingReview,
|
||||||
|
edit: ReviewEditFields,
|
||||||
|
release_types: &'static [(&'static str, &'static str, &'static str)],
|
||||||
|
lang_code: &'static str,
|
||||||
context_pretty: String,
|
context_pretty: String,
|
||||||
result_pretty: String,
|
result_pretty: String,
|
||||||
error_message: String,
|
error_message: String,
|
||||||
stats: Option<scheduler::ProcessingStats>,
|
stats: Option<scheduler::ProcessingStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ReviewEditFields {
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
year: String,
|
||||||
|
track_number: String,
|
||||||
|
genre: String,
|
||||||
|
featured_artists: String,
|
||||||
|
release_type: String,
|
||||||
|
notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Form)]
|
||||||
|
pub struct ReviewApproveForm {
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
year: String,
|
||||||
|
track_number: String,
|
||||||
|
genre: String,
|
||||||
|
featured_artists: String,
|
||||||
|
release_type: String,
|
||||||
|
notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_trimmed(value: &str) -> Option<String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_optional_i32(value: &str) -> Option<i32> {
|
||||||
|
value.trim().parse::<i32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_featured_artists(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_fields_from_normalized(
|
||||||
|
normalized: &crate::agent::dto::NormalizedFields,
|
||||||
|
) -> ReviewEditFields {
|
||||||
|
ReviewEditFields {
|
||||||
|
title: normalized.title.clone().unwrap_or_default(),
|
||||||
|
artist: normalized.artist.clone().unwrap_or_default(),
|
||||||
|
album: normalized.album.clone().unwrap_or_default(),
|
||||||
|
year: normalized.year.map(|v| v.to_string()).unwrap_or_default(),
|
||||||
|
track_number: normalized
|
||||||
|
.track_number
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
genre: normalized.genre.clone().unwrap_or_default(),
|
||||||
|
featured_artists: normalized.featured_artists.join(", "),
|
||||||
|
release_type: normalized
|
||||||
|
.release_type
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "album".to_owned()),
|
||||||
|
notes: normalized.notes.clone().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_from_result_json(result_json: &str) -> crate::agent::dto::NormalizedFields {
|
||||||
|
serde_json::from_str(result_json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_from_review_form(form: &ReviewApproveForm) -> crate::agent::dto::NormalizedFields {
|
||||||
|
crate::agent::dto::NormalizedFields {
|
||||||
|
title: optional_trimmed(&form.title),
|
||||||
|
artist: optional_trimmed(&form.artist),
|
||||||
|
album: optional_trimmed(&form.album),
|
||||||
|
year: parse_optional_i32(&form.year),
|
||||||
|
track_number: parse_optional_i32(&form.track_number),
|
||||||
|
genre: optional_trimmed(&form.genre),
|
||||||
|
featured_artists: parse_featured_artists(&form.featured_artists),
|
||||||
|
release_type: optional_trimmed(&form.release_type).or_else(|| Some("album".to_owned())),
|
||||||
|
confidence: Some(1.0),
|
||||||
|
notes: optional_trimmed(&form.notes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn review_detail(
|
pub async fn review_detail(
|
||||||
admin: AuthenticatedUser,
|
admin: AuthenticatedUser,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
@@ -1830,12 +1922,17 @@ pub async fn review_detail(
|
|||||||
let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id)
|
let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
|
let normalized = normalized_from_result_json(review.result_json_str());
|
||||||
|
let edit = edit_fields_from_normalized(&normalized);
|
||||||
|
|
||||||
let template = ReviewDetailTemplate {
|
let template = ReviewDetailTemplate {
|
||||||
t: i18n.t,
|
t: i18n.t,
|
||||||
user_name: admin.name,
|
user_name: admin.name,
|
||||||
user_role: admin.role.code().to_owned(),
|
user_role: admin.role.code().to_owned(),
|
||||||
review,
|
review,
|
||||||
|
edit,
|
||||||
|
release_types: RELEASE_TYPES,
|
||||||
|
lang_code: i18n.t.lang.code(),
|
||||||
context_pretty,
|
context_pretty,
|
||||||
result_pretty,
|
result_pretty,
|
||||||
error_message,
|
error_message,
|
||||||
@@ -1850,24 +1947,29 @@ pub async fn review_approve(
|
|||||||
db: &Database,
|
db: &Database,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
review_id: i64,
|
review_id: i64,
|
||||||
|
form: RequestForm<ReviewApproveForm>,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
let mut review = PendingReview::get_by_id(db, review_id)
|
let mut review = PendingReview::get_by_id(db, review_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||||
.ok_or_else(|| cot::Error::internal("review not found"))?;
|
.ok_or_else(|| cot::Error::internal("review not found"))?;
|
||||||
|
|
||||||
let result_str = review.result_json_str().to_owned();
|
let RequestForm(form_result) = form;
|
||||||
|
let normalized = match form_result {
|
||||||
|
FormResult::Ok(data) => normalized_from_review_form(&data),
|
||||||
|
FormResult::ValidationError(_) => {
|
||||||
|
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result_str = serde_json::to_string(&normalized)
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?;
|
||||||
|
review
|
||||||
|
.set_result_json(db, result_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?;
|
||||||
let context_str = review.context_json_str().to_owned();
|
let context_str = review.context_json_str().to_owned();
|
||||||
let input_path = review.input_path_str().to_owned();
|
let input_path = review.input_path_str().to_owned();
|
||||||
|
|
||||||
if result_str.is_empty() {
|
|
||||||
let _ = review.set_rejected(db).await;
|
|
||||||
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized: crate::agent::dto::NormalizedFields = serde_json::from_str(&result_str)
|
|
||||||
.map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?;
|
|
||||||
|
|
||||||
let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default();
|
let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default();
|
||||||
|
|
||||||
// Load live config from DB so admin-set values are used
|
// Load live config from DB so admin-set values are used
|
||||||
|
|||||||
@@ -328,6 +328,23 @@ pub async fn save_cover_to_storage(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some((id,)) = existing {
|
if let Some((id,)) = existing {
|
||||||
|
if let Some((file_path,)) = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT file_path FROM furumusic__media_file WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
let path = PathBuf::from(&file_path);
|
||||||
|
let path = if path.is_absolute() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
Path::new(storage_dir).join(path)
|
||||||
|
};
|
||||||
|
if let Err(err) = crate::agent::cover_variants::ensure_cover_variants(&path).await {
|
||||||
|
tracing::warn!(media_file_id = id, error = %err, "Failed to generate cover variants");
|
||||||
|
}
|
||||||
|
}
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +391,14 @@ pub async fn save_cover_to_storage(
|
|||||||
"Saved cover art"
|
"Saved cover art"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if let Err(err) = crate::agent::cover_variants::ensure_cover_variants(&dest_path).await {
|
||||||
|
tracing::warn!(
|
||||||
|
media_file_id = media_file.id_val(),
|
||||||
|
error = %err,
|
||||||
|
"Failed to generate cover variants"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(media_file.id_val())
|
Ok(media_file.id_val())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CoverVariant {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub max_edge: u32,
|
||||||
|
pub quality: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const COVER_VARIANTS: &[CoverVariant] = &[
|
||||||
|
CoverVariant {
|
||||||
|
name: "small",
|
||||||
|
max_edge: 96,
|
||||||
|
quality: 80,
|
||||||
|
},
|
||||||
|
CoverVariant {
|
||||||
|
name: "medium",
|
||||||
|
max_edge: 256,
|
||||||
|
quality: 82,
|
||||||
|
},
|
||||||
|
CoverVariant {
|
||||||
|
name: "large",
|
||||||
|
max_edge: 512,
|
||||||
|
quality: 85,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn variant_by_name(name: &str) -> Option<CoverVariant> {
|
||||||
|
COVER_VARIANTS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|variant| variant.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn variant_path(original_path: &Path, variant: CoverVariant) -> PathBuf {
|
||||||
|
let stem = original_path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("cover");
|
||||||
|
let filename = format!("{stem}.{}.jpg", variant.name);
|
||||||
|
original_path.with_file_name(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn missing_variants(original_path: &Path) -> Vec<CoverVariant> {
|
||||||
|
COVER_VARIANTS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|variant| !variant_path(original_path, *variant).exists())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_cover_variants(original_path: &Path) -> anyhow::Result<usize> {
|
||||||
|
let missing = missing_variants(original_path);
|
||||||
|
if missing.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let original_path = original_path.to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || generate_missing_variants_sync(&original_path, &missing))
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cover variant task failed: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_missing_variants_sync(
|
||||||
|
original_path: &Path,
|
||||||
|
variants: &[CoverVariant],
|
||||||
|
) -> anyhow::Result<usize> {
|
||||||
|
let data = std::fs::read(original_path)?;
|
||||||
|
let image = image::load_from_memory(&data)?;
|
||||||
|
|
||||||
|
let mut created = 0usize;
|
||||||
|
for variant in variants {
|
||||||
|
let path = variant_path(original_path, *variant);
|
||||||
|
if path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resized = image
|
||||||
|
.resize(variant.max_edge, variant.max_edge, FilterType::Lanczos3)
|
||||||
|
.to_rgb8();
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let mut encoder = JpegEncoder::new_with_quality(&mut output, variant.quality);
|
||||||
|
encoder.encode(
|
||||||
|
&resized,
|
||||||
|
resized.width(),
|
||||||
|
resized.height(),
|
||||||
|
image::ExtendedColorType::Rgb8,
|
||||||
|
)?;
|
||||||
|
std::fs::write(path, output)?;
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod cover_art;
|
pub mod cover_art;
|
||||||
|
pub mod cover_variants;
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod mover;
|
pub mod mover;
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub struct ConfigSources {
|
|||||||
pub agent_confidence_threshold: ConfigSource,
|
pub agent_confidence_threshold: ConfigSource,
|
||||||
pub agent_context_limit: ConfigSource,
|
pub agent_context_limit: ConfigSource,
|
||||||
pub agent_concurrency: ConfigSource,
|
pub agent_concurrency: ConfigSource,
|
||||||
|
pub lastfm_api_key: ConfigSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigSources {
|
impl Default for ConfigSources {
|
||||||
@@ -158,6 +159,7 @@ impl Default for ConfigSources {
|
|||||||
agent_confidence_threshold: ConfigSource::Default,
|
agent_confidence_threshold: ConfigSource::Default,
|
||||||
agent_context_limit: ConfigSource::Default,
|
agent_context_limit: ConfigSource::Default,
|
||||||
agent_concurrency: ConfigSource::Default,
|
agent_concurrency: ConfigSource::Default,
|
||||||
|
lastfm_api_key: ConfigSource::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +264,8 @@ pub struct AppConfig {
|
|||||||
pub agent_context_limit: u64,
|
pub agent_context_limit: u64,
|
||||||
/// Number of files to process in parallel via the LLM.
|
/// Number of files to process in parallel via the LLM.
|
||||||
pub agent_concurrency: u64,
|
pub agent_concurrency: u64,
|
||||||
|
/// Last.fm API key for weekly popularity enrichment.
|
||||||
|
pub lastfm_api_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -287,6 +291,7 @@ impl Default for AppConfig {
|
|||||||
agent_confidence_threshold: 0.85,
|
agent_confidence_threshold: 0.85,
|
||||||
agent_context_limit: 8192,
|
agent_context_limit: 8192,
|
||||||
agent_concurrency: 2,
|
agent_concurrency: 2,
|
||||||
|
lastfm_api_key: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,14 +318,21 @@ impl_env_overrides!(
|
|||||||
agent_confidence_threshold,
|
agent_confidence_threshold,
|
||||||
agent_context_limit,
|
agent_context_limit,
|
||||||
agent_concurrency,
|
agent_concurrency,
|
||||||
|
lastfm_api_key,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
fn normalize_host_paths(&mut self) {
|
||||||
|
self.agent_inbox_dir = normalize_host_path(&self.agent_inbox_dir);
|
||||||
|
self.agent_storage_dir = normalize_host_path(&self.agent_storage_dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// Build config: start from defaults, then overlay env vars.
|
/// Build config: start from defaults, then overlay env vars.
|
||||||
/// Used at startup before the DB is available (to get `database_url`).
|
/// Used at startup before the DB is available (to get `database_url`).
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
let mut cfg = Self::default();
|
let mut cfg = Self::default();
|
||||||
cfg.apply_env_overrides();
|
cfg.apply_env_overrides();
|
||||||
|
cfg.normalize_host_paths();
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +343,7 @@ impl AppConfig {
|
|||||||
let mut sources = ConfigSources::default();
|
let mut sources = ConfigSources::default();
|
||||||
cfg.apply_db_overrides(db, &mut sources).await;
|
cfg.apply_db_overrides(db, &mut sources).await;
|
||||||
cfg.apply_env_overrides_tracked(&mut sources);
|
cfg.apply_env_overrides_tracked(&mut sources);
|
||||||
|
cfg.normalize_host_paths();
|
||||||
(cfg, sources)
|
(cfg, sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +402,48 @@ impl AppConfig {
|
|||||||
apply_db_field!(agent_confidence_threshold);
|
apply_db_field!(agent_confidence_threshold);
|
||||||
apply_db_field!(agent_context_limit);
|
apply_db_field!(agent_context_limit);
|
||||||
apply_db_field!(agent_concurrency);
|
apply_db_field!(agent_concurrency);
|
||||||
|
apply_db_field!(lastfm_api_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_host_path(value: &str) -> String {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_windows_user_path(trimmed).unwrap_or_else(|| trimmed.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn normalize_windows_user_path(value: &str) -> Option<String> {
|
||||||
|
let normalized = value.replace('\\', "/");
|
||||||
|
let mut parts = normalized.split('/').filter(|part| !part.is_empty());
|
||||||
|
let drive = parts.next()?;
|
||||||
|
if drive.len() != 2 || !drive.ends_with(':') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !parts.next()?.eq_ignore_ascii_case("Users") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user = parts.next()?;
|
||||||
|
if user.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = format!("/Users/{user}");
|
||||||
|
for part in parts {
|
||||||
|
out.push('/');
|
||||||
|
out.push_str(part);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn normalize_windows_user_path(_value: &str) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -403,6 +455,24 @@ mod tests {
|
|||||||
assert_eq!(cfg.log_level, "info");
|
assert_eq!(cfg.log_level, "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn normalizes_windows_user_path_on_unix() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_host_path(r"C:\Users\ab\repos\furumusic\media\uploads"),
|
||||||
|
"/Users/ab/repos/furumusic/media/uploads"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn leaves_unix_path_unchanged() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_host_path("/Users/ab/repos/furumusic/media/uploads"),
|
||||||
|
"/Users/ab/repos/furumusic/media/uploads"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
||||||
unsafe fn set(k: &str, v: &str) {
|
unsafe fn set(k: &str, v: &str) {
|
||||||
unsafe { std::env::set_var(k, v) };
|
unsafe { std::env::set_var(k, v) };
|
||||||
|
|||||||
@@ -264,4 +264,160 @@ translations! {
|
|||||||
settings_agent_completion_tokens: "Completion tokens" , "Токенов на ответ";
|
settings_agent_completion_tokens: "Completion tokens" , "Токенов на ответ";
|
||||||
settings_agent_tokens_per_sec: "Tokens/sec" , "Токенов/сек";
|
settings_agent_tokens_per_sec: "Tokens/sec" , "Токенов/сек";
|
||||||
settings_agent_status_loading: "Checking connection" , "Проверка подключения";
|
settings_agent_status_loading: "Checking connection" , "Проверка подключения";
|
||||||
|
|
||||||
|
// Player UI
|
||||||
|
player_library: "Library" , "Библиотека";
|
||||||
|
player_artists: "Artists" , "Артисты";
|
||||||
|
player_releases: "Releases" , "Релизы";
|
||||||
|
player_tracks: "Tracks" , "Треки";
|
||||||
|
player_title: "Title" , "Название";
|
||||||
|
player_duration: "Duration" , "Длительность";
|
||||||
|
player_following: "Following" , "Подписки";
|
||||||
|
player_follow: "Follow" , "Подписаться";
|
||||||
|
player_followed: "Following" , "Вы подписаны";
|
||||||
|
player_unfollow_artist: "Unfollow artist" , "Отписаться от артиста";
|
||||||
|
player_follow_artist: "Follow artist" , "Подписаться на артиста";
|
||||||
|
player_no_followed_artists: "No followed artists" , "Нет подписок на артистов";
|
||||||
|
player_playlists: "Playlists" , "Плейлисты";
|
||||||
|
player_published_playlists: "Published Playlists" , "Опубликованные плейлисты";
|
||||||
|
player_public: "Public" , "Публичный";
|
||||||
|
player_published: "Published" , "Опубликован";
|
||||||
|
player_by: "by" , "от";
|
||||||
|
player_tracks_count: "tracks" , "треков";
|
||||||
|
player_files_count: "files" , "файлов";
|
||||||
|
player_releases_count: "releases" , "релизов";
|
||||||
|
player_plays_count: "plays" , "прослушиваний";
|
||||||
|
player_likes_count: "likes" , "лайков";
|
||||||
|
player_likes_playlist: "Likes" , "Лайки";
|
||||||
|
player_listened: "listened" , "прослушано";
|
||||||
|
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||||
|
player_no_results: "No results found" , "Ничего не найдено";
|
||||||
|
player_new_playlist: "New Playlist" , "Новый плейлист";
|
||||||
|
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
|
||||||
|
player_playlist_name: "Playlist name" , "Название плейлиста";
|
||||||
|
player_add_to_playlist: "Add to Playlist" , "Добавить в плейлист";
|
||||||
|
player_cancel: "Cancel" , "Отмена";
|
||||||
|
player_create: "Create" , "Создать";
|
||||||
|
player_save: "Save" , "Сохранить";
|
||||||
|
player_delete: "Delete" , "Удалить";
|
||||||
|
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||||
|
player_rename: "Rename" , "Переименовать";
|
||||||
|
player_close: "Close" , "Закрыть";
|
||||||
|
player_log_out: "Log out" , "Выйти";
|
||||||
|
player_admin_panel: "Admin Panel" , "Админка";
|
||||||
|
player_info: "Info" , "Информация";
|
||||||
|
player_no_details: "No details available." , "Нет подробностей.";
|
||||||
|
player_release_info: "Release info" , "Информация о релизе";
|
||||||
|
player_track_info: "Track info" , "Информация о треке";
|
||||||
|
player_type: "Type" , "Тип";
|
||||||
|
player_year: "Year" , "Год";
|
||||||
|
player_uploaders: "Uploaders" , "Загрузили";
|
||||||
|
player_unknown: "unknown" , "неизвестно";
|
||||||
|
player_unknown_size: "unknown size" , "размер неизвестен";
|
||||||
|
player_unknown_release: "Unknown release" , "Неизвестный релиз";
|
||||||
|
player_unknown_track: "Unknown track" , "Неизвестный трек";
|
||||||
|
player_unknown_audio: "unknown audio details" , "детали аудио неизвестны";
|
||||||
|
player_release_year: "Release year" , "Год релиза";
|
||||||
|
player_audio: "Audio" , "Аудио";
|
||||||
|
player_size: "Size" , "Размер";
|
||||||
|
player_uploader: "Uploader" , "Загрузил";
|
||||||
|
player_lastfm_rating: "Last.fm popularity" , "Популярность Last.fm";
|
||||||
|
player_lastfm_listeners: "Last.fm listeners" , "Слушатели Last.fm";
|
||||||
|
player_lastfm_playcount: "Last.fm plays" , "Прослушивания Last.fm";
|
||||||
|
player_lastfm_updated: "Last.fm updated" , "Last.fm обновлён";
|
||||||
|
player_lastfm_not_loaded: "not loaded yet" , "ещё не загружено";
|
||||||
|
player_play: "Play" , "Играть";
|
||||||
|
player_like: "Like" , "Лайк";
|
||||||
|
player_add_to_queue: "Add to queue" , "Добавить в очередь";
|
||||||
|
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
|
||||||
|
player_play_next: "Play next" , "Играть следующим";
|
||||||
|
player_queue: "Queue" , "Очередь";
|
||||||
|
player_next: "Next" , "Далее";
|
||||||
|
player_previous: "Previous" , "Назад";
|
||||||
|
player_clear: "Clear" , "Очистить";
|
||||||
|
player_remove: "Remove" , "Удалить";
|
||||||
|
player_queue_empty: "Queue is empty" , "Очередь пуста";
|
||||||
|
player_shuffle: "Shuffle" , "Перемешать";
|
||||||
|
player_repeat: "Repeat" , "Повтор";
|
||||||
|
player_volume: "Volume" , "Громкость";
|
||||||
|
player_appears_on: "Appears on" , "Участвует в";
|
||||||
|
player_albums: "Albums" , "Альбомы";
|
||||||
|
player_eps: "EPs" , "EP";
|
||||||
|
player_singles: "Singles" , "Синглы";
|
||||||
|
player_compilations: "Compilations" , "Сборники";
|
||||||
|
player_mixtapes: "Mixtapes" , "Микстейпы";
|
||||||
|
player_live_releases: "Live releases" , "Концертные релизы";
|
||||||
|
player_soundtracks: "Soundtracks" , "Саундтреки";
|
||||||
|
|
||||||
|
// Player torrent/history UI
|
||||||
|
player_torrent_manager: "Torrent manager" , "Торрент-менеджер";
|
||||||
|
player_import_torrent: "Import torrent" , "Импортировать торрент";
|
||||||
|
player_client_idle: "Client idle" , "Клиент простаивает";
|
||||||
|
player_active: "active" , "активно";
|
||||||
|
player_ai_idle: "AI idle" , "ИИ простаивает";
|
||||||
|
player_ai_prefix: "AI" , "ИИ";
|
||||||
|
player_processing: "processing" , "обрабатывается";
|
||||||
|
player_queued: "queued" , "в очереди";
|
||||||
|
player_saved: "saved" , "сохранено";
|
||||||
|
player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
|
||||||
|
player_refresh: "Refresh" , "Обновить";
|
||||||
|
player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет";
|
||||||
|
player_upload: "Upload" , "Загрузить";
|
||||||
|
player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы.";
|
||||||
|
player_local_files: "Local audio files" , "Локальные аудиофайлы";
|
||||||
|
player_torrent_file: "Torrent file" , "Torrent-файл";
|
||||||
|
player_magnet_link: "Magnet link" , "Magnet-ссылка";
|
||||||
|
player_upload_content: "Upload" , "Загрузить";
|
||||||
|
player_download_selected: "Download selected" , "Скачать выбранное";
|
||||||
|
player_pause_download: "Pause download" , "Поставить на паузу";
|
||||||
|
player_expand_all: "Expand all" , "Развернуть всё";
|
||||||
|
player_collapse: "Collapse" , "Свернуть";
|
||||||
|
player_selected: "selected" , "выбрано";
|
||||||
|
player_preview: "Preview" , "Предпросмотр";
|
||||||
|
player_resolving: "Resolving metadata" , "Получаю метаданные";
|
||||||
|
player_downloading: "Downloading" , "Скачивается";
|
||||||
|
player_moving: "Moving" , "Перемещается";
|
||||||
|
player_completed: "Completed" , "Готово";
|
||||||
|
player_failed: "Failed" , "Ошибка";
|
||||||
|
player_paused: "Paused" , "Пауза";
|
||||||
|
player_no_torrent_selected: "No torrent selected" , "Торрент не выбран";
|
||||||
|
player_downloaded: "Downloaded" , "Загружено";
|
||||||
|
player_speed: "Speed" , "Скорость";
|
||||||
|
player_down: "down" , "вниз";
|
||||||
|
player_up: "up" , "вверх";
|
||||||
|
player_peers: "peers" , "пиры";
|
||||||
|
player_live: "live" , "активных";
|
||||||
|
player_seen: "seen" , "видели";
|
||||||
|
player_eta: "eta" , "осталось";
|
||||||
|
player_loading_history: "Loading history..." , "Загрузка истории...";
|
||||||
|
player_failed_load_history: "Failed to load history" , "Не удалось загрузить историю";
|
||||||
|
player_total_plays: "total plays" , "прослушиваний всего";
|
||||||
|
player_play_history: "Play history" , "История прослушиваний";
|
||||||
|
player_no_plays_yet: "No plays yet" , "Прослушиваний пока нет";
|
||||||
|
player_page: "Page" , "Страница";
|
||||||
|
player_of: "of" , "из";
|
||||||
|
player_choose_torrent: "Choose local files, paste a magnet link, or choose a .torrent file." , "Выберите локальные файлы, вставьте magnet-ссылку или выберите .torrent файл.";
|
||||||
|
player_uploading_files: "Uploading files..." , "Загружаю файлы...";
|
||||||
|
player_upload_complete: "Upload complete. Files are queued for processing." , "Загрузка завершена. Файлы поставлены в обработку.";
|
||||||
|
player_upload_failed: "Upload failed" , "Загрузка не удалась";
|
||||||
|
player_reading_torrent: "Reading torrent file..." , "Читаю torrent-файл...";
|
||||||
|
player_resolving_magnet: "Resolving magnet metadata. This can take a while..." , "Получаю метаданные magnet-ссылки. Это может занять время...";
|
||||||
|
player_preview_failed: "Preview failed" , "Предпросмотр не удался";
|
||||||
|
player_all_files_selected: "All files are selected by default. Clear or adjust the tree before download." , "Все файлы выбраны по умолчанию. Перед скачиванием можно очистить или изменить выбор.";
|
||||||
|
player_opening_saved_torrent: "Opening saved torrent..." , "Открываю сохранённый торрент...";
|
||||||
|
player_saved_torrent_opened: "Saved torrent opened. Adjust files or resume download." , "Сохранённый торрент открыт. Можно изменить файлы или продолжить скачивание.";
|
||||||
|
player_remove_torrent_confirm: "Remove this torrent from the client list? Downloaded files will stay on disk." , "Удалить этот торрент из списка клиента? Скачанные файлы останутся на диске.";
|
||||||
|
player_torrent_removed: "Torrent removed from the client list." , "Торрент удалён из списка клиента.";
|
||||||
|
player_select_one_file: "Select at least one file." , "Выберите хотя бы один файл.";
|
||||||
|
player_starting_download: "Starting download..." , "Запускаю скачивание...";
|
||||||
|
player_download_started: "Download started. Files will move to inbox when complete." , "Скачивание началось. После завершения файлы будут перенесены во входящие.";
|
||||||
|
player_pausing_download: "Pausing download..." , "Ставлю скачивание на паузу...";
|
||||||
|
player_download_paused: "Download paused. Start again when you are ready." , "Скачивание на паузе. Можно продолжить позже.";
|
||||||
|
player_status_failed: "Status failed" , "Не удалось получить статус";
|
||||||
|
player_start_failed: "Start failed" , "Не удалось запустить";
|
||||||
|
player_pause_failed: "Pause failed" , "Не удалось поставить на паузу";
|
||||||
|
player_load_torrents_failed: "Could not load torrents" , "Не удалось загрузить торренты";
|
||||||
|
player_open_torrent_failed: "Could not open torrent" , "Не удалось открыть торрент";
|
||||||
|
player_delete_torrent_failed: "Could not delete torrent" , "Не удалось удалить торрент";
|
||||||
|
player_load_ai_queue_failed: "Could not load AI queue" , "Не удалось загрузить очередь ИИ";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::agent::cover_variants;
|
||||||
|
use crate::scheduler::{Job, JobContext, JobLog};
|
||||||
|
|
||||||
|
pub struct CoverVariantBackfillJob;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Job for CoverVariantBackfillJob {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"cover_variant_backfill"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Generate missing resized cover image variants"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cron(&self) -> &'static str {
|
||||||
|
// Once a day after cover extraction and artist image assignment.
|
||||||
|
"0 45 3 * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
let storage_dir = &ctx.config.agent_storage_dir;
|
||||||
|
if storage_dir.is_empty() {
|
||||||
|
log.warn("agent_storage_dir is not configured, skipping cover variant backfill");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<(i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, file_path FROM furumusic__media_file WHERE file_type = 'cover_art' ORDER BY id",
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
log.info("No cover art media files found");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Found {} cover art media file(s), checking variants...",
|
||||||
|
rows.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut created = 0usize;
|
||||||
|
let mut unchanged = 0usize;
|
||||||
|
let mut missing_original = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
|
||||||
|
for (media_file_id, file_path) in rows {
|
||||||
|
let path = resolve_media_path(storage_dir, &file_path);
|
||||||
|
if !path.exists() {
|
||||||
|
missing_original += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Media file {media_file_id}: original cover not found at {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match cover_variants::ensure_cover_variants(&path).await {
|
||||||
|
Ok(0) => unchanged += 1,
|
||||||
|
Ok(count) => {
|
||||||
|
created += count;
|
||||||
|
log.info(&format!(
|
||||||
|
"Media file {media_file_id}: created {count} variant(s)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Media file {media_file_id}: failed to create variants: {err}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Cover variant backfill complete: {created} variant(s) created, \
|
||||||
|
{unchanged} original(s) already complete, {missing_original} missing original(s), \
|
||||||
|
{failed} failed original(s)"
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_media_path(storage_dir: &str, file_path: &str) -> PathBuf {
|
||||||
|
let path = PathBuf::from(file_path);
|
||||||
|
if path.is_absolute() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
Path::new(storage_dir).join(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::scheduler::{Job, JobContext, JobLog};
|
||||||
|
|
||||||
|
pub struct LastfmPopularityJob;
|
||||||
|
|
||||||
|
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct TrackLookupRow {
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
artist_name: Option<String>,
|
||||||
|
lastfm_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LastfmTrackInfoResponse {
|
||||||
|
track: Option<LastfmTrack>,
|
||||||
|
error: Option<i32>,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LastfmTrack {
|
||||||
|
listeners: Option<String>,
|
||||||
|
playcount: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Job for LastfmPopularityJob {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"lastfm_popularity"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Update Last.fm playcount/listener popularity for library tracks"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cron(&self) -> &'static str {
|
||||||
|
// Sundays at 04:15
|
||||||
|
"0 15 4 * * Sun"
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_track_info(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
api_key: &str,
|
||||||
|
artist: &str,
|
||||||
|
track: &str,
|
||||||
|
) -> anyhow::Result<Option<(i64, i64)>> {
|
||||||
|
let response = client
|
||||||
|
.get("https://ws.audioscrobbler.com/2.0/")
|
||||||
|
.query(&[
|
||||||
|
("method", "track.getInfo"),
|
||||||
|
("api_key", api_key),
|
||||||
|
("artist", artist),
|
||||||
|
("track", track),
|
||||||
|
("autocorrect", "1"),
|
||||||
|
("format", "json"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let response = response.error_for_status()?;
|
||||||
|
let body: LastfmTrackInfoResponse = response.json().await?;
|
||||||
|
if let Some(code) = body.error {
|
||||||
|
if code == 29 {
|
||||||
|
anyhow::bail!("Last.fm rate limit exceeded");
|
||||||
|
}
|
||||||
|
if code == 6 || code == 7 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
anyhow::bail!(
|
||||||
|
"Last.fm API error {code}: {}",
|
||||||
|
body.message.unwrap_or_else(|| "unknown error".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some(info) = body.track else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
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))))
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
pub mod artist_image_backfill;
|
pub mod artist_image_backfill;
|
||||||
pub mod artist_track_image_backfill;
|
pub mod artist_track_image_backfill;
|
||||||
pub mod cover_backfill;
|
pub mod cover_backfill;
|
||||||
|
pub mod cover_variant_backfill;
|
||||||
pub mod inbox_discover;
|
pub mod inbox_discover;
|
||||||
pub mod inbox_process;
|
pub mod inbox_process;
|
||||||
|
pub mod lastfm_popularity;
|
||||||
pub mod metadata_backfill;
|
pub mod metadata_backfill;
|
||||||
|
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
registry.register(jobs::cover_backfill::CoverBackfillJob);
|
registry.register(jobs::cover_backfill::CoverBackfillJob);
|
||||||
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
|
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
|
||||||
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
|
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
|
||||||
|
registry.register(jobs::cover_variant_backfill::CoverVariantBackfillJob);
|
||||||
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
||||||
|
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1578,6 +1578,120 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(add_media_file_uploader).build()];
|
&[Operation::custom(add_media_file_uploader).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M0031: persistent torrent import sessions ---------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_torrent_session(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__torrent_session (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
info_hash VARCHAR(80) NOT NULL,
|
||||||
|
source_kind VARCHAR(32) NOT NULL,
|
||||||
|
source_label TEXT,
|
||||||
|
torrent_bytes BYTEA NOT NULL,
|
||||||
|
files_json TEXT NOT NULL,
|
||||||
|
selected_files_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
total_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
selected_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
downloaded_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
uploaded_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
progress_percent DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
created_at VARCHAR(32) NOT NULL,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
completed_at VARCHAR(32)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_torrent_session_user_updated
|
||||||
|
ON furumusic__torrent_session (user_id, updated_at DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_torrent_session_user_status
|
||||||
|
ON furumusic__torrent_session (user_id, status)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0031CreateTorrentSession;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0031CreateTorrentSession {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0031_create_torrent_session";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0030_add_media_file_uploader",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_torrent_session).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0032: Last.fm track popularity ------------------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_lastfm_track_popularity(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_listeners BIGINT")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_playcount BIGINT")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_rating DOUBLE PRECISION")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_updated_at VARCHAR(32)")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS 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
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_track_popularity_history_track
|
||||||
|
ON furumusic__track_popularity_history (track_id, fetched_at DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0032CreateLastfmTrackPopularity;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0032CreateLastfmTrackPopularity {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0032_create_lastfm_track_popularity";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0031_create_torrent_session",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_lastfm_track_popularity).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1599,5 +1713,7 @@ pub mod db_migrations {
|
|||||||
&M0028AddModelNameColumns,
|
&M0028AddModelNameColumns,
|
||||||
&M0029AddPlaybackVolume,
|
&M0029AddPlaybackVolume,
|
||||||
&M0030AddMediaFileUploader,
|
&M0030AddMediaFileUploader,
|
||||||
|
&M0031CreateTorrentSession,
|
||||||
|
&M0032CreateLastfmTrackPopularity,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -389,11 +389,7 @@ pub async fn oidc_callback_handler(
|
|||||||
config.oidc_user_groups,
|
config.oidc_user_groups,
|
||||||
);
|
);
|
||||||
|
|
||||||
if !is_allowed_by_groups(
|
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
|
||||||
&groups,
|
|
||||||
&config.oidc_user_groups,
|
|
||||||
&config.oidc_admin_groups,
|
|
||||||
) {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||||
config.oidc_user_groups,
|
config.oidc_user_groups,
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ pub(super) struct TrackItem {
|
|||||||
pub(super) audio_sample_rate: Option<i32>,
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
pub(super) lastfm_listeners: Option<i64>,
|
||||||
|
pub(super) lastfm_playcount: Option<i64>,
|
||||||
|
pub(super) lastfm_rating: Option<f64>,
|
||||||
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -84,6 +88,10 @@ pub(super) struct ArtistAppearanceTrack {
|
|||||||
pub(super) audio_sample_rate: Option<i32>,
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
pub(super) lastfm_listeners: Option<i64>,
|
||||||
|
pub(super) lastfm_playcount: Option<i64>,
|
||||||
|
pub(super) lastfm_rating: Option<f64>,
|
||||||
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -162,6 +170,12 @@ pub(super) struct UserProfile {
|
|||||||
pub(super) stats: UserStats,
|
pub(super) stats: UserStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct AgentQueueStatus {
|
||||||
|
pub(super) queued_count: i64,
|
||||||
|
pub(super) processing_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayHistoryItem {
|
pub(super) struct PlayHistoryItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
use crate::player::dto::UploaderSummary;
|
use crate::player::dto::UploaderSummary;
|
||||||
use crate::player::rows::ReleaseUploaderRow;
|
use crate::player::rows::ReleaseUploaderRow;
|
||||||
|
|
||||||
pub(super) fn cover_url(file_id: Option<i64>) -> Option<String> {
|
pub(super) fn cover_variant_url(file_id: Option<i64>, variant: &str) -> Option<String> {
|
||||||
file_id.map(|id| format!("/api/player/cover/{id}"))
|
file_id.map(|id| format!("/api/player/cover/{id}/{variant}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn track_cover_url(
|
pub(super) fn track_cover_variant_url(
|
||||||
track_cover: Option<i64>,
|
track_cover: Option<i64>,
|
||||||
release_cover: Option<i64>,
|
release_cover: Option<i64>,
|
||||||
|
variant: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
cover_url(track_cover.or(release_cover))
|
cover_variant_url(track_cover.or(release_cover), variant)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn load_release_uploaders(
|
pub(super) async fn load_release_uploaders(
|
||||||
|
|||||||
+606
-36
@@ -2,7 +2,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
use cot::http::StatusCode;
|
use cot::http::StatusCode;
|
||||||
use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
|
use cot::http::header::{
|
||||||
|
ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HeaderName, RANGE,
|
||||||
|
};
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
use cot::request::extractors::Path;
|
use cot::request::extractors::Path;
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
@@ -23,7 +25,7 @@ mod queries;
|
|||||||
mod rows;
|
mod rows;
|
||||||
|
|
||||||
use dto::*;
|
use dto::*;
|
||||||
use helpers::{cover_url, load_release_uploaders, track_cover_url};
|
use helpers::{cover_variant_url, load_release_uploaders, track_cover_variant_url};
|
||||||
use queries::*;
|
use queries::*;
|
||||||
use rows::*;
|
use rows::*;
|
||||||
|
|
||||||
@@ -40,6 +42,13 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct LocalUploadResponse {
|
||||||
|
ok: bool,
|
||||||
|
filename: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SPA shell
|
// SPA shell
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -105,6 +114,36 @@ async fn me_handler(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/player/agent-queue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn agent_queue_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (queued_count, processing_count): (i64, i64) = sqlx::query_as(
|
||||||
|
r#"SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'queued') AS queued_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'processing') AS processing_count
|
||||||
|
FROM furumusic__pending_review"#,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Json(AgentQueueStatus {
|
||||||
|
queued_count,
|
||||||
|
processing_count,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/player/artists?page=N&limit=N
|
// GET /api/player/artists?page=N&limit=N
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -164,7 +203,7 @@ async fn artists_handler(
|
|||||||
.map(|r| ArtistCard {
|
.map(|r| ArtistCard {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
image_url: cover_url(r.image_file_id),
|
image_url: cover_variant_url(r.image_file_id, "medium"),
|
||||||
release_count: r.release_count,
|
release_count: r.release_count,
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
})
|
})
|
||||||
@@ -240,7 +279,7 @@ async fn artist_detail_handler(
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
release_type: r.release_type,
|
release_type: r.release_type,
|
||||||
year: r.year,
|
year: r.year,
|
||||||
cover_url: cover_url(r.cover_file_id),
|
cover_url: cover_variant_url(r.cover_file_id, "medium"),
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
@@ -274,7 +313,11 @@ async fn artist_detail_handler(
|
|||||||
mf.audio_bitrate,
|
mf.audio_bitrate,
|
||||||
mf.audio_sample_rate,
|
mf.audio_sample_rate,
|
||||||
mf.audio_bit_depth,
|
mf.audio_bit_depth,
|
||||||
mf.file_size_bytes
|
mf.file_size_bytes,
|
||||||
|
t.lastfm_listeners,
|
||||||
|
t.lastfm_playcount,
|
||||||
|
t.lastfm_rating,
|
||||||
|
t.lastfm_updated_at
|
||||||
FROM furumusic__track_artist ta
|
FROM furumusic__track_artist ta
|
||||||
JOIN furumusic__track t ON t.id = ta.track_id
|
JOIN furumusic__track t ON t.id = ta.track_id
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
@@ -343,7 +386,11 @@ async fn artist_detail_handler(
|
|||||||
duration_seconds: t.duration_seconds,
|
duration_seconds: t.duration_seconds,
|
||||||
artists: featured_main_artists.remove(&tid).unwrap_or_default(),
|
artists: featured_main_artists.remove(&tid).unwrap_or_default(),
|
||||||
featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_variant_url(
|
||||||
|
t.cover_file_id,
|
||||||
|
t.release_cover_file_id,
|
||||||
|
"medium",
|
||||||
|
),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
uploader_name: t.uploader_name,
|
uploader_name: t.uploader_name,
|
||||||
audio_format: t.audio_format,
|
audio_format: t.audio_format,
|
||||||
@@ -351,6 +398,10 @@ async fn artist_detail_handler(
|
|||||||
audio_sample_rate: t.audio_sample_rate,
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
audio_bit_depth: t.audio_bit_depth,
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
file_size_bytes: t.file_size_bytes,
|
file_size_bytes: t.file_size_bytes,
|
||||||
|
lastfm_listeners: t.lastfm_listeners,
|
||||||
|
lastfm_playcount: t.lastfm_playcount,
|
||||||
|
lastfm_rating: t.lastfm_rating,
|
||||||
|
lastfm_updated_at: t.lastfm_updated_at,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -358,7 +409,7 @@ async fn artist_detail_handler(
|
|||||||
Json(ArtistDetail {
|
Json(ArtistDetail {
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
image_url: cover_url(image_file_id),
|
image_url: cover_variant_url(image_file_id, "large"),
|
||||||
total_track_count,
|
total_track_count,
|
||||||
total_play_count,
|
total_play_count,
|
||||||
releases: release_cards,
|
releases: release_cards,
|
||||||
@@ -420,7 +471,11 @@ async fn release_detail_handler(
|
|||||||
mf.audio_bitrate,
|
mf.audio_bitrate,
|
||||||
mf.audio_sample_rate,
|
mf.audio_sample_rate,
|
||||||
mf.audio_bit_depth,
|
mf.audio_bit_depth,
|
||||||
mf.file_size_bytes
|
mf.file_size_bytes,
|
||||||
|
t.lastfm_listeners,
|
||||||
|
t.lastfm_playcount,
|
||||||
|
t.lastfm_rating,
|
||||||
|
t.lastfm_updated_at
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
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__media_file mf ON mf.id = t.audio_file_id
|
||||||
@@ -488,7 +543,11 @@ async fn release_detail_handler(
|
|||||||
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
release_year: t.release_year,
|
release_year: t.release_year,
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_variant_url(
|
||||||
|
t.cover_file_id,
|
||||||
|
t.release_cover_file_id,
|
||||||
|
"medium",
|
||||||
|
),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
uploader_name: t.uploader_name,
|
uploader_name: t.uploader_name,
|
||||||
audio_format: t.audio_format,
|
audio_format: t.audio_format,
|
||||||
@@ -496,6 +555,10 @@ async fn release_detail_handler(
|
|||||||
audio_sample_rate: t.audio_sample_rate,
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
audio_bit_depth: t.audio_bit_depth,
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
file_size_bytes: t.file_size_bytes,
|
file_size_bytes: t.file_size_bytes,
|
||||||
|
lastfm_listeners: t.lastfm_listeners,
|
||||||
|
lastfm_playcount: t.lastfm_playcount,
|
||||||
|
lastfm_rating: t.lastfm_rating,
|
||||||
|
lastfm_updated_at: t.lastfm_updated_at,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -510,7 +573,7 @@ async fn release_detail_handler(
|
|||||||
title: release.title,
|
title: release.title,
|
||||||
release_type: release.release_type,
|
release_type: release.release_type,
|
||||||
year: release.year,
|
year: release.year,
|
||||||
cover_url: cover_url(release.cover_file_id),
|
cover_url: cover_variant_url(release.cover_file_id, "large"),
|
||||||
artists: release_artists
|
artists: release_artists
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| ArtistRef {
|
.map(|a| ArtistRef {
|
||||||
@@ -650,7 +713,11 @@ async fn playlist_detail_handler(
|
|||||||
mf.audio_bitrate,
|
mf.audio_bitrate,
|
||||||
mf.audio_sample_rate,
|
mf.audio_sample_rate,
|
||||||
mf.audio_bit_depth,
|
mf.audio_bit_depth,
|
||||||
mf.file_size_bytes
|
mf.file_size_bytes,
|
||||||
|
t.lastfm_listeners,
|
||||||
|
t.lastfm_playcount,
|
||||||
|
t.lastfm_rating,
|
||||||
|
t.lastfm_updated_at
|
||||||
FROM furumusic__playlist_track pt
|
FROM furumusic__playlist_track pt
|
||||||
JOIN furumusic__track t ON t.id = pt.track_id
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
@@ -738,7 +805,11 @@ async fn build_track_items(
|
|||||||
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
release_year: t.release_year,
|
release_year: t.release_year,
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_variant_url(
|
||||||
|
t.cover_file_id,
|
||||||
|
t.release_cover_file_id,
|
||||||
|
"medium",
|
||||||
|
),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
uploader_name: t.uploader_name,
|
uploader_name: t.uploader_name,
|
||||||
audio_format: t.audio_format,
|
audio_format: t.audio_format,
|
||||||
@@ -746,6 +817,10 @@ async fn build_track_items(
|
|||||||
audio_sample_rate: t.audio_sample_rate,
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
audio_bit_depth: t.audio_bit_depth,
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
file_size_bytes: t.file_size_bytes,
|
file_size_bytes: t.file_size_bytes,
|
||||||
|
lastfm_listeners: t.lastfm_listeners,
|
||||||
|
lastfm_playcount: t.lastfm_playcount,
|
||||||
|
lastfm_rating: t.lastfm_rating,
|
||||||
|
lastfm_updated_at: t.lastfm_updated_at,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -880,6 +955,140 @@ async fn stream_handler(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn local_upload_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
config: AppConfig,
|
||||||
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||||
|
request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let inbox_dir = config.agent_inbox_dir.trim();
|
||||||
|
if inbox_dir.is_empty() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"agent_inbox_dir is not configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let inbox_root = std::path::PathBuf::from(inbox_dir);
|
||||||
|
if !inbox_root.is_absolute() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"agent_inbox_dir must be an absolute path",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename_header = HeaderName::from_static("x-furumusic-filename");
|
||||||
|
let original_name = request
|
||||||
|
.headers()
|
||||||
|
.get(filename_header)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(percent_decode_header)
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "upload.mp3".to_string());
|
||||||
|
let filename = sanitize_upload_filename(&original_name);
|
||||||
|
|
||||||
|
let bytes = request
|
||||||
|
.into_body()
|
||||||
|
.into_bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|err| cot::Error::internal(err.to_string()))?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"uploaded file is empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let upload_dir = inbox_root
|
||||||
|
.join("user_uploads")
|
||||||
|
.join(user.id.to_string())
|
||||||
|
.join(format!("local-{}", uuid::Uuid::new_v4()));
|
||||||
|
tokio::fs::create_dir_all(&upload_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|err| cot::Error::internal(err.to_string()))?;
|
||||||
|
let destination = upload_dir.join(&filename);
|
||||||
|
tokio::fs::write(&destination, &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|err| cot::Error::internal(err.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(handle) = scheduler_handle.get() {
|
||||||
|
let handle = Arc::clone(handle);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle.trigger_job_now("inbox_discover").await {
|
||||||
|
tracing::warn!("failed to trigger inbox_discover after local upload: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(LocalUploadResponse {
|
||||||
|
ok: true,
|
||||||
|
filename,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_upload_filename(value: &str) -> String {
|
||||||
|
let name = std::path::Path::new(value)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("upload.mp3");
|
||||||
|
let sanitized: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
c if c.is_control() => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = sanitized.trim().trim_matches('.').trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"upload.mp3".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode_header(value: &str) -> String {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
match bytes[index] {
|
||||||
|
b'%' if index + 2 < bytes.len() => {
|
||||||
|
let hi = hex_value(bytes[index + 1]);
|
||||||
|
let lo = hex_value(bytes[index + 2]);
|
||||||
|
if let (Some(hi), Some(lo)) = (hi, lo) {
|
||||||
|
out.push((hi << 4) | lo);
|
||||||
|
index += 3;
|
||||||
|
} else {
|
||||||
|
out.push(bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byte => {
|
||||||
|
out.push(byte);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_value(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Some(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
||||||
let bytes_prefix = "bytes=";
|
let bytes_prefix = "bytes=";
|
||||||
if !header.starts_with(bytes_prefix) {
|
if !header.starts_with(bytes_prefix) {
|
||||||
@@ -941,13 +1150,40 @@ async fn cover_handler(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
path: Path<PathMediaFileId>,
|
path: Path<PathMediaFileId>,
|
||||||
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
|
cover_response(session, db, pool, config, path.0.media_file_id, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cover_variant_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
config: &AppConfig,
|
||||||
|
path: Path<PathMediaFileVariant>,
|
||||||
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
|
cover_response(
|
||||||
|
session,
|
||||||
|
db,
|
||||||
|
pool,
|
||||||
|
config,
|
||||||
|
path.0.media_file_id,
|
||||||
|
Some(path.0.variant.as_str()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cover_response(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
config: &AppConfig,
|
||||||
|
media_file_id: i64,
|
||||||
|
variant_name: Option<&str>,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let media_file_id = path.0.media_file_id;
|
|
||||||
|
|
||||||
let media = sqlx::query_as::<_, MediaFileRow>(
|
let media = sqlx::query_as::<_, MediaFileRow>(
|
||||||
"SELECT file_path, mime_type::text as mime_type, file_size_bytes FROM furumusic__media_file WHERE id = $1",
|
"SELECT file_path, mime_type::text as mime_type, file_size_bytes FROM furumusic__media_file WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -966,13 +1202,25 @@ async fn cover_handler(
|
|||||||
return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk"));
|
return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = tokio::fs::read(&full_path)
|
let (response_path, content_type) = variant_name
|
||||||
|
.and_then(crate::agent::cover_variants::variant_by_name)
|
||||||
|
.map(|variant| {
|
||||||
|
let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant);
|
||||||
|
if variant_path.exists() {
|
||||||
|
(variant_path, "image/jpeg")
|
||||||
|
} else {
|
||||||
|
(full_path.clone(), media.mime_type.as_str())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str()));
|
||||||
|
|
||||||
|
let data = tokio::fs::read(&response_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let response = cot::http::Response::builder()
|
let response = cot::http::Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(CONTENT_TYPE, media.mime_type.as_str())
|
.header(CONTENT_TYPE, content_type)
|
||||||
.header(CONTENT_LENGTH, data.len().to_string())
|
.header(CONTENT_LENGTH, data.len().to_string())
|
||||||
.header("Cache-Control", "public, max-age=86400")
|
.header("Cache-Control", "public, max-age=86400")
|
||||||
.body(Body::fixed(data))
|
.body(Body::fixed(data))
|
||||||
@@ -1234,7 +1482,11 @@ async fn search_handler(
|
|||||||
mf.audio_bitrate,
|
mf.audio_bitrate,
|
||||||
mf.audio_sample_rate,
|
mf.audio_sample_rate,
|
||||||
mf.audio_bit_depth,
|
mf.audio_bit_depth,
|
||||||
mf.file_size_bytes
|
mf.file_size_bytes,
|
||||||
|
t.lastfm_listeners,
|
||||||
|
t.lastfm_playcount,
|
||||||
|
t.lastfm_rating,
|
||||||
|
t.lastfm_updated_at
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release rel ON rel.id = t.release_id
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
||||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
@@ -1299,7 +1551,7 @@ async fn search_handler(
|
|||||||
let t = sqlx::query_as::<_, SearchTrackRow>(
|
let t = sqlx::query_as::<_, SearchTrackRow>(
|
||||||
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id,
|
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id,
|
||||||
release_cover_file_id, release_year, uploader_name, audio_format, audio_bitrate,
|
release_cover_file_id, release_year, uploader_name, audio_format, audio_bitrate,
|
||||||
audio_sample_rate, audio_bit_depth, file_size_bytes FROM (
|
audio_sample_rate, audio_bit_depth, file_size_bytes, lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at FROM (
|
||||||
SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
rel.cover_file_id AS release_cover_file_id,
|
rel.cover_file_id AS release_cover_file_id,
|
||||||
@@ -1310,20 +1562,27 @@ async fn search_handler(
|
|||||||
mf.audio_sample_rate,
|
mf.audio_sample_rate,
|
||||||
mf.audio_bit_depth,
|
mf.audio_bit_depth,
|
||||||
mf.file_size_bytes,
|
mf.file_size_bytes,
|
||||||
|
t.lastfm_listeners,
|
||||||
|
t.lastfm_playcount,
|
||||||
|
t.lastfm_rating,
|
||||||
|
t.lastfm_updated_at,
|
||||||
MAX(sim) AS similarity
|
MAX(sim) AS similarity
|
||||||
FROM (
|
FROM (
|
||||||
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
||||||
|
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
|
||||||
similarity(title_sort, $1) AS sim
|
similarity(title_sort, $1) AS sim
|
||||||
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
|
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
||||||
|
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
|
||||||
0.01::real AS sim
|
0.01::real AS sim
|
||||||
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
||||||
) t
|
) t
|
||||||
JOIN furumusic__release rel ON rel.id = t.release_id
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
||||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.year,
|
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.year,
|
||||||
mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
|
mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes,
|
||||||
|
t.lastfm_listeners, t.lastfm_playcount, t.lastfm_rating, t.lastfm_updated_at
|
||||||
ORDER BY similarity DESC
|
ORDER BY similarity DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
) sub"#,
|
) sub"#,
|
||||||
@@ -1382,7 +1641,7 @@ async fn search_handler(
|
|||||||
.map(|r| ArtistCard {
|
.map(|r| ArtistCard {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
image_url: cover_url(r.image_file_id),
|
image_url: cover_variant_url(r.image_file_id, "medium"),
|
||||||
release_count: r.release_count,
|
release_count: r.release_count,
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
})
|
})
|
||||||
@@ -1400,7 +1659,7 @@ async fn search_handler(
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
release_type: r.release_type,
|
release_type: r.release_type,
|
||||||
year: r.year,
|
year: r.year,
|
||||||
cover_url: cover_url(r.cover_file_id),
|
cover_url: cover_variant_url(r.cover_file_id, "medium"),
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
@@ -1419,7 +1678,11 @@ async fn search_handler(
|
|||||||
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
release_year: t.release_year,
|
release_year: t.release_year,
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_variant_url(
|
||||||
|
t.cover_file_id,
|
||||||
|
t.release_cover_file_id,
|
||||||
|
"medium",
|
||||||
|
),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
uploader_name: t.uploader_name,
|
uploader_name: t.uploader_name,
|
||||||
audio_format: t.audio_format,
|
audio_format: t.audio_format,
|
||||||
@@ -1427,6 +1690,10 @@ async fn search_handler(
|
|||||||
audio_sample_rate: t.audio_sample_rate,
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
audio_bit_depth: t.audio_bit_depth,
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
file_size_bytes: t.file_size_bytes,
|
file_size_bytes: t.file_size_bytes,
|
||||||
|
lastfm_listeners: t.lastfm_listeners,
|
||||||
|
lastfm_playcount: t.lastfm_playcount,
|
||||||
|
lastfm_rating: t.lastfm_rating,
|
||||||
|
lastfm_updated_at: t.lastfm_updated_at,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1885,7 +2152,7 @@ async fn followed_artists_handler(
|
|||||||
.map(|r| ArtistCard {
|
.map(|r| ArtistCard {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
image_url: cover_url(r.image_file_id),
|
image_url: cover_variant_url(r.image_file_id, "small"),
|
||||||
release_count: r.release_count,
|
release_count: r.release_count,
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
})
|
})
|
||||||
@@ -1991,7 +2258,11 @@ async fn tracks_by_ids_handler(
|
|||||||
mf.audio_bitrate,
|
mf.audio_bitrate,
|
||||||
mf.audio_sample_rate,
|
mf.audio_sample_rate,
|
||||||
mf.audio_bit_depth,
|
mf.audio_bit_depth,
|
||||||
mf.file_size_bytes
|
mf.file_size_bytes,
|
||||||
|
t.lastfm_listeners,
|
||||||
|
t.lastfm_playcount,
|
||||||
|
t.lastfm_rating,
|
||||||
|
t.lastfm_updated_at
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
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__media_file mf ON mf.id = t.audio_file_id
|
||||||
@@ -2058,7 +2329,11 @@ async fn tracks_by_ids_handler(
|
|||||||
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
release_year: t.release_year,
|
release_year: t.release_year,
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_variant_url(
|
||||||
|
t.cover_file_id,
|
||||||
|
t.release_cover_file_id,
|
||||||
|
"medium",
|
||||||
|
),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
uploader_name: t.uploader_name,
|
uploader_name: t.uploader_name,
|
||||||
audio_format: t.audio_format,
|
audio_format: t.audio_format,
|
||||||
@@ -2066,6 +2341,10 @@ async fn tracks_by_ids_handler(
|
|||||||
audio_sample_rate: t.audio_sample_rate,
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
audio_bit_depth: t.audio_bit_depth,
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
file_size_bytes: t.file_size_bytes,
|
file_size_bytes: t.file_size_bytes,
|
||||||
|
lastfm_listeners: t.lastfm_listeners,
|
||||||
|
lastfm_playcount: t.lastfm_playcount,
|
||||||
|
lastfm_rating: t.lastfm_rating,
|
||||||
|
lastfm_updated_at: t.lastfm_updated_at,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2134,30 +2413,198 @@ impl App for PlayerApp {
|
|||||||
},
|
},
|
||||||
"player_me",
|
"player_me",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/agent-queue",
|
||||||
|
{
|
||||||
|
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("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
agent_queue_handler(session, db, pg_pool).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"player_agent_queue",
|
||||||
|
),
|
||||||
// -- Torrent import widget --
|
// -- Torrent import widget --
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/torrents/preview",
|
"/torrents",
|
||||||
{
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let torrent_service = Arc::clone(&torrent_service);
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
post(
|
get(move |session: Session, db: Database| {
|
||||||
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.list(pg_pool, user.id).await {
|
||||||
|
Ok(items) => Json(items).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"player_torrent_list",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/session/{id}",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
get({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let torrent_service = Arc::clone(&torrent_service);
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
let scheduler_handle = Arc::clone(&scheduler_handle);
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
async move {
|
async move {
|
||||||
let Some(_user) = auth::get_session_user(&session, &db).await
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
else {
|
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"not authenticated",
|
"not authenticated",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match service.preview(json.0).await {
|
match service.details(pg_pool, user.id, &path.0.id).await {
|
||||||
|
Ok(details) => Json(details).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.delete(
|
||||||
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.remove(pg_pool, user.id, &path.0.id).await {
|
||||||
|
Ok(()) => {
|
||||||
|
Json(serde_json::json!({ "ok": true })).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_detail",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/preview",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.preview(pg_pool, user.id, json.0).await {
|
||||||
Ok(preview) => Json(preview).into_response(),
|
Ok(preview) => Json(preview).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
@@ -2169,9 +2616,34 @@ impl App for PlayerApp {
|
|||||||
},
|
},
|
||||||
"player_torrent_preview",
|
"player_torrent_preview",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/uploads/local",
|
||||||
|
{
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |session: Session, db: Database, request: cot::request::Request| {
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
local_upload_handler(
|
||||||
|
session,
|
||||||
|
db,
|
||||||
|
live_config,
|
||||||
|
scheduler_handle,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_local_upload",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/torrents/{id}/start",
|
"/torrents/{id}/start",
|
||||||
{
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let torrent_service = Arc::clone(&torrent_service);
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
post(
|
post(
|
||||||
@@ -2179,6 +2651,8 @@ impl App for PlayerApp {
|
|||||||
db: Database,
|
db: Database,
|
||||||
path: Path<PathStringId>,
|
path: Path<PathStringId>,
|
||||||
json: Json<TorrentStartRequest>| {
|
json: Json<TorrentStartRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let torrent_service = Arc::clone(&torrent_service);
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
let scheduler_handle = Arc::clone(&scheduler_handle);
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
async move {
|
async move {
|
||||||
@@ -2188,6 +2662,15 @@ impl App for PlayerApp {
|
|||||||
"not authenticated",
|
"not authenticated",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
@@ -2196,6 +2679,7 @@ impl App for PlayerApp {
|
|||||||
.await;
|
.await;
|
||||||
match service
|
match service
|
||||||
.start(
|
.start(
|
||||||
|
pg_pool,
|
||||||
&path.0.id,
|
&path.0.id,
|
||||||
json.0.selected_files,
|
json.0.selected_files,
|
||||||
live_config.agent_inbox_dir,
|
live_config.agent_inbox_dir,
|
||||||
@@ -2215,28 +2699,86 @@ impl App for PlayerApp {
|
|||||||
"player_torrent_start",
|
"player_torrent_start",
|
||||||
),
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/torrents/{id}/status",
|
"/torrents/{id}/pause",
|
||||||
{
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let torrent_service = Arc::clone(&torrent_service);
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
get(
|
post(
|
||||||
move |session: Session, db: Database, path: Path<PathStringId>| {
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let torrent_service = Arc::clone(&torrent_service);
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
let scheduler_handle = Arc::clone(&scheduler_handle);
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
async move {
|
async move {
|
||||||
let Some(_user) = auth::get_session_user(&session, &db).await
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
else {
|
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"not authenticated",
|
"not authenticated",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
let service = torrent_service
|
let service = torrent_service
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match service.status(&path.0.id).await {
|
match service.pause(pg_pool, user.id, &path.0.id).await {
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_pause",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/{id}/status",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
get(
|
||||||
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.status(pg_pool, user.id, &path.0.id).await {
|
||||||
Ok(job) => Json(job).into_response(),
|
Ok(job) => Json(job).into_response(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
||||||
@@ -2647,6 +3189,34 @@ impl App for PlayerApp {
|
|||||||
"player_stream",
|
"player_stream",
|
||||||
),
|
),
|
||||||
// -- Cover art --
|
// -- Cover art --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/cover/{media_file_id}/{variant}",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let config = Arc::clone(&self.config);
|
||||||
|
get(
|
||||||
|
move |session: Session, db: Database, path: Path<PathMediaFileVariant>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let config = Arc::clone(&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("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
cover_variant_handler(session, db, pg_pool, &config, path).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_cover_variant",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/cover/{media_file_id}",
|
"/cover/{media_file_id}",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,3 +70,9 @@ pub(super) struct PathTrackId {
|
|||||||
pub(super) struct PathMediaFileId {
|
pub(super) struct PathMediaFileId {
|
||||||
pub(super) media_file_id: i64,
|
pub(super) media_file_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathMediaFileVariant {
|
||||||
|
pub(super) media_file_id: i64,
|
||||||
|
pub(super) variant: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ pub(super) struct TrackRow {
|
|||||||
pub(super) audio_sample_rate: Option<i32>,
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
pub(super) lastfm_listeners: Option<i64>,
|
||||||
|
pub(super) lastfm_playcount: Option<i64>,
|
||||||
|
pub(super) lastfm_rating: Option<f64>,
|
||||||
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -110,6 +114,10 @@ pub(super) struct PlaylistTrackRow {
|
|||||||
pub(super) audio_sample_rate: Option<i32>,
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
pub(super) lastfm_listeners: Option<i64>,
|
||||||
|
pub(super) lastfm_playcount: Option<i64>,
|
||||||
|
pub(super) lastfm_rating: Option<f64>,
|
||||||
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -128,6 +136,10 @@ pub(super) struct AppearanceTrackRow {
|
|||||||
pub(super) audio_sample_rate: Option<i32>,
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
pub(super) lastfm_listeners: Option<i64>,
|
||||||
|
pub(super) lastfm_playcount: Option<i64>,
|
||||||
|
pub(super) lastfm_rating: Option<f64>,
|
||||||
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -165,6 +177,10 @@ pub(super) struct SearchTrackRow {
|
|||||||
pub(super) audio_sample_rate: Option<i32>,
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
pub(super) lastfm_listeners: Option<i64>,
|
||||||
|
pub(super) lastfm_playcount: Option<i64>,
|
||||||
|
pub(super) lastfm_rating: Option<f64>,
|
||||||
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|||||||
+11
-1
@@ -460,6 +460,16 @@ impl PendingReview {
|
|||||||
self.save(db).await
|
self.save(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_result_json(
|
||||||
|
&mut self,
|
||||||
|
db: &Database,
|
||||||
|
result_json: String,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
self.result_json = Some(result_json);
|
||||||
|
self.updated_at = now_iso();
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_failed(&mut self, db: &Database, error: &str) -> cot::db::Result<()> {
|
pub async fn set_failed(&mut self, db: &Database, error: &str) -> cot::db::Result<()> {
|
||||||
self.status = LimitedString::new("failed").unwrap();
|
self.status = LimitedString::new("failed").unwrap();
|
||||||
self.error_message = Some(error.to_owned());
|
self.error_message = Some(error.to_owned());
|
||||||
@@ -1347,7 +1357,7 @@ async fn run_scheduled_job(
|
|||||||
|
|
||||||
// Check agent_enabled (re-read from DB every run)
|
// Check agent_enabled (re-read from DB every run)
|
||||||
let (live_config, _) = AppConfig::load_with_db(db).await;
|
let (live_config, _) = AppConfig::load_with_db(db).await;
|
||||||
if !live_config.agent_enabled {
|
if !live_config.agent_enabled && job_name != "lastfm_popularity" {
|
||||||
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+965
-128
File diff suppressed because it is too large
Load Diff
@@ -25,28 +25,76 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if review.status_str() == "pending" %}
|
||||||
|
<h2>{{ t.reviews_result }}</h2>
|
||||||
|
<form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="margin: 1rem 0;">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><label for="artist">Artist</label></td>
|
||||||
|
<td><input name="artist" id="artist" value="{{ edit.artist }}" style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="album">Album</label></td>
|
||||||
|
<td><input name="album" id="album" value="{{ edit.album }}" style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="title">Title</label></td>
|
||||||
|
<td><input name="title" id="title" value="{{ edit.title }}" style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="year">Year</label></td>
|
||||||
|
<td><input name="year" id="year" type="number" min="0" max="3000" value="{{ edit.year }}" style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="track_number">Track</label></td>
|
||||||
|
<td><input name="track_number" id="track_number" type="number" min="0" value="{{ edit.track_number }}" style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="genre">Genre</label></td>
|
||||||
|
<td><input name="genre" id="genre" value="{{ edit.genre }}" style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="featured_artists">Featured artists</label></td>
|
||||||
|
<td><input name="featured_artists" id="featured_artists" value="{{ edit.featured_artists }}" style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="release_type">{{ t.releases_type }}</label></td>
|
||||||
|
<td>
|
||||||
|
<select name="release_type" id="release_type" style="width:100%; padding:.4rem;">
|
||||||
|
{% for rt in release_types %}
|
||||||
|
<option value="{{ rt.0 }}"{% if edit.release_type == rt.0 %} selected{% endif %}>{% if lang_code == "ru" %}{{ rt.2 }}{% else %}{{ rt.1 }}{% endif %} ({{ rt.0 }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="notes">Notes</label></td>
|
||||||
|
<td><textarea name="notes" id="notes" style="width:100%; min-height:4rem;">{{ edit.notes }}</textarea></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button type="submit" style="margin-top:1rem; padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button>
|
||||||
|
</form>
|
||||||
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
|
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
|
||||||
{% if review.status_str() == "pending" %}
|
|
||||||
<form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="display:inline;">
|
|
||||||
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/admin/reviews/{{ review.id_val() }}/reject" style="display:inline;">
|
<form method="post" action="/admin/reviews/{{ review.id_val() }}/reject" style="display:inline;">
|
||||||
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_reject }}</button>
|
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_reject }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
|
||||||
{% if review.status_str() == "failed" || review.status_str() == "processing" %}
|
{% if review.status_str() == "failed" || review.status_str() == "processing" %}
|
||||||
<form method="post" action="/admin/reviews/{{ review.id_val() }}/requeue" style="display:inline;" onsubmit="return confirm('{{ t.reviews_requeue_confirm }}');">
|
<form method="post" action="/admin/reviews/{{ review.id_val() }}/requeue" style="display:inline;" onsubmit="return confirm('{{ t.reviews_requeue_confirm }}');">
|
||||||
<button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button>
|
<button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if !context_pretty.is_empty() %}
|
{% if !context_pretty.is_empty() %}
|
||||||
<h2>{{ t.reviews_context }}</h2>
|
<h2>{{ t.reviews_context }}</h2>
|
||||||
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre>
|
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if !result_pretty.is_empty() %}
|
{% if !result_pretty.is_empty() && review.status_str() != "pending" %}
|
||||||
<h2>{{ t.reviews_result }}</h2>
|
<h2>{{ t.reviews_result }}</h2>
|
||||||
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre>
|
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+712
-13
@@ -667,6 +667,161 @@ tbody tr:hover {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-page {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-side .settings-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-field {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-field label,
|
||||||
|
.setting-toggle label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-field input {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-field input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-toggle {
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-toggle-row span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-toggle input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-help {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 850;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-pill.env { background: rgba(90, 167, 255, 0.16); color: #9ccbff; }
|
||||||
|
.source-pill.database { background: rgba(29, 185, 84, 0.16); color: #8ef0b2; }
|
||||||
|
.source-pill.default { background: rgba(255, 255, 255, 0.08); color: var(--text-subdued); }
|
||||||
|
|
||||||
|
.settings-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-note {
|
||||||
|
padding: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-intro {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.library-row {
|
.library-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
|
grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
|
||||||
@@ -696,7 +851,8 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
.field textarea {
|
.field textarea,
|
||||||
|
.field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -799,40 +955,45 @@ tbody tr:hover {
|
|||||||
|
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">Operations</div>
|
<div class="nav-label">Operations</div>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="activeView = 'reviews'">
|
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
|
||||||
<i data-lucide="inbox"></i>
|
<i data-lucide="inbox"></i>
|
||||||
<span>Review Queue</span>
|
<span>Review Queue</span>
|
||||||
<span class="nav-count" x-text="reviews.total || 0"></span>
|
<span class="nav-count" x-text="reviews.total || 0"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="activeView = 'jobs'; loadJobs()">
|
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
|
||||||
<i data-lucide="calendar-clock"></i>
|
<i data-lucide="calendar-clock"></i>
|
||||||
<span>Tasks</span>
|
<span>Tasks</span>
|
||||||
<span class="nav-count" x-text="jobs.length || 0"></span>
|
<span class="nav-count" x-text="jobs.length || 0"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'library'}" @click="activeView = 'library'; loadLibrary()">
|
<button class="nav-btn" :class="{active: activeView === 'library'}" @click="openLibrary(libraryKind)">
|
||||||
<i data-lucide="library"></i>
|
<i data-lucide="library"></i>
|
||||||
<span>Library Workbench</span>
|
<span>Library Workbench</span>
|
||||||
<span class="nav-count" x-text="fmt(stats.tracks || 0)"></span>
|
<span class="nav-count" x-text="fmt(stats.tracks || 0)"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="activeView = 'tools'">
|
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="openTools()">
|
||||||
<i data-lucide="wrench"></i>
|
<i data-lucide="wrench"></i>
|
||||||
<span>Future Tools</span>
|
<span>Future Tools</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
||||||
|
<i data-lucide="settings"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
<span class="nav-count" x-text="settings.lastfm_api_key_configured ? 'ok' : ''"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">Entities</div>
|
<div class="nav-label">Entities</div>
|
||||||
<button class="nav-btn" @click="activeView = 'library'; libraryKind = 'artists'; loadLibrary()">
|
<button class="nav-btn" @click="openLibrary('artists')">
|
||||||
<i data-lucide="mic-2"></i>
|
<i data-lucide="mic-2"></i>
|
||||||
<span>Artists</span>
|
<span>Artists</span>
|
||||||
<span class="nav-count" x-text="fmt(libraryOverview.artists || 0)"></span>
|
<span class="nav-count" x-text="fmt(libraryOverview.artists || 0)"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" @click="activeView = 'library'; libraryKind = 'releases'; loadLibrary()">
|
<button class="nav-btn" @click="openLibrary('releases')">
|
||||||
<i data-lucide="disc-3"></i>
|
<i data-lucide="disc-3"></i>
|
||||||
<span>Releases</span>
|
<span>Releases</span>
|
||||||
<span class="nav-count" x-text="fmt(libraryOverview.releases || 0)"></span>
|
<span class="nav-count" x-text="fmt(libraryOverview.releases || 0)"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" @click="activeView = 'library'; libraryKind = 'playlists'; loadLibrary()">
|
<button class="nav-btn" @click="openLibrary('playlists')">
|
||||||
<i data-lucide="list-music"></i>
|
<i data-lucide="list-music"></i>
|
||||||
<span>Playlists</span>
|
<span>Playlists</span>
|
||||||
<span class="nav-count" x-text="fmt(libraryOverview.playlists || 0)"></span>
|
<span class="nav-count" x-text="fmt(libraryOverview.playlists || 0)"></span>
|
||||||
@@ -1130,9 +1291,9 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="segmented">
|
<div class="segmented">
|
||||||
<button class="seg-btn" :class="{active: libraryKind === 'artists'}" @click="libraryKind = 'artists'; loadLibrary()">Artists</button>
|
<button class="seg-btn" :class="{active: libraryKind === 'artists'}" @click="openLibrary('artists')">Artists</button>
|
||||||
<button class="seg-btn" :class="{active: libraryKind === 'releases'}" @click="libraryKind = 'releases'; loadLibrary()">Releases</button>
|
<button class="seg-btn" :class="{active: libraryKind === 'releases'}" @click="openLibrary('releases')">Releases</button>
|
||||||
<button class="seg-btn" :class="{active: libraryKind === 'playlists'}" @click="libraryKind = 'playlists'; loadLibrary()">Playlists</button>
|
<button class="seg-btn" :class="{active: libraryKind === 'playlists'}" @click="openLibrary('playlists')">Playlists</button>
|
||||||
</div>
|
</div>
|
||||||
<input class="search" placeholder="Search library" x-model="librarySearch" @input.debounce.350ms="loadLibrary()" />
|
<input class="search" placeholder="Search library" x-model="librarySearch" @input.debounce.350ms="loadLibrary()" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1236,6 +1397,253 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="content" x-show="activeView === 'settings'">
|
||||||
|
<div class="settings-page">
|
||||||
|
<form class="settings-layout" @submit.prevent="saveSettings()">
|
||||||
|
<div class="settings-column">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>OIDC</strong>
|
||||||
|
<span>Identity provider and group mapping</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>Callback URL</label>
|
||||||
|
<input readonly :value="callbackUrl()" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>SSO button text</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_button_text')" x-text="settingSource('oidc_button_text')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_button_text" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Issuer URL</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_issuer')" x-text="settingSource('oidc_issuer')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_issuer" placeholder="https://accounts.google.com" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Client ID</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_client_id')" x-text="settingSource('oidc_client_id')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_client_id" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Client secret</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_client_secret')" x-text="settingSource('oidc_client_secret')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="password" x-model="settingsDraft.oidc_client_secret" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Admin groups</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>User groups</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.oidc_user_groups" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Agent</strong>
|
||||||
|
<span>AI processing directories, LLM endpoint, and execution limits</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>Agent enabled</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_enabled')" x-text="settingSource('agent_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.agent_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.agent_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Concurrency</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>
|
||||||
|
<span>Inbox directory</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_inbox_dir')" x-text="settingSource('agent_inbox_dir')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_inbox_dir" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>
|
||||||
|
<span>Storage directory</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_storage_dir')" x-text="settingSource('agent_storage_dir')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_storage_dir" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field settings-wide">
|
||||||
|
<label>
|
||||||
|
<span>LLM API URL</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_llm_url" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>LLM model</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_llm_model" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>LLM auth header</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Confidence threshold</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_confidence_threshold" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Context limit</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
|
||||||
|
</label>
|
||||||
|
<input x-model="settingsDraft.agent_context_limit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-column settings-side">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Authentication</strong>
|
||||||
|
<span>Password and SSO access switches</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>Password login</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('auth_password_enabled')" x-text="settingSource('auth_password_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.auth_password_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.auth_password_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>SSO login</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('auth_sso_enabled')" x-text="settingSource('auth_sso_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.auth_sso_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.auth_sso_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>API</strong>
|
||||||
|
<span>Developer and enrichment integrations</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="settings.lastfm_api_key_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_api_key_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-toggle">
|
||||||
|
<label>
|
||||||
|
<span>Swagger UI</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-help">Interactive API docs at /swagger/ after restart.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Last.fm API key</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('lastfm_api_key')" x-text="settingSource('lastfm_api_key')"></span>
|
||||||
|
</label>
|
||||||
|
<input type="password" x-model="settingsDraft.lastfm_api_key" autocomplete="off" />
|
||||||
|
<div class="setting-help">Used by the weekly Last.fm popularity task.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Agent Status</strong>
|
||||||
|
<span x-text="settingsProbeSubtitle()"></span>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="settingsProbeBadge()" x-text="settingsProbe.status || 'idle'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="probe-body">
|
||||||
|
<p class="probe-intro" x-show="settingsProbe.model_intro" x-text="settingsProbe.model_intro"></p>
|
||||||
|
<p class="probe-intro muted" x-show="!settingsProbe.model_intro" x-text="settingsProbeText()"></p>
|
||||||
|
<div class="probe-table" x-show="settingsProbe.ok">
|
||||||
|
<div class="probe-row"><span>Model</span><strong x-text="settingsProbe.model_name || 'unknown'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Latency</span><strong x-text="settingsProbe.latency_ms + ' ms'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Prompt tokens</span><strong x-text="settingsProbe.prompt_tokens ?? '-'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Completion tokens</span><strong x-text="settingsProbe.completion_tokens ?? '-'"></strong></div>
|
||||||
|
<div class="probe-row"><span>Tokens/sec</span><strong x-text="settingsProbe.tokens_per_sec != null ? settingsProbe.tokens_per_sec.toFixed(1) : '-'"></strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" style="margin-top:14px">
|
||||||
|
<button class="btn" type="button" @click="loadSettingsProbe()" :disabled="settingsProbeLoading">
|
||||||
|
<i data-lucide="activity"></i>
|
||||||
|
Test agent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-strip settings-actions">
|
||||||
|
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn" type="button" @click="loadSettings()">
|
||||||
|
<i data-lucide="refresh-cw"></i>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" type="submit" :disabled="settingsSaving">
|
||||||
|
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
|
||||||
|
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
|
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
|
||||||
@@ -1266,7 +1674,60 @@ tbody tr:hover {
|
|||||||
<label>Error</label>
|
<label>Error</label>
|
||||||
<textarea readonly x-text="activeReview?.error_message || ''"></textarea>
|
<textarea readonly x-text="activeReview?.error_message || ''"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div x-show="activeReview?.status === 'pending'">
|
||||||
|
<div class="field">
|
||||||
|
<label>Artist</label>
|
||||||
|
<input x-model="reviewDraft.artist" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Album</label>
|
||||||
|
<input x-model="reviewDraft.album" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input x-model="reviewDraft.title" />
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||||
|
<div class="field">
|
||||||
|
<label>Year</label>
|
||||||
|
<input type="number" min="0" max="3000" x-model="reviewDraft.year" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Track</label>
|
||||||
|
<input type="number" min="0" x-model="reviewDraft.track_number" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Genre</label>
|
||||||
|
<input x-model="reviewDraft.genre" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Featured artists</label>
|
||||||
|
<input x-model="reviewDraft.featured_artists" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Release type</label>
|
||||||
|
<select x-model="reviewDraft.release_type">
|
||||||
|
<option value="album">Album</option>
|
||||||
|
<option value="single">Single</option>
|
||||||
|
<option value="ep">EP</option>
|
||||||
|
<option value="compilation">Compilation</option>
|
||||||
|
<option value="soundtrack">Soundtrack</option>
|
||||||
|
<option value="live">Live</option>
|
||||||
|
<option value="remix">Remix</option>
|
||||||
|
<option value="unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea x-model="reviewDraft.notes"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
<button class="btn primary" x-show="activeReview?.status === 'pending'" @click="approveActiveReview()">
|
||||||
|
<i data-lucide="check"></i>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
<button class="btn warn" @click="bulkOneReview('requeue', activeReview)">
|
<button class="btn warn" @click="bulkOneReview('requeue', activeReview)">
|
||||||
<i data-lucide="rotate-ccw"></i>
|
<i data-lucide="rotate-ccw"></i>
|
||||||
Requeue
|
Requeue
|
||||||
@@ -1344,6 +1805,17 @@ function adminV2() {
|
|||||||
selectedReviewIds: {},
|
selectedReviewIds: {},
|
||||||
reviewSelectionScope: 'ids',
|
reviewSelectionScope: 'ids',
|
||||||
activeReview: null,
|
activeReview: null,
|
||||||
|
reviewDraft: {
|
||||||
|
title: '',
|
||||||
|
artist: '',
|
||||||
|
album: '',
|
||||||
|
year: '',
|
||||||
|
track_number: '',
|
||||||
|
genre: '',
|
||||||
|
featured_artists: '',
|
||||||
|
release_type: 'album',
|
||||||
|
notes: ''
|
||||||
|
},
|
||||||
reviewModalOpen: false,
|
reviewModalOpen: false,
|
||||||
reviewStatuses: [
|
reviewStatuses: [
|
||||||
{ value: null, label: 'All' },
|
{ value: null, label: 'All' },
|
||||||
@@ -1366,10 +1838,43 @@ function adminV2() {
|
|||||||
activeLibraryItem: null,
|
activeLibraryItem: null,
|
||||||
editorOpen: false,
|
editorOpen: false,
|
||||||
editorDraft: { title: '', hidden: 'false' },
|
editorDraft: { title: '', hidden: 'false' },
|
||||||
|
settings: { values: {}, sources: {}, lastfm_api_key_configured: false },
|
||||||
|
settingsDraft: {
|
||||||
|
auth_password_enabled: false,
|
||||||
|
auth_sso_enabled: false,
|
||||||
|
oidc_button_text: '',
|
||||||
|
oidc_issuer: '',
|
||||||
|
oidc_client_id: '',
|
||||||
|
oidc_client_secret: '',
|
||||||
|
oidc_admin_groups: '',
|
||||||
|
oidc_user_groups: '',
|
||||||
|
swagger_enabled: false,
|
||||||
|
lastfm_api_key: '',
|
||||||
|
agent_enabled: false,
|
||||||
|
agent_inbox_dir: '',
|
||||||
|
agent_storage_dir: '',
|
||||||
|
agent_llm_url: '',
|
||||||
|
agent_llm_model: '',
|
||||||
|
agent_llm_auth: '',
|
||||||
|
agent_confidence_threshold: '',
|
||||||
|
agent_context_limit: '',
|
||||||
|
agent_concurrency: ''
|
||||||
|
},
|
||||||
|
settingsProbe: { status: 'idle', ok: false },
|
||||||
|
settingsProbeLoading: false,
|
||||||
|
settingsSaving: false,
|
||||||
|
routeReady: false,
|
||||||
poller: null,
|
poller: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
this.applyRouteFromHash();
|
||||||
await this.refreshAll();
|
await this.refreshAll();
|
||||||
|
this.routeReady = true;
|
||||||
|
this.activateCurrentView(false);
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
this.applyRouteFromHash();
|
||||||
|
this.activateCurrentView(false);
|
||||||
|
});
|
||||||
this.poller = setInterval(() => this.poll(), 6000);
|
this.poller = setInterval(() => this.poll(), 6000);
|
||||||
this.icons();
|
this.icons();
|
||||||
},
|
},
|
||||||
@@ -1399,6 +1904,7 @@ function adminV2() {
|
|||||||
this.jobs = data.jobs || [];
|
this.jobs = data.jobs || [];
|
||||||
this.recentRuns = data.recent_runs || [];
|
this.recentRuns = data.recent_runs || [];
|
||||||
if (!this.activeJobName && this.jobs.length) this.activeJobName = this.jobs[0].name;
|
if (!this.activeJobName && this.jobs.length) this.activeJobName = this.jobs[0].name;
|
||||||
|
await this.loadSettings(false);
|
||||||
await this.loadLibrary(false);
|
await this.loadLibrary(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(error.message);
|
this.showToast(error.message);
|
||||||
@@ -1413,6 +1919,84 @@ function adminV2() {
|
|||||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
applyRouteFromHash() {
|
||||||
|
const raw = (window.location.hash || '#reviews').replace(/^#\/?/, '');
|
||||||
|
const parts = raw.split('/').filter(Boolean);
|
||||||
|
const view = parts[0] || 'reviews';
|
||||||
|
if (view === 'reviews') {
|
||||||
|
this.activeView = 'reviews';
|
||||||
|
this.reviewFilter.status = parts[1] || null;
|
||||||
|
} else if (view === 'jobs') {
|
||||||
|
this.activeView = 'jobs';
|
||||||
|
if (parts[1]) this.activeJobName = decodeURIComponent(parts[1]);
|
||||||
|
} else if (view === 'library') {
|
||||||
|
this.activeView = 'library';
|
||||||
|
this.libraryKind = ['artists', 'releases', 'playlists'].includes(parts[1]) ? parts[1] : 'artists';
|
||||||
|
} else if (view === 'settings') {
|
||||||
|
this.activeView = 'settings';
|
||||||
|
} else if (view === 'tools') {
|
||||||
|
this.activeView = 'tools';
|
||||||
|
} else {
|
||||||
|
this.activeView = 'reviews';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setRoute(path) {
|
||||||
|
if (!path.startsWith('#')) path = '#' + path;
|
||||||
|
if (window.location.hash !== path) {
|
||||||
|
window.history.pushState(null, '', path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async activateCurrentView(updateRoute = true) {
|
||||||
|
if (this.activeView === 'reviews') {
|
||||||
|
if (updateRoute) this.setRoute(this.reviewFilter.status ? `#reviews/${this.reviewFilter.status}` : '#reviews');
|
||||||
|
await this.loadReviews(false);
|
||||||
|
} else if (this.activeView === 'jobs') {
|
||||||
|
if (updateRoute) this.setRoute(this.activeJobName ? `#jobs/${encodeURIComponent(this.activeJobName)}` : '#jobs');
|
||||||
|
await this.loadJobs();
|
||||||
|
if (this.activeJobName) await this.loadRunsForJob(this.activeJobName);
|
||||||
|
} else if (this.activeView === 'library') {
|
||||||
|
if (updateRoute) this.setRoute(`#library/${this.libraryKind}`);
|
||||||
|
await this.loadLibrary(false);
|
||||||
|
} else if (this.activeView === 'settings') {
|
||||||
|
if (updateRoute) this.setRoute('#settings');
|
||||||
|
await this.loadSettings();
|
||||||
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||||
|
await this.loadSettingsProbe(false);
|
||||||
|
}
|
||||||
|
} else if (this.activeView === 'tools' && updateRoute) {
|
||||||
|
this.setRoute('#tools');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openReviews(status = null) {
|
||||||
|
this.activeView = 'reviews';
|
||||||
|
this.reviewFilter.status = status;
|
||||||
|
this.setRoute(status ? `#reviews/${status}` : '#reviews');
|
||||||
|
this.loadReviews();
|
||||||
|
},
|
||||||
|
|
||||||
|
openJobs(name = this.activeJobName) {
|
||||||
|
this.activeView = 'jobs';
|
||||||
|
if (name) this.activeJobName = name;
|
||||||
|
this.setRoute(this.activeJobName ? `#jobs/${encodeURIComponent(this.activeJobName)}` : '#jobs');
|
||||||
|
this.loadJobs();
|
||||||
|
if (this.activeJobName) this.loadRunsForJob(this.activeJobName);
|
||||||
|
},
|
||||||
|
|
||||||
|
openLibrary(kind = this.libraryKind) {
|
||||||
|
this.activeView = 'library';
|
||||||
|
this.libraryKind = ['artists', 'releases', 'playlists'].includes(kind) ? kind : 'artists';
|
||||||
|
this.setRoute(`#library/${this.libraryKind}`);
|
||||||
|
this.loadLibrary();
|
||||||
|
},
|
||||||
|
|
||||||
|
openTools() {
|
||||||
|
this.activeView = 'tools';
|
||||||
|
this.setRoute('#tools');
|
||||||
|
},
|
||||||
|
|
||||||
async loadReviews(resetOffset = true) {
|
async loadReviews(resetOffset = true) {
|
||||||
if (resetOffset) this.reviews.offset = 0;
|
if (resetOffset) this.reviews.offset = 0;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -1462,13 +2046,108 @@ function adminV2() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadSettings(showErrors = true) {
|
||||||
|
try {
|
||||||
|
this.settings = await this.request(`${this.apiBase}/settings`);
|
||||||
|
this.settingsDraft = Object.assign({}, this.settingsDraft, this.settings.values || {});
|
||||||
|
} catch (error) {
|
||||||
|
if (showErrors) this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
if (this.settingsSaving) return;
|
||||||
|
this.settingsSaving = true;
|
||||||
|
try {
|
||||||
|
await this.request(`${this.apiBase}/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(this.settingsDraft)
|
||||||
|
});
|
||||||
|
await this.loadSettings(false);
|
||||||
|
this.showToast('Settings saved');
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.settingsSaving = false;
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSettings() {
|
||||||
|
this.activeView = 'settings';
|
||||||
|
this.setRoute('#settings');
|
||||||
|
await this.loadSettings();
|
||||||
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||||
|
await this.loadSettingsProbe(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSettingsProbe(showErrors = true) {
|
||||||
|
this.settingsProbeLoading = true;
|
||||||
|
try {
|
||||||
|
this.settingsProbe = await this.request(`${this.apiBase}/settings/probe`);
|
||||||
|
} catch (error) {
|
||||||
|
this.settingsProbe = { status: 'error', ok: false, error: error.message };
|
||||||
|
if (showErrors) this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.settingsProbeLoading = false;
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
settingSource(key) {
|
||||||
|
return (this.settings.sources || {})[key] || 'default';
|
||||||
|
},
|
||||||
|
|
||||||
|
sourceClass(key) {
|
||||||
|
return this.settingSource(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
callbackUrl() {
|
||||||
|
return `${window.location.origin}/auth/oidc/callback`;
|
||||||
|
},
|
||||||
|
|
||||||
|
settingsProbeBadge() {
|
||||||
|
if (this.settingsProbeLoading) return 'running';
|
||||||
|
if (this.settingsProbe.status === 'ok') return 'ok';
|
||||||
|
if (this.settingsProbe.status === 'error') return 'failed';
|
||||||
|
return 'disabled';
|
||||||
|
},
|
||||||
|
|
||||||
|
settingsProbeSubtitle() {
|
||||||
|
if (this.settingsProbeLoading) return 'Checking LLM connection';
|
||||||
|
if (this.settingsProbe.status === 'ok') return 'LLM connection OK';
|
||||||
|
if (this.settingsProbe.status === 'error') return 'LLM connection error';
|
||||||
|
if (this.settingsProbe.status === 'disabled') return 'Agent is disabled';
|
||||||
|
if (this.settingsProbe.status === 'not_configured') return 'LLM URL is not configured';
|
||||||
|
return 'Connection probe';
|
||||||
|
},
|
||||||
|
|
||||||
|
settingsProbeText() {
|
||||||
|
if (this.settingsProbeLoading) return 'Checking connection...';
|
||||||
|
if (this.settingsProbe.error) return this.settingsProbe.error;
|
||||||
|
return this.settingsProbeSubtitle();
|
||||||
|
},
|
||||||
|
|
||||||
setReviewStatus(status) {
|
setReviewStatus(status) {
|
||||||
this.reviewFilter.status = status;
|
this.openReviews(status);
|
||||||
this.loadReviews();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
openReview(row) {
|
openReview(row) {
|
||||||
this.activeReview = row;
|
this.activeReview = row;
|
||||||
|
this.reviewDraft = Object.assign({
|
||||||
|
title: '',
|
||||||
|
artist: '',
|
||||||
|
album: '',
|
||||||
|
year: '',
|
||||||
|
track_number: '',
|
||||||
|
genre: '',
|
||||||
|
featured_artists: '',
|
||||||
|
release_type: 'album',
|
||||||
|
notes: ''
|
||||||
|
}, row.normalized || {});
|
||||||
this.activeRunDetail = null;
|
this.activeRunDetail = null;
|
||||||
this.reviewModalOpen = true;
|
this.reviewModalOpen = true;
|
||||||
},
|
},
|
||||||
@@ -1562,6 +2241,23 @@ function adminV2() {
|
|||||||
this.reviewModalOpen = false;
|
this.reviewModalOpen = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async approveActiveReview() {
|
||||||
|
if (!this.activeReview) return;
|
||||||
|
try {
|
||||||
|
await this.request(`${this.apiBase}/reviews/${this.activeReview.id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(this.reviewDraft)
|
||||||
|
});
|
||||||
|
this.showToast('Review approved');
|
||||||
|
this.reviewModalOpen = false;
|
||||||
|
this.activeReview = null;
|
||||||
|
await this.loadReviews(false);
|
||||||
|
await this.loadLibrary(false);
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async runJob(job) {
|
async runJob(job) {
|
||||||
job.launching = true;
|
job.launching = true;
|
||||||
try {
|
try {
|
||||||
@@ -1590,6 +2286,7 @@ function adminV2() {
|
|||||||
|
|
||||||
async selectJob(name) {
|
async selectJob(name) {
|
||||||
this.activeJobName = name;
|
this.activeJobName = name;
|
||||||
|
this.setRoute(`#jobs/${encodeURIComponent(name)}`);
|
||||||
this.activeReview = null;
|
this.activeReview = null;
|
||||||
this.activeRunDetail = null;
|
this.activeRunDetail = null;
|
||||||
await this.loadRunsForJob(name);
|
await this.loadRunsForJob(name);
|
||||||
@@ -1770,6 +2467,7 @@ function adminV2() {
|
|||||||
if (this.activeView === 'library') return 'Library Workbench';
|
if (this.activeView === 'library') return 'Library Workbench';
|
||||||
if (this.activeView === 'jobs') return 'Tasks';
|
if (this.activeView === 'jobs') return 'Tasks';
|
||||||
if (this.activeView === 'tools') return 'Future Tools';
|
if (this.activeView === 'tools') return 'Future Tools';
|
||||||
|
if (this.activeView === 'settings') return 'Settings';
|
||||||
return 'Review Queue';
|
return 'Review Queue';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1777,6 +2475,7 @@ function adminV2() {
|
|||||||
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, and playlists';
|
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, and playlists';
|
||||||
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
||||||
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
|
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
|
||||||
|
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
||||||
return 'Full-screen review triage with filter-aware bulk actions';
|
return 'Full-screen review triage with filter-aware bulk actions';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+4
-5117
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
|||||||
|
<!-- Info Modal -->
|
||||||
|
<template x-if="$store.info.modal">
|
||||||
|
<div class="modal-overlay" @click.self="$store.info.close()">
|
||||||
|
<div class="modal-box info-modal">
|
||||||
|
<div class="info-modal-head">
|
||||||
|
<h3 x-text="$store.info.modal.title"></h3>
|
||||||
|
<button class="mobile-list-action" @click="$store.info.close()" title="{{ t.player_close }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="info-modal-body" x-text="$store.info.modal.body"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Create / Rename Playlist Modal -->
|
||||||
|
<template x-if="$store.playlists.modal">
|
||||||
|
<div class="modal-overlay" @click.self="$store.playlists.modal = null">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 x-text="$store.playlists.modal.mode === 'create' ? '{{ t.player_new_playlist }}' : '{{ t.player_rename_playlist }}'"></h3>
|
||||||
|
<input type="text" x-model="$store.playlists.modal.title" placeholder="{{ t.player_playlist_name }}"
|
||||||
|
@keydown.enter="$store.playlists.submitModal()" x-init="$nextTick(() => $el.focus())">
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.modal = null">{{ t.player_cancel }}</button>
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.playlists.submitModal()"
|
||||||
|
x-text="$store.playlists.modal.mode === 'create' ? '{{ t.player_create }}' : '{{ t.player_save }}'"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Add to Playlist Modal -->
|
||||||
|
<template x-if="$store.playlists.picker">
|
||||||
|
<div class="modal-overlay" @click.self="$store.playlists.picker = null">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>{{ t.player_add_to_playlist }}</h3>
|
||||||
|
<div class="modal-playlist-list">
|
||||||
|
<template x-for="pl in $store.playlists.list.filter(p => p.kind === 'user' && p.is_own)" :key="pl.id">
|
||||||
|
<div class="modal-playlist-item" @click="$store.playlists.addToPicked(pl.id)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
|
<span x-text="pl.title"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.picker = null">{{ t.player_cancel }}</button>
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.playlists.picker = null; $store.playlists.showCreate()">{{ t.player_new_playlist }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Torrent Import Modal -->
|
||||||
|
<template x-if="$store.torrents.modal">
|
||||||
|
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
||||||
|
<div class="modal-box torrent-modal">
|
||||||
|
<div class="torrent-modal-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ t.player_torrent_manager }}</h3>
|
||||||
|
<p class="torrent-message" style="margin:4px 0 0"
|
||||||
|
:class="{ error: $store.torrents.error }"
|
||||||
|
x-text="$store.torrents.message"></p>
|
||||||
|
</div>
|
||||||
|
<button class="torrent-modal-close"
|
||||||
|
@click="$store.torrents.close()"
|
||||||
|
title="{{ t.player_close }}"
|
||||||
|
aria-label="{{ t.player_close }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="torrent-client-status">
|
||||||
|
<span class="torrent-status-pill"
|
||||||
|
:class="{ active: $store.torrents.activeCount() > 0 }"
|
||||||
|
x-text="$store.torrents.clientSummary()"></span>
|
||||||
|
<span class="torrent-status-pill torrent-agent-pill"
|
||||||
|
:class="{ active: $store.torrents.agentBusy() }">
|
||||||
|
<span class="torrent-agent-dot"></span>
|
||||||
|
<span x-text="$store.torrents.agentSummary()"></span>
|
||||||
|
</span>
|
||||||
|
<span class="torrent-status-pill"
|
||||||
|
x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="torrent-manager-layout">
|
||||||
|
<aside class="torrent-manager-sidebar">
|
||||||
|
<div class="torrent-manager-title">
|
||||||
|
<span>{{ t.player_saved_torrents }}</span>
|
||||||
|
<button class="modal-btn modal-btn-ghost" style="padding:4px 8px"
|
||||||
|
@click="$store.torrents.loadSessions()"
|
||||||
|
:disabled="$store.torrents.loading">{{ t.player_refresh }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-session-list">
|
||||||
|
<template x-if="!$store.torrents.loadingSessions && $store.torrents.sessions.length === 0">
|
||||||
|
<div class="empty-state" style="padding:28px 12px">
|
||||||
|
<p>{{ t.player_no_saved_torrents }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="job in $store.torrents.sessions" :key="job.id">
|
||||||
|
<div class="torrent-session-row"
|
||||||
|
:class="{ active: $store.torrents.workspaceMode === 'session' && $store.torrents.previewData && $store.torrents.previewData.id === job.id }"
|
||||||
|
@click="$store.torrents.openSession(job.id)">
|
||||||
|
<div class="torrent-session-main">
|
||||||
|
<div class="torrent-session-topline">
|
||||||
|
<div class="torrent-session-name" x-text="job.name"></div>
|
||||||
|
<span class="torrent-status-badge"
|
||||||
|
:class="$store.torrents.statusBadgeClass(job)"
|
||||||
|
x-text="$store.torrents.statusLabel(job)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-session-meta" x-text="$store.torrents.sessionMeta(job)"></div>
|
||||||
|
<div class="torrent-session-progress">
|
||||||
|
<div class="torrent-session-progress-bar"
|
||||||
|
:style="'width:' + $store.torrents.progressValue(job) + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button"
|
||||||
|
class="torrent-session-row torrent-session-add"
|
||||||
|
:class="{ active: $store.torrents.isImporting() }"
|
||||||
|
@click="$store.torrents.addNew()"
|
||||||
|
:disabled="$store.torrents.loading">
|
||||||
|
<span class="torrent-session-add-icon">+</span>
|
||||||
|
<span>{{ t.player_upload }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="torrent-workspace">
|
||||||
|
<template x-if="$store.torrents.workspaceMode === 'empty'">
|
||||||
|
<div class="empty-state torrent-workspace-empty">
|
||||||
|
<p x-text="T.chooseSavedOrAddTorrent"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.isImporting()">
|
||||||
|
<div class="torrent-import-panel">
|
||||||
|
<div class="torrent-modal-grid">
|
||||||
|
<div>
|
||||||
|
<label for="local-file-input">{{ t.player_local_files }}</label>
|
||||||
|
<input id="local-file-input" type="file" multiple accept="audio/*,.mp3,.flac,.wav,.m4a,.ogg,.opus,.aac"
|
||||||
|
@change="$store.torrents.setLocalFiles($event.target.files)">
|
||||||
|
<div class="torrent-upload-summary" x-text="$store.torrents.localUploadSummary()"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
|
||||||
|
<input id="torrent-magnet-input" type="text"
|
||||||
|
x-model="$store.torrents.magnet"
|
||||||
|
placeholder="magnet:?xt=urn:btih:...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="torrent-file-input">{{ t.player_torrent_file }}</label>
|
||||||
|
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
|
||||||
|
@change="$store.torrents.file = $event.target.files[0] || null">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-upload-progress"
|
||||||
|
x-show="$store.torrents.uploadProgress > 0 || ($store.torrents.localFiles.length > 0 && $store.torrents.loading)">
|
||||||
|
<div class="torrent-progress-head">
|
||||||
|
<span x-text="$store.torrents.uploadProgress >= 100 ? T.uploadComplete : T.uploadingFiles"></span>
|
||||||
|
<span x-text="$store.torrents.uploadProgressText"></span>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-progress-track">
|
||||||
|
<div class="torrent-progress-bar"
|
||||||
|
:style="'width:' + $store.torrents.uploadProgress + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-actions">
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
|
||||||
|
{{ t.player_upload_content }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.currentJob">
|
||||||
|
<div class="torrent-progress-card">
|
||||||
|
<div class="torrent-progress-head">
|
||||||
|
<span x-text="$store.torrents.statusText($store.torrents.currentJob)"></span>
|
||||||
|
<span x-text="$store.torrents.progressValue($store.torrents.currentJob).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-progress-track">
|
||||||
|
<div class="torrent-progress-bar"
|
||||||
|
:style="'width:' + $store.torrents.progressValue($store.torrents.currentJob) + '%'"></div>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-progress-details"
|
||||||
|
:class="{ completed: $store.torrents.isCompleted($store.torrents.currentJob) }">
|
||||||
|
<span class="torrent-progress-metric">
|
||||||
|
<span class="torrent-progress-label"
|
||||||
|
x-text="$store.torrents.isCompleted($store.torrents.currentJob) ? T.size : T.downloaded"></span>
|
||||||
|
<span class="torrent-progress-value"
|
||||||
|
x-text="$store.torrents.progressDetailText($store.torrents.currentJob)"></span>
|
||||||
|
</span>
|
||||||
|
<span class="torrent-progress-metric"
|
||||||
|
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
|
||||||
|
<span class="torrent-progress-label" x-text="T.speed"></span>
|
||||||
|
<span class="torrent-progress-value"
|
||||||
|
x-text="$store.torrents.speedText($store.torrents.currentJob)"></span>
|
||||||
|
</span>
|
||||||
|
<span class="torrent-progress-metric"
|
||||||
|
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
|
||||||
|
<span class="torrent-progress-label" x-text="T.peers"></span>
|
||||||
|
<span class="torrent-progress-value"
|
||||||
|
x-text="$store.torrents.peerText($store.torrents.currentJob)"></span>
|
||||||
|
</span>
|
||||||
|
<span class="torrent-progress-metric"
|
||||||
|
x-show="!$store.torrents.isCompleted($store.torrents.currentJob) && $store.torrents.etaText($store.torrents.currentJob)">
|
||||||
|
<span class="torrent-progress-label" x-text="T.eta"></span>
|
||||||
|
<span class="torrent-progress-value"
|
||||||
|
x-text="$store.torrents.etaText($store.torrents.currentJob)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="$store.torrents.previewData">
|
||||||
|
<div class="torrent-preview-panel">
|
||||||
|
<div class="torrent-preview-head">
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div class="torrent-preview-title" x-text="$store.torrents.previewData.name"></div>
|
||||||
|
<div class="torrent-preview-meta"
|
||||||
|
x-text="$store.torrents.previewData.files.length + ' {{ t.player_files_count }} - ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-preview-actions">
|
||||||
|
<button class="modal-btn"
|
||||||
|
:class="$store.torrents.actionButtonClass()"
|
||||||
|
@click="$store.torrents.toggleDownloadAction()"
|
||||||
|
:disabled="$store.torrents.actionButtonDisabled()">
|
||||||
|
<span x-text="$store.torrents.actionButtonText()"></span>
|
||||||
|
</button>
|
||||||
|
<button class="modal-btn modal-btn-danger"
|
||||||
|
@click="$store.torrents.removeSession($store.torrents.previewData.id)"
|
||||||
|
:disabled="$store.torrents.loading">
|
||||||
|
{{ t.player_delete }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<div class="torrent-actions" style="margin-top:0">
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(true)">{{ t.player_expand_all }}</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(false)">{{ t.player_collapse }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-file-tree">
|
||||||
|
<template x-for="node in $store.torrents.visibleNodes()" :key="node.key">
|
||||||
|
<div class="torrent-tree-row" :style="'--indent:' + $store.torrents.rowIndent(node) + 'px'">
|
||||||
|
<button class="torrent-tree-toggle"
|
||||||
|
:class="{ expanded: $store.torrents.expanded.has(node.key) }"
|
||||||
|
@click="$store.torrents.toggleExpand(node)"
|
||||||
|
:style="node.type === 'folder' ? '' : 'visibility:hidden'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="torrent-tree-check"
|
||||||
|
:class="$store.torrents.nodeCheckClass(node)"
|
||||||
|
@click="$store.torrents.toggleNode(node)">
|
||||||
|
<template x-if="$store.torrents.nodeState(node) === 'checked'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="$store.torrents.nodeState(node) === 'partial'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<div class="torrent-tree-label" :title="node.name">
|
||||||
|
<template x-if="node.type === 'folder'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="node.type === 'file'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<span class="torrent-file-name" x-text="node.name"></span>
|
||||||
|
</div>
|
||||||
|
<span class="torrent-file-size" x-text="$store.torrents.bytes(node.size)"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Play History Modal -->
|
||||||
|
<template x-if="$store.history.modal">
|
||||||
|
<div class="modal-overlay" @click.self="$store.history.close()">
|
||||||
|
<div class="modal-box history-modal">
|
||||||
|
<h3>{{ t.player_play_history }}</h3>
|
||||||
|
<p class="torrent-message" :class="{ error: $store.history.error }"
|
||||||
|
x-text="$store.history.message"></p>
|
||||||
|
<div class="history-list">
|
||||||
|
<template x-if="!$store.history.loading && $store.history.items.length === 0">
|
||||||
|
<div class="empty-state" style="padding:32px 16px">
|
||||||
|
<p>{{ t.player_no_plays_yet }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="item in $store.history.items" :key="item.id">
|
||||||
|
<div class="history-row">
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div class="history-title" x-text="item.track_title"></div>
|
||||||
|
<div class="history-release" x-text="item.release_title || '{{ t.player_unknown_release }}'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="history-date" x-text="$store.history.date(item.played_at)"></div>
|
||||||
|
<div class="history-duration" x-text="$store.history.duration(item.duration_listened)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="history-pager">
|
||||||
|
<button class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.history.load($store.history.page - 1)"
|
||||||
|
:disabled="$store.history.loading || $store.history.page <= 1">
|
||||||
|
{{ t.player_previous }}
|
||||||
|
</button>
|
||||||
|
<span class="history-release"
|
||||||
|
x-text="'{{ t.player_page }} ' + $store.history.page + ' {{ t.player_of }} ' + $store.history.totalPages()"></span>
|
||||||
|
<button class="modal-btn modal-btn-primary"
|
||||||
|
@click="$store.history.load($store.history.page + 1)"
|
||||||
|
:disabled="$store.history.loading || $store.history.page >= $store.history.totalPages()">
|
||||||
|
{{ t.player_next }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user