Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
291265be7d | ||
|
|
624e75839d | ||
|
|
fd766cda24 | ||
|
|
49000f716c | ||
|
|
ee4990e2f1 | ||
|
|
e64b61c167 | ||
|
|
b737ced3fc | ||
|
|
c2bdd62a51 | ||
|
|
d1370c6a28 | ||
|
|
2fc5fd7960 | ||
|
|
63506e3af2 | ||
|
|
5b339aa921 | ||
|
|
42c772f735 | ||
|
|
e738086573 | ||
|
|
4b7756c36e | ||
|
|
4381750c6e | ||
|
|
3485f643f4 | ||
|
|
bca0f5e2f0 | ||
|
|
53b2ff29f8 | ||
|
|
c349512fb0 | ||
|
|
0615356785 | ||
|
|
184371afca | ||
|
|
716da908c9 | ||
|
|
0c120c0868 | ||
|
|
d9d0fbb7d1 | ||
|
|
71d6556ba8 | ||
|
|
0ac59eb0ca | ||
|
|
652c6a470d | ||
|
|
1c54782dd7 | ||
|
|
8fa06038fe | ||
|
|
6b69cc0fc0 | ||
|
|
624cadab64 | ||
|
|
a8756c95de | ||
|
|
952d11e6f5 | ||
|
|
df16713aa2 | ||
|
|
3a9240b82c | ||
|
|
d31dce3ece | ||
|
|
1e1453e465 | ||
|
|
d2a8f301b8 | ||
|
|
0a4f78acfa | ||
|
|
f716c22f86 | ||
|
|
a1dafaa5f2 | ||
|
|
c244b3d4d8 | ||
|
|
27ee56c5b7 | ||
|
|
88b5c7f7d1 | ||
|
|
a60432610f | ||
|
|
e1a4b6267f | ||
|
|
496c501076 | ||
|
|
dedddc7cd8 | ||
|
|
97c82b4ba2 | ||
|
|
de7626a6a9 | ||
|
|
fb7d0c7e1a | ||
|
|
c3b70dc16c | ||
|
|
66bb127d43 | ||
|
|
1bb5a2f973 | ||
|
|
8073ac9a97 | ||
|
|
ec7c5c9049 | ||
|
|
d1113effa5 | ||
|
|
072c00a48e | ||
|
|
34e25fac2c | ||
|
|
0cb731fb26 | ||
|
|
c43ee02b00 | ||
|
|
fc6090d6a0 | ||
|
|
476b300a6c | ||
|
|
59910bc34e | ||
|
|
5600a8065d | ||
|
|
015d75c701 | ||
|
|
1c70349df8 | ||
|
|
65da460c0c | ||
|
|
538a6f6abf | ||
|
|
04c30bc4b8 | ||
|
|
c0342ed987 | ||
|
|
4b8797bb2e | ||
|
|
d425bf3087 | ||
|
|
82923c871e | ||
|
|
3878d746d2 | ||
|
|
31ae57a5a3 | ||
|
|
16de1fb711 |
@@ -2,3 +2,4 @@
|
|||||||
/nul
|
/nul
|
||||||
/.claude
|
/.claude
|
||||||
/media
|
/media
|
||||||
|
/federation
|
||||||
|
|||||||
Generated
+2630
-601
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -1,17 +1,24 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.10"
|
version = "0.9.1"
|
||||||
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"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cot = { version = "0.6.0", features = ["postgres", "json", "openapi", "swagger-ui"] }
|
# default-features off: cot's defaults include the sqlite backend, whose old
|
||||||
|
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
|
||||||
|
# binary). This server only ever talks PostgreSQL.
|
||||||
|
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json", "openapi", "swagger-ui"] }
|
||||||
schemars = { version = "0.9", features = ["derive"] }
|
schemars = { version = "0.9", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
openidconnect = "4.0"
|
openidconnect = "4.0"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||||
|
async-stream = "0.3"
|
||||||
|
bytes = "1"
|
||||||
|
tower = "0.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
blake3 = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -20,10 +27,16 @@ 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"
|
||||||
|
md-5 = "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"
|
||||||
croner = "3"
|
croner = "3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
postcard = { version = "1", features = ["alloc"] }
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
|
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||||
|
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||||
|
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||||
|
|||||||
+299
-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,84 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_reviews_bulk",
|
"admin_v2_reviews_bulk",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/users",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session, db: Database,
|
||||||
|
query: UrlQuery<v2::UsersQuery>| {
|
||||||
|
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::users(session, db, pg_pool, query.0).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_users",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/users/{id}",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session, db: Database, path: Path<PathId>| {
|
||||||
|
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::user_detail(session, db, pg_pool, path.0.id).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_user_detail",
|
||||||
|
),
|
||||||
|
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",
|
||||||
{
|
{
|
||||||
@@ -253,6 +331,58 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_jobs",
|
"admin_v2_jobs",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/jobs/metadata_backfill/run-options",
|
||||||
|
cot::router::method::post({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::MetadataBackfillRunRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
v2::run_metadata_backfill(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"admin_v2_metadata_backfill_run_options",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/jobs/artwork_backfill/run-options",
|
||||||
|
cot::router::method::post({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::ArtworkBackfillRunRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("admin pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
v2::run_artwork_backfill(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"admin_v2_artwork_backfill_run_options",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/jobs/{name}/run",
|
"/v2/api/jobs/{name}/run",
|
||||||
cot::router::method::post({
|
cot::router::method::post({
|
||||||
@@ -264,6 +394,59 @@ 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(
|
||||||
|
"/v2/api/federation",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_status(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_status",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/sync",
|
||||||
|
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_sync(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_sync",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/ticket",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_ticket(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_ticket",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/connect",
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::FederationConnectRequest>| async move {
|
||||||
|
v2::federation_connect(session, db, json).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"admin_v2_federation_connect",
|
||||||
|
),
|
||||||
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({
|
||||||
@@ -378,6 +561,114 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_library_item",
|
"admin_v2_library_item",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/item/detail",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
query: UrlQuery<v2::LibraryItemDetailQuery>| {
|
||||||
|
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::library_item_detail(session, db, pg_pool, query.0).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_library_item_detail",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/tracks/search",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
query: UrlQuery<v2::TrackSearchQuery>| {
|
||||||
|
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::track_search(session, db, pg_pool, query.0).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_library_tracks_search",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/item/image",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::SetLibraryImageRequest>| {
|
||||||
|
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::set_library_item_image(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_library_item_image",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/item/upload-image",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::UploadLibraryImageRequest>| {
|
||||||
|
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::upload_library_item_image(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_library_item_upload_image",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/library/bulk",
|
"/v2/api/library/bulk",
|
||||||
{
|
{
|
||||||
@@ -1027,7 +1318,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 +1335,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -1090,6 +1382,9 @@ impl App for AdminApp {
|
|||||||
all.extend(cot::db::migrations::wrap_migrations(
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
crate::scheduler::db_migrations::MIGRATIONS,
|
crate::scheduler::db_migrations::MIGRATIONS,
|
||||||
));
|
));
|
||||||
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
|
crate::auth::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
all
|
all
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2247
-11
File diff suppressed because it is too large
Load Diff
+143
-12
@@ -184,6 +184,16 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
|||||||
config.agent_concurrency.to_string(),
|
config.agent_concurrency.to_string(),
|
||||||
defaults.agent_concurrency.to_string()
|
defaults.agent_concurrency.to_string()
|
||||||
),
|
),
|
||||||
|
entry!(
|
||||||
|
lastfm_api_key,
|
||||||
|
config.lastfm_api_key.clone(),
|
||||||
|
defaults.lastfm_api_key.clone()
|
||||||
|
),
|
||||||
|
entry!(
|
||||||
|
lastfm_shared_secret,
|
||||||
|
config.lastfm_shared_secret.clone(),
|
||||||
|
defaults.lastfm_shared_secret.clone()
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +268,10 @@ struct SettingsTemplate {
|
|||||||
agent_context_limit_source: &'static str,
|
agent_context_limit_source: &'static str,
|
||||||
agent_concurrency: String,
|
agent_concurrency: String,
|
||||||
agent_concurrency_source: &'static str,
|
agent_concurrency_source: &'static str,
|
||||||
|
lastfm_api_key: String,
|
||||||
|
lastfm_api_key_source: &'static str,
|
||||||
|
lastfm_shared_secret: String,
|
||||||
|
lastfm_shared_secret_source: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn settings_handler(
|
pub async fn settings_handler(
|
||||||
@@ -310,6 +324,10 @@ pub async fn settings_handler(
|
|||||||
agent_context_limit_source: sources.agent_context_limit.code(),
|
agent_context_limit_source: sources.agent_context_limit.code(),
|
||||||
agent_concurrency: config.agent_concurrency.to_string(),
|
agent_concurrency: config.agent_concurrency.to_string(),
|
||||||
agent_concurrency_source: sources.agent_concurrency.code(),
|
agent_concurrency_source: sources.agent_concurrency.code(),
|
||||||
|
lastfm_api_key: config.lastfm_api_key.clone(),
|
||||||
|
lastfm_api_key_source: sources.lastfm_api_key.code(),
|
||||||
|
lastfm_shared_secret: config.lastfm_shared_secret.clone(),
|
||||||
|
lastfm_shared_secret_source: sources.lastfm_shared_secret.code(),
|
||||||
};
|
};
|
||||||
Ok(Html::new(template.render()?))
|
Ok(Html::new(template.render()?))
|
||||||
}
|
}
|
||||||
@@ -334,6 +352,8 @@ pub struct OidcSettingsForm {
|
|||||||
agent_confidence_threshold: Option<String>,
|
agent_confidence_threshold: Option<String>,
|
||||||
agent_context_limit: Option<String>,
|
agent_context_limit: Option<String>,
|
||||||
agent_concurrency: Option<String>,
|
agent_concurrency: Option<String>,
|
||||||
|
lastfm_api_key: Option<String>,
|
||||||
|
lastfm_shared_secret: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn settings_submit(
|
pub async fn settings_submit(
|
||||||
@@ -380,7 +400,9 @@ pub async fn settings_submit(
|
|||||||
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
|
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
|
||||||
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
|
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
|
||||||
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
|
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
|
||||||
let fields: [(&str, &str); 18] = [
|
let lastfm_api_key = data.lastfm_api_key.unwrap_or_default();
|
||||||
|
let lastfm_shared_secret = data.lastfm_shared_secret.unwrap_or_default();
|
||||||
|
let fields: [(&str, &str); 20] = [
|
||||||
("auth_password_enabled", pw_enabled),
|
("auth_password_enabled", pw_enabled),
|
||||||
("auth_sso_enabled", sso_enabled),
|
("auth_sso_enabled", sso_enabled),
|
||||||
("oidc_button_text", &oidc_button_text),
|
("oidc_button_text", &oidc_button_text),
|
||||||
@@ -399,6 +421,8 @@ pub async fn settings_submit(
|
|||||||
("agent_confidence_threshold", &agent_confidence_threshold),
|
("agent_confidence_threshold", &agent_confidence_threshold),
|
||||||
("agent_context_limit", &agent_context_limit),
|
("agent_context_limit", &agent_context_limit),
|
||||||
("agent_concurrency", &agent_concurrency),
|
("agent_concurrency", &agent_concurrency),
|
||||||
|
("lastfm_api_key", &lastfm_api_key),
|
||||||
|
("lastfm_shared_secret", &lastfm_shared_secret),
|
||||||
];
|
];
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
|
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
|
||||||
@@ -799,7 +823,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 +903,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"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1378,6 +1402,8 @@ pub struct MetadataBackfillForm {
|
|||||||
audio_sample_rate: Option<String>,
|
audio_sample_rate: Option<String>,
|
||||||
audio_bit_depth: Option<String>,
|
audio_bit_depth: Option<String>,
|
||||||
duration_seconds: Option<String>,
|
duration_seconds: Option<String>,
|
||||||
|
local_genres: Option<String>,
|
||||||
|
lastfm_tags: Option<String>,
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,6 +1470,9 @@ pub async fn metadata_backfill_run(
|
|||||||
audio_sample_rate: data.audio_sample_rate.is_some(),
|
audio_sample_rate: data.audio_sample_rate.is_some(),
|
||||||
audio_bit_depth: data.audio_bit_depth.is_some(),
|
audio_bit_depth: data.audio_bit_depth.is_some(),
|
||||||
duration_seconds: data.duration_seconds.is_some(),
|
duration_seconds: data.duration_seconds.is_some(),
|
||||||
|
local_genres: data.local_genres.is_some(),
|
||||||
|
lastfm_tags: data.lastfm_tags.is_some(),
|
||||||
|
musicbrainz_tags: true,
|
||||||
overwrite: data.mode.as_deref() == Some("overwrite"),
|
overwrite: data.mode.as_deref() == Some("overwrite"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1794,12 +1823,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 +1951,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 +1976,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
|
||||||
|
|||||||
+73
-8
@@ -3,6 +3,7 @@
|
|||||||
//! Sources (in priority order):
|
//! Sources (in priority order):
|
||||||
//! 1. Standalone image files in the album folder (cover.jpg, folder.jpg, etc.)
|
//! 1. Standalone image files in the album folder (cover.jpg, folder.jpg, etc.)
|
||||||
//! 2. Embedded cover art in audio file metadata (ID3 APIC, Vorbis METADATA_BLOCK_PICTURE, etc.)
|
//! 2. Embedded cover art in audio file metadata (ID3 APIC, Vorbis METADATA_BLOCK_PICTURE, etc.)
|
||||||
|
//! 3. Remote metadata providers used by background backfill jobs.
|
||||||
//!
|
//!
|
||||||
//! The first usable image found is saved as a MediaFile with file_type="cover_art"
|
//! The first usable image found is saved as a MediaFile with file_type="cover_art"
|
||||||
//! and linked to the Release via cover_file_id.
|
//! and linked to the Release via cover_file_id.
|
||||||
@@ -26,6 +27,8 @@ pub enum CoverSource {
|
|||||||
FolderFile(PathBuf),
|
FolderFile(PathBuf),
|
||||||
/// Embedded in an audio file's metadata.
|
/// Embedded in an audio file's metadata.
|
||||||
Embedded(PathBuf),
|
Embedded(PathBuf),
|
||||||
|
/// Downloaded from a remote metadata provider.
|
||||||
|
Remote(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Well-known cover art filenames, in priority order.
|
/// Well-known cover art filenames, in priority order.
|
||||||
@@ -125,13 +128,21 @@ pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<C
|
|||||||
match tokio::fs::read(img_path).await {
|
match tokio::fs::read(img_path).await {
|
||||||
Ok(data) if !data.is_empty() => {
|
Ok(data) if !data.is_empty() => {
|
||||||
let mime = mime_for_image(img_path);
|
let mime = mime_for_image(img_path);
|
||||||
|
crate::metrics::record_agent_cover_lookup("folder", "ok", data.len());
|
||||||
return Some(CoverImage {
|
return Some(CoverImage {
|
||||||
data,
|
data,
|
||||||
mime_type: mime,
|
mime_type: mime,
|
||||||
source: CoverSource::FolderFile(img_path.clone()),
|
source: CoverSource::FolderFile(img_path.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => continue,
|
Ok(_) => {
|
||||||
|
crate::metrics::record_agent_cover_lookup("folder", "empty", 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
crate::metrics::record_agent_cover_lookup("folder", "error", 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +151,12 @@ pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<C
|
|||||||
let path = audio_path.to_path_buf();
|
let path = audio_path.to_path_buf();
|
||||||
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
|
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
|
||||||
if let Ok(Some(cover)) = result {
|
if let Ok(Some(cover)) = result {
|
||||||
|
crate::metrics::record_agent_cover_lookup("embedded", "ok", cover.data.len());
|
||||||
return Some(cover);
|
return Some(cover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crate::metrics::record_agent_cover_lookup("none", "not_found", 0);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,24 +333,62 @@ pub async fn save_cover_to_storage(
|
|||||||
let hash = hash_image(&cover.data);
|
let hash = hash_image(&cover.data);
|
||||||
|
|
||||||
// Check if we already have this exact image in the DB
|
// Check if we already have this exact image in the DB
|
||||||
let existing: Option<(i64,)> = sqlx::query_as(
|
let existing: Option<(i64, String)> = sqlx::query_as(
|
||||||
"SELECT id FROM furumusic__media_file WHERE sha256_hash = $1 AND file_type = 'cover_art' LIMIT 1",
|
"SELECT id, file_path FROM furumusic__media_file WHERE sha256_hash = $1 AND file_type = 'cover_art' LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(&hash)
|
.bind(&hash)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some((id,)) = existing {
|
if let Some((id, file_path)) = existing {
|
||||||
return Ok(id);
|
let path = crate::media_paths::resolve_media_file_path(storage_dir, &file_path);
|
||||||
|
let is_inside_storage = crate::media_paths::path_for_root(storage_dir, &path).is_some();
|
||||||
|
if !is_inside_storage {
|
||||||
|
tracing::warn!(
|
||||||
|
media_file_id = id,
|
||||||
|
path = %path.display(),
|
||||||
|
"Ignoring duplicate cover hash whose stored file is outside agent_storage_dir"
|
||||||
|
);
|
||||||
|
} else if !path.exists() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
match tokio::fs::write(&path, &cover.data).await {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
media_file_id = id,
|
||||||
|
path = %path.display(),
|
||||||
|
"Restored missing cover file for existing MediaFile"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
media_file_id = id,
|
||||||
|
path = %path.display(),
|
||||||
|
error = %err,
|
||||||
|
"Failed to restore missing cover file for existing MediaFile; creating a new cover file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_inside_storage && path.exists() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ext = extension_for_mime(&cover.mime_type);
|
let ext = extension_for_mime(&cover.mime_type);
|
||||||
let filename = format!("cover.{ext}");
|
let hash_prefix: String = hash.chars().take(12).collect();
|
||||||
|
let filename = format!("cover-{hash_prefix}.{ext}");
|
||||||
|
|
||||||
let artist_dir = sanitize_dir_name(artist_name);
|
let artist_dir = sanitize_dir_name(artist_name);
|
||||||
let album_dir = sanitize_dir_name(release_title);
|
let album_dir = sanitize_dir_name(release_title);
|
||||||
|
|
||||||
let dest_dir = Path::new(storage_dir).join(&artist_dir).join(&album_dir);
|
let dest_dir = crate::media_paths::resolve_config_path_buf(storage_dir)
|
||||||
|
.join(&artist_dir)
|
||||||
|
.join(&album_dir);
|
||||||
tokio::fs::create_dir_all(&dest_dir).await?;
|
tokio::fs::create_dir_all(&dest_dir).await?;
|
||||||
|
|
||||||
let dest_path = dest_dir.join(&filename);
|
let dest_path = dest_dir.join(&filename);
|
||||||
@@ -345,7 +396,13 @@ pub async fn save_cover_to_storage(
|
|||||||
// Write image data
|
// Write image data
|
||||||
tokio::fs::write(&dest_path, &cover.data).await?;
|
tokio::fs::write(&dest_path, &cover.data).await?;
|
||||||
|
|
||||||
let relative_path = dest_path.to_string_lossy().to_string();
|
let relative_path = crate::media_paths::media_file_path_for_storage(storage_dir, &dest_path)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"cover destination is outside agent_storage_dir: {}",
|
||||||
|
dest_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let file_size = cover.data.len() as i64;
|
let file_size = cover.data.len() as i64;
|
||||||
|
|
||||||
let media_file = crate::music::MediaFile::create(
|
let media_file = crate::music::MediaFile::create(
|
||||||
@@ -374,6 +431,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,112 @@
|
|||||||
|
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 start = std::time::Instant::now();
|
||||||
|
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);
|
||||||
|
let result = encoder.encode(
|
||||||
|
&resized,
|
||||||
|
resized.width(),
|
||||||
|
resized.height(),
|
||||||
|
image::ExtendedColorType::Rgb8,
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
crate::metrics::record_agent_cover_variant(variant.name, "ok", start.elapsed())
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
crate::metrics::record_agent_cover_variant(variant.name, "error", start.elapsed());
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|||||||
+30
-2
@@ -330,6 +330,7 @@ pub async fn normalize_batch(
|
|||||||
// If over 80% of context limit and more than 1 file, split
|
// If over 80% of context limit and more than 1 file, split
|
||||||
let limit_80 = context_limit * 80 / 100;
|
let limit_80 = context_limit * 80 / 100;
|
||||||
if estimated > limit_80 && files.len() > 1 {
|
if estimated > limit_80 && files.len() > 1 {
|
||||||
|
crate::metrics::record_agent_llm_split("estimated_context");
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
estimated_tokens = estimated,
|
estimated_tokens = estimated,
|
||||||
context_limit,
|
context_limit,
|
||||||
@@ -419,6 +420,7 @@ pub async fn normalize_batch(
|
|||||||
|| err_str.contains("length")
|
|| err_str.contains("length")
|
||||||
|| err_str.contains("token");
|
|| err_str.contains("token");
|
||||||
if is_context_error {
|
if is_context_error {
|
||||||
|
crate::metrics::record_agent_llm_split("context_error");
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
file_count = files.len(),
|
file_count = files.len(),
|
||||||
"LLM error suggests context overflow, splitting batch: {e}"
|
"LLM error suggests context overflow, splitting batch: {e}"
|
||||||
@@ -466,14 +468,40 @@ pub async fn normalize_batch(
|
|||||||
}
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => {
|
||||||
|
crate::metrics::record_agent_llm(
|
||||||
|
llm_model,
|
||||||
|
"error",
|
||||||
|
std::time::Duration::from_millis(duration_ms),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
files.len(),
|
||||||
|
Some(estimated),
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let prompt_tokens = usage.prompt_tokens.unwrap_or(0) as u64;
|
let prompt_tokens = usage.prompt_tokens.unwrap_or(0) as u64;
|
||||||
let completion_tokens = usage.completion_tokens.unwrap_or(0) as u64;
|
let completion_tokens = usage.completion_tokens.unwrap_or(0) as u64;
|
||||||
|
crate::metrics::record_agent_llm(
|
||||||
|
&resp_model,
|
||||||
|
"ok",
|
||||||
|
std::time::Duration::from_millis(duration_ms),
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
files.len(),
|
||||||
|
Some(estimated),
|
||||||
|
);
|
||||||
|
|
||||||
// Parse batch response
|
// Parse batch response
|
||||||
let results = parse_batch_response(&response_text, &files)?;
|
let results = match parse_batch_response(&response_text, &files) {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(error) => {
|
||||||
|
crate::metrics::record_agent_llm_parse_failure(&resp_model);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(BatchNormalizeResult {
|
Ok(BatchNormalizeResult {
|
||||||
results,
|
results,
|
||||||
|
|||||||
+525
-11
@@ -1,14 +1,27 @@
|
|||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use cot::aide::openapi::{
|
||||||
|
MediaType, Operation, ReferenceOr, RequestBody, Response as OpenApiResponse, SchemaObject,
|
||||||
|
StatusCode as OpenApiStatusCode,
|
||||||
|
};
|
||||||
|
use cot::auth::PasswordVerificationResult;
|
||||||
|
use cot::common_types::Password;
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
|
use cot::http::StatusCode;
|
||||||
|
use cot::http::header::CONTENT_TYPE;
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
|
use cot::openapi::{AsApiOperation, RouteContext};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::openapi::api_get;
|
use cot::router::method::openapi::{api_get, api_post};
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body};
|
use cot::{App, Body, RequestHandler};
|
||||||
use schemars::JsonSchema;
|
use schemars::{JsonSchema, SchemaGenerator};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::user::User;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
@@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DocumentedJsonHandler<H, Req, Res> {
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
_marker: PhantomData<fn(Req) -> Res>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DocumentedResponseHandler<H, Res> {
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
_marker: PhantomData<fn() -> Res>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn documented_json_handler<Req, Res, H>(
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
) -> DocumentedJsonHandler<H, Req, Res> {
|
||||||
|
DocumentedJsonHandler {
|
||||||
|
handler,
|
||||||
|
summary,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn documented_response_handler<Res, H>(
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
) -> DocumentedResponseHandler<H, Res> {
|
||||||
|
DocumentedResponseHandler {
|
||||||
|
handler,
|
||||||
|
summary,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HandlerParams, H, Req, Res> RequestHandler<HandlerParams>
|
||||||
|
for DocumentedJsonHandler<H, Req, Res>
|
||||||
|
where
|
||||||
|
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
|
||||||
|
self.handler.handle(request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HandlerParams, H, Res> RequestHandler<HandlerParams> for DocumentedResponseHandler<H, Res>
|
||||||
|
where
|
||||||
|
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
|
||||||
|
self.handler.handle(request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, Req, Res> AsApiOperation for DocumentedJsonHandler<H, Req, Res>
|
||||||
|
where
|
||||||
|
Req: JsonSchema,
|
||||||
|
Res: JsonSchema,
|
||||||
|
{
|
||||||
|
fn as_api_operation(
|
||||||
|
&self,
|
||||||
|
_route_context: &RouteContext<'_>,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) -> Option<Operation> {
|
||||||
|
let mut operation = Operation {
|
||||||
|
summary: Some(self.summary.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request_body = RequestBody {
|
||||||
|
required: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
request_body.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Req::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
operation.request_body = Some(ReferenceOr::Item(request_body));
|
||||||
|
|
||||||
|
let responses = operation.responses.get_or_insert_default();
|
||||||
|
let mut ok = OpenApiResponse {
|
||||||
|
description: "OK".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
ok.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Res::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
responses
|
||||||
|
.responses
|
||||||
|
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
|
||||||
|
|
||||||
|
Some(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, Res> AsApiOperation for DocumentedResponseHandler<H, Res>
|
||||||
|
where
|
||||||
|
Res: JsonSchema,
|
||||||
|
{
|
||||||
|
fn as_api_operation(
|
||||||
|
&self,
|
||||||
|
_route_context: &RouteContext<'_>,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) -> Option<Operation> {
|
||||||
|
let mut operation = Operation {
|
||||||
|
summary: Some(self.summary.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
add_json_response::<Res>(&mut operation, schema_generator);
|
||||||
|
Some(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_json_response<Res: JsonSchema>(
|
||||||
|
operation: &mut Operation,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) {
|
||||||
|
let responses = operation.responses.get_or_insert_default();
|
||||||
|
let mut ok = OpenApiResponse {
|
||||||
|
description: "OK".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
ok.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Res::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
responses
|
||||||
|
.responses
|
||||||
|
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_json_content_type(value: &str) -> bool {
|
||||||
|
value
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_json_request<T>(
|
||||||
|
request: cot::request::Request,
|
||||||
|
) -> cot::Result<Result<T, cot::response::Response>>
|
||||||
|
where
|
||||||
|
T: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let content_type = request
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !is_json_content_type(content_type) {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"expected application/json",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = request.into_body().into_bytes().await?;
|
||||||
|
let body = match serde_json::from_slice::<T>(&bytes) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid JSON body",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Ok(body))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/me
|
// GET /api/me
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,8 +240,85 @@ struct MeResponse {
|
|||||||
role: String,
|
role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
struct AuthUserResponse {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AuthTokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
token_type: String,
|
||||||
|
expires_in_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AuthLoginResponse {
|
||||||
|
user: AuthUserResponse,
|
||||||
|
tokens: AuthTokenResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct PasswordLoginRequest {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
device_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct RefreshRequest {
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct SsoExchangeRequest {
|
||||||
|
code: String,
|
||||||
|
device_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct LogoutRequest {
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct LogoutResponse {
|
||||||
|
revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_response(user: auth::AuthenticatedUser) -> AuthUserResponse {
|
||||||
|
AuthUserResponse {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.code().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_response(tokens: auth::ApiTokenPair) -> AuthTokenResponse {
|
||||||
|
AuthTokenResponse {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
token_type: tokens.token_type.to_owned(),
|
||||||
|
expires_in_seconds: tokens.expires_in_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_response(user: auth::AuthenticatedUser, tokens: auth::ApiTokenPair) -> AuthLoginResponse {
|
||||||
|
AuthLoginResponse {
|
||||||
|
user: user_response(user),
|
||||||
|
tokens: token_response(tokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
cot::http::StatusCode::UNAUTHORIZED,
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
"not authenticated",
|
"not authenticated",
|
||||||
@@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn password_login_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<PasswordLoginRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.auth_password_enabled {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "disabled");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"password login is disabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = match User::get_by_username(&db, request.username.trim()).await {
|
||||||
|
Ok(Some(user)) if user.is_active() => user,
|
||||||
|
_ => {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(hash) = user.password_ref() else {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match hash.verify(&Password::new(&request.password)) {
|
||||||
|
PasswordVerificationResult::Ok | PasswordVerificationResult::OkObsolete(_) => {
|
||||||
|
let auth_user = auth::AuthenticatedUser {
|
||||||
|
id: user.id_val(),
|
||||||
|
name: {
|
||||||
|
let display = user.display_name_str();
|
||||||
|
if display.is_empty() {
|
||||||
|
user.username_str().to_owned()
|
||||||
|
} else {
|
||||||
|
display
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role: user.role(),
|
||||||
|
};
|
||||||
|
let tokens =
|
||||||
|
auth::create_api_session(&db, user.id_val(), request.device_name.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("api_password");
|
||||||
|
Json(login_response(auth_user, tokens)).into_response()
|
||||||
|
}
|
||||||
|
PasswordVerificationResult::Invalid => {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<RefreshRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
match auth::refresh_api_session(&db, request.refresh_token.trim()).await {
|
||||||
|
Ok(Some(tokens)) => Json(token_response(tokens)).into_response(),
|
||||||
|
Ok(None) => Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid refresh token",
|
||||||
|
)),
|
||||||
|
Err(err) => Err(cot::Error::internal(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sso_exchange_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<SsoExchangeRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
match auth::exchange_mobile_code_for_api_session(
|
||||||
|
&db,
|
||||||
|
request.code.trim(),
|
||||||
|
request.device_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some((user, tokens))) => {
|
||||||
|
crate::metrics::record_auth_attempt("api_sso_exchange", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("api_sso_exchange");
|
||||||
|
Json(login_response(user, tokens)).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
crate::metrics::record_auth_attempt("api_sso_exchange", "failure", "bad_code");
|
||||||
|
Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid SSO exchange code",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => Err(cot::Error::internal(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<LogoutRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
let revoked = auth::revoke_api_session(
|
||||||
|
&db,
|
||||||
|
auth_ctx.bearer_token(),
|
||||||
|
request.refresh_token.as_deref().map(str::trim),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Json(LogoutResponse { revoked }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,10 +485,101 @@ impl App for ApiApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
Router::with_urls([Route::with_api_handler_and_name(
|
Router::with_urls([
|
||||||
"/me",
|
Route::with_api_handler_and_name(
|
||||||
api_get(me_handler),
|
"/me",
|
||||||
"api_me",
|
api_get(documented_response_handler::<MeResponse, _>(
|
||||||
)])
|
me_handler,
|
||||||
|
"Get the current authenticated user",
|
||||||
|
)),
|
||||||
|
"api_me",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/password",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
PasswordLoginRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
password_login_handler,
|
||||||
|
"Log in with username and password",
|
||||||
|
)),
|
||||||
|
"api_auth_password",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/refresh",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
RefreshRequest,
|
||||||
|
AuthTokenResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
refresh_handler, "Refresh an API token pair"
|
||||||
|
)),
|
||||||
|
"api_auth_refresh",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/sso/exchange",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
SsoExchangeRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
sso_exchange_handler,
|
||||||
|
"Exchange a mobile SSO code for API tokens",
|
||||||
|
)),
|
||||||
|
"api_auth_sso_exchange",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/logout",
|
||||||
|
api_post(documented_json_handler::<LogoutRequest, LogoutResponse, _>(
|
||||||
|
logout_handler,
|
||||||
|
"Revoke an API session",
|
||||||
|
)),
|
||||||
|
"api_auth_logout",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use cot::aide::openapi::{PathItem, ReferenceOr};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_get_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { get: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_post_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { post: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openapi_includes_auth_routes() {
|
||||||
|
let openapi = ApiApp.router().as_api();
|
||||||
|
let paths = openapi.paths.expect("OpenAPI paths");
|
||||||
|
|
||||||
|
assert_get_path(&paths, "/me");
|
||||||
|
assert_post_path(&paths, "/auth/password");
|
||||||
|
assert_post_path(&paths, "/auth/refresh");
|
||||||
|
assert_post_path(&paths, "/auth/sso/exchange");
|
||||||
|
assert_post_path(&paths, "/auth/logout");
|
||||||
|
|
||||||
|
let Some(ReferenceOr::Item(PathItem {
|
||||||
|
post: Some(operation),
|
||||||
|
..
|
||||||
|
})) = paths.paths.get("/auth/password")
|
||||||
|
else {
|
||||||
|
panic!("password auth path should be documented as POST");
|
||||||
|
};
|
||||||
|
assert!(operation.request_body.is_some());
|
||||||
|
assert!(operation.responses.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+592
-6
@@ -1,7 +1,13 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
use cot::Body;
|
use cot::Body;
|
||||||
use cot::db::Database;
|
use cot::db::{Auto, Database, LimitedString, Model};
|
||||||
|
use cot::http::header::AUTHORIZATION;
|
||||||
|
use cot::request::RequestHead;
|
||||||
|
use cot::request::extractors::FromRequestHead;
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ impl Role {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SESSION_USER_ID: &str = "user_id";
|
const SESSION_USER_ID: &str = "user_id";
|
||||||
|
const SESSION_POST_LOGIN_REDIRECT: &str = "post_login_redirect";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
@@ -45,11 +52,7 @@ pub struct AuthenticatedUser {
|
|||||||
pub role: Role,
|
pub role: Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `user_id` from the session, fetch the `User` from DB, return
|
fn authenticated_user_from_user(user: User) -> Option<AuthenticatedUser> {
|
||||||
/// `AuthenticatedUser` if the user exists and is active.
|
|
||||||
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
|
|
||||||
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
|
|
||||||
let user = User::get_by_id(db, user_id).await.ok()??;
|
|
||||||
if !user.is_active() {
|
if !user.is_active() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -61,6 +64,7 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
|
|||||||
display
|
display
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
crate::metrics::record_active_user(user.id_val());
|
||||||
Some(AuthenticatedUser {
|
Some(AuthenticatedUser {
|
||||||
id: user.id_val(),
|
id: user.id_val(),
|
||||||
name,
|
name,
|
||||||
@@ -68,6 +72,362 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read `user_id` from the session, fetch the `User` from DB, return
|
||||||
|
/// `AuthenticatedUser` if the user exists and is active.
|
||||||
|
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
|
||||||
|
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
|
||||||
|
let user = User::get_by_id(db, user_id).await.ok()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API bearer-token auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_PREFIX: &str = "furu_at_";
|
||||||
|
const REFRESH_TOKEN_PREFIX: &str = "furu_rt_";
|
||||||
|
const MOBILE_EXCHANGE_CODE_PREFIX: &str = "furu_mx_";
|
||||||
|
const ACCESS_TOKEN_TTL_MINUTES: i64 = 15;
|
||||||
|
const REFRESH_TOKEN_TTL_DAYS: i64 = 60;
|
||||||
|
const MOBILE_EXCHANGE_CODE_TTL_MINUTES: i64 = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
bearer_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
pub fn bearer_token(&self) -> Option<&str> {
|
||||||
|
self.bearer_token.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestHead for AuthContext {
|
||||||
|
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||||
|
let bearer_token = head
|
||||||
|
.headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(parse_bearer_token)
|
||||||
|
.map(str::to_owned);
|
||||||
|
Ok(Self { bearer_token })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bearer_token(header: &str) -> Option<&str> {
|
||||||
|
let header = header.trim();
|
||||||
|
let (scheme, token) = header.split_once(' ')?;
|
||||||
|
if !scheme.eq_ignore_ascii_case("Bearer") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = token.trim();
|
||||||
|
if token.is_empty() || token.len() > 512 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiTokenPair {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub token_type: &'static str,
|
||||||
|
pub expires_in_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct ApiSession {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<String>,
|
||||||
|
access_token_hash: LimitedString<128>,
|
||||||
|
refresh_token_hash: LimitedString<128>,
|
||||||
|
access_expires_at: String,
|
||||||
|
refresh_expires_at: String,
|
||||||
|
created_at: String,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct MobileExchangeCode {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
code_hash: LimitedString<128>,
|
||||||
|
user_id: i64,
|
||||||
|
created_at: String,
|
||||||
|
expires_at: String,
|
||||||
|
consumed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiSession {
|
||||||
|
pub async fn create_for_user(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
let now = now_iso();
|
||||||
|
let mut session = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
user_id,
|
||||||
|
device_name: device_name.and_then(normalize_device_name),
|
||||||
|
access_token_hash: LimitedString::new(&token_hash(&tokens.access_token)).unwrap(),
|
||||||
|
refresh_token_hash: LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(),
|
||||||
|
access_expires_at: access_expires_at(),
|
||||||
|
refresh_expires_at: refresh_expires_at(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
last_used_at: Some(now),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
session.insert(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_access_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $access_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_refresh_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $refresh_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_revoked(&self) -> bool {
|
||||||
|
self.revoked_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.access_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.refresh_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rotate(&mut self, db: &Database) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
self.access_token_hash = LimitedString::new(&token_hash(&tokens.access_token)).unwrap();
|
||||||
|
self.refresh_token_hash = LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap();
|
||||||
|
self.access_expires_at = access_expires_at();
|
||||||
|
self.refresh_expires_at = refresh_expires_at();
|
||||||
|
self.last_used_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
if self.revoked_at.is_none() {
|
||||||
|
self.revoked_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_session(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
ApiSession::create_for_user(db, user_id, device_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_bearer_user(db: &Database, token: &str) -> Option<AuthenticatedUser> {
|
||||||
|
let session = ApiSession::find_by_access_token(db, token).await.ok()??;
|
||||||
|
if !session.access_token_valid() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user = User::get_by_id(db, session.user_id).await.ok()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_request_user(
|
||||||
|
auth: &AuthContext,
|
||||||
|
session: &Session,
|
||||||
|
db: &Database,
|
||||||
|
) -> Option<AuthenticatedUser> {
|
||||||
|
if let Some(token) = auth.bearer_token() {
|
||||||
|
return get_bearer_user(db, token).await;
|
||||||
|
}
|
||||||
|
get_session_user(session, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_api_session(
|
||||||
|
db: &Database,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> cot::db::Result<Option<ApiTokenPair>> {
|
||||||
|
let Some(mut session) = ApiSession::find_by_refresh_token(db, refresh_token).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !session.refresh_token_valid() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, session.user_id).await? else {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !user.is_active() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(session.rotate(db).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_api_session(
|
||||||
|
db: &Database,
|
||||||
|
access_token: Option<&str>,
|
||||||
|
refresh_token: Option<&str>,
|
||||||
|
) -> cot::db::Result<bool> {
|
||||||
|
let mut session = if let Some(token) = access_token {
|
||||||
|
ApiSession::find_by_access_token(db, token).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if session.is_none() {
|
||||||
|
if let Some(token) = refresh_token {
|
||||||
|
session = ApiSession::find_by_refresh_token(db, token).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
session.revoke(db).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileExchangeCode {
|
||||||
|
pub async fn create_for_user(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
let code = random_token(MOBILE_EXCHANGE_CODE_PREFIX);
|
||||||
|
let now = now_iso();
|
||||||
|
let mut row = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
code_hash: LimitedString::new(&token_hash(&code)).unwrap(),
|
||||||
|
user_id,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: mobile_exchange_code_expires_at(),
|
||||||
|
consumed_at: None,
|
||||||
|
};
|
||||||
|
row.insert(db).await?;
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_code(db: &Database, code: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(code)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(MobileExchangeCode, $code_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid(&self) -> bool {
|
||||||
|
self.consumed_at.is_none() && self.expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.consumed_at = Some(now_iso());
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_mobile_exchange_code(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
MobileExchangeCode::create_for_user(db, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exchange_mobile_code_for_api_session(
|
||||||
|
db: &Database,
|
||||||
|
code: &str,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<Option<(AuthenticatedUser, ApiTokenPair)>> {
|
||||||
|
let Some(mut exchange_code) = MobileExchangeCode::find_by_code(db, code).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !exchange_code.is_valid() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, exchange_code.user_id).await? else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(auth_user) = authenticated_user_from_user(user) else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
let tokens = ApiSession::create_for_user(db, auth_user.id, device_name).await?;
|
||||||
|
Ok(Some((auth_user, tokens)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_token_pair() -> ApiTokenPair {
|
||||||
|
ApiTokenPair {
|
||||||
|
access_token: random_token(ACCESS_TOKEN_PREFIX),
|
||||||
|
refresh_token: random_token(REFRESH_TOKEN_PREFIX),
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in_seconds: ACCESS_TOKEN_TTL_MINUTES * 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_token(prefix: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{prefix}{}{}",
|
||||||
|
uuid::Uuid::new_v4().simple(),
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_hash(token: &str) -> String {
|
||||||
|
let digest = Sha256::digest(token.as_bytes());
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_device_name(name: &str) -> Option<String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(trimmed.chars().take(255).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::days(REFRESH_TOKEN_TTL_DAYS))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_exchange_code_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(MOBILE_EXCHANGE_CODE_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
||||||
/// `Err(response)` — a redirect to `/login` or a 403.
|
/// `Err(response)` — a redirect to `/login` or a 403.
|
||||||
pub async fn require_admin_or_redirect(
|
pub async fn require_admin_or_redirect(
|
||||||
@@ -75,9 +435,11 @@ pub async fn require_admin_or_redirect(
|
|||||||
db: &Database,
|
db: &Database,
|
||||||
) -> Result<AuthenticatedUser, cot::response::Response> {
|
) -> Result<AuthenticatedUser, cot::response::Response> {
|
||||||
let Some(user) = get_session_user(session, db).await else {
|
let Some(user) = get_session_user(session, db).await else {
|
||||||
|
crate::metrics::record_authorization_denied("unauthenticated");
|
||||||
return Err(redirect("/login"));
|
return Err(redirect("/login"));
|
||||||
};
|
};
|
||||||
if user.role != Role::Admin {
|
if user.role != Role::Admin {
|
||||||
|
crate::metrics::record_authorization_denied("forbidden");
|
||||||
return Err("Forbidden"
|
return Err("Forbidden"
|
||||||
.with_status(cot::http::StatusCode::FORBIDDEN)
|
.with_status(cot::http::StatusCode::FORBIDDEN)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -96,9 +458,47 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
|||||||
.insert(SESSION_USER_ID, user_id)
|
.insert(SESSION_USER_ID, user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
crate::metrics::record_active_user(user_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn remember_post_login_redirect(session: &Session, location: &str) -> cot::Result<()> {
|
||||||
|
if let Some(location) = safe_internal_redirect(location) {
|
||||||
|
session
|
||||||
|
.insert(SESSION_POST_LOGIN_REDIRECT, location)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_post_login_redirect(session: &Session) -> cot::Result<Option<String>> {
|
||||||
|
let location: Option<String> = session
|
||||||
|
.get(SESSION_POST_LOGIN_REDIRECT)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(location.and_then(|value| safe_internal_redirect(&value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_post_login_redirect(session: &Session) -> cot::Result<()> {
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_POST_LOGIN_REDIRECT)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_internal_redirect(location: &str) -> Option<String> {
|
||||||
|
let location = location.trim();
|
||||||
|
if !location.starts_with('/') || location.starts_with("//") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if location.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(location.chars().take(2048).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Flush (destroy) the session.
|
/// Flush (destroy) the session.
|
||||||
pub async fn logout(session: &Session) -> cot::Result<()> {
|
pub async fn logout(session: &Session) -> cot::Result<()> {
|
||||||
session
|
session
|
||||||
@@ -117,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::Response {
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Migrations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub mod db_migrations {
|
||||||
|
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||||
|
use cot::db::{DatabaseField, Identifier, LimitedString};
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0038CreateApiSession;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0038CreateApiSession {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0038_create_api_session";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0003_create_user",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
|
.table_name(Identifier::new("furumusic__api_session"))
|
||||||
|
.fields(&[
|
||||||
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
|
.primary_key()
|
||||||
|
.auto(),
|
||||||
|
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("device_name"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("access_token_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("refresh_token_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("access_expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("refresh_expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("created_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("last_used_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("revoked_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
])
|
||||||
|
.build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_api_session_indexes(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_api_session_access_token_hash \
|
||||||
|
ON furumusic__api_session (access_token_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_api_session_refresh_token_hash \
|
||||||
|
ON furumusic__api_session (refresh_token_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX idx_api_session_user_id \
|
||||||
|
ON furumusic__api_session (user_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0039CreateApiSessionIndexes;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0039CreateApiSessionIndexes {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0039_create_api_session_indexes";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0038_create_api_session",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_api_session_indexes).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0040CreateMobileExchangeCode;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0040CreateMobileExchangeCode {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0040_create_mobile_exchange_code";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0039_create_api_session_indexes",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
|
.table_name(Identifier::new("furumusic__mobile_exchange_code"))
|
||||||
|
.fields(&[
|
||||||
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
|
.primary_key()
|
||||||
|
.auto(),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("code_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("created_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("consumed_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
])
|
||||||
|
.build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_mobile_exchange_code_indexes(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_mobile_exchange_code_hash \
|
||||||
|
ON furumusic__mobile_exchange_code (code_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX idx_mobile_exchange_code_user_id \
|
||||||
|
ON furumusic__mobile_exchange_code (user_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0041CreateMobileExchangeCodeIndexes;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0041CreateMobileExchangeCodeIndexes {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0041_create_mobile_exchange_code_indexes";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0040_create_mobile_exchange_code",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_mobile_exchange_code_indexes).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
|
&M0038CreateApiSession,
|
||||||
|
&M0039CreateApiSessionIndexes,
|
||||||
|
&M0040CreateMobileExchangeCode,
|
||||||
|
&M0041CreateMobileExchangeCodeIndexes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+120
-1
@@ -133,6 +133,11 @@ 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,
|
||||||
|
pub lastfm_shared_secret: ConfigSource,
|
||||||
|
pub federation_enabled: ConfigSource,
|
||||||
|
pub federation_network_id: ConfigSource,
|
||||||
|
pub federation_save_on_listen: ConfigSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigSources {
|
impl Default for ConfigSources {
|
||||||
@@ -158,6 +163,11 @@ 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,
|
||||||
|
lastfm_shared_secret: ConfigSource::Default,
|
||||||
|
federation_enabled: ConfigSource::Default,
|
||||||
|
federation_network_id: ConfigSource::Default,
|
||||||
|
federation_save_on_listen: ConfigSource::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +272,19 @@ 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,
|
||||||
|
/// Last.fm shared secret for authenticated scrobbling calls.
|
||||||
|
pub lastfm_shared_secret: String,
|
||||||
|
/// Whether this server participates in the furumi federation (publishes
|
||||||
|
/// its library into the shared DHT and serves audio to peers).
|
||||||
|
pub federation_enabled: bool,
|
||||||
|
/// Federation network id — the shared secret every peer of the network
|
||||||
|
/// uses to find the others.
|
||||||
|
pub federation_network_id: String,
|
||||||
|
/// Whether a federated track requested for playback is imported into the
|
||||||
|
/// shared local library. This is a server-wide administrator policy.
|
||||||
|
pub federation_save_on_listen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -287,6 +310,11 @@ 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(),
|
||||||
|
lastfm_shared_secret: String::new(),
|
||||||
|
federation_enabled: false,
|
||||||
|
federation_network_id: String::new(),
|
||||||
|
federation_save_on_listen: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,17 +341,70 @@ impl_env_overrides!(
|
|||||||
agent_confidence_threshold,
|
agent_confidence_threshold,
|
||||||
agent_context_limit,
|
agent_context_limit,
|
||||||
agent_concurrency,
|
agent_concurrency,
|
||||||
|
lastfm_api_key,
|
||||||
|
lastfm_shared_secret,
|
||||||
|
federation_enabled,
|
||||||
|
federation_network_id,
|
||||||
|
federation_save_on_listen,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
fn normalize_host_paths(&mut self) {
|
||||||
|
self.agent_inbox_dir = crate::media_paths::resolve_config_path(&self.agent_inbox_dir);
|
||||||
|
self.agent_storage_dir = crate::media_paths::resolve_config_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.apply_startup_db_overrides();
|
||||||
|
cfg.apply_env_overrides();
|
||||||
|
cfg.normalize_host_paths();
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_startup_db_overrides(&mut self) {
|
||||||
|
if self.database_url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
|
tracing::warn!("skipping startup DB config load from inside an existing Tokio runtime");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_url = self.database_url.clone();
|
||||||
|
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
tracing::warn!("failed to create runtime for startup DB config load");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = runtime.block_on(async move {
|
||||||
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await?;
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT value FROM furumusic__config_entry WHERE key = 'swagger_enabled'",
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Some(value)) => match value.parse::<bool>() {
|
||||||
|
Ok(value) => self.swagger_enabled = value,
|
||||||
|
Err(_) => tracing::warn!("ignoring invalid DB config value for swagger_enabled"),
|
||||||
|
},
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(err) => tracing::warn!("failed to read startup DB config overrides: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build config with full 3-layer resolution (default → DB → env) and
|
/// Build config with full 3-layer resolution (default → DB → env) and
|
||||||
/// track the source of each field.
|
/// track the source of each field.
|
||||||
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
||||||
@@ -331,6 +412,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,12 +471,24 @@ 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);
|
||||||
|
apply_db_field!(lastfm_shared_secret);
|
||||||
|
apply_db_field!(federation_enabled);
|
||||||
|
apply_db_field!(federation_network_id);
|
||||||
|
apply_db_field!(federation_save_on_listen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn lock_env() -> MutexGuard<'static, ()> {
|
||||||
|
ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_are_sane() {
|
fn defaults_are_sane() {
|
||||||
@@ -403,7 +497,29 @@ mod tests {
|
|||||||
assert_eq!(cfg.log_level, "info");
|
assert_eq!(cfg.log_level, "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
#[test]
|
||||||
|
fn resolves_relative_media_paths_from_working_dir() {
|
||||||
|
let expected = std::env::current_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join("media")
|
||||||
|
.join("uploads")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
crate::media_paths::resolve_config_path("media/uploads"),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_absolute_windows_media_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
crate::media_paths::resolve_config_path(r"C:\Users\ab\repos\furumusic\media\uploads"),
|
||||||
|
"C:/Users/ab/repos/furumusic/media/uploads"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: environment-mutating tests take ENV_LOCK before changing vars.
|
||||||
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) };
|
||||||
}
|
}
|
||||||
@@ -413,6 +529,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_string_field() {
|
fn env_override_string_field() {
|
||||||
|
let _guard = lock_env();
|
||||||
unsafe {
|
unsafe {
|
||||||
set("FURU_OIDC_ISSUER", "https://example.com");
|
set("FURU_OIDC_ISSUER", "https://example.com");
|
||||||
}
|
}
|
||||||
@@ -425,6 +542,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_bool_field() {
|
fn env_override_bool_field() {
|
||||||
|
let _guard = lock_env();
|
||||||
unsafe {
|
unsafe {
|
||||||
set("FURU_AUTH_SSO_ENABLED", "true");
|
set("FURU_AUTH_SSO_ENABLED", "true");
|
||||||
}
|
}
|
||||||
@@ -437,6 +555,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn source_tracking_env() {
|
fn source_tracking_env() {
|
||||||
|
let _guard = lock_env();
|
||||||
unsafe {
|
unsafe {
|
||||||
set("FURU_OIDC_ISSUER", "https://tracked.example.com");
|
set("FURU_OIDC_ISSUER", "https://tracked.example.com");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
//! Receiving side of music federation.
|
||||||
|
//!
|
||||||
|
//! User-facing identity is content-addressed. An `(owner, item_id)` pair is
|
||||||
|
//! only a source locator and several locators may resolve the same track.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::{ItemKind, LibraryItem, normalize_content_id};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::Row as _;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
use super::{Federation, now_iso};
|
||||||
|
|
||||||
|
const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackKeyDto {
|
||||||
|
pub content_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ArtistKeyDto {
|
||||||
|
pub normalized_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ArtistRefDto {
|
||||||
|
pub key: ArtistKeyDto,
|
||||||
|
pub name: String,
|
||||||
|
pub local_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ReleaseKeyDto {
|
||||||
|
pub normalized_title: String,
|
||||||
|
pub primary_artists: Vec<String>,
|
||||||
|
pub release_type: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ReleaseRefDto {
|
||||||
|
pub key: ReleaseKeyDto,
|
||||||
|
pub local_id: Option<i64>,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FederationSourceDto {
|
||||||
|
pub owner: String,
|
||||||
|
pub item_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LocalAvailabilityDto {
|
||||||
|
pub track_id: i64,
|
||||||
|
pub stream_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackMetadataDto {
|
||||||
|
pub title: String,
|
||||||
|
pub artists: Vec<ArtistRefDto>,
|
||||||
|
pub featured_artists: Vec<ArtistRefDto>,
|
||||||
|
pub release: Option<ReleaseRefDto>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_seconds: Option<f64>,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackAvailabilityDto {
|
||||||
|
pub state: &'static str,
|
||||||
|
pub local: Option<LocalAvailabilityDto>,
|
||||||
|
pub federation: Vec<FederationSourceDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackDto {
|
||||||
|
pub key: TrackKeyDto,
|
||||||
|
pub metadata: TrackMetadataDto,
|
||||||
|
pub availability: TrackAvailabilityDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SearchEvent {
|
||||||
|
pub search_id: String,
|
||||||
|
pub sequence: u64,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub peer: Option<String>,
|
||||||
|
pub entity_key: Value,
|
||||||
|
pub entity: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Federation {
|
||||||
|
pub fn stream_artist_catalogs(
|
||||||
|
self: &std::sync::Arc<Self>,
|
||||||
|
name: String,
|
||||||
|
) -> tokio::sync::mpsc::UnboundedReceiver<Result<(String, music_dht::catalog::CatalogArtist)>>
|
||||||
|
{
|
||||||
|
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let federation = std::sync::Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = async {
|
||||||
|
let service = federation.service().await?;
|
||||||
|
let normalized = music_dht::normalize_name(&name);
|
||||||
|
let outcome = service
|
||||||
|
.search_network(&name)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("federated artist search failed: {err}"))?;
|
||||||
|
let owners: std::collections::HashSet<_> = outcome
|
||||||
|
.network_results
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|
||||||
|
|| item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.chain(&item.featured_artist_names)
|
||||||
|
.any(|artist| music_dht::normalize_name(artist) == normalized)
|
||||||
|
})
|
||||||
|
.map(|item| item.owner)
|
||||||
|
.collect();
|
||||||
|
for owner in owners {
|
||||||
|
let service = std::sync::Arc::clone(&service);
|
||||||
|
let sender = sender.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(8),
|
||||||
|
fetch_artist_catalog(&service, owner, &name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("catalog request timed out"))
|
||||||
|
.and_then(|result| result)
|
||||||
|
.map(|artist| (owner.to_string(), artist));
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(err) = result {
|
||||||
|
let _ = sender.send(Err(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs one bounded DHT search and returns entity upserts. The HTTP
|
||||||
|
/// layer streams each upsert independently; catalog fan-out can append
|
||||||
|
/// events to the same contract without changing the browser model.
|
||||||
|
pub async fn search_events(&self, search_id: &str, query: &str) -> Result<Vec<SearchEvent>> {
|
||||||
|
let query = query.trim();
|
||||||
|
anyhow::ensure!(!query.is_empty(), "search query is empty");
|
||||||
|
anyhow::ensure!(query.chars().count() <= 200, "search query is too long");
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let service = self.service().await?;
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
connected_peers = service.connected_peers().len(),
|
||||||
|
known_contacts = service.known_peers().len(),
|
||||||
|
"federated search started"
|
||||||
|
);
|
||||||
|
let own = service.endpoint_id();
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(20),
|
||||||
|
service.search_network(query),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("federated search timed out after 20 seconds"))?
|
||||||
|
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
local_results = result.local_results.len(),
|
||||||
|
network_results = result.network_results.len(),
|
||||||
|
queried_nodes = result.queried_nodes,
|
||||||
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"federated DHT search finished"
|
||||||
|
);
|
||||||
|
let all_items: Vec<LibraryItem> = result
|
||||||
|
.local_results
|
||||||
|
.into_iter()
|
||||||
|
.chain(result.network_results)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut by_content: HashMap<String, TrackDto> = HashMap::new();
|
||||||
|
for item in all_items.iter().filter(|item| item.kind == ItemKind::Track) {
|
||||||
|
let Some(content_id) = item.content_id.as_deref().and_then(normalize_content_id) else {
|
||||||
|
// A globally usable track reference must be verifiable.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let local = local_availability(&pool, &content_id).await?;
|
||||||
|
let source = FederationSourceDto {
|
||||||
|
owner: item.owner.to_string(),
|
||||||
|
item_id: hex(item.id.as_bytes()),
|
||||||
|
};
|
||||||
|
let entry = by_content.entry(content_id.clone()).or_insert_with(|| {
|
||||||
|
track_from_item(content_id.clone(), item, local, item.owner == own)
|
||||||
|
});
|
||||||
|
if !entry.availability.federation.iter().any(|candidate| {
|
||||||
|
candidate.owner == source.owner && candidate.item_id == source.item_id
|
||||||
|
}) {
|
||||||
|
entry.availability.federation.push(source);
|
||||||
|
}
|
||||||
|
if entry.availability.local.is_some() {
|
||||||
|
entry.availability.state = "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tracks: Vec<_> = by_content.into_values().collect();
|
||||||
|
tracks.sort_by(|left, right| {
|
||||||
|
left.metadata
|
||||||
|
.title
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&right.metadata.title.to_lowercase())
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(all_items.len());
|
||||||
|
for (index, track) in tracks.into_iter().enumerate() {
|
||||||
|
persist_track_ref(&pool, &track).await?;
|
||||||
|
let peer = track
|
||||||
|
.availability
|
||||||
|
.federation
|
||||||
|
.first()
|
||||||
|
.map(|source| source.owner.clone());
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence: index as u64 + 1,
|
||||||
|
kind: "federation.track",
|
||||||
|
peer,
|
||||||
|
entity_key: serde_json::to_value(&track.key)?,
|
||||||
|
entity: serde_json::to_value(track)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut artist_peers: HashMap<String, (String, Vec<String>)> = HashMap::new();
|
||||||
|
let mut releases: HashMap<String, Value> = HashMap::new();
|
||||||
|
for item in &all_items {
|
||||||
|
match item.kind {
|
||||||
|
ItemKind::Artist => {
|
||||||
|
let key = music_dht::normalize_name(&item.name);
|
||||||
|
let entry = artist_peers
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| (item.name.clone(), Vec::new()));
|
||||||
|
let owner = item.owner.to_string();
|
||||||
|
if !entry.1.contains(&owner) {
|
||||||
|
entry.1.push(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ItemKind::Release => {
|
||||||
|
let artist_keys: Vec<String> = item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|name| music_dht::normalize_name(name))
|
||||||
|
.collect();
|
||||||
|
let normalized_title = music_dht::normalize_name(&item.name);
|
||||||
|
let cover_url = all_items
|
||||||
|
.iter()
|
||||||
|
.find(|track| {
|
||||||
|
track.kind == ItemKind::Track
|
||||||
|
&& track.release_title.as_deref().is_some_and(|title| {
|
||||||
|
music_dht::normalize_name(title) == normalized_title
|
||||||
|
})
|
||||||
|
&& track.year == item.year
|
||||||
|
})
|
||||||
|
.map(|track| {
|
||||||
|
format!(
|
||||||
|
"/api/player/federation/tracks/artwork?owner={}&item_id={}",
|
||||||
|
track.owner,
|
||||||
|
hex(track.id.as_bytes())
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let key = format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
normalized_title,
|
||||||
|
artist_keys.join(","),
|
||||||
|
item.year.map_or_else(String::new, |year| year.to_string())
|
||||||
|
);
|
||||||
|
releases.entry(key.clone()).or_insert_with(|| {
|
||||||
|
json!({
|
||||||
|
"key": {
|
||||||
|
"normalized_title": music_dht::normalize_name(&item.name),
|
||||||
|
"primary_artists": artist_keys,
|
||||||
|
"release_type": null,
|
||||||
|
"year": item.year,
|
||||||
|
},
|
||||||
|
"title": item.name,
|
||||||
|
"artists": item.artist_names,
|
||||||
|
"year": item.year,
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"sources": [{
|
||||||
|
"owner": item.owner.to_string(),
|
||||||
|
"item_id": hex(item.id.as_bytes()),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ItemKind::Track => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, (name, peers)) in artist_peers {
|
||||||
|
let sequence = events.len() as u64 + 1;
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence,
|
||||||
|
kind: "federation.artist",
|
||||||
|
peer: peers.first().cloned(),
|
||||||
|
entity_key: json!({ "normalized_name": key }),
|
||||||
|
entity: json!({
|
||||||
|
"key": { "normalized_name": key },
|
||||||
|
"name": name,
|
||||||
|
"image_url": null,
|
||||||
|
"peers": peers,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (key, release) in releases {
|
||||||
|
let sequence = events.len() as u64 + 1;
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence,
|
||||||
|
kind: "federation.release",
|
||||||
|
peer: None,
|
||||||
|
entity_key: json!({ "composite": key }),
|
||||||
|
entity: release,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
events = events.len(),
|
||||||
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"federated search response ready"
|
||||||
|
);
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_artist_catalog(
|
||||||
|
service: &music_dht::MusicDhtService,
|
||||||
|
owner: music_dht::EndpointId,
|
||||||
|
artist: &str,
|
||||||
|
) -> Result<music_dht::catalog::CatalogArtist> {
|
||||||
|
let mut stream = service
|
||||||
|
.open_stream(owner, super::CATALOG_ALPN)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach catalog peer: {err}"))?;
|
||||||
|
let mut request = serde_json::to_vec(&music_dht::catalog::CatalogRequest {
|
||||||
|
artist: artist.to_owned(),
|
||||||
|
want: Some("catalog".to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
})?;
|
||||||
|
request.push(b'\n');
|
||||||
|
stream.send.write_all(&request).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
stream
|
||||||
|
.recv
|
||||||
|
.take(MAX_CATALOG_BYTES + 1)
|
||||||
|
.read_to_end(&mut payload)
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
payload.len() as u64 <= MAX_CATALOG_BYTES,
|
||||||
|
"catalog response is too large"
|
||||||
|
);
|
||||||
|
let response: music_dht::catalog::CatalogResponse =
|
||||||
|
serde_json::from_slice(&payload).context("invalid catalog response")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
response.ok,
|
||||||
|
"peer refused catalog: {}",
|
||||||
|
response.error.unwrap_or_else(|| "unknown error".to_owned())
|
||||||
|
);
|
||||||
|
response.artist.context("peer returned no artist catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_from_item(
|
||||||
|
content_id: String,
|
||||||
|
item: &LibraryItem,
|
||||||
|
local: Option<LocalAvailabilityDto>,
|
||||||
|
own: bool,
|
||||||
|
) -> TrackDto {
|
||||||
|
let owner = item.owner.to_string();
|
||||||
|
let item_id = hex(item.id.as_bytes());
|
||||||
|
let artists = artist_refs(&item.artist_names);
|
||||||
|
let featured_artists = artist_refs(&item.featured_artist_names);
|
||||||
|
let release = item.release_title.as_ref().map(|title| ReleaseRefDto {
|
||||||
|
key: ReleaseKeyDto {
|
||||||
|
normalized_title: music_dht::normalize_name(title),
|
||||||
|
primary_artists: item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|artist| music_dht::normalize_name(artist))
|
||||||
|
.collect(),
|
||||||
|
release_type: None,
|
||||||
|
year: item.year,
|
||||||
|
},
|
||||||
|
local_id: None,
|
||||||
|
title: title.clone(),
|
||||||
|
});
|
||||||
|
let state = if local.is_some() || own {
|
||||||
|
"local"
|
||||||
|
} else {
|
||||||
|
"federated"
|
||||||
|
};
|
||||||
|
TrackDto {
|
||||||
|
key: TrackKeyDto { content_id },
|
||||||
|
metadata: TrackMetadataDto {
|
||||||
|
title: item.name.clone(),
|
||||||
|
artists,
|
||||||
|
featured_artists,
|
||||||
|
release,
|
||||||
|
year: item.year,
|
||||||
|
duration_seconds: item.duration_seconds,
|
||||||
|
track_number: item.track_number,
|
||||||
|
disc_number: item.disc_number,
|
||||||
|
cover_url: Some(format!(
|
||||||
|
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
availability: TrackAvailabilityDto {
|
||||||
|
state,
|
||||||
|
local,
|
||||||
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artist_refs(names: &[String]) -> Vec<ArtistRefDto> {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|name| ArtistRefDto {
|
||||||
|
key: ArtistKeyDto {
|
||||||
|
normalized_name: music_dht::normalize_name(name),
|
||||||
|
},
|
||||||
|
name: name.clone(),
|
||||||
|
local_id: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_availability(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<Option<LocalAvailabilityDto>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT t.id
|
||||||
|
FROM furumusic__federation_content_id_cache c
|
||||||
|
JOIN furumusic__track t ON t.audio_file_id = c.media_file_id
|
||||||
|
WHERE c.content_id = $1 AND t.is_hidden = false
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
LocalAvailabilityDto {
|
||||||
|
track_id,
|
||||||
|
stream_url: format!("/api/player/stream/{track_id}"),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_track_ref(pool: &sqlx::PgPool, track: &TrackDto) -> Result<()> {
|
||||||
|
let metadata = serde_json::to_value(&track.metadata)?;
|
||||||
|
let local_id = track
|
||||||
|
.availability
|
||||||
|
.local
|
||||||
|
.as_ref()
|
||||||
|
.map(|local| local.track_id);
|
||||||
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO furumusic__track_ref
|
||||||
|
(content_id, local_track_id, title, release_title, year,
|
||||||
|
duration_seconds, metadata_json, metadata_authority, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'federation', $8, $8)
|
||||||
|
ON CONFLICT (content_id) DO UPDATE SET
|
||||||
|
local_track_id = COALESCE(furumusic__track_ref.local_track_id, EXCLUDED.local_track_id),
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
release_title = EXCLUDED.release_title,
|
||||||
|
year = EXCLUDED.year,
|
||||||
|
duration_seconds = EXCLUDED.duration_seconds,
|
||||||
|
metadata_json = EXCLUDED.metadata_json,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&track.key.content_id)
|
||||||
|
.bind(local_id)
|
||||||
|
.bind(&track.metadata.title)
|
||||||
|
.bind(
|
||||||
|
track
|
||||||
|
.metadata
|
||||||
|
.release
|
||||||
|
.as_ref()
|
||||||
|
.map(|release| &release.title),
|
||||||
|
)
|
||||||
|
.bind(track.metadata.year)
|
||||||
|
.bind(track.metadata.duration_seconds)
|
||||||
|
.bind(metadata)
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("persisting content-addressed track reference failed")?;
|
||||||
|
let track_ref_id: i64 = row.get(0);
|
||||||
|
for source in &track.availability.federation {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_track_source
|
||||||
|
(track_ref_id, owner_peer_id, item_id, last_seen_ms, metadata_json)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (owner_peer_id, item_id) DO UPDATE SET
|
||||||
|
track_ref_id = EXCLUDED.track_ref_id,
|
||||||
|
last_seen_ms = EXCLUDED.last_seen_ms,
|
||||||
|
metadata_json = EXCLUDED.metadata_json",
|
||||||
|
)
|
||||||
|
.bind(track_ref_id)
|
||||||
|
.bind(&source.owner)
|
||||||
|
.bind(&source.item_id)
|
||||||
|
.bind(chrono::Utc::now().timestamp_millis())
|
||||||
|
.bind(json!({ "track": track.metadata }))
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,991 @@
|
|||||||
|
//! P2P federation for the furumusic server.
|
||||||
|
//!
|
||||||
|
//! When enabled in the admin settings, the server becomes a regular peer of
|
||||||
|
//! the furumi federation: it publishes its whole visible library (artists,
|
||||||
|
//! releases, tracks — names and small metadata, never files) into the
|
||||||
|
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
||||||
|
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
||||||
|
//! clients speak among themselves. Serve-only: the server does not search
|
||||||
|
//! or download from other peers.
|
||||||
|
//!
|
||||||
|
//! Settings are the regular admin config entries (`federation_enabled`,
|
||||||
|
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
|
||||||
|
//! starts, stops or re-joins the node without a server restart.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod devices;
|
||||||
|
mod receive;
|
||||||
|
mod serve;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::{
|
||||||
|
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||||
|
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use storage::PostgresFederationStorage;
|
||||||
|
|
||||||
|
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||||
|
|
||||||
|
/// How often the published library is re-synchronized with the database.
|
||||||
|
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
||||||
|
|
||||||
|
struct Running {
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
network_name: String,
|
||||||
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentHashJob {
|
||||||
|
media_file_id: i64,
|
||||||
|
sha256_hash: String,
|
||||||
|
file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CachedArtwork {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
mime: String,
|
||||||
|
fetched_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TransportSample {
|
||||||
|
at: String,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
peer_id: String,
|
||||||
|
selected_path: String,
|
||||||
|
open_paths: usize,
|
||||||
|
direct_paths: usize,
|
||||||
|
relay_paths: usize,
|
||||||
|
custom_paths: usize,
|
||||||
|
selected_rtt_ms: Option<u64>,
|
||||||
|
selected_tx_bytes: u64,
|
||||||
|
selected_rx_bytes: u64,
|
||||||
|
total_tx_bytes: u64,
|
||||||
|
total_rx_bytes: u64,
|
||||||
|
lost_packets: u64,
|
||||||
|
lost_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportSample {
|
||||||
|
fn from_stats(
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stats: ByteStreamConnectionStats,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
at: now_iso(),
|
||||||
|
protocol,
|
||||||
|
direction,
|
||||||
|
phase,
|
||||||
|
peer_id: stats.peer_id.to_string(),
|
||||||
|
selected_path: stats.selected_path.as_str().to_string(),
|
||||||
|
open_paths: stats.open_paths,
|
||||||
|
direct_paths: stats.direct_paths,
|
||||||
|
relay_paths: stats.relay_paths,
|
||||||
|
custom_paths: stats.custom_paths,
|
||||||
|
selected_rtt_ms: stats
|
||||||
|
.selected_rtt
|
||||||
|
.map(|duration| duration.as_millis() as u64),
|
||||||
|
selected_tx_bytes: stats.selected_tx_bytes,
|
||||||
|
selected_rx_bytes: stats.selected_rx_bytes,
|
||||||
|
total_tx_bytes: stats.total_tx_bytes,
|
||||||
|
total_rx_bytes: stats.total_rx_bytes,
|
||||||
|
lost_packets: stats.lost_packets,
|
||||||
|
lost_bytes: stats.lost_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TransportStatsState {
|
||||||
|
total_samples: u64,
|
||||||
|
direct_samples: u64,
|
||||||
|
relay_samples: u64,
|
||||||
|
custom_samples: u64,
|
||||||
|
unknown_samples: u64,
|
||||||
|
audio_samples: u64,
|
||||||
|
catalog_samples: u64,
|
||||||
|
sync_samples: u64,
|
||||||
|
last: VecDeque<TransportSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct TransportStats {
|
||||||
|
inner: std::sync::Mutex<TransportStatsState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportStats {
|
||||||
|
fn reset(&self) {
|
||||||
|
*lock(&self.inner) = TransportStatsState::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(
|
||||||
|
&self,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stats: ByteStreamConnectionStats,
|
||||||
|
) {
|
||||||
|
let sample = TransportSample::from_stats(protocol, direction, phase, stats);
|
||||||
|
let mut state = lock(&self.inner);
|
||||||
|
state.total_samples += 1;
|
||||||
|
match sample.selected_path.as_str() {
|
||||||
|
"direct" => state.direct_samples += 1,
|
||||||
|
"relay" => state.relay_samples += 1,
|
||||||
|
"custom" => state.custom_samples += 1,
|
||||||
|
_ => state.unknown_samples += 1,
|
||||||
|
}
|
||||||
|
match protocol {
|
||||||
|
"audio" => state.audio_samples += 1,
|
||||||
|
"catalog" => state.catalog_samples += 1,
|
||||||
|
"device-sync" => state.sync_samples += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
state.last.push_front(sample);
|
||||||
|
while state.last.len() > TRANSPORT_SAMPLE_LIMIT {
|
||||||
|
state.last.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self) -> Value {
|
||||||
|
let state = lock(&self.inner);
|
||||||
|
let latest = state.last.front();
|
||||||
|
json!({
|
||||||
|
"total_samples": state.total_samples,
|
||||||
|
"direct_samples": state.direct_samples,
|
||||||
|
"relay_samples": state.relay_samples,
|
||||||
|
"custom_samples": state.custom_samples,
|
||||||
|
"unknown_samples": state.unknown_samples,
|
||||||
|
"audio_samples": state.audio_samples,
|
||||||
|
"catalog_samples": state.catalog_samples,
|
||||||
|
"sync_samples": state.sync_samples,
|
||||||
|
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
||||||
|
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
||||||
|
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
||||||
|
"last": state.last.iter().map(|sample| json!({
|
||||||
|
"at": sample.at,
|
||||||
|
"protocol": sample.protocol,
|
||||||
|
"direction": sample.direction,
|
||||||
|
"phase": sample.phase,
|
||||||
|
"peer_id": sample.peer_id,
|
||||||
|
"selected_path": sample.selected_path,
|
||||||
|
"open_paths": sample.open_paths,
|
||||||
|
"direct_paths": sample.direct_paths,
|
||||||
|
"relay_paths": sample.relay_paths,
|
||||||
|
"custom_paths": sample.custom_paths,
|
||||||
|
"selected_rtt_ms": sample.selected_rtt_ms,
|
||||||
|
"selected_tx_bytes": sample.selected_tx_bytes,
|
||||||
|
"selected_rx_bytes": sample.selected_rx_bytes,
|
||||||
|
"total_tx_bytes": sample.total_tx_bytes,
|
||||||
|
"total_rx_bytes": sample.total_rx_bytes,
|
||||||
|
"lost_packets": sample.lost_packets,
|
||||||
|
"lost_bytes": sample.lost_bytes,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_stream_transport(
|
||||||
|
stats: &Arc<TransportStats>,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stream: &ByteStream,
|
||||||
|
) {
|
||||||
|
stats.record(protocol, direction, phase, stream.connection_stats());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Federation {
|
||||||
|
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||||
|
data_dir: PathBuf,
|
||||||
|
database_url: std::sync::Mutex<String>,
|
||||||
|
storage_dir: std::sync::Mutex<String>,
|
||||||
|
save_on_listen: std::sync::atomic::AtomicBool,
|
||||||
|
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
|
||||||
|
content_pending: std::sync::Mutex<HashSet<i64>>,
|
||||||
|
prepared_cache: std::sync::Mutex<HashMap<String, (PathBuf, String)>>,
|
||||||
|
artwork_cache: std::sync::Mutex<HashMap<String, CachedArtwork>>,
|
||||||
|
download_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||||
|
pool: tokio::sync::OnceCell<PgPool>,
|
||||||
|
running: tokio::sync::Mutex<Option<Running>>,
|
||||||
|
last_sync: std::sync::Mutex<Option<String>>,
|
||||||
|
last_error: std::sync::Mutex<Option<String>>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
|
mutex
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide federation handle.
|
||||||
|
pub fn handle() -> Arc<Federation> {
|
||||||
|
static HANDLE: OnceLock<Arc<Federation>> = OnceLock::new();
|
||||||
|
Arc::clone(HANDLE.get_or_init(|| {
|
||||||
|
Arc::new(Federation {
|
||||||
|
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
|
||||||
|
database_url: std::sync::Mutex::new(String::new()),
|
||||||
|
storage_dir: std::sync::Mutex::new(String::new()),
|
||||||
|
save_on_listen: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
content_cache: std::sync::Mutex::new(Default::default()),
|
||||||
|
content_pending: std::sync::Mutex::new(Default::default()),
|
||||||
|
prepared_cache: std::sync::Mutex::new(Default::default()),
|
||||||
|
artwork_cache: std::sync::Mutex::new(Default::default()),
|
||||||
|
download_locks: std::sync::Mutex::new(Default::default()),
|
||||||
|
pool: tokio::sync::OnceCell::new(),
|
||||||
|
running: tokio::sync::Mutex::new(None),
|
||||||
|
last_sync: std::sync::Mutex::new(None),
|
||||||
|
last_error: std::sync::Mutex::new(None),
|
||||||
|
transport_stats: Arc::new(TransportStats::default()),
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Federation {
|
||||||
|
fn set_error(&self, message: Option<String>) {
|
||||||
|
*lock(&self.last_error) = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pool(&self) -> Result<PgPool> {
|
||||||
|
let url = lock(&self.database_url).clone();
|
||||||
|
anyhow::ensure!(!url.is_empty(), "database is not configured");
|
||||||
|
let pool = self
|
||||||
|
.pool
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(4)
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(pool.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the node at boot when federation was left enabled. The
|
||||||
|
/// settings live in the config KV table, so this waits for the database
|
||||||
|
/// and resolves the same default → DB → env precedence the config uses.
|
||||||
|
pub async fn boot(self: &Arc<Self>, config: &AppConfig) {
|
||||||
|
*lock(&self.database_url) = config.database_url.clone();
|
||||||
|
if config.database_url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pool = match self.pool().await {
|
||||||
|
Ok(pool) => pool,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation boot: database unavailable: {err:#}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// `config` carries defaults + env; overlay the DB rows for fields
|
||||||
|
// that have no env override (env > DB > default).
|
||||||
|
let mut effective = config.clone();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT key, value FROM furumusic__config_entry
|
||||||
|
WHERE key IN ('federation_enabled', 'federation_network_id',
|
||||||
|
'federation_save_on_listen', 'agent_storage_dir')",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
for row in rows {
|
||||||
|
let key: String = row.get(0);
|
||||||
|
let value: String = row.get(1);
|
||||||
|
let env_key = format!("FURU_{}", key.to_ascii_uppercase());
|
||||||
|
if std::env::var(&env_key).is_ok() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match key.as_str() {
|
||||||
|
"federation_enabled" => {
|
||||||
|
if let Ok(parsed) = value.parse() {
|
||||||
|
effective.federation_enabled = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"federation_network_id" => effective.federation_network_id = value,
|
||||||
|
"federation_save_on_listen" => {
|
||||||
|
if let Ok(parsed) = value.parse() {
|
||||||
|
effective.federation_save_on_listen = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"agent_storage_dir" => {
|
||||||
|
effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.apply(&effective).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the effective configuration: starts, stops or re-joins the
|
||||||
|
/// node. Called at boot and every time the admin settings are saved.
|
||||||
|
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
|
||||||
|
*lock(&self.database_url) = config.database_url.clone();
|
||||||
|
*lock(&self.storage_dir) = config.agent_storage_dir.clone();
|
||||||
|
self.save_on_listen.store(
|
||||||
|
config.federation_save_on_listen,
|
||||||
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
let network = config.federation_network_id.trim().to_string();
|
||||||
|
if config.federation_enabled && !network.is_empty() {
|
||||||
|
if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await {
|
||||||
|
tracing::error!("federation start failed: {err:#}");
|
||||||
|
self.set_error(Some(format!("start failed: {err}")));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the DHT node. Idempotent per network name; a node on another
|
||||||
|
/// network is stopped and re-joined.
|
||||||
|
async fn start(self: &Arc<Self>, network_name: String, storage_dir: String) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut guard = self.running.lock().await;
|
||||||
|
if let Some(running) = guard.as_ref() {
|
||||||
|
if running.network_name == network_name {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
stop_running(guard.take()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||||
|
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||||
|
self.transport_stats.reset();
|
||||||
|
|
||||||
|
let config = MusicDhtConfig::builder()
|
||||||
|
.data_dir(&self.data_dir)
|
||||||
|
.network_id(NetworkId::from_name(&network_name))
|
||||||
|
// Peers of the network find each other knowing only its name.
|
||||||
|
.rendezvous(RendezvousConfig::default())
|
||||||
|
.stream_protocol(AUDIO_ALPN)
|
||||||
|
.stream_protocol(CATALOG_ALPN)
|
||||||
|
.stream_protocol(devices::SYNC_ALPN)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
|
let (service, mut events) =
|
||||||
|
MusicDhtService::start_with_storage_and_secret_key(config, dht_storage, secret_key)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to start the DHT node: {err}"))?;
|
||||||
|
let service = Arc::new(service);
|
||||||
|
tracing::info!(
|
||||||
|
endpoint_id = %service.endpoint_id(),
|
||||||
|
network = %network_name,
|
||||||
|
"federation started"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain DHT events into the log; the channel is bounded.
|
||||||
|
let event_task = tokio::spawn(async move {
|
||||||
|
while let Some(event) = events.recv().await {
|
||||||
|
tracing::debug!("federation event: {event:?}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Keep the published library in sync with the database.
|
||||||
|
let sync_self = Arc::clone(self);
|
||||||
|
let sync_service = Arc::clone(&service);
|
||||||
|
let sync_task = tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let _ = sync_self.sync_once(&sync_service).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Serve audio and catalog requests from other peers.
|
||||||
|
let audio_acceptor = service
|
||||||
|
.stream_acceptor(AUDIO_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the audio acceptor: {err}"))?;
|
||||||
|
let audio_task = tokio::spawn(serve::serve_audio(
|
||||||
|
audio_acceptor,
|
||||||
|
pool.clone(),
|
||||||
|
storage_dir.clone(),
|
||||||
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
|
let catalog_acceptor = service
|
||||||
|
.stream_acceptor(CATALOG_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
|
||||||
|
let catalog_task = tokio::spawn(serve::serve_catalog(
|
||||||
|
catalog_acceptor,
|
||||||
|
pool.clone(),
|
||||||
|
storage_dir,
|
||||||
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
|
let device_acceptor = service
|
||||||
|
.stream_acceptor(devices::SYNC_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
||||||
|
let device_hub = crate::player::PlayerDeviceHub::shared();
|
||||||
|
let device_task = tokio::spawn(devices::serve_peers(
|
||||||
|
device_acceptor,
|
||||||
|
pool.clone(),
|
||||||
|
Arc::clone(&service),
|
||||||
|
Arc::clone(&device_hub),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
|
let device_sync_task = tokio::spawn(devices::sync_loop(
|
||||||
|
pool,
|
||||||
|
Arc::clone(&service),
|
||||||
|
device_hub,
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
|
|
||||||
|
*guard = Some(Running {
|
||||||
|
service,
|
||||||
|
network_name,
|
||||||
|
tasks: vec![
|
||||||
|
event_task,
|
||||||
|
sync_task,
|
||||||
|
audio_task,
|
||||||
|
catalog_task,
|
||||||
|
device_task,
|
||||||
|
device_sync_task,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
self.set_error(None);
|
||||||
|
drop(guard);
|
||||||
|
// Publish right away instead of waiting for the first timer tick.
|
||||||
|
self.spawn_sync_soon().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self) {
|
||||||
|
let mut guard = self.running.lock().await;
|
||||||
|
stop_running(guard.take()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn service(&self) -> Result<Arc<MusicDhtService>> {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|running| Arc::clone(&running.service))
|
||||||
|
.context("federation is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||||
|
if let Ok(service) = self.service().await {
|
||||||
|
let fed = Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = fed.sync_once(&service).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let sync_stats = self.sync_once(&service).await?;
|
||||||
|
let publish_stats = match service.republish().await {
|
||||||
|
Ok(stats) => stats,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation republish failed: {err}");
|
||||||
|
self.set_error(Some(format!("republish failed: {err}")));
|
||||||
|
anyhow::bail!("republish failed: {err}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.record_publish_success(sync_stats, publish_stats);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_once(self: &Arc<Self>, service: &MusicDhtService) -> Result<SyncStats> {
|
||||||
|
let specs = match self.collect_specs().await {
|
||||||
|
Ok(specs) => specs,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation sync: library read failed: {err:#}");
|
||||||
|
self.set_error(Some(format!("library read failed: {err}")));
|
||||||
|
anyhow::bail!("library read failed: {err}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match service.sync_library(specs).await {
|
||||||
|
Ok(stats) => {
|
||||||
|
self.record_sync_success(stats);
|
||||||
|
if stats.failed > 0 {
|
||||||
|
self.set_error(Some(format!(
|
||||||
|
"{} item(s) failed to publish in the last sync",
|
||||||
|
stats.failed
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
self.set_error(None);
|
||||||
|
}
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation sync failed: {err}");
|
||||||
|
self.set_error(Some(format!("sync failed: {err}")));
|
||||||
|
Err(anyhow::anyhow!("sync failed: {err}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_sync_success(&self, stats: SyncStats) {
|
||||||
|
*lock(&self.last_sync) = Some(format!(
|
||||||
|
"{} (+{} ~{} −{}, unchanged {}, failed {})",
|
||||||
|
now_iso(),
|
||||||
|
stats.added,
|
||||||
|
stats.updated,
|
||||||
|
stats.removed,
|
||||||
|
stats.unchanged,
|
||||||
|
stats.failed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_publish_success(&self, sync_stats: SyncStats, publish_stats: PublishStats) {
|
||||||
|
*lock(&self.last_sync) = Some(format!(
|
||||||
|
"{} (+{} ~{} −{}, unchanged {}, failed {}; republished {} records, {} keys, remote nodes {})",
|
||||||
|
now_iso(),
|
||||||
|
sync_stats.added,
|
||||||
|
sync_stats.updated,
|
||||||
|
sync_stats.removed,
|
||||||
|
sync_stats.unchanged,
|
||||||
|
sync_stats.failed,
|
||||||
|
publish_stats.records,
|
||||||
|
publish_stats.keys,
|
||||||
|
publish_stats.remote_nodes,
|
||||||
|
));
|
||||||
|
self.set_error(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the regular player shows, as DHT item specs: non-hidden
|
||||||
|
/// artists, releases and tracks (a track also hides with its release).
|
||||||
|
async fn collect_specs(self: &Arc<Self>) -> Result<Vec<ItemSpec>> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut specs = Vec::new();
|
||||||
|
|
||||||
|
let artists = sqlx::query("SELECT id, name FROM furumusic__artist WHERE is_hidden = false")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
for row in &artists {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
specs.push(ItemSpec {
|
||||||
|
local_key: format!("artist:{id}"),
|
||||||
|
kind: ItemKind::Artist,
|
||||||
|
name: row.get(1),
|
||||||
|
artist_names: Vec::new(),
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
year: None,
|
||||||
|
release_type: None,
|
||||||
|
release_title: None,
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let release_artists = sqlx::query(
|
||||||
|
"SELECT ra.release_id, a.name FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||||
|
ORDER BY ra.release_id, ra.position",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let mut artists_of_release: std::collections::HashMap<i64, Vec<String>> =
|
||||||
|
Default::default();
|
||||||
|
for row in &release_artists {
|
||||||
|
artists_of_release
|
||||||
|
.entry(row.get(0))
|
||||||
|
.or_default()
|
||||||
|
.push(row.get(1));
|
||||||
|
}
|
||||||
|
let releases = sqlx::query(
|
||||||
|
"SELECT id, title, year, release_type FROM furumusic__release
|
||||||
|
WHERE is_hidden = false",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
for row in &releases {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
specs.push(ItemSpec {
|
||||||
|
local_key: format!("release:{id}"),
|
||||||
|
kind: ItemKind::Release,
|
||||||
|
name: row.get(1),
|
||||||
|
artist_names: artists_of_release.remove(&id).unwrap_or_default(),
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
year: row.get(2),
|
||||||
|
release_type: row.get(3),
|
||||||
|
release_title: None,
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let track_artists = sqlx::query(
|
||||||
|
"SELECT ta.track_id, a.name, ta.role FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.role IN ('main', 'featuring')
|
||||||
|
ORDER BY ta.track_id,
|
||||||
|
CASE ta.role WHEN 'main' THEN 0 ELSE 1 END,
|
||||||
|
ta.position",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
|
let mut featured_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
|
for row in &track_artists {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
let name: String = row.get(1);
|
||||||
|
if row.get::<String, _>(2) == "featuring" {
|
||||||
|
featured_of_track.entry(id).or_default().push(name);
|
||||||
|
} else {
|
||||||
|
artists_of_track.entry(id).or_default().push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tracks = sqlx::query(
|
||||||
|
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds,
|
||||||
|
r.title, r.release_type, t.track_number, t.disc_number,
|
||||||
|
t.audio_file_id, m.file_path, m.sha256_hash, c.content_id
|
||||||
|
FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__federation_content_id_cache c
|
||||||
|
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||||
|
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let storage_dir = lock(&self.storage_dir).clone();
|
||||||
|
let mut content_hash_jobs = Vec::new();
|
||||||
|
for row in &tracks {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
let duration: f64 = row.get(3);
|
||||||
|
let media_file_id: i64 = row.get(8);
|
||||||
|
let file_path: String = row.get(9);
|
||||||
|
let sha256_hash: String = row.get(10);
|
||||||
|
let cached_content_id: Option<String> = row.get(11);
|
||||||
|
let content_id = cached_content_id
|
||||||
|
.or_else(|| self.cached_content_id_for_media(media_file_id, &sha256_hash));
|
||||||
|
if content_id.is_none()
|
||||||
|
&& !storage_dir.trim().is_empty()
|
||||||
|
&& self.mark_content_hash_pending(media_file_id)
|
||||||
|
{
|
||||||
|
content_hash_jobs.push(ContentHashJob {
|
||||||
|
media_file_id,
|
||||||
|
sha256_hash,
|
||||||
|
file_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
specs.push(ItemSpec {
|
||||||
|
local_key: format!("track:{id}"),
|
||||||
|
kind: ItemKind::Track,
|
||||||
|
name: row.get(1),
|
||||||
|
artist_names: artists_of_track.remove(&id).unwrap_or_default(),
|
||||||
|
featured_artist_names: featured_of_track.remove(&id).unwrap_or_default(),
|
||||||
|
year: row.get(2),
|
||||||
|
release_type: row.get(5),
|
||||||
|
release_title: Some(row.get(4)),
|
||||||
|
track_number: row.get(6),
|
||||||
|
disc_number: row.get(7),
|
||||||
|
duration_seconds: (duration > 0.0).then_some(duration),
|
||||||
|
content_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.spawn_content_warmer(pool.clone(), storage_dir, content_hash_jobs);
|
||||||
|
|
||||||
|
Ok(specs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_content_id_for_media(&self, media_file_id: i64, sha256_hash: &str) -> Option<String> {
|
||||||
|
if let Some((cached_hash, content_id)) = lock(&self.content_cache).get(&media_file_id)
|
||||||
|
&& cached_hash == sha256_hash
|
||||||
|
{
|
||||||
|
return Some(content_id.clone());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_content_hash_pending(&self, media_file_id: i64) -> bool {
|
||||||
|
lock(&self.content_pending).insert(media_file_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_content_warmer(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
jobs: Vec<ContentHashJob>,
|
||||||
|
) {
|
||||||
|
if jobs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fed = Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let total = jobs.len();
|
||||||
|
let mut stored = 0usize;
|
||||||
|
for job in jobs {
|
||||||
|
let job_storage_dir = storage_dir.clone();
|
||||||
|
let job_file_path = job.file_path.clone();
|
||||||
|
let content_id = tokio::task::spawn_blocking(move || {
|
||||||
|
audio_content_id(&job_storage_dir, &job_file_path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
lock(&fed.content_pending).remove(&job.media_file_id);
|
||||||
|
if let Some(content_id) = content_id {
|
||||||
|
lock(&fed.content_cache).insert(
|
||||||
|
job.media_file_id,
|
||||||
|
(job.sha256_hash.clone(), content_id.clone()),
|
||||||
|
);
|
||||||
|
if let Err(err) =
|
||||||
|
persist_content_id(&pool, job.media_file_id, &job.sha256_hash, &content_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
media_file_id = job.media_file_id,
|
||||||
|
"federation content-id cache write failed: {err:#}"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stored += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(total, stored, "federation content-id cache warm finished");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live status for the admin page.
|
||||||
|
pub async fn status(&self) -> Value {
|
||||||
|
let guard = self.running.lock().await;
|
||||||
|
let node = match guard.as_ref() {
|
||||||
|
Some(running) => {
|
||||||
|
let service = &running.service;
|
||||||
|
let published = service
|
||||||
|
.list_local_items()
|
||||||
|
.await
|
||||||
|
.map(|items| items.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let peers: Vec<String> = service
|
||||||
|
.connected_peers()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"running": true,
|
||||||
|
"network": running.network_name,
|
||||||
|
"endpoint_id": service.endpoint_id().to_string(),
|
||||||
|
"connected_peers": peers,
|
||||||
|
"known_contacts": service.known_peers().len(),
|
||||||
|
"published_items": published,
|
||||||
|
"transport": self.transport_stats.snapshot(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => json!({ "running": false }),
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"node": node,
|
||||||
|
"last_sync": lock(&self.last_sync).clone(),
|
||||||
|
"last_error": lock(&self.last_error).clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ticket(&self) -> Result<String> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let ticket = service
|
||||||
|
.ticket()
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot create a ticket: {err}"))?;
|
||||||
|
Ok(ticket.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&self, ticket: &str) -> Result<String> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let ticket: PeerTicket = ticket
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|err| anyhow::anyhow!("malformed ticket: {err}"))?;
|
||||||
|
let peer = service
|
||||||
|
.connect(ticket)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
||||||
|
Ok(peer.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_status(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
user_name: &str,
|
||||||
|
) -> Result<devices::FedDeviceStatus> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::status(&pool, user_id, user_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_invite(&self, user_id: i64, user_name: &str) -> Result<String> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::create_invite(&pool, service, user_id, user_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_connect(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
user_name: &str,
|
||||||
|
invite: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let network_id = devices::invite_network_id(invite)?;
|
||||||
|
{
|
||||||
|
let guard = self.running.lock().await;
|
||||||
|
let Some(running) = guard.as_ref() else {
|
||||||
|
anyhow::bail!("federation is not running");
|
||||||
|
};
|
||||||
|
let expected = NetworkId::from_name(&running.network_name);
|
||||||
|
anyhow::ensure!(
|
||||||
|
network_id == expected,
|
||||||
|
"device invite belongs to a different federation network"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::connect_invite(
|
||||||
|
&pool,
|
||||||
|
service,
|
||||||
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
user_id,
|
||||||
|
user_name,
|
||||||
|
invite,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_answer_pairing(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
request_id: &str,
|
||||||
|
accept: bool,
|
||||||
|
use_requester_group: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::answer_pairing(&pool, user_id, request_id, accept, use_requester_group).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_revoke(&self, user_id: i64, device_id: &str) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::revoke_device(&pool, user_id, device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_sync_now(&self, user_id: i64) -> Result<()> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::sync_once(
|
||||||
|
&pool,
|
||||||
|
service,
|
||||||
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_command(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
target_device_id: &str,
|
||||||
|
command: &str,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
current_state: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_playback_command(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
target_device_id,
|
||||||
|
command,
|
||||||
|
payload,
|
||||||
|
current_state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_active_transfer(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
target_device_id: &str,
|
||||||
|
previous_device_id: Option<&str>,
|
||||||
|
state: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_active_transfer(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
target_device_id,
|
||||||
|
previous_device_id,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_active_takeover(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
previous_device_id: &str,
|
||||||
|
state: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_active_takeover(&pool, user_id, previous_device_id, state).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_content_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
media_file_id: i64,
|
||||||
|
sha256_hash: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_content_id_cache
|
||||||
|
(media_file_id, sha256_hash, content_id, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (media_file_id) DO UPDATE SET
|
||||||
|
sha256_hash = EXCLUDED.sha256_hash,
|
||||||
|
content_id = EXCLUDED.content_id,
|
||||||
|
updated_at = EXCLUDED.updated_at",
|
||||||
|
)
|
||||||
|
.bind(media_file_id)
|
||||||
|
.bind(sha256_hash)
|
||||||
|
.bind(content_id)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_content_id(storage_dir: &str, file_path: &str) -> Option<String> {
|
||||||
|
if storage_dir.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||||
|
let mut file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
std::io::copy(&mut file, &mut hasher).ok()?;
|
||||||
|
Some(format!("b3:{}", hasher.finalize().to_hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_running(running: Option<Running>) {
|
||||||
|
let Some(running) = running else { return };
|
||||||
|
for task in &running.tasks {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
if let Err(err) = running.service.shutdown().await {
|
||||||
|
tracing::warn!("federation node shutdown reported an error: {err}");
|
||||||
|
}
|
||||||
|
tracing::info!("federation stopped");
|
||||||
|
}
|
||||||
@@ -0,0 +1,914 @@
|
|||||||
|
//! Verified federated audio download and trusted materialization.
|
||||||
|
//!
|
||||||
|
//! This module never writes inbox, processing-task, or review tables. Peer
|
||||||
|
//! metadata is the authority for this import path.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::EndpointId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||||
|
|
||||||
|
use super::{Federation, now_iso};
|
||||||
|
|
||||||
|
const MAX_LINE: usize = 4096;
|
||||||
|
const MAX_AUDIO_BYTES: u64 = 4 * 1024 * 1024 * 1024;
|
||||||
|
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
struct TrackMetadata {
|
||||||
|
title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
artists: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
featured_artists: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
album_artists: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
release_title: String,
|
||||||
|
release_type: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
track_number: Option<i32>,
|
||||||
|
disc_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AudioRequest<'a> {
|
||||||
|
item_id: &'a str,
|
||||||
|
offset: u64,
|
||||||
|
want_cover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AudioHeader {
|
||||||
|
ok: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
mime_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
total_size: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
metadata: Option<TrackMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
cover_size: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
cover_mime: String,
|
||||||
|
#[serde(default)]
|
||||||
|
artist_image_size: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
artist_image_mime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PreparedTrack {
|
||||||
|
pub local_track_id: Option<i64>,
|
||||||
|
pub stream_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DownloadProgress {
|
||||||
|
pub phase: &'static str,
|
||||||
|
pub received: u64,
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Downloaded {
|
||||||
|
path: PathBuf,
|
||||||
|
mime: String,
|
||||||
|
metadata: TrackMetadata,
|
||||||
|
cover: Option<(Vec<u8>, String)>,
|
||||||
|
artist_image: Option<(Vec<u8>, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Federation {
|
||||||
|
pub async fn discover_catalog_artwork(
|
||||||
|
&self,
|
||||||
|
artist: &str,
|
||||||
|
release: Option<&str>,
|
||||||
|
) -> Result<Option<(Vec<u8>, String)>> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let normalized = music_dht::normalize_name(artist);
|
||||||
|
let outcome = service
|
||||||
|
.search_network(artist)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("artwork peer discovery failed: {err}"))?;
|
||||||
|
let mut owners = Vec::new();
|
||||||
|
for item in outcome.local_results.iter().chain(&outcome.network_results) {
|
||||||
|
let matches = (item.kind == music_dht::ItemKind::Artist
|
||||||
|
&& item.normalized_name == normalized)
|
||||||
|
|| item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.chain(&item.featured_artist_names)
|
||||||
|
.any(|name| music_dht::normalize_name(name) == normalized);
|
||||||
|
let owner = item.owner.to_string();
|
||||||
|
if matches && !owners.contains(&owner) {
|
||||||
|
owners.push(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner in owners {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
self.catalog_artwork(&owner, artist, release),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(Some(artwork))) => return Ok(Some(artwork)),
|
||||||
|
Ok(Ok(None)) => {}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
tracing::debug!(%owner, %artist, ?release, "catalog artwork peer failed: {err:#}");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!(%owner, %artist, ?release, "catalog artwork peer timed out");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn catalog_artwork(
|
||||||
|
&self,
|
||||||
|
owner: &str,
|
||||||
|
artist: &str,
|
||||||
|
release: Option<&str>,
|
||||||
|
) -> Result<Option<(Vec<u8>, String)>> {
|
||||||
|
let cache_key = format!("catalog:{owner}:{artist}:{}", release.unwrap_or_default());
|
||||||
|
if let Some(cached) = cached_artwork(self, &cache_key) {
|
||||||
|
return Ok(Some(cached));
|
||||||
|
}
|
||||||
|
let owner = EndpointId::from_str(owner).context("invalid federation owner")?;
|
||||||
|
let service = self.service().await?;
|
||||||
|
let mut stream = service
|
||||||
|
.open_stream(owner, super::CATALOG_ALPN)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach catalog peer: {err}"))?;
|
||||||
|
let mut request = serde_json::to_vec(&music_dht::catalog::CatalogRequest {
|
||||||
|
artist: artist.to_owned(),
|
||||||
|
want: Some(if release.is_some() {
|
||||||
|
"release_cover".to_owned()
|
||||||
|
} else {
|
||||||
|
"artist_image".to_owned()
|
||||||
|
}),
|
||||||
|
release: release.map(str::to_owned),
|
||||||
|
..Default::default()
|
||||||
|
})?;
|
||||||
|
request.push(b'\n');
|
||||||
|
stream.send.write_all(&request).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let header: music_dht::catalog::CatalogImageHeader =
|
||||||
|
serde_json::from_slice(&read_line(&mut stream.recv).await?)
|
||||||
|
.context("invalid catalog artwork response")?;
|
||||||
|
if !header.ok || header.size == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
header.size <= MAX_IMAGE_BYTES,
|
||||||
|
"catalog artwork is too large"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
header.mime_type.starts_with("image/"),
|
||||||
|
"invalid catalog artwork mime type"
|
||||||
|
);
|
||||||
|
let mut bytes = vec![0; header.size as usize];
|
||||||
|
stream.recv.read_exact(&mut bytes).await?;
|
||||||
|
let artwork = (bytes, header.mime_type);
|
||||||
|
cache_artwork(self, cache_key, &artwork);
|
||||||
|
Ok(Some(artwork))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn track_artwork(
|
||||||
|
&self,
|
||||||
|
owner: &str,
|
||||||
|
item_id: &str,
|
||||||
|
) -> Result<Option<(Vec<u8>, String)>> {
|
||||||
|
let cache_key = format!("{owner}:{item_id}");
|
||||||
|
if let Some(cached) = cached_artwork(self, &cache_key) {
|
||||||
|
return Ok(Some(cached));
|
||||||
|
}
|
||||||
|
let owner = EndpointId::from_str(owner).context("invalid federation owner")?;
|
||||||
|
anyhow::ensure!(item_id.len() == 64, "invalid federation item id");
|
||||||
|
let service = self.service().await?;
|
||||||
|
let mut stream = service
|
||||||
|
.open_stream(owner, super::AUDIO_ALPN)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach track owner: {err}"))?;
|
||||||
|
write_line(
|
||||||
|
&mut stream.send,
|
||||||
|
&AudioRequest {
|
||||||
|
item_id,
|
||||||
|
offset: 0,
|
||||||
|
want_cover: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
|
||||||
|
.context("invalid artwork response")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
header.ok,
|
||||||
|
"peer refused artwork: {}",
|
||||||
|
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||||
|
);
|
||||||
|
let artwork = read_segment(
|
||||||
|
&mut stream.recv,
|
||||||
|
header.cover_size,
|
||||||
|
&header.cover_mime,
|
||||||
|
"cover",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some((bytes, mime)) = artwork {
|
||||||
|
let artwork = (bytes, mime);
|
||||||
|
cache_artwork(self, cache_key, &artwork);
|
||||||
|
Ok(Some(artwork))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn prepare_content_with_progress<F>(
|
||||||
|
self: &std::sync::Arc<Self>,
|
||||||
|
content_id: &str,
|
||||||
|
owner: &str,
|
||||||
|
item_id: &str,
|
||||||
|
mut progress: F,
|
||||||
|
) -> Result<PreparedTrack>
|
||||||
|
where
|
||||||
|
F: FnMut(DownloadProgress) + Send,
|
||||||
|
{
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "checking",
|
||||||
|
received: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
let content_id =
|
||||||
|
music_dht::normalize_content_id(content_id).context("invalid content id")?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let token = content_id.trim_start_matches("b3:").to_owned();
|
||||||
|
let download_lock = {
|
||||||
|
let mut locks = super::lock(&self.download_locks);
|
||||||
|
std::sync::Arc::clone(
|
||||||
|
locks
|
||||||
|
.entry(content_id.clone())
|
||||||
|
.or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let _download_guard = download_lock.lock().await;
|
||||||
|
if let Some(track_id) = local_track_id(&pool, &content_id).await? {
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "ready",
|
||||||
|
received: 1,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
return Ok(PreparedTrack {
|
||||||
|
local_track_id: Some(track_id),
|
||||||
|
stream_url: format!("/api/player/stream/{track_id}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let save = self
|
||||||
|
.save_on_listen
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if !save
|
||||||
|
&& let Some((_path, _mime)) = super::lock(&self.prepared_cache).get(&token).cloned()
|
||||||
|
{
|
||||||
|
return Ok(PreparedTrack {
|
||||||
|
local_track_id: None,
|
||||||
|
stream_url: format!("/api/player/federation/cache/{token}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let owner = EndpointId::from_str(owner).context("invalid federation owner")?;
|
||||||
|
anyhow::ensure!(item_id.len() == 64, "invalid federation item id");
|
||||||
|
let service = self.service().await?;
|
||||||
|
let storage_root = if save {
|
||||||
|
PathBuf::from(super::lock(&self.storage_dir).clone())
|
||||||
|
} else {
|
||||||
|
PathBuf::from(crate::media_paths::resolve_config_path("federation-cache"))
|
||||||
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
!storage_root.as_os_str().is_empty(),
|
||||||
|
"media storage directory is not configured"
|
||||||
|
);
|
||||||
|
let dir = storage_root.join("federation");
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
let downloaded =
|
||||||
|
download(&service, owner, item_id, &content_id, &dir, &mut progress).await?;
|
||||||
|
if !save {
|
||||||
|
super::lock(&self.prepared_cache)
|
||||||
|
.insert(token.clone(), (downloaded.path, downloaded.mime));
|
||||||
|
return Ok(PreparedTrack {
|
||||||
|
local_track_id: None,
|
||||||
|
stream_url: format!("/api/player/federation/cache/{token}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "saving",
|
||||||
|
received: 1,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
let track_id = materialize(&pool, &storage_root, &content_id, downloaded).await?;
|
||||||
|
// The normal periodic sync will publish it; this immediate sync keeps
|
||||||
|
// save-on-listen useful to the federation without waiting a minute.
|
||||||
|
if let Err(err) = self.sync_now().await {
|
||||||
|
tracing::warn!(track_id, "post-import federation publish failed: {err:#}");
|
||||||
|
}
|
||||||
|
Ok(PreparedTrack {
|
||||||
|
local_track_id: Some(track_id),
|
||||||
|
stream_url: format!("/api/player/stream/{track_id}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepared_cache_file(&self, token: &str) -> Option<(PathBuf, String)> {
|
||||||
|
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
super::lock(&self.prepared_cache).get(token).cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_artwork(federation: &Federation, key: &str) -> Option<(Vec<u8>, String)> {
|
||||||
|
let mut cache = super::lock(&federation.artwork_cache);
|
||||||
|
let cached = cache.get(key).cloned()?;
|
||||||
|
if cached.fetched_at.elapsed() > ARTWORK_CACHE_TTL {
|
||||||
|
cache.remove(key);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((cached.bytes, cached.mime))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_artwork(federation: &Federation, key: String, artwork: &(Vec<u8>, String)) {
|
||||||
|
let mut cache = super::lock(&federation.artwork_cache);
|
||||||
|
if cache.len() >= 512 {
|
||||||
|
cache.retain(|_, value| value.fetched_at.elapsed() <= ARTWORK_CACHE_TTL);
|
||||||
|
if cache.len() >= 512
|
||||||
|
&& let Some(oldest) = cache
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, value)| value.fetched_at)
|
||||||
|
.map(|(key, _)| key.clone())
|
||||||
|
{
|
||||||
|
cache.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.insert(
|
||||||
|
key,
|
||||||
|
super::CachedArtwork {
|
||||||
|
bytes: artwork.0.clone(),
|
||||||
|
mime: artwork.1.clone(),
|
||||||
|
fetched_at: std::time::Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(
|
||||||
|
service: &music_dht::MusicDhtService,
|
||||||
|
owner: EndpointId,
|
||||||
|
item_id: &str,
|
||||||
|
content_id: &str,
|
||||||
|
dir: &Path,
|
||||||
|
progress: &mut (impl FnMut(DownloadProgress) + Send),
|
||||||
|
) -> Result<Downloaded> {
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "connecting",
|
||||||
|
received: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
let mut stream = service
|
||||||
|
.open_stream(owner, super::AUDIO_ALPN)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach track owner: {err}"))?;
|
||||||
|
write_line(
|
||||||
|
&mut stream.send,
|
||||||
|
&AudioRequest {
|
||||||
|
item_id,
|
||||||
|
offset: 0,
|
||||||
|
want_cover: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
|
||||||
|
.context("invalid audio response")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
header.ok,
|
||||||
|
"peer refused audio: {}",
|
||||||
|
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
header.total_size > 0 && header.total_size <= MAX_AUDIO_BYTES,
|
||||||
|
"invalid federated audio size"
|
||||||
|
);
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "downloading",
|
||||||
|
received: 0,
|
||||||
|
total: header.total_size,
|
||||||
|
});
|
||||||
|
let metadata = header.metadata.context("peer returned no track metadata")?;
|
||||||
|
let cover = read_segment(
|
||||||
|
&mut stream.recv,
|
||||||
|
header.cover_size,
|
||||||
|
&header.cover_mime,
|
||||||
|
"cover",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let artist_image = read_segment(
|
||||||
|
&mut stream.recv,
|
||||||
|
header.artist_image_size,
|
||||||
|
&header.artist_image_mime,
|
||||||
|
"artist image",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let stem = content_id.trim_start_matches("b3:");
|
||||||
|
let extension = audio_extension(&header.mime_type);
|
||||||
|
let final_path = dir.join(format!("{stem}.{extension}"));
|
||||||
|
let part_path = dir.join(format!(".{stem}.{extension}.part"));
|
||||||
|
let mut file = tokio::fs::File::create(&part_path).await?;
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
let mut received = 0u64;
|
||||||
|
let mut buf = vec![0u8; 64 * 1024];
|
||||||
|
while received < header.total_size {
|
||||||
|
let remaining = (header.total_size - received).min(buf.len() as u64) as usize;
|
||||||
|
let count = stream.recv.read(&mut buf[..remaining]).await?.unwrap_or(0);
|
||||||
|
anyhow::ensure!(count > 0, "audio stream ended early");
|
||||||
|
file.write_all(&buf[..count]).await?;
|
||||||
|
hasher.update(&buf[..count]);
|
||||||
|
received += count as u64;
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "downloading",
|
||||||
|
received,
|
||||||
|
total: header.total_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
file.flush().await?;
|
||||||
|
drop(file);
|
||||||
|
let actual = format!("b3:{}", hasher.finalize().to_hex());
|
||||||
|
progress(DownloadProgress {
|
||||||
|
phase: "verifying",
|
||||||
|
received,
|
||||||
|
total: header.total_size,
|
||||||
|
});
|
||||||
|
if actual != content_id {
|
||||||
|
let _ = tokio::fs::remove_file(&part_path).await;
|
||||||
|
anyhow::bail!("downloaded audio content id mismatch");
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&part_path, &final_path).await?;
|
||||||
|
Ok(Downloaded {
|
||||||
|
path: final_path,
|
||||||
|
mime: header.mime_type,
|
||||||
|
metadata,
|
||||||
|
cover,
|
||||||
|
artist_image,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn materialize(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
storage_root: &Path,
|
||||||
|
content_id: &str,
|
||||||
|
downloaded: Downloaded,
|
||||||
|
) -> Result<i64> {
|
||||||
|
if let Some(track_id) = local_track_id(pool, content_id).await? {
|
||||||
|
return Ok(track_id);
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&downloaded.path).await?;
|
||||||
|
let sha256 = format!("{:x}", Sha256::digest(&bytes));
|
||||||
|
let relative = downloaded
|
||||||
|
.path
|
||||||
|
.strip_prefix(storage_root)
|
||||||
|
.unwrap_or(&downloaded.path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let existing_media: Option<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__media_file
|
||||||
|
WHERE file_type = 'audio' AND sha256_hash = $1 LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&sha256)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let media_id = match existing_media {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO furumusic__media_file
|
||||||
|
(file_type, file_path, original_filename, mime_type,
|
||||||
|
file_size_bytes, sha256_hash, audio_format,
|
||||||
|
uploaded_by_user_id, uploader_name, created_at)
|
||||||
|
VALUES ('audio', $1, $2, $3, $4, $5, $6, NULL, 'Federation', $7)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&relative)
|
||||||
|
.bind(
|
||||||
|
downloaded
|
||||||
|
.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("federated-audio"),
|
||||||
|
)
|
||||||
|
.bind(&downloaded.mime)
|
||||||
|
.bind(bytes.len() as i64)
|
||||||
|
.bind(&sha256)
|
||||||
|
.bind(audio_extension(&downloaded.mime))
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_content_id_cache
|
||||||
|
(media_file_id, sha256_hash, content_id, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (media_file_id) DO UPDATE SET
|
||||||
|
sha256_hash = EXCLUDED.sha256_hash,
|
||||||
|
content_id = EXCLUDED.content_id,
|
||||||
|
updated_at = EXCLUDED.updated_at",
|
||||||
|
)
|
||||||
|
.bind(media_id)
|
||||||
|
.bind(&sha256)
|
||||||
|
.bind(content_id)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if let Some(track_id) = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT id FROM furumusic__track WHERE audio_file_id = $1 LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(media_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(track_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let release_title = nonempty(&downloaded.metadata.release_title).unwrap_or("Unknown release");
|
||||||
|
let release_sort = music_dht::normalize_name(release_title);
|
||||||
|
let release_id: i64 = if let Some(id) = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__release
|
||||||
|
WHERE title_sort = $1 AND year IS NOT DISTINCT FROM $2
|
||||||
|
ORDER BY id LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&release_sort)
|
||||||
|
.bind(downloaded.metadata.year)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO furumusic__release
|
||||||
|
(title, title_sort, release_type, year, is_hidden, model_name,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, false, NULL, $5, $5)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(release_title)
|
||||||
|
.bind(&release_sort)
|
||||||
|
.bind(
|
||||||
|
downloaded
|
||||||
|
.metadata
|
||||||
|
.release_type
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("album"),
|
||||||
|
)
|
||||||
|
.bind(downloaded.metadata.year)
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
let duration: f64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(duration_seconds, 0)
|
||||||
|
FROM furumusic__track_ref WHERE content_id = $1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let track_id: i64 = sqlx::query_scalar(
|
||||||
|
"INSERT INTO furumusic__track
|
||||||
|
(title, title_sort, release_id, track_number, disc_number,
|
||||||
|
duration_seconds, audio_file_id, year, is_hidden, model_name,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, NULL, $9, $9)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&downloaded.metadata.title)
|
||||||
|
.bind(music_dht::normalize_name(&downloaded.metadata.title))
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(downloaded.metadata.track_number)
|
||||||
|
.bind(downloaded.metadata.disc_number)
|
||||||
|
.bind(duration)
|
||||||
|
.bind(media_id)
|
||||||
|
.bind(downloaded.metadata.year)
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let main_artists = if downloaded.metadata.artists.is_empty() {
|
||||||
|
&downloaded.metadata.album_artists
|
||||||
|
} else {
|
||||||
|
&downloaded.metadata.artists
|
||||||
|
};
|
||||||
|
let mut main_artist_ids = Vec::new();
|
||||||
|
for (position, name) in main_artists.iter().enumerate() {
|
||||||
|
let artist_id = ensure_artist(&mut tx, name).await?;
|
||||||
|
main_artist_ids.push(artist_id);
|
||||||
|
link_track_artist(&mut tx, track_id, artist_id, "main", position as i32).await?;
|
||||||
|
link_release_artist(&mut tx, release_id, artist_id, position as i32).await?;
|
||||||
|
}
|
||||||
|
for (position, name) in downloaded.metadata.featured_artists.iter().enumerate() {
|
||||||
|
let artist_id = ensure_artist(&mut tx, name).await?;
|
||||||
|
link_track_artist(&mut tx, track_id, artist_id, "featuring", position as i32).await?;
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE furumusic__track_ref
|
||||||
|
SET local_track_id = $2, metadata_authority = 'federation', updated_at = $3
|
||||||
|
WHERE content_id = $1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.bind(track_id)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE furumusic__fed_state_like
|
||||||
|
SET local_track_id = $2 WHERE content_id = $1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.bind(track_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE furumusic__fed_state_playlist_item
|
||||||
|
SET local_track_id = $2 WHERE content_id = $1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.bind(track_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
super::devices::materialize_content_state(pool, content_id, track_id).await?;
|
||||||
|
|
||||||
|
// Artwork is non-authoritative for identity and may be installed after
|
||||||
|
// the audio transaction. Failure does not invalidate a verified track.
|
||||||
|
if let Some((bytes, mime)) = downloaded.cover
|
||||||
|
&& let Err(err) =
|
||||||
|
install_release_artwork(pool, storage_root, release_id, &bytes, &mime).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
release_id,
|
||||||
|
"failed to install federated release artwork: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((bytes, mime)) = downloaded.artist_image
|
||||||
|
&& let Err(err) =
|
||||||
|
install_artist_artwork(pool, storage_root, &main_artist_ids, &bytes, &mime).await
|
||||||
|
{
|
||||||
|
tracing::warn!("failed to install federated artist artwork: {err:#}");
|
||||||
|
}
|
||||||
|
Ok(track_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_artwork(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
storage_root: &Path,
|
||||||
|
release_id: i64,
|
||||||
|
bytes: &[u8],
|
||||||
|
mime: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let media_id = persist_artwork(pool, storage_root, bytes, mime).await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE furumusic__release
|
||||||
|
SET cover_file_id = $1, updated_at = $3
|
||||||
|
WHERE id = $2 AND cover_file_id IS NULL",
|
||||||
|
)
|
||||||
|
.bind(media_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_artist_artwork(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
storage_root: &Path,
|
||||||
|
artist_ids: &[i64],
|
||||||
|
bytes: &[u8],
|
||||||
|
mime: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
if artist_ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let media_id = persist_artwork(pool, storage_root, bytes, mime).await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE furumusic__artist
|
||||||
|
SET image_file_id = $1, updated_at = $3
|
||||||
|
WHERE id = ANY($2) AND image_file_id IS NULL",
|
||||||
|
)
|
||||||
|
.bind(media_id)
|
||||||
|
.bind(artist_ids)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_artwork(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
storage_root: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
mime: &str,
|
||||||
|
) -> Result<i64> {
|
||||||
|
anyhow::ensure!(!bytes.is_empty() && bytes.len() as u64 <= MAX_IMAGE_BYTES);
|
||||||
|
anyhow::ensure!(mime.starts_with("image/"), "invalid artwork mime type");
|
||||||
|
let hash = format!("{:x}", Sha256::digest(bytes));
|
||||||
|
if let Some(id) = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__media_file
|
||||||
|
WHERE file_type = 'cover_art' AND sha256_hash = $1 LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let extension = image_extension(mime);
|
||||||
|
let filename = format!("federation-artwork-{}.{}", &hash[..16], extension);
|
||||||
|
let dir = storage_root.join("federation").join("artwork");
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
let path = dir.join(&filename);
|
||||||
|
tokio::fs::write(&path, bytes).await?;
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(storage_root)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let id = sqlx::query_scalar(
|
||||||
|
"INSERT INTO furumusic__media_file
|
||||||
|
(file_type, file_path, original_filename, mime_type,
|
||||||
|
file_size_bytes, sha256_hash, uploaded_by_user_id,
|
||||||
|
uploader_name, created_at)
|
||||||
|
VALUES ('cover_art', $1, $2, $3, $4, $5, NULL, 'Federation', $6)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&relative)
|
||||||
|
.bind(&filename)
|
||||||
|
.bind(mime)
|
||||||
|
.bind(bytes.len() as i64)
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if let Err(err) = crate::agent::cover_variants::ensure_cover_variants(&path).await {
|
||||||
|
tracing::warn!(
|
||||||
|
media_id = id,
|
||||||
|
"failed to generate federated artwork variants: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_extension(mime: &str) -> &'static str {
|
||||||
|
match mime {
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
"image/gif" => "gif",
|
||||||
|
"image/avif" => "avif",
|
||||||
|
_ => "jpg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_artist(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, name: &str) -> Result<i64> {
|
||||||
|
let normalized = music_dht::normalize_name(name);
|
||||||
|
if let Some(id) = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM furumusic__artist WHERE name_sort = $1 ORDER BY id LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&normalized)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
Ok(sqlx::query_scalar(
|
||||||
|
"INSERT INTO furumusic__artist
|
||||||
|
(name, name_sort, is_hidden, model_name, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, false, NULL, $3, $3) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(normalized)
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn link_track_artist(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
track_id: i64,
|
||||||
|
artist_id: i64,
|
||||||
|
role: &str,
|
||||||
|
position: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__track_artist
|
||||||
|
(track_id, artist_id, role, position) VALUES ($1, $2, $3, $4)",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.bind(artist_id)
|
||||||
|
.bind(role)
|
||||||
|
.bind(position)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn link_release_artist(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
release_id: i64,
|
||||||
|
artist_id: i64,
|
||||||
|
position: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__release_artist
|
||||||
|
(release_id, artist_id, position)
|
||||||
|
SELECT $1, $2, $3 WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM furumusic__release_artist
|
||||||
|
WHERE release_id = $1 AND artist_id = $2
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(artist_id)
|
||||||
|
.bind(position)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_track_id(pool: &sqlx::PgPool, content_id: &str) -> Result<Option<i64>> {
|
||||||
|
Ok(sqlx::query_scalar(
|
||||||
|
"SELECT t.id
|
||||||
|
FROM furumusic__federation_content_id_cache c
|
||||||
|
JOIN furumusic__track t ON t.audio_file_id = c.media_file_id
|
||||||
|
WHERE c.content_id = $1 AND t.is_hidden = false LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||||
|
let mut line = Vec::new();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
loop {
|
||||||
|
anyhow::ensure!(
|
||||||
|
reader.read_exact(&mut byte).await.is_ok(),
|
||||||
|
"stream ended early"
|
||||||
|
);
|
||||||
|
if byte[0] == b'\n' {
|
||||||
|
return Ok(line);
|
||||||
|
}
|
||||||
|
line.push(byte[0]);
|
||||||
|
anyhow::ensure!(line.len() <= MAX_LINE, "protocol line too large");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_line<W: AsyncWrite + Unpin>(writer: &mut W, value: &impl Serialize) -> Result<()> {
|
||||||
|
let mut line = serde_json::to_vec(value)?;
|
||||||
|
line.push(b'\n');
|
||||||
|
writer.write_all(&line).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_segment<R: AsyncRead + Unpin>(
|
||||||
|
reader: &mut R,
|
||||||
|
size: u64,
|
||||||
|
mime: &str,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Option<(Vec<u8>, String)>> {
|
||||||
|
if size == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(size <= MAX_IMAGE_BYTES, "{label} is too large");
|
||||||
|
let mut bytes = vec![0u8; size as usize];
|
||||||
|
reader.read_exact(&mut bytes).await?;
|
||||||
|
Ok(Some((bytes, mime.to_owned())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_extension(mime: &str) -> &'static str {
|
||||||
|
match mime {
|
||||||
|
"audio/mpeg" => "mp3",
|
||||||
|
"audio/flac" => "flac",
|
||||||
|
"audio/ogg" => "ogg",
|
||||||
|
"audio/opus" => "opus",
|
||||||
|
"audio/wav" => "wav",
|
||||||
|
"audio/mp4" => "m4a",
|
||||||
|
"audio/aac" => "aac",
|
||||||
|
_ => "bin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonempty(value: &str) -> Option<&str> {
|
||||||
|
(!value.trim().is_empty()).then_some(value.trim())
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
//! Serve side of the federation wire protocols (audio + catalog), backed by
|
||||||
|
//! the PostgreSQL library and the media storage directory. Wire compatible
|
||||||
|
//! with the furumi TUI client and any other furumi peer.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
pub use music_dht::catalog::CATALOG_ALPN;
|
||||||
|
use music_dht::catalog::{
|
||||||
|
CatalogArtist, CatalogArtistPreview, CatalogImageHeader as ImageHeader, CatalogRelease,
|
||||||
|
CatalogRequest, CatalogResponse, CatalogTrack,
|
||||||
|
};
|
||||||
|
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor, normalize_name};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
use super::{TransportStats, record_stream_transport};
|
||||||
|
|
||||||
|
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||||
|
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||||
|
|
||||||
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
|
/// Images above this size are skipped rather than transferred.
|
||||||
|
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wire shapes (shared with the furumi TUI client)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AudioRequest {
|
||||||
|
item_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
offset: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
want_cover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize)]
|
||||||
|
struct AudioResponseHeader {
|
||||||
|
ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
mime_type: String,
|
||||||
|
total_size: u64,
|
||||||
|
offset: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
metadata: Option<TrackMetadata>,
|
||||||
|
cover_size: u64,
|
||||||
|
cover_mime: String,
|
||||||
|
artist_image_size: u64,
|
||||||
|
artist_image_mime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
struct TrackMetadata {
|
||||||
|
title: String,
|
||||||
|
artists: Vec<String>,
|
||||||
|
featured_artists: Vec<String>,
|
||||||
|
album_artists: Vec<String>,
|
||||||
|
release_title: String,
|
||||||
|
release_type: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
track_number: Option<i32>,
|
||||||
|
disc_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Framing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_decode_item_id(value: &str) -> Option<ItemId> {
|
||||||
|
if value.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||||
|
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
|
||||||
|
}
|
||||||
|
Some(ItemId::from_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||||
|
let mut line = Vec::new();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut byte).await?;
|
||||||
|
if n == 0 {
|
||||||
|
anyhow::bail!("stream ended before the protocol line was complete");
|
||||||
|
}
|
||||||
|
if byte[0] == b'\n' {
|
||||||
|
return Ok(line);
|
||||||
|
}
|
||||||
|
line.push(byte[0]);
|
||||||
|
if line.len() > MAX_PROTOCOL_LINE {
|
||||||
|
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||||
|
writer: &mut W,
|
||||||
|
value: &impl Serialize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut line = serde_json::to_vec(value)?;
|
||||||
|
line.push(b'\n');
|
||||||
|
writer.write_all(&line).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_id_of(own: &EndpointId, track_id: i64) -> String {
|
||||||
|
hex_encode(ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")).as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_media_path(storage_dir: &str, file_path: &str) -> PathBuf {
|
||||||
|
crate::media_paths::resolve_media_file_path(storage_dir, file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guess_mime(path: &Path) -> &'static str {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"mp3" => "audio/mpeg",
|
||||||
|
"flac" => "audio/flac",
|
||||||
|
"ogg" | "oga" => "audio/ogg",
|
||||||
|
"opus" => "audio/opus",
|
||||||
|
"wav" => "audio/wav",
|
||||||
|
"m4a" | "mp4" | "alac" => "audio/mp4",
|
||||||
|
"aac" => "audio/aac",
|
||||||
|
"aiff" | "aif" => "audio/aiff",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads an image media file from disk, bounded by [`MAX_IMAGE_BYTES`].
|
||||||
|
async fn read_image(
|
||||||
|
storage_dir: &str,
|
||||||
|
media: Option<(String, String)>,
|
||||||
|
) -> Option<(Vec<u8>, String)> {
|
||||||
|
let (file_path, mime) = media?;
|
||||||
|
let path = resolve_media_path(storage_dir, &file_path);
|
||||||
|
let size = tokio::fs::metadata(&path).await.ok()?.len();
|
||||||
|
if size == 0 || size > MAX_IMAGE_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&path).await.ok()?;
|
||||||
|
let mime = if mime.trim().is_empty() {
|
||||||
|
"image/jpeg".to_string()
|
||||||
|
} else {
|
||||||
|
mime
|
||||||
|
};
|
||||||
|
Some((bytes, mime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Library lookups (PostgreSQL)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Finds the visible track whose derived DHT item id matches `item_id`.
|
||||||
|
async fn resolve_track_id(pool: &PgPool, own: &EndpointId, item_id: ItemId) -> Result<Option<i64>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT t.id FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
if ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")) == item_id {
|
||||||
|
return Ok(Some(track_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (file_path, mime_type) of the track's audio media file.
|
||||||
|
async fn track_audio_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
WHERE t.id = $1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track cover (falling back to the release cover) as (file_path, mime).
|
||||||
|
async fn track_cover_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = COALESCE(t.cover_file_id, r.cover_file_id)
|
||||||
|
WHERE t.id = $1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main artist's image of a track as (file_path, mime).
|
||||||
|
async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||||
|
WHERE ta.track_id = $1 AND ta.role = 'main'
|
||||||
|
ORDER BY ta.position LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_catalog_artist_names(
|
||||||
|
pool: &PgPool,
|
||||||
|
track_id: i64,
|
||||||
|
) -> Result<(Vec<String>, Vec<String>)> {
|
||||||
|
let mut artists = Vec::new();
|
||||||
|
let mut featured = Vec::new();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
let name: String = row.get(0);
|
||||||
|
match row.get::<String, _>(1).as_str() {
|
||||||
|
"featuring" => featured.push(name),
|
||||||
|
"main" => artists.push(name),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((artists, featured))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
||||||
|
let Some(track) = sqlx::query(
|
||||||
|
"SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year),
|
||||||
|
t.release_id, r.title, r.release_type
|
||||||
|
FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE t.id = $1",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let release_id: i64 = track.get(4);
|
||||||
|
|
||||||
|
let mut artists = Vec::new();
|
||||||
|
let mut featured = Vec::new();
|
||||||
|
let artist_rows = sqlx::query(
|
||||||
|
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||||
|
)
|
||||||
|
.bind(track_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
for row in artist_rows {
|
||||||
|
let name: String = row.get(0);
|
||||||
|
match row.get::<String, _>(1).as_str() {
|
||||||
|
"featuring" => featured.push(name),
|
||||||
|
"main" => artists.push(name),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let album_artists: Vec<String> = sqlx::query(
|
||||||
|
"SELECT a.name FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||||
|
WHERE ra.release_id = $1 ORDER BY ra.position",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.get(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Some(TrackMetadata {
|
||||||
|
title: track.get(0),
|
||||||
|
artists,
|
||||||
|
featured_artists: featured,
|
||||||
|
album_artists,
|
||||||
|
release_title: track.get(5),
|
||||||
|
release_type: Some(track.get(6)),
|
||||||
|
year: track.get(3),
|
||||||
|
track_number: track.get(1),
|
||||||
|
disc_number: track.get(2),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio protocol
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Runs the audio accept loop until the acceptor closes. Every visible
|
||||||
|
/// track of the library is streamable by every peer of the network.
|
||||||
|
pub async fn serve_audio(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let pool = pool.clone();
|
||||||
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let peer = stream.peer_id;
|
||||||
|
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own, transport_stats).await
|
||||||
|
{
|
||||||
|
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_audio_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "open", &stream);
|
||||||
|
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
|
tracing::info!(
|
||||||
|
peer = %stream.peer_id,
|
||||||
|
item = %request.item_id,
|
||||||
|
offset = request.offset,
|
||||||
|
"federation peer requested audio"
|
||||||
|
);
|
||||||
|
|
||||||
|
let track_id = match hex_decode_item_id(&request.item_id) {
|
||||||
|
Some(item_id) => match resolve_track_id(&pool, &own, item_id).await {
|
||||||
|
Ok(Some(track_id)) => track_id,
|
||||||
|
Ok(None) => return refuse_audio(stream, "track not found in the library").await,
|
||||||
|
Err(err) => {
|
||||||
|
return refuse_audio(stream, &format!("library lookup failed: {err:#}")).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => return refuse_audio(stream, "malformed item_id").await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some((file_path, mime_type)) = track_audio_file(&pool, track_id).await? else {
|
||||||
|
return refuse_audio(stream, "audio file record is missing").await;
|
||||||
|
};
|
||||||
|
let path = resolve_media_path(&storage_dir, &file_path);
|
||||||
|
let mut file = match tokio::fs::File::open(&path).await {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(err) => {
|
||||||
|
return refuse_audio(stream, &format!("audio file is not readable: {err}")).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total_size = file.metadata().await?.len();
|
||||||
|
let offset = request.offset.min(total_size);
|
||||||
|
if offset > 0 {
|
||||||
|
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = match track_metadata(&pool, track_id).await {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(track_id, "federation metadata lookup failed: {err:#}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (cover, artist_image) = if request.want_cover {
|
||||||
|
(
|
||||||
|
read_image(
|
||||||
|
&storage_dir,
|
||||||
|
track_cover_file(&pool, track_id).await.ok().flatten(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
read_image(
|
||||||
|
&storage_dir,
|
||||||
|
track_artist_image_file(&pool, track_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mime_type = if mime_type.trim().is_empty() {
|
||||||
|
guess_mime(&path).to_string()
|
||||||
|
} else {
|
||||||
|
mime_type
|
||||||
|
};
|
||||||
|
write_line(
|
||||||
|
&mut stream.send,
|
||||||
|
&AudioResponseHeader {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
mime_type,
|
||||||
|
total_size,
|
||||||
|
offset,
|
||||||
|
metadata,
|
||||||
|
cover_size: cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||||
|
cover_mime: cover
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, mime)| mime.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
artist_image_size: artist_image
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||||
|
artist_image_mime: artist_image
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, mime)| mime.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some((bytes, _)) = &cover {
|
||||||
|
stream.send.write_all(bytes).await?;
|
||||||
|
}
|
||||||
|
if let Some((bytes, _)) = &artist_image {
|
||||||
|
stream.send.write_all(bytes).await?;
|
||||||
|
}
|
||||||
|
tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
// Wait until the peer read everything before dropping the stream,
|
||||||
|
// otherwise the tail of the file is lost.
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "done", &stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refuse_audio(mut stream: ByteStream, message: &str) -> Result<()> {
|
||||||
|
write_line(
|
||||||
|
&mut stream.send,
|
||||||
|
&AudioResponseHeader {
|
||||||
|
ok: false,
|
||||||
|
error: Some(message.to_string()),
|
||||||
|
..AudioResponseHeader::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
anyhow::bail!("refused audio request: {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalog protocol
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Runs the catalog accept loop until the acceptor closes.
|
||||||
|
pub async fn serve_catalog(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
let pool = pool.clone();
|
||||||
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let peer = stream.peer_id;
|
||||||
|
if let Err(err) =
|
||||||
|
serve_catalog_one(stream, pool, storage_dir, own, transport_stats).await
|
||||||
|
{
|
||||||
|
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_catalog_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "open", &stream);
|
||||||
|
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
|
tracing::info!(
|
||||||
|
peer = %stream.peer_id,
|
||||||
|
artist = %request.artist,
|
||||||
|
want = request.want.as_deref().unwrap_or("catalog"),
|
||||||
|
"federation peer requested a catalog"
|
||||||
|
);
|
||||||
|
|
||||||
|
match request.want.as_deref() {
|
||||||
|
None | Some("catalog") => {
|
||||||
|
let response = match build_catalog(&pool, &own, &request.artist).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(err) => CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some("artists") => {
|
||||||
|
let cursor = request.cursor.clone();
|
||||||
|
let limit = request.limit.unwrap_or(64).clamp(1, 200);
|
||||||
|
let response = match build_artist_slice(&pool, cursor, limit).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(err) => CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some(format!("artist slice lookup failed: {err:#}")),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(want @ ("artist_image" | "release_cover")) => {
|
||||||
|
let media = if want == "release_cover" {
|
||||||
|
release_cover_by_names(
|
||||||
|
&pool,
|
||||||
|
&request.artist,
|
||||||
|
request.release.as_deref().unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
artist_image_by_name(&pool, &request.artist).await?
|
||||||
|
};
|
||||||
|
let image = read_image(&storage_dir, media).await;
|
||||||
|
let header = match &image {
|
||||||
|
Some((bytes, mime)) => ImageHeader {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
mime_type: mime.clone(),
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
},
|
||||||
|
None => ImageHeader {
|
||||||
|
ok: false,
|
||||||
|
error: Some("no image".to_string()),
|
||||||
|
..ImageHeader::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_line(&mut stream.send, &header).await?;
|
||||||
|
if let Some((bytes, _)) = &image {
|
||||||
|
stream.send.write_all(bytes).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
let response = CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some(format!("unknown request kind '{other}'")),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||||
|
let Some(artist_row) = sqlx::query(
|
||||||
|
"SELECT id, name FROM furumusic__artist
|
||||||
|
WHERE LOWER(name) = LOWER($1) AND is_hidden = false
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(artist)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(CatalogResponse {
|
||||||
|
ok: false,
|
||||||
|
error: Some("artist not found in the library".to_string()),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let artist_id: i64 = artist_row.get(0);
|
||||||
|
|
||||||
|
let release_rows = sqlx::query(
|
||||||
|
"SELECT r.id, r.title, r.release_type, r.year
|
||||||
|
FROM furumusic__release r
|
||||||
|
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||||
|
WHERE ra.artist_id = $1 AND r.is_hidden = false
|
||||||
|
ORDER BY r.year NULLS LAST, r.title",
|
||||||
|
)
|
||||||
|
.bind(artist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut releases = Vec::new();
|
||||||
|
for release_row in release_rows {
|
||||||
|
let release_id: i64 = release_row.get(0);
|
||||||
|
let track_rows = sqlx::query(
|
||||||
|
"SELECT t.id, t.title, t.track_number, t.disc_number, t.duration_seconds,
|
||||||
|
c.content_id
|
||||||
|
FROM furumusic__track t
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__federation_content_id_cache c
|
||||||
|
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||||
|
WHERE t.release_id = $1 AND t.is_hidden = false
|
||||||
|
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||||
|
for row in track_rows {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
let duration: f64 = row.get(4);
|
||||||
|
let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?;
|
||||||
|
tracks.push(CatalogTrack {
|
||||||
|
title: row.get(1),
|
||||||
|
artists,
|
||||||
|
featured_artists,
|
||||||
|
track_number: row.get(2),
|
||||||
|
disc_number: row.get(3),
|
||||||
|
duration_seconds: (duration > 0.0).then_some(duration),
|
||||||
|
content_id: row.get(5),
|
||||||
|
item_id: item_id_of(own, track_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
releases.push(CatalogRelease {
|
||||||
|
title: release_row.get(1),
|
||||||
|
release_type: release_row.get(2),
|
||||||
|
year: release_row.get(3),
|
||||||
|
tracks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CatalogResponse {
|
||||||
|
ok: true,
|
||||||
|
artist: Some(CatalogArtist {
|
||||||
|
name: artist_row.get(1),
|
||||||
|
releases,
|
||||||
|
appears_on: Vec::new(),
|
||||||
|
}),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_artist_slice(
|
||||||
|
pool: &PgPool,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<CatalogResponse> {
|
||||||
|
let offset = cursor
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| value.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(0);
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"SELECT a.name::text AS name,
|
||||||
|
mf.file_path::text AS image_path,
|
||||||
|
COALESCE(s.release_count, 0)::bigint AS release_count,
|
||||||
|
COALESCE(s.track_count, 0)::bigint AS track_count
|
||||||
|
FROM furumusic__artist a
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = a.image_file_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT appearance.artist_id,
|
||||||
|
COUNT(DISTINCT appearance.release_id) FILTER (WHERE appearance.is_primary_release_artist) AS release_count,
|
||||||
|
COUNT(DISTINCT appearance.track_id) AS track_count
|
||||||
|
FROM (
|
||||||
|
SELECT ta.artist_id,
|
||||||
|
t.id AS track_id,
|
||||||
|
r.id AS release_id,
|
||||||
|
primary_release.artist_id IS NOT NULL AS is_primary_release_artist
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||||
|
LEFT JOIN furumusic__release_artist primary_release
|
||||||
|
ON primary_release.release_id = r.id
|
||||||
|
AND primary_release.artist_id = ta.artist_id
|
||||||
|
AND primary_release.position = 0
|
||||||
|
) appearance
|
||||||
|
GROUP BY appearance.artist_id
|
||||||
|
) s ON s.artist_id = a.id
|
||||||
|
WHERE a.is_hidden = false
|
||||||
|
AND COALESCE(s.track_count, 0) > 0
|
||||||
|
ORDER BY (COALESCE(s.release_count, 0) > 0) DESC,
|
||||||
|
COALESCE(s.release_count, 0) DESC,
|
||||||
|
COALESCE(s.track_count, 0) DESC,
|
||||||
|
a.name_sort
|
||||||
|
LIMIT $1 OFFSET $2"#,
|
||||||
|
)
|
||||||
|
.bind(limit as i64 + 1)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut artists = Vec::with_capacity(rows.len().min(limit));
|
||||||
|
let has_more = rows.len() > limit;
|
||||||
|
for row in rows.into_iter().take(limit) {
|
||||||
|
let name: String = row.get(0);
|
||||||
|
artists.push(CatalogArtistPreview {
|
||||||
|
artist_key: normalize_name(&name),
|
||||||
|
name,
|
||||||
|
image_path: row.get(1),
|
||||||
|
release_count: row.get(2),
|
||||||
|
track_count: row.get(3),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let next_cursor = has_more.then(|| (offset + artists.len() as i64).to_string());
|
||||||
|
Ok(CatalogResponse {
|
||||||
|
ok: true,
|
||||||
|
artists,
|
||||||
|
next_cursor,
|
||||||
|
..CatalogResponse::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn artist_image_by_name(pool: &PgPool, artist: &str) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__artist a
|
||||||
|
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||||
|
WHERE LOWER(a.name) = LOWER($1) AND a.is_hidden = false
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(artist)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_cover_by_names(
|
||||||
|
pool: &PgPool,
|
||||||
|
artist: &str,
|
||||||
|
release: &str,
|
||||||
|
) -> Result<Option<(String, String)>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT m.file_path, m.mime_type FROM furumusic__release r
|
||||||
|
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||||
|
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = r.cover_file_id
|
||||||
|
WHERE LOWER(a.name) = LOWER($1) AND LOWER(r.title) = LOWER($2)
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(artist)
|
||||||
|
.bind(release)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use music_dht::{
|
||||||
|
DhtKey, EndpointId, LibraryItem, MAX_RECORDS_PER_RESPONSE, MusicDhtError, MusicDhtStorage,
|
||||||
|
NodeContact, NodeId, SecretKey, StoreDecision, StoredRecord, decide_store,
|
||||||
|
};
|
||||||
|
use sqlx::{PgPool, Row as _};
|
||||||
|
|
||||||
|
const IDENTITY_NAME: &str = "default";
|
||||||
|
|
||||||
|
const SCHEMA: &[&str] = &[
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_identity (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
secret_key BYTEA NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_local_item (
|
||||||
|
id BYTEA PRIMARY KEY,
|
||||||
|
normalized_name TEXT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
payload BYTEA NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_local_item_normalized_name
|
||||||
|
ON furumusic__federation_local_item(normalized_name)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_dht_record (
|
||||||
|
dht_key BYTEA NOT NULL,
|
||||||
|
item_id BYTEA NOT NULL,
|
||||||
|
owner_peer_id TEXT NOT NULL,
|
||||||
|
payload BYTEA NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL,
|
||||||
|
expires_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (dht_key, item_id, owner_peer_id)
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_dht_record_expires_at
|
||||||
|
ON furumusic__federation_dht_record(expires_at_ms)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_known_peer (
|
||||||
|
peer_id TEXT PRIMARY KEY,
|
||||||
|
node_id BYTEA NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
last_seen_ms BIGINT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache(content_id)",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PostgresFederationStorage {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresFederationStorage {
|
||||||
|
pub async fn new(pool: PgPool) -> music_dht::Result<Self> {
|
||||||
|
let storage = Self { pool };
|
||||||
|
storage.ensure_schema().await?;
|
||||||
|
Ok(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_or_create_secret_key(&self) -> music_dht::Result<SecretKey> {
|
||||||
|
if let Some(bytes) = sqlx::query_scalar::<_, Vec<u8>>(
|
||||||
|
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
{
|
||||||
|
return secret_from_bytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = SecretKey::generate();
|
||||||
|
let key_bytes = key.to_bytes();
|
||||||
|
let now = now_iso();
|
||||||
|
let inserted = sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_identity
|
||||||
|
(name, secret_key, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (name) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.bind(key_bytes.as_slice())
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
.rows_affected();
|
||||||
|
if inserted == 1 {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = sqlx::query_scalar::<_, Vec<u8>>(
|
||||||
|
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
secret_from_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_schema(&self) -> music_dht::Result<()> {
|
||||||
|
for sql in SCHEMA {
|
||||||
|
sqlx::query(sql)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MusicDhtStorage for PostgresFederationStorage {
|
||||||
|
async fn upsert_local_item(&self, item: &LibraryItem) -> music_dht::Result<()> {
|
||||||
|
let payload = postcard::to_stdvec(item).map_err(db_error)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_local_item
|
||||||
|
(id, normalized_name, revision, deleted, updated_at_ms, payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
normalized_name = EXCLUDED.normalized_name,
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
deleted = EXCLUDED.deleted,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms,
|
||||||
|
payload = EXCLUDED.payload",
|
||||||
|
)
|
||||||
|
.bind(item.id.as_bytes().as_slice())
|
||||||
|
.bind(&item.normalized_name)
|
||||||
|
.bind(item.revision as i64)
|
||||||
|
.bind(item.deleted)
|
||||||
|
.bind(item.updated_at_ms as i64)
|
||||||
|
.bind(payload)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_local_items(&self, include_deleted: bool) -> music_dht::Result<Vec<LibraryItem>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT payload
|
||||||
|
FROM furumusic__federation_local_item
|
||||||
|
WHERE $1 OR deleted = false
|
||||||
|
ORDER BY normalized_name",
|
||||||
|
)
|
||||||
|
.bind(include_deleted)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| postcard::from_bytes::<LibraryItem>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_dht_record(&self, key: DhtKey, record: StoredRecord) -> music_dht::Result<bool> {
|
||||||
|
let mut conn = self.pool.acquire().await.map_err(db_error)?;
|
||||||
|
store_record_in_conn(&mut conn, &key, &record).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_dht_records(
|
||||||
|
&self,
|
||||||
|
entries: Vec<(DhtKey, StoredRecord)>,
|
||||||
|
) -> music_dht::Result<Vec<bool>> {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(db_error)?;
|
||||||
|
let mut stored = Vec::with_capacity(entries.len());
|
||||||
|
for (key, record) in &entries {
|
||||||
|
stored.push(store_record_in_conn(&mut tx, key, record).await?);
|
||||||
|
}
|
||||||
|
tx.commit().await.map_err(db_error)?;
|
||||||
|
Ok(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dht_records_by_key(
|
||||||
|
&self,
|
||||||
|
key: DhtKey,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> music_dht::Result<Vec<StoredRecord>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT payload
|
||||||
|
FROM furumusic__federation_dht_record
|
||||||
|
WHERE dht_key = $1 AND expires_at_ms > $2
|
||||||
|
ORDER BY expires_at_ms DESC, item_id
|
||||||
|
LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(now_ms as i64)
|
||||||
|
.bind(MAX_RECORDS_PER_RESPONSE as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| postcard::from_bytes::<StoredRecord>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_expired_records(&self, now_ms: u64) -> music_dht::Result<usize> {
|
||||||
|
let result =
|
||||||
|
sqlx::query("DELETE FROM furumusic__federation_dht_record WHERE expires_at_ms <= $1")
|
||||||
|
.bind(now_ms as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(result.rows_affected() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_known_peer(&self, contact: &NodeContact) -> music_dht::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_known_peer
|
||||||
|
(peer_id, node_id, ticket, last_seen_ms)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (peer_id) DO UPDATE SET
|
||||||
|
node_id = EXCLUDED.node_id,
|
||||||
|
ticket = EXCLUDED.ticket,
|
||||||
|
last_seen_ms = EXCLUDED.last_seen_ms",
|
||||||
|
)
|
||||||
|
.bind(contact.peer_id.to_string())
|
||||||
|
.bind(contact.node_id.as_bytes().as_slice())
|
||||||
|
.bind(&contact.ticket)
|
||||||
|
.bind(contact.last_seen_ms as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_known_peer(&self, peer_id: EndpointId) -> music_dht::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM furumusic__federation_known_peer WHERE peer_id = $1")
|
||||||
|
.bind(peer_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_known_peers(&self) -> music_dht::Result<Vec<NodeContact>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT peer_id, node_id, ticket, last_seen_ms
|
||||||
|
FROM furumusic__federation_known_peer",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
let mut contacts = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let peer_id: String = row.get(0);
|
||||||
|
let node_id: Vec<u8> = row.get(1);
|
||||||
|
let ticket: String = row.get(2);
|
||||||
|
let last_seen_ms: i64 = row.get(3);
|
||||||
|
let Ok(peer_id) = EndpointId::from_str(&peer_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(node_id) = <[u8; 32]>::try_from(node_id.as_slice()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
contacts.push(NodeContact {
|
||||||
|
node_id: NodeId::from_bytes(node_id),
|
||||||
|
peer_id,
|
||||||
|
ticket,
|
||||||
|
last_seen_ms: last_seen_ms as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(contacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secret_from_bytes(bytes: Vec<u8>) -> music_dht::Result<SecretKey> {
|
||||||
|
let bytes: [u8; 32] = bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| MusicDhtError::Database("stored federation identity is corrupted".into()))?;
|
||||||
|
Ok(SecretKey::from_bytes(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one validated record following the revision/tombstone rules.
|
||||||
|
/// Returns `true` if the record was written or refreshed. Runs against a
|
||||||
|
/// pooled connection or an open transaction.
|
||||||
|
async fn store_record_in_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
key: &DhtKey,
|
||||||
|
record: &StoredRecord,
|
||||||
|
) -> music_dht::Result<bool> {
|
||||||
|
let existing = sqlx::query(
|
||||||
|
"SELECT revision, deleted, expires_at_ms
|
||||||
|
FROM furumusic__federation_dht_record
|
||||||
|
WHERE dht_key = $1 AND item_id = $2 AND owner_peer_id = $3",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(record.item.id.as_bytes().as_slice())
|
||||||
|
.bind(record.item.owner.to_string())
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
.map(|row| {
|
||||||
|
(
|
||||||
|
row.get::<i64, _>(0) as u64,
|
||||||
|
row.get::<bool, _>(1),
|
||||||
|
row.get::<i64, _>(2) as u64,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
match decide_store(existing, record) {
|
||||||
|
StoreDecision::Ignore => return Ok(false),
|
||||||
|
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = postcard::to_stdvec(record).map_err(db_error)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_dht_record
|
||||||
|
(dht_key, item_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (dht_key, item_id, owner_peer_id) DO UPDATE SET
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
deleted = EXCLUDED.deleted,
|
||||||
|
expires_at_ms = EXCLUDED.expires_at_ms",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(record.item.id.as_bytes().as_slice())
|
||||||
|
.bind(record.item.owner.to_string())
|
||||||
|
.bind(payload)
|
||||||
|
.bind(record.item.revision as i64)
|
||||||
|
.bind(record.item.deleted)
|
||||||
|
.bind(record.expires_at_ms as i64)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
|
||||||
|
MusicDhtError::Database(err.to_string())
|
||||||
|
}
|
||||||
@@ -95,6 +95,10 @@ translations! {
|
|||||||
settings_api: "API" , "API";
|
settings_api: "API" , "API";
|
||||||
settings_swagger: "Swagger UI" , "Swagger UI";
|
settings_swagger: "Swagger UI" , "Swagger UI";
|
||||||
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
|
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
|
||||||
|
settings_lastfm_api_key: "Last.fm API key" , "API ключ Last.fm";
|
||||||
|
settings_lastfm_api_key_help: "Used for Last.fm popularity and account connection" , "Используется для популярности Last.fm и подключения аккаунта";
|
||||||
|
settings_lastfm_shared_secret: "Last.fm shared secret" , "Shared secret Last.fm";
|
||||||
|
settings_lastfm_shared_secret_help: "Required for signed Last.fm scrobbling requests" , "Нужен для подписанных запросов скробблинга Last.fm";
|
||||||
|
|
||||||
// OIDC login errors
|
// OIDC login errors
|
||||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||||
@@ -264,4 +268,255 @@ 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_global_library: "Global" , "Global";
|
||||||
|
player_featured_only_artists: "Featured only" , "Только фиты";
|
||||||
|
player_release: "Release" , "Релиз";
|
||||||
|
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_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||||
|
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||||
|
player_active_device: "Active device" , "Активный девайс";
|
||||||
|
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_done: "Done" , "Готово";
|
||||||
|
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_lastfm_profile: "Last.fm" , "Last.fm";
|
||||||
|
player_lastfm_connect: "Connect Last.fm" , "Подключить Last.fm";
|
||||||
|
player_lastfm_connected: "Connected as {user}" , "Подключён: {user}";
|
||||||
|
player_lastfm_reconnect: "Reconnect Last.fm" , "Переподключить Last.fm";
|
||||||
|
player_lastfm_not_configured: "Last.fm is not configured" , "Last.fm не настроен";
|
||||||
|
player_lastfm_status_connect: "connect account" , "подключить аккаунт";
|
||||||
|
player_lastfm_status_connected: "connected" , "подключён";
|
||||||
|
player_lastfm_status_reconnect: "reconnect account" , "переподключить аккаунт";
|
||||||
|
player_lastfm_status_not_configured: "not configured" , "не настроен";
|
||||||
|
player_lastfm_disconnect_confirm: "Disconnect Last.fm account {user}?" , "Отвязать аккаунт Last.fm {user}?";
|
||||||
|
player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm";
|
||||||
|
player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm";
|
||||||
|
player_play: "Play" , "Играть";
|
||||||
|
player_listen: "Listen" , "Слушать";
|
||||||
|
player_listen_artist: "Listen to artist" , "Слушать артиста";
|
||||||
|
player_start_radio: "Start radio" , "Запустить радио";
|
||||||
|
player_radio_failed: "Could not start radio" , "Не удалось запустить радио";
|
||||||
|
player_played_at: "Played" , "Прослушано";
|
||||||
|
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_share: "Share" , "Поделиться";
|
||||||
|
player_share_track: "Share track" , "Поделиться треком";
|
||||||
|
player_share_queue: "Share queue" , "Поделиться очередью";
|
||||||
|
player_shared_playlist: "Shared playlist" , "Общий плейлист";
|
||||||
|
player_jam_play_on_this_device: "Play on this device" , "Играть на этом устройстве";
|
||||||
|
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_top_tracks: "Popular tracks" , "Популярные треки";
|
||||||
|
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_import: "Import" , "Импорт";
|
||||||
|
player_upload: "Upload" , "Загрузить";
|
||||||
|
player_my_uploads: "My uploads" , "Мои загрузки";
|
||||||
|
player_my_uploaded_tracks: "My uploaded tracks" , "Мои загруженные треки";
|
||||||
|
player_no_uploaded_tracks: "No uploaded tracks yet" , "Загруженных треков пока нет";
|
||||||
|
player_needs_approval: "Needs approval" , "Нужно подтверждение";
|
||||||
|
player_pending_or_failed: "pending or failed" , "ожидают или с ошибкой";
|
||||||
|
player_no_tracks_need_approval: "No tracks need approval" , "Нет треков для подтверждения";
|
||||||
|
player_queued_processing: "Queued / processing" , "В очереди / обработке";
|
||||||
|
player_showing: "Showing" , "Показано";
|
||||||
|
player_status: "Status" , "Статус";
|
||||||
|
player_file: "File" , "Файл";
|
||||||
|
player_created: "Created" , "Создано";
|
||||||
|
player_updated: "Updated" , "Обновлено";
|
||||||
|
player_error: "Error" , "Ошибка";
|
||||||
|
player_pending: "Pending" , "Ожидает";
|
||||||
|
player_artist: "Artist" , "Артист";
|
||||||
|
player_album: "Album" , "Альбом";
|
||||||
|
player_album_artists: "Album artists" , "Артисты альбома";
|
||||||
|
player_featured: "Featured" , "При участии";
|
||||||
|
player_featured_short: "feat." , "уч.";
|
||||||
|
player_track_number: "Track #" , "Трек #";
|
||||||
|
player_disc_number: "Disc #" , "Диск #";
|
||||||
|
player_genre: "Genre" , "Жанр";
|
||||||
|
player_notes: "Notes" , "Заметки";
|
||||||
|
player_type_unchanged: "Type unchanged" , "Тип без изменений";
|
||||||
|
player_visibility_unchanged: "Visibility unchanged" , "Видимость без изменений";
|
||||||
|
player_visible: "Visible" , "Видимый";
|
||||||
|
player_hidden: "Hidden" , "Скрыт";
|
||||||
|
player_no_year: "no year" , "год неизвестен";
|
||||||
|
player_apply: "Apply" , "Применить";
|
||||||
|
player_edit: "Edit" , "Редактировать";
|
||||||
|
player_edit_release: "Edit release" , "Редактировать релиз";
|
||||||
|
player_edit_track: "Edit track" , "Редактировать трек";
|
||||||
|
player_edit_metadata: "Edit metadata" , "Редактировать метаданные";
|
||||||
|
player_metadata: "Metadata" , "Метаданные";
|
||||||
|
player_release_metadata: "Release metadata" , "Метаданные релиза";
|
||||||
|
player_track_metadata: "Track metadata" , "Метаданные трека";
|
||||||
|
player_approve_metadata: "Approve metadata" , "Подтвердить метаданные";
|
||||||
|
player_delete_review: "Delete review" , "Удалить проверку";
|
||||||
|
player_approve: "Approve" , "Подтвердить";
|
||||||
|
player_save_track: "Save track" , "Сохранить трек";
|
||||||
|
player_save_release: "Save release" , "Сохранить релиз";
|
||||||
|
player_artists_placeholder: "Artist, Artist" , "Артист, артист";
|
||||||
|
player_artist_featured_placeholder: "Artist, Featured Artist" , "Артист, приглашённый артист";
|
||||||
|
player_release_type_album: "Album" , "Альбом";
|
||||||
|
player_release_type_single: "Single" , "Сингл";
|
||||||
|
player_release_type_ep: "EP" , "EP";
|
||||||
|
player_release_type_compilation: "Compilation" , "Сборник";
|
||||||
|
player_release_type_mixtape: "Mixtape" , "Микстейп";
|
||||||
|
player_release_type_live: "Live" , "Концерт";
|
||||||
|
player_release_type_soundtrack: "Soundtrack" , "Саундтрек";
|
||||||
|
player_release_type_remix: "Remix" , "Ремикс";
|
||||||
|
player_release_type_demo: "Demo" , "Демо";
|
||||||
|
player_failed_load_uploaded_tracks: "Failed to load uploaded tracks" , "Не удалось загрузить загруженные треки";
|
||||||
|
player_failed_save_track: "Failed to save track" , "Не удалось сохранить трек";
|
||||||
|
player_track_metadata_saved: "Track metadata saved" , "Метаданные трека сохранены";
|
||||||
|
player_failed_save_release: "Failed to save release" , "Не удалось сохранить релиз";
|
||||||
|
player_release_metadata_saved: "Release metadata saved" , "Метаданные релиза сохранены";
|
||||||
|
player_failed_delete_review: "Failed to delete review" , "Не удалось удалить проверку";
|
||||||
|
player_review_deleted: "Review deleted" , "Проверка удалена";
|
||||||
|
player_failed_approve_review: "Failed to approve review" , "Не удалось подтвердить проверку";
|
||||||
|
player_track_approved_imported: "Track approved and imported" , "Трек подтверждён и импортирован";
|
||||||
|
player_failed_update_selected_tracks: "Failed to update selected tracks" , "Не удалось обновить выбранные треки";
|
||||||
|
player_selected_tracks_updated: "Selected tracks updated" , "Выбранные треки обновлены";
|
||||||
|
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_loading_more: "Loading more..." , "Загружаю ещё...";
|
||||||
|
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,431 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::scheduler::{Job, JobContext, JobLog};
|
||||||
|
|
||||||
|
const SAMPLE_LOG_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
pub struct ArchiveCleanupJob;
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct TrackFileRow {
|
||||||
|
track_id: i64,
|
||||||
|
track_title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: Option<String>,
|
||||||
|
media_file_id: Option<i64>,
|
||||||
|
file_type: Option<String>,
|
||||||
|
file_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MissingTrack {
|
||||||
|
track_id: i64,
|
||||||
|
track_title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: Option<String>,
|
||||||
|
media_file_id: Option<i64>,
|
||||||
|
file_path: Option<String>,
|
||||||
|
reason: MissingReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum MissingReason {
|
||||||
|
MissingMediaRow,
|
||||||
|
InvalidMediaType(String),
|
||||||
|
EmptyPath,
|
||||||
|
MissingFile,
|
||||||
|
NotRegularFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct DeleteStats {
|
||||||
|
playback_states_cleared: u64,
|
||||||
|
playlist_entries_deleted: u64,
|
||||||
|
likes_deleted: u64,
|
||||||
|
play_history_deleted: u64,
|
||||||
|
popularity_history_deleted: u64,
|
||||||
|
scrobble_outbox_deleted: u64,
|
||||||
|
track_genres_deleted: u64,
|
||||||
|
entity_tags_deleted: u64,
|
||||||
|
external_ids_deleted: u64,
|
||||||
|
track_artists_deleted: u64,
|
||||||
|
tracks_deleted: u64,
|
||||||
|
media_files_deleted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Job for ArchiveCleanupJob {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"archive_cleanup"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Clean stale archive records, starting with tracks whose audio files are missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cron(&self) -> &'static str {
|
||||||
|
// Daily at 04:45.
|
||||||
|
"0 45 4 * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
run_missing_audio_cleanup(ctx, log).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_missing_audio_cleanup(ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
let storage_dir = ctx.config.agent_storage_dir.trim();
|
||||||
|
if storage_dir.is_empty() {
|
||||||
|
log.warn("Archive cleanup: agent_storage_dir is not configured, skipping file checks");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, TrackFileRow>(
|
||||||
|
r#"SELECT t.id AS track_id,
|
||||||
|
t.title::text AS track_title,
|
||||||
|
t.release_id,
|
||||||
|
r.title::text AS release_title,
|
||||||
|
mf.id AS media_file_id,
|
||||||
|
mf.file_type::text AS file_type,
|
||||||
|
mf.file_path::text AS file_path
|
||||||
|
FROM furumusic__track t
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
|
ORDER BY t.id"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
log.info("Archive cleanup: no tracks found");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: checking {} track audio reference(s)",
|
||||||
|
rows.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut missing_tracks = Vec::new();
|
||||||
|
let mut skipped_io_errors = 0u64;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let Some(media_file_id) = row.media_file_id else {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_type = row.file_type.clone();
|
||||||
|
match file_type.as_deref() {
|
||||||
|
Some("audio") => {}
|
||||||
|
Some(file_type) => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(
|
||||||
|
row,
|
||||||
|
MissingReason::InvalidMediaType(file_type.to_owned()),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_path) = row
|
||||||
|
.file_path
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
else {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::EmptyPath));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let absolute_path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||||
|
match tokio::fs::metadata(&absolute_path).await {
|
||||||
|
Ok(meta) if meta.is_file() => {}
|
||||||
|
Ok(_) => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::NotRegularFile));
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingFile));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
skipped_io_errors += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: skipping track {} media_file_id={media_file_id}; cannot inspect {}: {err}",
|
||||||
|
row.track_id,
|
||||||
|
absolute_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing_tracks.is_empty() {
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: all checked tracks have readable audio files; skipped_io_errors={skipped_io_errors}"
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, track) in missing_tracks.iter().take(SAMPLE_LOG_LIMIT).enumerate() {
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: deleting stale track {} \"{}\"{}{} ({})",
|
||||||
|
track.track_id,
|
||||||
|
track.track_title,
|
||||||
|
track
|
||||||
|
.release_title
|
||||||
|
.as_deref()
|
||||||
|
.map(|title| format!(" from \"{title}\""))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
track
|
||||||
|
.file_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|path| format!(", path={path}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
track.reason
|
||||||
|
));
|
||||||
|
if index + 1 == SAMPLE_LOG_LIMIT && missing_tracks.len() > SAMPLE_LOG_LIMIT {
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: suppressing per-track logs for remaining {} stale track(s)",
|
||||||
|
missing_tracks.len() - SAMPLE_LOG_LIMIT
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let track_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.track_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let media_file_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|track| track.media_file_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let release_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.release_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats =
|
||||||
|
delete_tracks_and_unreferenced_audio_media(&ctx.pool, &track_ids, &media_file_ids).await?;
|
||||||
|
let empty_release_count = count_empty_releases(&ctx.pool, &release_ids).await?;
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: deleted {} track(s), {} unreferenced audio media_file row(s); cleared playback_states={}, playlist_entries={}, likes={}, play_history={}, popularity_history={}, scrobble_outbox={}, track_genres={}, entity_tags={}, external_ids={}, track_artists={}; skipped_io_errors={skipped_io_errors}; empty_releases_left={empty_release_count}",
|
||||||
|
stats.tracks_deleted,
|
||||||
|
stats.media_files_deleted,
|
||||||
|
stats.playback_states_cleared,
|
||||||
|
stats.playlist_entries_deleted,
|
||||||
|
stats.likes_deleted,
|
||||||
|
stats.play_history_deleted,
|
||||||
|
stats.popularity_history_deleted,
|
||||||
|
stats.scrobble_outbox_deleted,
|
||||||
|
stats.track_genres_deleted,
|
||||||
|
stats.entity_tags_deleted,
|
||||||
|
stats.external_ids_deleted,
|
||||||
|
stats.track_artists_deleted,
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissingTrack {
|
||||||
|
fn from_row(row: TrackFileRow, reason: MissingReason) -> Self {
|
||||||
|
Self {
|
||||||
|
track_id: row.track_id,
|
||||||
|
track_title: row.track_title,
|
||||||
|
release_id: row.release_id,
|
||||||
|
release_title: row.release_title,
|
||||||
|
media_file_id: row.media_file_id,
|
||||||
|
file_path: row.file_path,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MissingReason {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingMediaRow => f.write_str("missing media_file row"),
|
||||||
|
Self::InvalidMediaType(file_type) => write!(f, "invalid media_file type {file_type:?}"),
|
||||||
|
Self::EmptyPath => f.write_str("empty media file path"),
|
||||||
|
Self::MissingFile => f.write_str("audio file not found on disk"),
|
||||||
|
Self::NotRegularFile => f.write_str("audio path is not a regular file"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_sorted(values: Vec<i64>) -> Vec<i64> {
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_tracks_and_unreferenced_audio_media(
|
||||||
|
pool: &PgPool,
|
||||||
|
track_ids: &[i64],
|
||||||
|
media_file_ids: &[i64],
|
||||||
|
) -> anyhow::Result<DeleteStats> {
|
||||||
|
if track_ids.is_empty() {
|
||||||
|
return Ok(DeleteStats::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let mut stats = DeleteStats::default();
|
||||||
|
|
||||||
|
stats.playback_states_cleared = sqlx::query(
|
||||||
|
r#"UPDATE furumusic__playback_state
|
||||||
|
SET current_track_id = NULL
|
||||||
|
WHERE current_track_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.playlist_entries_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__playlist_track", track_ids).await?;
|
||||||
|
stats.likes_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__user_liked_track", track_ids).await?;
|
||||||
|
stats.play_history_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__play_history", track_ids).await?;
|
||||||
|
stats.popularity_history_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_popularity_history", track_ids).await?;
|
||||||
|
stats.scrobble_outbox_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__lastfm_scrobble_outbox", track_ids).await?;
|
||||||
|
stats.track_genres_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_genre", track_ids).await?;
|
||||||
|
|
||||||
|
stats.entity_tags_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__entity_genre_tag
|
||||||
|
WHERE entity_kind = 'track'
|
||||||
|
AND entity_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.external_ids_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__external_metadata_id
|
||||||
|
WHERE entity_kind = 'track'
|
||||||
|
AND entity_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.track_artists_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_artist", track_ids).await?;
|
||||||
|
|
||||||
|
stats.tracks_deleted = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if !media_file_ids.is_empty() {
|
||||||
|
stats.media_files_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__media_file mf
|
||||||
|
WHERE mf.id = ANY($1)
|
||||||
|
AND mf.file_type = 'audio'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__track t
|
||||||
|
WHERE t.audio_file_id = mf.id
|
||||||
|
OR t.cover_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__release r
|
||||||
|
WHERE r.cover_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__artist a
|
||||||
|
WHERE a.image_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__playlist p
|
||||||
|
WHERE p.cover_file_id = mf.id
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.bind(media_file_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_track_rows(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
table: &str,
|
||||||
|
track_ids: &[i64],
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let sql = format!("DELETE FROM {table} WHERE track_id = ANY($1)");
|
||||||
|
Ok(sqlx::query(&sql)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_empty_releases(pool: &PgPool, release_ids: &[i64]) -> anyhow::Result<i64> {
|
||||||
|
if release_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT COUNT(*)
|
||||||
|
FROM furumusic__release r
|
||||||
|
WHERE r.id = ANY($1)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__track t
|
||||||
|
WHERE t.release_id = r.id
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.bind(release_ids)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_sorted_deduplicates_ids() {
|
||||||
|
assert_eq!(unique_sorted(vec![3, 1, 3, 2, 1]), vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_reason_display_is_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
MissingReason::InvalidMediaType("cover_art".to_owned()).to_string(),
|
||||||
|
"invalid media_file type \"cover_art\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MissingReason::MissingFile.to_string(),
|
||||||
|
"audio file not found on disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
use crate::scheduler::{Job, JobContext, JobLog};
|
|
||||||
|
|
||||||
/// Periodic job that auto-assigns artist images from their release covers.
|
|
||||||
///
|
|
||||||
/// For every artist that has no `image_file_id`, picks the cover of the most
|
|
||||||
/// recent release (by year) that has one. Runs after the cover backfill job
|
|
||||||
/// so freshly-extracted covers are available.
|
|
||||||
pub struct ArtistImageBackfillJob;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Job for ArtistImageBackfillJob {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"artist_image_backfill"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
|
||||||
"Auto-assign artist images from release covers"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_cron(&self) -> &'static str {
|
|
||||||
// 03:15 daily — after cover_backfill at 03:00
|
|
||||||
"0 15 3 * * *"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE furumusic__artist a \
|
|
||||||
SET image_file_id = ( \
|
|
||||||
SELECT r.cover_file_id \
|
|
||||||
FROM furumusic__release_artist ra \
|
|
||||||
JOIN furumusic__release r ON r.id = ra.release_id \
|
|
||||||
WHERE ra.artist_id = a.id \
|
|
||||||
AND r.cover_file_id IS NOT NULL \
|
|
||||||
ORDER BY r.year DESC NULLS LAST \
|
|
||||||
LIMIT 1 \
|
|
||||||
), \
|
|
||||||
updated_at = $1 \
|
|
||||||
WHERE a.image_file_id IS NULL \
|
|
||||||
AND EXISTS ( \
|
|
||||||
SELECT 1 FROM furumusic__release_artist ra2 \
|
|
||||||
JOIN furumusic__release r2 ON r2.id = ra2.release_id \
|
|
||||||
WHERE ra2.artist_id = a.id AND r2.cover_file_id IS NOT NULL \
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
|
||||||
.execute(&ctx.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let count = result.rows_affected();
|
|
||||||
if count > 0 {
|
|
||||||
log.info(&format!(
|
|
||||||
"Assigned images to {count} artists from release covers"
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
log.info("All artists already have images (or no covers available)");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
use crate::scheduler::{Job, JobContext, JobLog};
|
|
||||||
|
|
||||||
/// Fallback job that assigns artist images from track cover art.
|
|
||||||
///
|
|
||||||
/// The primary `artist_image_backfill` job uses release covers. This job
|
|
||||||
/// runs afterwards and covers the case where the release itself has no
|
|
||||||
/// cover but individual tracks do (e.g. when cover art is embedded in the
|
|
||||||
/// audio file and extracted per-track rather than per-release).
|
|
||||||
///
|
|
||||||
/// For every artist that *still* has no `image_file_id` after the release-
|
|
||||||
/// based backfill, picks the `cover_file_id` of the most recent track
|
|
||||||
/// (by year, then track id) that has one.
|
|
||||||
pub struct ArtistTrackImageBackfillJob;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Job for ArtistTrackImageBackfillJob {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"artist_track_image_backfill"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
|
||||||
"Auto-assign artist images from track covers (fallback)"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_cron(&self) -> &'static str {
|
|
||||||
// 03:30 daily — after artist_image_backfill at 03:15
|
|
||||||
"0 30 3 * * *"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE furumusic__artist a \
|
|
||||||
SET image_file_id = ( \
|
|
||||||
SELECT t.cover_file_id \
|
|
||||||
FROM furumusic__track_artist ta \
|
|
||||||
JOIN furumusic__track t ON t.id = ta.track_id \
|
|
||||||
WHERE ta.artist_id = a.id \
|
|
||||||
AND t.cover_file_id IS NOT NULL \
|
|
||||||
AND t.is_hidden = false \
|
|
||||||
ORDER BY t.year DESC NULLS LAST, t.id DESC \
|
|
||||||
LIMIT 1 \
|
|
||||||
), \
|
|
||||||
updated_at = $1 \
|
|
||||||
WHERE a.image_file_id IS NULL \
|
|
||||||
AND a.is_hidden = false \
|
|
||||||
AND EXISTS ( \
|
|
||||||
SELECT 1 FROM furumusic__track_artist ta2 \
|
|
||||||
JOIN furumusic__track t2 ON t2.id = ta2.track_id \
|
|
||||||
WHERE ta2.artist_id = a.id \
|
|
||||||
AND t2.cover_file_id IS NOT NULL \
|
|
||||||
AND t2.is_hidden = false \
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
|
||||||
.execute(&ctx.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let count = result.rows_affected();
|
|
||||||
if count > 0 {
|
|
||||||
log.info(&format!(
|
|
||||||
"Assigned images to {count} artists from track covers"
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
log.info("All artists already have images (or no track covers available)");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::agent::cover_art;
|
|
||||||
use crate::scheduler::{Job, JobContext, JobLog};
|
|
||||||
|
|
||||||
/// One-shot / periodic job that finds releases without cover art and attempts
|
|
||||||
/// to extract or discover covers from their audio files in storage.
|
|
||||||
pub struct CoverBackfillJob;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Job for CoverBackfillJob {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"cover_backfill"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
|
||||||
"Backfill cover art for releases missing covers"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_cron(&self) -> &'static str {
|
|
||||||
// Once a day at 03:00
|
|
||||||
"0 0 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 backfill");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all releases without a cover
|
|
||||||
let rows: Vec<(i64, String)> = sqlx::query_as(
|
|
||||||
"SELECT r.id, r.title \
|
|
||||||
FROM furumusic__release r \
|
|
||||||
WHERE r.cover_file_id IS NULL \
|
|
||||||
ORDER BY r.id",
|
|
||||||
)
|
|
||||||
.fetch_all(&ctx.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
log.info("All releases already have cover art, nothing to backfill");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(&format!(
|
|
||||||
"Found {} releases without cover art, starting backfill...",
|
|
||||||
rows.len()
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut assigned = 0u32;
|
|
||||||
let mut failed = 0u32;
|
|
||||||
let mut skipped_no_audio = 0u32;
|
|
||||||
let mut skipped_no_cover = 0u32;
|
|
||||||
let total = rows.len();
|
|
||||||
|
|
||||||
for (i, (release_id, release_title)) in rows.iter().enumerate() {
|
|
||||||
log.info(&format!(
|
|
||||||
"[{}/{}] Processing release {release_id} \"{release_title}\"...",
|
|
||||||
i + 1,
|
|
||||||
total,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Find audio files belonging to this release via tracks → media_file
|
|
||||||
let audio_paths: Vec<(String,)> = sqlx::query_as(
|
|
||||||
"SELECT mf.file_path \
|
|
||||||
FROM furumusic__track t \
|
|
||||||
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id \
|
|
||||||
WHERE t.release_id = $1 AND mf.file_type = 'audio'",
|
|
||||||
)
|
|
||||||
.bind(release_id)
|
|
||||||
.fetch_all(&ctx.pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if audio_paths.is_empty() {
|
|
||||||
log.warn(&format!(
|
|
||||||
"Release {release_id} \"{release_title}\": no audio files found, skipping"
|
|
||||||
));
|
|
||||||
skipped_no_audio += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the folder from the first audio file's path
|
|
||||||
let first_path = Path::new(&audio_paths[0].0);
|
|
||||||
let folder = first_path.parent().unwrap_or(Path::new("."));
|
|
||||||
|
|
||||||
// Collect all audio file paths as PathBuf
|
|
||||||
let audio_files: Vec<PathBuf> =
|
|
||||||
audio_paths.iter().map(|(p,)| PathBuf::from(p)).collect();
|
|
||||||
|
|
||||||
// Try to find cover art
|
|
||||||
let cover = match cover_art::find_best_cover(folder, &audio_files).await {
|
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
log.info(&format!(
|
|
||||||
"Release {release_id} \"{release_title}\": no cover image found in {} audio files, skipping",
|
|
||||||
audio_files.len(),
|
|
||||||
));
|
|
||||||
skipped_no_cover += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let source_desc = match &cover.source {
|
|
||||||
cover_art::CoverSource::FolderFile(p) => format!("folder: {}", p.display()),
|
|
||||||
cover_art::CoverSource::Embedded(p) => format!("embedded: {}", p.display()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Look up artist name for storage path
|
|
||||||
let artist_name: String = sqlx::query_scalar(
|
|
||||||
"SELECT a.name FROM furumusic__artist a \
|
|
||||||
JOIN furumusic__release_artist ra ON ra.artist_id = a.id \
|
|
||||||
WHERE ra.release_id = $1 \
|
|
||||||
ORDER BY ra.position LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(release_id)
|
|
||||||
.fetch_optional(&ctx.pool)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or_else(|| "Unknown Artist".to_string());
|
|
||||||
|
|
||||||
match cover_art::save_cover_to_storage(
|
|
||||||
&ctx.db,
|
|
||||||
&ctx.pool,
|
|
||||||
storage_dir,
|
|
||||||
&artist_name,
|
|
||||||
release_title,
|
|
||||||
&cover,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(cover_file_id) => {
|
|
||||||
if let Err(e) =
|
|
||||||
cover_art::assign_cover_to_release(&ctx.pool, *release_id, cover_file_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log.warn(&format!(
|
|
||||||
"Release {release_id} \"{release_title}\": saved cover but failed to assign: {e}"
|
|
||||||
));
|
|
||||||
failed += 1;
|
|
||||||
} else {
|
|
||||||
log.info(&format!(
|
|
||||||
"Release {release_id} \"{release_title}\": assigned cover from {source_desc}"
|
|
||||||
));
|
|
||||||
assigned += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log.warn(&format!(
|
|
||||||
"Release {release_id} \"{release_title}\": failed to save cover: {e}"
|
|
||||||
));
|
|
||||||
failed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(&format!(
|
|
||||||
"Cover backfill complete: {assigned} assigned, {failed} failed, \
|
|
||||||
{skipped_no_audio} skipped (no audio), {skipped_no_cover} skipped (no cover found)"
|
|
||||||
));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+162
-11
@@ -12,6 +12,21 @@ const AUDIO_EXTENSIONS: &[&str] = &[
|
|||||||
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// How long a `failed` review must stay untouched before discover
|
||||||
|
/// automatically requeues it (instead of creating a new row per attempt).
|
||||||
|
const FAILED_RETRY_COOLDOWN_SECS: i64 = 3600;
|
||||||
|
|
||||||
|
/// Leftover files that are safe to purge from inbox folders that no longer
|
||||||
|
/// contain any audio (covers, playlists, rip logs and similar sidecar files).
|
||||||
|
const JUNK_EXTENSIONS: &[&str] = &[
|
||||||
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "m3u", "m3u8", "cue", "log", "txt", "nfo", "sfv",
|
||||||
|
"md5", "accurip", "url", "ini", "pdf",
|
||||||
|
];
|
||||||
|
const JUNK_FILENAMES: &[&str] = &[".ds_store", "thumbs.db", "desktop.ini"];
|
||||||
|
|
||||||
|
/// Junk younger than this is kept — an upload might still be in progress.
|
||||||
|
const JUNK_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
|
||||||
|
|
||||||
pub struct InboxDiscoverJob;
|
pub struct InboxDiscoverJob;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -29,6 +44,8 @@ impl Job for InboxDiscoverJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
let run_start = std::time::Instant::now();
|
||||||
|
let run_outcome = "completed";
|
||||||
// Prevent overlapping discover runs
|
// Prevent overlapping discover runs
|
||||||
if DISCOVER_RUNNING
|
if DISCOVER_RUNNING
|
||||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
@@ -44,6 +61,19 @@ impl Job for InboxDiscoverJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _guard = Guard;
|
let _guard = Guard;
|
||||||
|
struct MetricsGuard {
|
||||||
|
start: std::time::Instant,
|
||||||
|
outcome: &'static str,
|
||||||
|
}
|
||||||
|
impl Drop for MetricsGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
crate::metrics::record_agent_discover_run(self.outcome, self.start.elapsed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut metrics_guard = MetricsGuard {
|
||||||
|
start: run_start,
|
||||||
|
outcome: run_outcome,
|
||||||
|
};
|
||||||
|
|
||||||
let config = &ctx.config;
|
let config = &ctx.config;
|
||||||
|
|
||||||
@@ -61,6 +91,10 @@ impl Job for InboxDiscoverJob {
|
|||||||
let mut audio_files = Vec::new();
|
let mut audio_files = Vec::new();
|
||||||
collect_audio_files(inbox, &mut audio_files).await?;
|
collect_audio_files(inbox, &mut audio_files).await?;
|
||||||
|
|
||||||
|
// Purge leftover junk (covers, playlists, logs) from subtrees that no
|
||||||
|
// longer contain audio, so processed uploads don't linger forever.
|
||||||
|
cleanup_inbox_junk(inbox, JUNK_MIN_AGE).await;
|
||||||
|
|
||||||
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
|
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
|
||||||
if audio_files.is_empty() {
|
if audio_files.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -72,18 +106,42 @@ impl Job for InboxDiscoverJob {
|
|||||||
let mut discovered = 0u64;
|
let mut discovered = 0u64;
|
||||||
let mut skipped_hash = 0u64;
|
let mut skipped_hash = 0u64;
|
||||||
let mut skipped_existing = 0u64;
|
let mut skipped_existing = 0u64;
|
||||||
|
let mut requeued = 0u64;
|
||||||
|
|
||||||
for (_folder, files) in &groups {
|
for (_folder, files) in &groups {
|
||||||
for file_path in files {
|
for file_path in files {
|
||||||
let input_path_str = file_path.to_string_lossy().to_string();
|
let input_path_str =
|
||||||
|
crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
|
||||||
|
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
// Skip if a PendingReview already exists for this path
|
// One review row per path: any existing row blocks creating a
|
||||||
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
|
// new one. A stale "failed" row is requeued in place instead,
|
||||||
Ok(true) => {
|
// so retries don't multiply rows. "rejected" stays rejected.
|
||||||
skipped_existing += 1;
|
match PendingReview::latest_for_path(&ctx.pool, &input_path_str).await {
|
||||||
|
Ok(None) => {}
|
||||||
|
Ok(Some((id, status, updated_at))) => {
|
||||||
|
if status == "failed" {
|
||||||
|
let stale = chrono::DateTime::parse_from_rfc3339(&updated_at)
|
||||||
|
.map(|t| {
|
||||||
|
chrono::Utc::now().signed_duration_since(t).num_seconds()
|
||||||
|
>= FAILED_RETRY_COOLDOWN_SECS
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
if stale {
|
||||||
|
match PendingReview::requeue_by_ids(&ctx.db, &[id]).await {
|
||||||
|
Ok(()) => requeued += 1,
|
||||||
|
Err(e) => log.warn(&format!(
|
||||||
|
"Failed to requeue review {id} for {input_path_str}: {e}"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped_existing += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped_existing += 1;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ok(false) => {}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log.warn(&format!(
|
log.warn(&format!(
|
||||||
"Error checking existing review for {}: {e}",
|
"Error checking existing review for {}: {e}",
|
||||||
@@ -95,6 +153,7 @@ impl Job for InboxDiscoverJob {
|
|||||||
|
|
||||||
// Compute SHA-256 hash
|
// Compute SHA-256 hash
|
||||||
let path_clone = file_path.to_path_buf();
|
let path_clone = file_path.to_path_buf();
|
||||||
|
let hash_start = std::time::Instant::now();
|
||||||
let (hash, file_size) =
|
let (hash, file_size) =
|
||||||
match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
|
match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
|
||||||
let data = std::fs::read(&path_clone)?;
|
let data = std::fs::read(&path_clone)?;
|
||||||
@@ -105,8 +164,16 @@ impl Job for InboxDiscoverJob {
|
|||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Ok(v) => v,
|
Ok(v) => {
|
||||||
|
crate::metrics::record_agent_file_hash(hash_start.elapsed(), v.1, "ok");
|
||||||
|
v
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
crate::metrics::record_agent_file_hash(
|
||||||
|
hash_start.elapsed(),
|
||||||
|
0,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -123,13 +190,18 @@ impl Job for InboxDiscoverJob {
|
|||||||
|
|
||||||
// Extract raw metadata
|
// Extract raw metadata
|
||||||
let path_for_meta = file_path.to_path_buf();
|
let path_for_meta = file_path.to_path_buf();
|
||||||
|
let metadata_start = std::time::Instant::now();
|
||||||
let raw_meta = match tokio::task::spawn_blocking(move || {
|
let raw_meta = match tokio::task::spawn_blocking(move || {
|
||||||
crate::agent::metadata::extract(&path_for_meta)
|
crate::agent::metadata::extract(&path_for_meta)
|
||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Ok(m) => m,
|
Ok(m) => {
|
||||||
|
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "ok");
|
||||||
|
m
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "error");
|
||||||
log.warn(&format!(
|
log.warn(&format!(
|
||||||
"Failed to extract metadata from {}: {e}",
|
"Failed to extract metadata from {}: {e}",
|
||||||
file_path.display()
|
file_path.display()
|
||||||
@@ -184,13 +256,19 @@ impl Job for InboxDiscoverJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
|
"Discovered {} new files, requeued {} failed, skipped {} (hash known), skipped {} (already tracked)",
|
||||||
discovered, skipped_hash, skipped_existing
|
discovered, requeued, skipped_hash, skipped_existing
|
||||||
));
|
));
|
||||||
|
crate::metrics::record_agent_discover_files(
|
||||||
|
audio_files.len() as u64,
|
||||||
|
discovered,
|
||||||
|
skipped_hash,
|
||||||
|
skipped_existing,
|
||||||
|
);
|
||||||
|
|
||||||
// Trigger inbox_process in background if new files were discovered
|
// Trigger inbox_process in background if new files were discovered
|
||||||
// and no orchestrator is already running
|
// and no orchestrator is already running
|
||||||
if discovered > 0 {
|
if discovered + requeued > 0 {
|
||||||
if crate::jobs::inbox_process::is_orchestrator_running() {
|
if crate::jobs::inbox_process::is_orchestrator_running() {
|
||||||
log.info(
|
log.info(
|
||||||
"New files discovered but inbox_process already running, it will pick them up",
|
"New files discovered but inbox_process already running, it will pick them up",
|
||||||
@@ -217,6 +295,7 @@ impl Job for InboxDiscoverJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metrics_guard.outcome = run_outcome;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,3 +340,75 @@ pub fn is_audio_file(name: &str) -> bool {
|
|||||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
AUDIO_EXTENSIONS.contains(&ext.as_str())
|
AUDIO_EXTENSIONS.contains(&ext.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_junk_file(name: &str) -> bool {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
// macOS AppleDouble sidecars ("._track.mp3") and well-known junk names
|
||||||
|
if lower.starts_with("._") || JUNK_FILENAMES.contains(&lower.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let ext = lower.rsplit('.').next().unwrap_or("");
|
||||||
|
JUNK_EXTENSIONS.contains(&ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove leftover sidecar files (covers, playlists, rip logs) from inbox
|
||||||
|
/// subtrees that no longer contain any audio, then prune emptied directories.
|
||||||
|
///
|
||||||
|
/// Junk younger than `min_age` is kept in case an upload is still in
|
||||||
|
/// progress, and unknown file types are never touched. Returns `true` when
|
||||||
|
/// `dir` still contains something worth keeping (so the caller must not
|
||||||
|
/// remove it).
|
||||||
|
async fn cleanup_inbox_junk(dir: &Path, min_age: std::time::Duration) -> bool {
|
||||||
|
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut has_audio = false;
|
||||||
|
let mut keep_other = false;
|
||||||
|
let mut junk: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let ft = match entry.file_type().await {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => {
|
||||||
|
keep_other = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ft.is_dir() {
|
||||||
|
if Box::pin(cleanup_inbox_junk(&entry.path(), min_age)).await {
|
||||||
|
keep_other = true;
|
||||||
|
} else {
|
||||||
|
let _ = tokio::fs::remove_dir(&entry.path()).await;
|
||||||
|
}
|
||||||
|
} else if !name.starts_with('.') && is_audio_file(&name) {
|
||||||
|
// dotfiles are invisible to discovery, so they don't count as audio
|
||||||
|
has_audio = true;
|
||||||
|
} else if is_junk_file(&name) {
|
||||||
|
junk.push(entry.path());
|
||||||
|
} else {
|
||||||
|
keep_other = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_audio {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut junk_left = false;
|
||||||
|
for path in junk {
|
||||||
|
let old_enough = tokio::fs::metadata(&path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.elapsed().ok())
|
||||||
|
.is_some_and(|age| age >= min_age);
|
||||||
|
if !old_enough || tokio::fs::remove_file(&path).await.is_err() {
|
||||||
|
junk_left = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keep_other || junk_left
|
||||||
|
}
|
||||||
|
|||||||
+278
-77
@@ -12,33 +12,109 @@ static ORCHESTRATOR_RUNNING: AtomicBool = AtomicBool::new(false);
|
|||||||
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
|
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
|
||||||
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
|
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
|
||||||
|
|
||||||
|
/// Maximum number of files sent to the LLM in a single batch call.
|
||||||
|
/// Folders with more files are processed in chunks of this size, otherwise
|
||||||
|
/// the model's completion window overflows and the JSON response is cut off.
|
||||||
|
const MAX_LLM_BATCH_FILES: usize = 20;
|
||||||
|
|
||||||
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
|
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
|
||||||
pub fn is_orchestrator_running() -> bool {
|
pub fn is_orchestrator_running() -> bool {
|
||||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
struct OrchestratorAdvisoryGuard {
|
||||||
/// Returns true if the lock was acquired (no other orchestrator is running).
|
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
|
||||||
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
}
|
||||||
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
|
||||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
impl Drop for OrchestratorAdvisoryGuard {
|
||||||
.fetch_one(pool)
|
fn drop(&mut self) {
|
||||||
.await
|
let Some(mut conn) = self.conn.take() else {
|
||||||
{
|
return;
|
||||||
Ok(acquired) => acquired,
|
};
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to acquire advisory lock: {e}");
|
tokio::spawn(async move {
|
||||||
false
|
match sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
|
||||||
}
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => tracing::info!("inbox_process: advisory lock released"),
|
||||||
|
Ok(false) => tracing::warn!(
|
||||||
|
"inbox_process: advisory lock was not held by the guard connection"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::error!("inbox_process: failed to release advisory lock: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release the PostgreSQL advisory lock for the orchestrator.
|
async fn connection_holds_orchestrator_lock(
|
||||||
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
|
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||||
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
|
) -> anyhow::Result<bool> {
|
||||||
|
let holds_lock = sqlx::query_scalar::<_, bool>(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_locks
|
||||||
|
WHERE locktype = 'advisory'
|
||||||
|
AND pid = pg_backend_pid()
|
||||||
|
AND mode = 'ExclusiveLock'
|
||||||
|
AND granted
|
||||||
|
AND objsubid = 1
|
||||||
|
AND classid::bigint = (($1::bigint >> 32) & 4294967295::bigint)
|
||||||
|
AND objid::bigint = ($1::bigint & 4294967295::bigint)
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
|
.fetch_one(&mut **conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(holds_lock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
||||||
|
///
|
||||||
|
/// The guard owns the same pooled connection that acquired the session-level
|
||||||
|
/// lock. This matters because PostgreSQL session advisory locks must be
|
||||||
|
/// released on the same connection, not just through the same pool.
|
||||||
|
async fn try_acquire_orchestrator_lock(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> anyhow::Result<Option<OrchestratorAdvisoryGuard>> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
|
||||||
|
// Older versions acquired the lock through the pool and could return a
|
||||||
|
// locked idle connection. If we get such a connection, drain that stale
|
||||||
|
// re-entrant lock before taking the new guarded lock.
|
||||||
|
let mut cleaned_stale_locks = 0u8;
|
||||||
|
while connection_holds_orchestrator_lock(&mut conn).await? {
|
||||||
|
let unlocked = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
|
||||||
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
cleaned_stale_locks = cleaned_stale_locks.saturating_add(u8::from(unlocked));
|
||||||
|
if cleaned_stale_locks >= 8 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cleaned_stale_locks > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
count = cleaned_stale_locks,
|
||||||
|
"inbox_process: released stale advisory lock(s) from an idle pooled connection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
||||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
.execute(pool)
|
.fetch_one(&mut *conn)
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
|
if acquired {
|
||||||
|
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
||||||
@@ -83,10 +159,10 @@ impl Job for InboxProcessJob {
|
|||||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
log.info(
|
log.warn(
|
||||||
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
||||||
);
|
);
|
||||||
return Ok(());
|
anyhow::bail!("another inbox_process orchestrator is already running in this process");
|
||||||
}
|
}
|
||||||
struct AtomicGuard;
|
struct AtomicGuard;
|
||||||
impl Drop for AtomicGuard {
|
impl Drop for AtomicGuard {
|
||||||
@@ -98,27 +174,16 @@ impl Job for InboxProcessJob {
|
|||||||
let _atomic_guard = AtomicGuard;
|
let _atomic_guard = AtomicGuard;
|
||||||
|
|
||||||
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
||||||
if !try_acquire_orchestrator_lock(&ctx.pool).await {
|
let _advisory_guard = match try_acquire_orchestrator_lock(&ctx.pool).await? {
|
||||||
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
|
Some(guard) => guard,
|
||||||
return Ok(());
|
None => {
|
||||||
}
|
log.warn("Another inbox_process orchestrator holds the advisory lock");
|
||||||
tracing::info!("inbox_process: advisory lock acquired");
|
anyhow::bail!(
|
||||||
let pool_for_unlock = ctx.pool.clone();
|
"inbox_process advisory lock is held by another database session; no in-process orchestrator is running"
|
||||||
struct AdvisoryGuard {
|
);
|
||||||
pool: sqlx::PgPool,
|
|
||||||
}
|
|
||||||
impl Drop for AdvisoryGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let pool = self.pool.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
release_orchestrator_lock(&pool).await;
|
|
||||||
tracing::info!("inbox_process: advisory lock released");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let _advisory_guard = AdvisoryGuard {
|
|
||||||
pool: pool_for_unlock,
|
|
||||||
};
|
};
|
||||||
|
tracing::info!("inbox_process: advisory lock acquired");
|
||||||
|
|
||||||
let config = Arc::clone(&ctx.config);
|
let config = Arc::clone(&ctx.config);
|
||||||
let mut total_ok = 0u64;
|
let mut total_ok = 0u64;
|
||||||
@@ -154,14 +219,25 @@ impl Job for InboxProcessJob {
|
|||||||
folder_rel, file_count,
|
folder_rel, file_count,
|
||||||
));
|
));
|
||||||
|
|
||||||
let (ok, fail) =
|
// Large folders are split into chunks: a single LLM call for
|
||||||
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
|
// 100+ files overflows the completion window and the whole
|
||||||
.await;
|
// batch fails with a truncated-JSON parse error.
|
||||||
|
for chunk in reviews.chunks(MAX_LLM_BATCH_FILES) {
|
||||||
|
let (ok, fail) = process_folder_batch(
|
||||||
|
&ctx.db,
|
||||||
|
&config,
|
||||||
|
&ctx.pool,
|
||||||
|
&folder_rel,
|
||||||
|
chunk.to_vec(),
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
total_ok += ok;
|
total_ok += ok;
|
||||||
total_fail += fail;
|
total_fail += fail;
|
||||||
|
}
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Folder done: {ok} ok, {fail} err. Total so far: {total_ok} ok, {total_fail} err"
|
"Folder done. Total so far: {total_ok} ok, {total_fail} err"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,14 +301,13 @@ fn group_reviews_by_folder(
|
|||||||
reviews: &[PendingReview],
|
reviews: &[PendingReview],
|
||||||
inbox_dir: &str,
|
inbox_dir: &str,
|
||||||
) -> Vec<(String, Vec<PendingReview>)> {
|
) -> Vec<(String, Vec<PendingReview>)> {
|
||||||
let inbox = Path::new(inbox_dir);
|
|
||||||
let mut map: HashMap<String, Vec<PendingReview>> = HashMap::new();
|
let mut map: HashMap<String, Vec<PendingReview>> = HashMap::new();
|
||||||
|
|
||||||
for r in reviews {
|
for r in reviews {
|
||||||
let path = Path::new(r.input_path_str());
|
let path = crate::media_paths::resolve_path_from_root(inbox_dir, r.input_path_str());
|
||||||
let folder = path.parent().unwrap_or(path);
|
let folder = path.parent().unwrap_or(path.as_path());
|
||||||
let rel = folder.strip_prefix(inbox).unwrap_or(folder);
|
let key = crate::media_paths::path_for_root(inbox_dir, folder)
|
||||||
let key = rel.to_string_lossy().to_string();
|
.unwrap_or_else(|| folder.to_string_lossy().to_string());
|
||||||
map.entry(key).or_default().push(r.clone());
|
map.entry(key).or_default().push(r.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,19 +360,18 @@ async fn process_folder_batch(
|
|||||||
log.info("Phase 1: extracting metadata...");
|
log.info("Phase 1: extracting metadata...");
|
||||||
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
|
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
|
||||||
let mut failed_reviews: Vec<PendingReview> = Vec::new();
|
let mut failed_reviews: Vec<PendingReview> = Vec::new();
|
||||||
|
let mut merged_count = 0u64;
|
||||||
|
|
||||||
for mut review in reviews {
|
for mut review in reviews {
|
||||||
let input_path_str = review.input_path_str().to_owned();
|
let stored_input_path = review.input_path_str().to_owned();
|
||||||
let file_path = Path::new(&input_path_str);
|
let file_path =
|
||||||
|
crate::media_paths::resolve_path_from_root(&config.agent_inbox_dir, &stored_input_path);
|
||||||
let filename = file_path
|
let filename = file_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
// Set status → processing
|
|
||||||
let _ = review.set_processing(db).await;
|
|
||||||
|
|
||||||
// Parse context_json
|
// Parse context_json
|
||||||
let mut context: serde_json::Value = review
|
let mut context: serde_json::Value = review
|
||||||
.context_json
|
.context_json
|
||||||
@@ -305,14 +379,56 @@ async fn process_folder_batch(
|
|||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Resolve duplicates and missing sources before any expensive work.
|
||||||
|
let sha = context
|
||||||
|
.get("sha256")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_owned();
|
||||||
|
let file_exists = file_path.exists();
|
||||||
|
if !sha.is_empty()
|
||||||
|
&& crate::agent::rag::file_hash_exists(pool, &sha)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
// Identical content is already in the library — drop the inbox
|
||||||
|
// copy (same as mover::Merged) and close the review.
|
||||||
|
if file_exists {
|
||||||
|
let _ = tokio::fs::remove_file(&file_path).await;
|
||||||
|
}
|
||||||
|
let _ = PendingReview::delete_by_ids(db, &[review.id_val()]).await;
|
||||||
|
log.info(&format!(
|
||||||
|
"{filename}: content already in library (sha256 match) — merged duplicate"
|
||||||
|
));
|
||||||
|
crate::metrics::record_agent_file_processed("ok", "merged_duplicate");
|
||||||
|
merged_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !file_exists {
|
||||||
|
let msg = format!("{filename}: source file missing: {stored_input_path}");
|
||||||
|
log.error(&msg);
|
||||||
|
let _ = review.set_failed(db, &msg).await;
|
||||||
|
failed_reviews.push(review);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set status → processing
|
||||||
|
let _ = review.set_processing(db).await;
|
||||||
|
|
||||||
// Extract metadata (with 60s timeout)
|
// Extract metadata (with 60s timeout)
|
||||||
let path_for_meta = file_path.to_path_buf();
|
let path_for_meta = file_path.to_path_buf();
|
||||||
|
let metadata_start = std::time::Instant::now();
|
||||||
let meta_future =
|
let meta_future =
|
||||||
tokio::task::spawn_blocking(move || crate::agent::metadata::extract(&path_for_meta));
|
tokio::task::spawn_blocking(move || crate::agent::metadata::extract(&path_for_meta));
|
||||||
let raw_meta =
|
let raw_meta =
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(60), meta_future).await {
|
match tokio::time::timeout(std::time::Duration::from_secs(60), meta_future).await {
|
||||||
Ok(Ok(Ok(m))) => m,
|
Ok(Ok(Ok(m))) => {
|
||||||
|
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "ok");
|
||||||
|
m
|
||||||
|
}
|
||||||
Ok(Ok(Err(e))) => {
|
Ok(Ok(Err(e))) => {
|
||||||
|
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "error");
|
||||||
|
crate::metrics::record_agent_failed("metadata");
|
||||||
let msg = format!("{filename}: metadata error: {e}");
|
let msg = format!("{filename}: metadata error: {e}");
|
||||||
log.error(&msg);
|
log.error(&msg);
|
||||||
let _ = review.set_failed(db, &msg).await;
|
let _ = review.set_failed(db, &msg).await;
|
||||||
@@ -320,6 +436,8 @@ async fn process_folder_batch(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
|
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "panic");
|
||||||
|
crate::metrics::record_agent_failed("metadata");
|
||||||
let msg = format!("{filename}: metadata panic: {e}");
|
let msg = format!("{filename}: metadata panic: {e}");
|
||||||
log.error(&msg);
|
log.error(&msg);
|
||||||
let _ = review.set_failed(db, &msg).await;
|
let _ = review.set_failed(db, &msg).await;
|
||||||
@@ -327,6 +445,8 @@ async fn process_folder_batch(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "timeout");
|
||||||
|
crate::metrics::record_agent_failed("metadata");
|
||||||
let msg = format!("{filename}: metadata timeout (60s)");
|
let msg = format!("{filename}: metadata timeout (60s)");
|
||||||
log.error(&msg);
|
log.error(&msg);
|
||||||
let _ = review.set_failed(db, &msg).await;
|
let _ = review.set_failed(db, &msg).await;
|
||||||
@@ -336,7 +456,7 @@ async fn process_folder_batch(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse path hints
|
// Parse path hints
|
||||||
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
|
let relative = file_path.strip_prefix(inbox_path).unwrap_or(&file_path);
|
||||||
let uploader = crate::jobs::uploader_from_relative_path(pool, relative).await;
|
let uploader = crate::jobs::uploader_from_relative_path(pool, relative).await;
|
||||||
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
||||||
let hints = crate::agent::path_hints::parse(&hinted_relative);
|
let hints = crate::agent::path_hints::parse(&hinted_relative);
|
||||||
@@ -374,15 +494,16 @@ async fn process_folder_batch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Phase 1 done: {} prepared, {} failed metadata",
|
"Phase 1 done: {} prepared, {} merged duplicates, {} failed",
|
||||||
prepared.len(),
|
prepared.len(),
|
||||||
|
merged_count,
|
||||||
failed_reviews.len(),
|
failed_reviews.len(),
|
||||||
));
|
));
|
||||||
|
|
||||||
if prepared.is_empty() {
|
if prepared.is_empty() {
|
||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||||
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
||||||
return (0, failed_reviews.len() as u64);
|
return (merged_count, failed_reviews.len() as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
|
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
|
||||||
@@ -416,6 +537,7 @@ async fn process_folder_batch(
|
|||||||
// Lookup all unique artist queries
|
// Lookup all unique artist queries
|
||||||
let mut all_similar_artists = Vec::new();
|
let mut all_similar_artists = Vec::new();
|
||||||
for q in &artist_queries {
|
for q in &artist_queries {
|
||||||
|
let rag_start = std::time::Instant::now();
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(30),
|
std::time::Duration::from_secs(30),
|
||||||
crate::agent::rag::find_similar_artists(pool, q, 5),
|
crate::agent::rag::find_similar_artists(pool, q, 5),
|
||||||
@@ -423,6 +545,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
|
crate::metrics::record_agent_rag(
|
||||||
|
"artist",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for a in results {
|
for a in results {
|
||||||
if !all_similar_artists
|
if !all_similar_artists
|
||||||
.iter()
|
.iter()
|
||||||
@@ -432,13 +560,20 @@ async fn process_folder_batch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => log.warn(&format!("RAG artist lookup failed for \"{q}\": {e}")),
|
Ok(Err(e)) => {
|
||||||
Err(_) => log.warn(&format!("RAG artist lookup timed out for \"{q}\"")),
|
crate::metrics::record_agent_rag("artist", "error", rag_start.elapsed(), 0);
|
||||||
|
log.warn(&format!("RAG artist lookup failed for \"{q}\": {e}"))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
crate::metrics::record_agent_rag("artist", "timeout", rag_start.elapsed(), 0);
|
||||||
|
log.warn(&format!("RAG artist lookup timed out for \"{q}\""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut all_similar_releases = Vec::new();
|
let mut all_similar_releases = Vec::new();
|
||||||
for q in &album_queries {
|
for q in &album_queries {
|
||||||
|
let rag_start = std::time::Instant::now();
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(30),
|
std::time::Duration::from_secs(30),
|
||||||
crate::agent::rag::find_similar_releases(pool, q, 5),
|
crate::agent::rag::find_similar_releases(pool, q, 5),
|
||||||
@@ -446,6 +581,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
|
crate::metrics::record_agent_rag(
|
||||||
|
"release",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for r in results {
|
for r in results {
|
||||||
if !all_similar_releases
|
if !all_similar_releases
|
||||||
.iter()
|
.iter()
|
||||||
@@ -455,8 +596,14 @@ async fn process_folder_batch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => log.warn(&format!("RAG release lookup failed for \"{q}\": {e}")),
|
Ok(Err(e)) => {
|
||||||
Err(_) => log.warn(&format!("RAG release lookup timed out for \"{q}\"")),
|
crate::metrics::record_agent_rag("release", "error", rag_start.elapsed(), 0);
|
||||||
|
log.warn(&format!("RAG release lookup failed for \"{q}\": {e}"))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
crate::metrics::record_agent_rag("release", "timeout", rag_start.elapsed(), 0);
|
||||||
|
log.warn(&format!("RAG release lookup timed out for \"{q}\""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,8 +618,11 @@ async fn process_folder_batch(
|
|||||||
|
|
||||||
// Build folder context from the first file's folder
|
// Build folder context from the first file's folder
|
||||||
let folder_ctx = {
|
let folder_ctx = {
|
||||||
let first_path = Path::new(prepared[0].review.input_path_str());
|
let first_path = crate::media_paths::resolve_path_from_root(
|
||||||
let folder = first_path.parent().unwrap_or(first_path);
|
&config.agent_inbox_dir,
|
||||||
|
prepared[0].review.input_path_str(),
|
||||||
|
);
|
||||||
|
let folder = first_path.parent().unwrap_or(first_path.as_path());
|
||||||
let mut folder_files: Vec<String> = std::fs::read_dir(folder)
|
let mut folder_files: Vec<String> = std::fs::read_dir(folder)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|rd| {
|
.map(|rd| {
|
||||||
@@ -544,18 +694,22 @@ async fn process_folder_batch(
|
|||||||
let batch_result = match llm_result {
|
let batch_result = match llm_result {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
crate::metrics::record_agent_failed("llm");
|
||||||
|
crate::metrics::record_agent_folder_batch("failed", file_count, batch_start.elapsed());
|
||||||
let err_msg = format!("Batch LLM call failed: {e}");
|
let err_msg = format!("Batch LLM call failed: {e}");
|
||||||
log.error(&err_msg);
|
log.error(&err_msg);
|
||||||
// Mark all files as failed
|
// Mark all files as failed
|
||||||
|
let prepared_count = prepared.len() as u64;
|
||||||
for mut p in prepared {
|
for mut p in prepared {
|
||||||
let _ = p.review.set_failed(db, &err_msg).await;
|
let _ = p.review.set_failed(db, &err_msg).await;
|
||||||
|
crate::metrics::record_agent_file_processed("failed", "failed");
|
||||||
}
|
}
|
||||||
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
|
let total_fail_count = failed_reviews.len() as u64 + prepared_count;
|
||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||||
let _ = run
|
let _ = run
|
||||||
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
||||||
.await;
|
.await;
|
||||||
return (0, total_fail_count);
|
return (merged_count, total_fail_count);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -579,7 +733,7 @@ async fn process_folder_batch(
|
|||||||
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
|
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
|
||||||
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
|
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
|
||||||
|
|
||||||
let mut ok_count = 0u64;
|
let mut ok_count = merged_count;
|
||||||
let mut fail_count = failed_reviews.len() as u64;
|
let mut fail_count = failed_reviews.len() as u64;
|
||||||
|
|
||||||
for mut p in prepared {
|
for mut p in prepared {
|
||||||
@@ -609,6 +763,7 @@ async fn process_folder_batch(
|
|||||||
|
|
||||||
let result_json = serde_json::to_string(normalized).unwrap_or_default();
|
let result_json = serde_json::to_string(normalized).unwrap_or_default();
|
||||||
let confidence = normalized.confidence.unwrap_or(0.0);
|
let confidence = normalized.confidence.unwrap_or(0.0);
|
||||||
|
crate::metrics::observe_agent_confidence(confidence);
|
||||||
|
|
||||||
let feat = if normalized.featured_artists.is_empty() {
|
let feat = if normalized.featured_artists.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -631,7 +786,11 @@ async fn process_folder_batch(
|
|||||||
p.review.result_json = Some(result_json);
|
p.review.result_json = Some(result_json);
|
||||||
let _ = p.review.save(db).await;
|
let _ = p.review.save(db).await;
|
||||||
|
|
||||||
let input_path_str = p.review.input_path_str().to_owned();
|
let input_path = crate::media_paths::resolve_path_from_root(
|
||||||
|
&config.agent_inbox_dir,
|
||||||
|
p.review.input_path_str(),
|
||||||
|
);
|
||||||
|
let input_path_str = input_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
if confidence >= config.agent_confidence_threshold {
|
if confidence >= config.agent_confidence_threshold {
|
||||||
match finalize_approved(
|
match finalize_approved(
|
||||||
@@ -648,9 +807,12 @@ async fn process_folder_batch(
|
|||||||
{
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = p.review.set_auto_approved(db).await;
|
let _ = p.review.set_auto_approved(db).await;
|
||||||
|
crate::metrics::record_agent_file_processed("ok", "auto_approved");
|
||||||
ok_count += 1;
|
ok_count += 1;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
crate::metrics::record_agent_failed("finalize");
|
||||||
|
crate::metrics::record_agent_file_processed("failed", "failed");
|
||||||
let msg = format!("{filename}: finalize failed: {e}");
|
let msg = format!("{filename}: finalize failed: {e}");
|
||||||
log.error(&msg);
|
log.error(&msg);
|
||||||
let _ = p.review.set_failed(db, &msg).await;
|
let _ = p.review.set_failed(db, &msg).await;
|
||||||
@@ -664,6 +826,7 @@ async fn process_folder_batch(
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let _ = p.review.save(db).await;
|
let _ = p.review.save(db).await;
|
||||||
|
crate::metrics::record_agent_file_processed("ok", "pending_review");
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"{filename}: manual review (confidence {confidence} < {})",
|
"{filename}: manual review (confidence {confidence} < {})",
|
||||||
config.agent_confidence_threshold,
|
config.agent_confidence_threshold,
|
||||||
@@ -675,9 +838,11 @@ async fn process_folder_batch(
|
|||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||||
if fail_count == 0 {
|
if fail_count == 0 {
|
||||||
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
||||||
|
crate::metrics::record_agent_folder_batch("completed", file_count, batch_start.elapsed());
|
||||||
} else {
|
} else {
|
||||||
let msg = format!("{fail_count} file(s) failed");
|
let msg = format!("{fail_count} file(s) failed");
|
||||||
let _ = run.set_failed(db, duration_ms, &log.output(), &msg).await;
|
let _ = run.set_failed(db, duration_ms, &log.output(), &msg).await;
|
||||||
|
crate::metrics::record_agent_folder_batch("failed", file_count, batch_start.elapsed());
|
||||||
}
|
}
|
||||||
|
|
||||||
(ok_count, fail_count)
|
(ok_count, fail_count)
|
||||||
@@ -787,10 +952,10 @@ pub async fn finalize_approved(
|
|||||||
format!("{}.{}", sanitize_filename(track_title), ext)
|
format!("{}.{}", sanitize_filename(track_title), ext)
|
||||||
};
|
};
|
||||||
|
|
||||||
let storage_dir = Path::new(storage_dir_str);
|
let storage_dir = crate::media_paths::resolve_config_path_buf(storage_dir_str);
|
||||||
let storage_path = if source_path.exists() {
|
let storage_path = if source_path.exists() {
|
||||||
match mover::move_to_storage(
|
match mover::move_to_storage(
|
||||||
storage_dir,
|
&storage_dir,
|
||||||
artist_name,
|
artist_name,
|
||||||
release_title,
|
release_title,
|
||||||
&dest_filename,
|
&dest_filename,
|
||||||
@@ -798,16 +963,29 @@ pub async fn finalize_approved(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
mover::MoveOutcome::Moved(p) => p.to_string_lossy().to_string(),
|
mover::MoveOutcome::Moved(p) | mover::MoveOutcome::Merged(p) => {
|
||||||
mover::MoveOutcome::Merged(p) => p.to_string_lossy().to_string(),
|
crate::media_paths::media_file_path_for_storage(storage_dir_str, &p).ok_or_else(
|
||||||
|
|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"storage destination is outside agent_storage_dir: {}",
|
||||||
|
p.display()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
storage_dir
|
let expected_path = storage_dir
|
||||||
.join(sanitize_filename(artist_name))
|
.join(sanitize_filename(artist_name))
|
||||||
.join(sanitize_filename(release_title))
|
.join(sanitize_filename(release_title))
|
||||||
.join(&dest_filename)
|
.join(&dest_filename);
|
||||||
.to_string_lossy()
|
crate::media_paths::media_file_path_for_storage(storage_dir_str, &expected_path)
|
||||||
.to_string()
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"storage destination is outside agent_storage_dir: {}",
|
||||||
|
expected_path.display()
|
||||||
|
)
|
||||||
|
})?
|
||||||
};
|
};
|
||||||
|
|
||||||
let media_file = MediaFile::create(
|
let media_file = MediaFile::create(
|
||||||
@@ -858,6 +1036,26 @@ pub async fn finalize_approved(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let approved_genre = normalized
|
||||||
|
.genre
|
||||||
|
.as_deref()
|
||||||
|
.or_else(|| context.get("raw_genre").and_then(|v| v.as_str()))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
if let Some(genre) = approved_genre {
|
||||||
|
if let Err(err) =
|
||||||
|
crate::jobs::metadata_backfill::save_approved_track_genres(pool, track.id_val(), genre)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
track_id = track.id_val(),
|
||||||
|
genre,
|
||||||
|
error = %err,
|
||||||
|
"failed to save approved track genre metadata"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cover art: if the release has no cover yet, try to find one
|
// Cover art: if the release has no cover yet, try to find one
|
||||||
if release.cover_file_id.is_none() {
|
if release.cover_file_id.is_none() {
|
||||||
let source_folder = Path::new(input_path_str).parent().unwrap_or(Path::new("."));
|
let source_folder = Path::new(input_path_str).parent().unwrap_or(Path::new("."));
|
||||||
@@ -887,6 +1085,9 @@ pub async fn finalize_approved(
|
|||||||
crate::agent::cover_art::CoverSource::Embedded(p) => {
|
crate::agent::cover_art::CoverSource::Embedded(p) => {
|
||||||
format!("embedded in: {}", p.display())
|
format!("embedded in: {}", p.display())
|
||||||
}
|
}
|
||||||
|
crate::agent::cover_art::CoverSource::Remote(url) => {
|
||||||
|
format!("remote: {url}")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match crate::agent::cover_art::save_cover_to_storage(
|
match crate::agent::cover_art::save_cover_to_storage(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use crate::lastfm;
|
||||||
|
use crate::scheduler::{Job, JobContext, JobLog};
|
||||||
|
|
||||||
|
pub struct LastfmScrobbleJob;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Job for LastfmScrobbleJob {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"lastfm_scrobble"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Send queued Last.fm scrobbles for connected users"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cron(&self) -> &'static str {
|
||||||
|
// Every minute.
|
||||||
|
"0 * * * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
if !lastfm::is_configured(&ctx.config) {
|
||||||
|
log.warn("Last.fm API key/shared secret are not configured; skipping scrobble outbox");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = lastfm::process_pending_scrobbles(&ctx.pool, &ctx.config, None, 50).await?;
|
||||||
|
log.info(&format!(
|
||||||
|
"Last.fm scrobble outbox processed: considered={}, sent={}, failed={}, blocked={}, skipped={}",
|
||||||
|
summary.considered, summary.sent, summary.failed, summary.blocked, summary.skipped
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+1281
-123
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,9 +1,11 @@
|
|||||||
pub mod artist_image_backfill;
|
pub mod archive_cleanup;
|
||||||
pub mod artist_track_image_backfill;
|
pub mod artwork_backfill;
|
||||||
pub mod cover_backfill;
|
|
||||||
pub mod inbox_discover;
|
pub mod inbox_discover;
|
||||||
pub mod inbox_process;
|
pub mod inbox_process;
|
||||||
|
pub mod lastfm_popularity;
|
||||||
|
pub mod lastfm_scrobble;
|
||||||
pub mod metadata_backfill;
|
pub mod metadata_backfill;
|
||||||
|
pub mod musicbrainz;
|
||||||
|
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use reqwest::{Client, StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const MUSICBRAINZ_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
||||||
|
const COVER_ART_ARCHIVE_BASE_URL: &str = "https://coverartarchive.org";
|
||||||
|
const MUSICBRAINZ_REQUEST_DELAY: Duration = Duration::from_millis(1100);
|
||||||
|
const MUSICBRAINZ_TAG_LIMIT: usize = 12;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MusicBrainzTag {
|
||||||
|
pub name: String,
|
||||||
|
pub weight: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MusicBrainzArtistMatch {
|
||||||
|
pub mbid: String,
|
||||||
|
pub score: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MusicBrainzReleaseMatch {
|
||||||
|
pub mbid: String,
|
||||||
|
pub release_group_mbid: Option<String>,
|
||||||
|
pub score: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MusicBrainzReleaseTags {
|
||||||
|
pub release_group_mbid: Option<String>,
|
||||||
|
pub tags: Vec<MusicBrainzTag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MusicBrainzClient {
|
||||||
|
client: Client,
|
||||||
|
last_musicbrainz_request: Mutex<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_external_id(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
entity_kind: &str,
|
||||||
|
entity_id: i64,
|
||||||
|
id_kind: &str,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
let value = sqlx::query_scalar::<_, String>(
|
||||||
|
r#"SELECT external_id::text
|
||||||
|
FROM furumusic__external_metadata_id
|
||||||
|
WHERE entity_kind = $1
|
||||||
|
AND entity_id = $2
|
||||||
|
AND source = 'musicbrainz'
|
||||||
|
AND id_kind = $3
|
||||||
|
LIMIT 1"#,
|
||||||
|
)
|
||||||
|
.bind(entity_kind)
|
||||||
|
.bind(entity_id)
|
||||||
|
.bind(id_kind)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_external_id(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
entity_kind: &str,
|
||||||
|
entity_id: i64,
|
||||||
|
id_kind: &str,
|
||||||
|
external_id: &str,
|
||||||
|
confidence: f64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO furumusic__external_metadata_id
|
||||||
|
(entity_kind, entity_id, source, id_kind, external_id, confidence, updated_at)
|
||||||
|
VALUES ($1, $2, 'musicbrainz', $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (entity_kind, entity_id, source, id_kind) DO UPDATE SET
|
||||||
|
external_id = EXCLUDED.external_id,
|
||||||
|
confidence = EXCLUDED.confidence,
|
||||||
|
updated_at = EXCLUDED.updated_at"#,
|
||||||
|
)
|
||||||
|
.bind(entity_kind)
|
||||||
|
.bind(entity_id)
|
||||||
|
.bind(id_kind)
|
||||||
|
.bind(external_id)
|
||||||
|
.bind(confidence)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_or_search_release_mbid(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
client: &MusicBrainzClient,
|
||||||
|
release_id: i64,
|
||||||
|
artist_name: &str,
|
||||||
|
release_title: &str,
|
||||||
|
representative_track_title: Option<&str>,
|
||||||
|
) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||||
|
let release_mbid = load_external_id(pool, "release", release_id, "release").await?;
|
||||||
|
let release_group_mbid = load_external_id(pool, "release", release_id, "release_group").await?;
|
||||||
|
if release_mbid.is_some() || release_group_mbid.is_some() {
|
||||||
|
return Ok((release_mbid, release_group_mbid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let found = match client.search_release(artist_name, release_title).await? {
|
||||||
|
Some(found) => Some(found),
|
||||||
|
None => {
|
||||||
|
if let Some(track_title) =
|
||||||
|
representative_track_title.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
client
|
||||||
|
.search_release_by_recording(artist_name, track_title)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(found) = found else {
|
||||||
|
return Ok((None, None));
|
||||||
|
};
|
||||||
|
save_external_id(
|
||||||
|
pool,
|
||||||
|
"release",
|
||||||
|
release_id,
|
||||||
|
"release",
|
||||||
|
&found.mbid,
|
||||||
|
found.score as f64 / 100.0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some(group_mbid) = found.release_group_mbid.as_deref() {
|
||||||
|
save_external_id(
|
||||||
|
pool,
|
||||||
|
"release",
|
||||||
|
release_id,
|
||||||
|
"release_group",
|
||||||
|
group_mbid,
|
||||||
|
found.score as f64 / 100.0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok((Some(found.mbid), found.release_group_mbid))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicBrainzClient {
|
||||||
|
pub fn new(user_agent_prefix: &str) -> anyhow::Result<Self> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.user_agent(format!(
|
||||||
|
"{}/{} (musicbrainz.org/doc/MusicBrainz_API)",
|
||||||
|
user_agent_prefix,
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
))
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.build()?;
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
last_musicbrainz_request: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn http_client(&self) -> &Client {
|
||||||
|
&self.client
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_artist(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<Option<MusicBrainzArtistMatch>> {
|
||||||
|
let query = format!("artist:\"{}\"", escape_search_value(name));
|
||||||
|
let response: Option<ArtistSearchResponse> = self
|
||||||
|
.get_musicbrainz_json(
|
||||||
|
"artist",
|
||||||
|
&[("query", query.as_str()), ("fmt", "json"), ("limit", "5")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(response) = response else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(response
|
||||||
|
.artists
|
||||||
|
.into_iter()
|
||||||
|
.filter(|artist| artist.id.trim().len() == 36)
|
||||||
|
.max_by_key(|artist| artist.score.unwrap_or(0))
|
||||||
|
.and_then(|artist| {
|
||||||
|
let score = artist.score.unwrap_or(0);
|
||||||
|
(score >= 70).then_some(MusicBrainzArtistMatch {
|
||||||
|
mbid: artist.id,
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_release(
|
||||||
|
&self,
|
||||||
|
artist: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> anyhow::Result<Option<MusicBrainzReleaseMatch>> {
|
||||||
|
let query = format!(
|
||||||
|
"release:\"{}\" AND artist:\"{}\"",
|
||||||
|
escape_search_value(title),
|
||||||
|
escape_search_value(artist)
|
||||||
|
);
|
||||||
|
let response: Option<ReleaseSearchResponse> = self
|
||||||
|
.get_musicbrainz_json(
|
||||||
|
"release",
|
||||||
|
&[("query", query.as_str()), ("fmt", "json"), ("limit", "5")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(response) = response else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(response
|
||||||
|
.releases
|
||||||
|
.into_iter()
|
||||||
|
.filter(|release| release.id.trim().len() == 36)
|
||||||
|
.max_by_key(|release| release.score.unwrap_or(0))
|
||||||
|
.and_then(|release| {
|
||||||
|
let score = release.score.unwrap_or(0);
|
||||||
|
(score >= 70).then_some(MusicBrainzReleaseMatch {
|
||||||
|
mbid: release.id,
|
||||||
|
release_group_mbid: release.release_group.map(|group| group.id),
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_release_by_recording(
|
||||||
|
&self,
|
||||||
|
artist: &str,
|
||||||
|
track_title: &str,
|
||||||
|
) -> anyhow::Result<Option<MusicBrainzReleaseMatch>> {
|
||||||
|
let query = format!(
|
||||||
|
"recording:\"{}\" AND artist:\"{}\"",
|
||||||
|
escape_search_value(track_title),
|
||||||
|
escape_search_value(artist)
|
||||||
|
);
|
||||||
|
let response: Option<RecordingSearchResponse> = self
|
||||||
|
.get_musicbrainz_json(
|
||||||
|
"recording",
|
||||||
|
&[("query", query.as_str()), ("fmt", "json"), ("limit", "5")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(response) = response else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(response
|
||||||
|
.recordings
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|recording| {
|
||||||
|
let recording_score = recording.score.unwrap_or(0);
|
||||||
|
recording
|
||||||
|
.releases
|
||||||
|
.into_iter()
|
||||||
|
.filter(move |release| release.id.trim().len() == 36)
|
||||||
|
.map(move |release| (recording_score, release))
|
||||||
|
})
|
||||||
|
.max_by_key(|(score, _)| *score)
|
||||||
|
.and_then(|(score, release)| {
|
||||||
|
(score >= 70).then_some(MusicBrainzReleaseMatch {
|
||||||
|
mbid: release.id,
|
||||||
|
release_group_mbid: release.release_group.map(|group| group.id),
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lookup_artist_tags(&self, mbid: &str) -> anyhow::Result<Vec<MusicBrainzTag>> {
|
||||||
|
let response: Option<TaggedEntityResponse> = self
|
||||||
|
.get_musicbrainz_json(
|
||||||
|
&format!("artist/{mbid}"),
|
||||||
|
&[("inc", "tags+genres"), ("fmt", "json")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(response.map(tags_from_entity).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lookup_release_tags(&self, mbid: &str) -> anyhow::Result<MusicBrainzReleaseTags> {
|
||||||
|
let response: Option<ReleaseLookupResponse> = self
|
||||||
|
.get_musicbrainz_json(
|
||||||
|
&format!("release/{mbid}"),
|
||||||
|
&[("inc", "tags+genres+release-groups"), ("fmt", "json")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(response) = response else {
|
||||||
|
return Ok(MusicBrainzReleaseTags {
|
||||||
|
release_group_mbid: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tags = tags_from_parts(response.tags, response.genres);
|
||||||
|
let release_group_mbid = response
|
||||||
|
.release_group
|
||||||
|
.as_ref()
|
||||||
|
.map(|group| group.id.clone());
|
||||||
|
if let Some(group_mbid) = release_group_mbid.as_deref() {
|
||||||
|
let group_response: Option<TaggedEntityResponse> = self
|
||||||
|
.get_musicbrainz_json(
|
||||||
|
&format!("release-group/{group_mbid}"),
|
||||||
|
&[("inc", "tags+genres"), ("fmt", "json")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
merge_tags(
|
||||||
|
&mut tags,
|
||||||
|
group_response.map(tags_from_entity).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tags.sort_by(|a, b| {
|
||||||
|
b.weight
|
||||||
|
.total_cmp(&a.weight)
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
tags.truncate(MUSICBRAINZ_TAG_LIMIT);
|
||||||
|
|
||||||
|
Ok(MusicBrainzReleaseTags {
|
||||||
|
release_group_mbid,
|
||||||
|
tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_cover_art_front_url(
|
||||||
|
&self,
|
||||||
|
release_mbid: Option<&str>,
|
||||||
|
release_group_mbid: Option<&str>,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
if let Some(mbid) = release_mbid {
|
||||||
|
if let Some(url) = self.cover_art_front_url("release", mbid).await? {
|
||||||
|
return Ok(Some(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mbid) = release_group_mbid {
|
||||||
|
if let Some(url) = self.cover_art_front_url("release-group", mbid).await? {
|
||||||
|
return Ok(Some(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_musicbrainz_json<T>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, &str)],
|
||||||
|
) -> anyhow::Result<Option<T>>
|
||||||
|
where
|
||||||
|
T: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
self.wait_for_musicbrainz_slot().await;
|
||||||
|
let url = format!("{MUSICBRAINZ_BASE_URL}/{}", path.trim_start_matches('/'));
|
||||||
|
let response = self.client.get(url).query(query).send().await?;
|
||||||
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if response.status() == StatusCode::TOO_MANY_REQUESTS
|
||||||
|
|| response.status() == StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"MusicBrainz rate limit or service unavailable: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let response = response.error_for_status()?;
|
||||||
|
Ok(Some(response.json::<T>().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cover_art_front_url(&self, kind: &str, mbid: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let url = format!("{COVER_ART_ARCHIVE_BASE_URL}/{kind}/{mbid}");
|
||||||
|
let response = self.client.get(url).send().await?;
|
||||||
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if response.status() == StatusCode::TOO_MANY_REQUESTS
|
||||||
|
|| response.status() == StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
{
|
||||||
|
anyhow::bail!("Cover Art Archive unavailable: {}", response.status());
|
||||||
|
}
|
||||||
|
let response = response.error_for_status()?;
|
||||||
|
let body = response.json::<CoverArtArchiveResponse>().await?;
|
||||||
|
Ok(best_cover_art_url(body.images))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_musicbrainz_slot(&self) {
|
||||||
|
let mut last = self.last_musicbrainz_request.lock().await;
|
||||||
|
if let Some(previous) = *last {
|
||||||
|
let elapsed = previous.elapsed();
|
||||||
|
if elapsed < MUSICBRAINZ_REQUEST_DELAY {
|
||||||
|
tokio::time::sleep(MUSICBRAINZ_REQUEST_DELAY - elapsed).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*last = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ArtistSearchResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
artists: Vec<ArtistSearchItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ArtistSearchItem {
|
||||||
|
id: String,
|
||||||
|
score: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReleaseSearchResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
releases: Vec<ReleaseSearchItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReleaseSearchItem {
|
||||||
|
id: String,
|
||||||
|
score: Option<i32>,
|
||||||
|
#[serde(rename = "release-group")]
|
||||||
|
release_group: Option<MusicBrainzIdRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RecordingSearchResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
recordings: Vec<RecordingSearchItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RecordingSearchItem {
|
||||||
|
score: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
releases: Vec<RecordingReleaseItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RecordingReleaseItem {
|
||||||
|
id: String,
|
||||||
|
#[serde(rename = "release-group")]
|
||||||
|
release_group: Option<MusicBrainzIdRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReleaseLookupResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
tags: Vec<MusicBrainzTagItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
genres: Vec<MusicBrainzTagItem>,
|
||||||
|
#[serde(rename = "release-group")]
|
||||||
|
release_group: Option<MusicBrainzIdRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TaggedEntityResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
tags: Vec<MusicBrainzTagItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
genres: Vec<MusicBrainzTagItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct MusicBrainzIdRef {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct MusicBrainzTagItem {
|
||||||
|
name: String,
|
||||||
|
count: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CoverArtArchiveResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
images: Vec<CoverArtArchiveImage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CoverArtArchiveImage {
|
||||||
|
image: Option<String>,
|
||||||
|
front: Option<bool>,
|
||||||
|
approved: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
types: Vec<String>,
|
||||||
|
thumbnails: Option<CoverArtArchiveThumbnails>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CoverArtArchiveThumbnails {
|
||||||
|
#[serde(rename = "1200")]
|
||||||
|
size_1200: Option<String>,
|
||||||
|
#[serde(rename = "500")]
|
||||||
|
size_500: Option<String>,
|
||||||
|
large: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_search_value(value: &str) -> String {
|
||||||
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags_from_entity(entity: TaggedEntityResponse) -> Vec<MusicBrainzTag> {
|
||||||
|
tags_from_parts(entity.tags, entity.genres)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags_from_parts(
|
||||||
|
tags: Vec<MusicBrainzTagItem>,
|
||||||
|
genres: Vec<MusicBrainzTagItem>,
|
||||||
|
) -> Vec<MusicBrainzTag> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
merge_items(&mut result, genres, 2.0);
|
||||||
|
merge_items(&mut result, tags, 1.0);
|
||||||
|
result.sort_by(|a, b| {
|
||||||
|
b.weight
|
||||||
|
.total_cmp(&a.weight)
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
result.truncate(MUSICBRAINZ_TAG_LIMIT);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_items(result: &mut Vec<MusicBrainzTag>, items: Vec<MusicBrainzTagItem>, multiplier: f64) {
|
||||||
|
for item in items {
|
||||||
|
let name = item.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let weight = item.count.unwrap_or(1).max(1) as f64 * multiplier;
|
||||||
|
if let Some(existing) = result
|
||||||
|
.iter_mut()
|
||||||
|
.find(|tag| tag.name.eq_ignore_ascii_case(name))
|
||||||
|
{
|
||||||
|
existing.weight = existing.weight.max(weight);
|
||||||
|
} else {
|
||||||
|
result.push(MusicBrainzTag {
|
||||||
|
name: name.to_string(),
|
||||||
|
weight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_tags(result: &mut Vec<MusicBrainzTag>, extra: Vec<MusicBrainzTag>) {
|
||||||
|
for tag in extra {
|
||||||
|
if let Some(existing) = result
|
||||||
|
.iter_mut()
|
||||||
|
.find(|candidate| candidate.name.eq_ignore_ascii_case(&tag.name))
|
||||||
|
{
|
||||||
|
existing.weight = existing.weight.max(tag.weight);
|
||||||
|
} else {
|
||||||
|
result.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn best_cover_art_url(mut images: Vec<CoverArtArchiveImage>) -> Option<String> {
|
||||||
|
images.sort_by_key(|image| {
|
||||||
|
let front = image.front.unwrap_or(false)
|
||||||
|
|| image
|
||||||
|
.types
|
||||||
|
.iter()
|
||||||
|
.any(|value| value.eq_ignore_ascii_case("front"));
|
||||||
|
let approved = image.approved.unwrap_or(false);
|
||||||
|
(u8::from(front), u8::from(approved))
|
||||||
|
});
|
||||||
|
images
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|image| {
|
||||||
|
image
|
||||||
|
.thumbnails
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|thumbs| {
|
||||||
|
thumbs
|
||||||
|
.size_1200
|
||||||
|
.as_deref()
|
||||||
|
.or(thumbs.size_500.as_deref())
|
||||||
|
.or(thumbs.large.as_deref())
|
||||||
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
.or(image.image)
|
||||||
|
})
|
||||||
|
.filter(|url| !url.trim().is_empty())
|
||||||
|
}
|
||||||
+619
@@ -0,0 +1,619 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
|
const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||||
|
const MAX_BATCH_SIZE: i64 = 50;
|
||||||
|
const MAX_ATTEMPTS: i32 = 8;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LastfmCredentials {
|
||||||
|
api_key: String,
|
||||||
|
shared_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LastfmSession {
|
||||||
|
pub username: String,
|
||||||
|
pub session_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LastfmTrackPayload {
|
||||||
|
pub artist: String,
|
||||||
|
pub track: String,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub album_artist: Option<String>,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub duration_seconds: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LastfmScrobblePayload {
|
||||||
|
pub track: LastfmTrackPayload,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LastfmApiError {
|
||||||
|
pub code: Option<i32>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for LastfmApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self.code {
|
||||||
|
Some(code) => write!(f, "Last.fm API error {code}: {}", self.message),
|
||||||
|
None => write!(f, "Last.fm API error: {}", self.message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for LastfmApiError {}
|
||||||
|
|
||||||
|
impl LastfmApiError {
|
||||||
|
fn new(code: Option<i32>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_invalid_session(&self) -> bool {
|
||||||
|
self.code == Some(9)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_retryable(&self) -> bool {
|
||||||
|
matches!(self.code, Some(11 | 16 | 29) | None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ScrobbleProcessSummary {
|
||||||
|
pub considered: u64,
|
||||||
|
pub sent: u64,
|
||||||
|
pub failed: u64,
|
||||||
|
pub blocked: u64,
|
||||||
|
pub skipped: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_configured(config: &AppConfig) -> bool {
|
||||||
|
!config.lastfm_api_key.trim().is_empty() && !config.lastfm_shared_secret.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LastfmCredentials {
|
||||||
|
pub fn from_config(config: &AppConfig) -> Option<Self> {
|
||||||
|
let api_key = config.lastfm_api_key.trim();
|
||||||
|
let shared_secret = config.lastfm_shared_secret.trim();
|
||||||
|
if api_key.is_empty() || shared_secret.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
api_key: api_key.to_owned(),
|
||||||
|
shared_secret: shared_secret.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_key(&self) -> &str {
|
||||||
|
&self.api_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LastfmClient {
|
||||||
|
client: Client,
|
||||||
|
credentials: LastfmCredentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LastfmClient {
|
||||||
|
pub fn new(credentials: LastfmCredentials) -> anyhow::Result<Self> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.user_agent(format!(
|
||||||
|
"furumusic-lastfm-scrobbler/{}",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
))
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.context("failed to build Last.fm HTTP client")?;
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
credentials,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_session(&self, token: &str) -> Result<LastfmSession, LastfmApiError> {
|
||||||
|
let params = self.signed_params(
|
||||||
|
"auth.getSession",
|
||||||
|
None,
|
||||||
|
vec![("token".to_string(), token.to_string())],
|
||||||
|
);
|
||||||
|
let body = self.post(params).await?;
|
||||||
|
let response: AuthSessionResponse = serde_json::from_str(&body)
|
||||||
|
.map_err(|err| LastfmApiError::new(None, err.to_string()))?;
|
||||||
|
if let Some(code) = response.error {
|
||||||
|
return Err(LastfmApiError::new(
|
||||||
|
Some(code),
|
||||||
|
response
|
||||||
|
.message
|
||||||
|
.unwrap_or_else(|| "authentication failed".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(session) = response.session else {
|
||||||
|
return Err(LastfmApiError::new(
|
||||||
|
None,
|
||||||
|
"Last.fm auth response did not include a session",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(LastfmSession {
|
||||||
|
username: session.name,
|
||||||
|
session_key: session.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_now_playing(
|
||||||
|
&self,
|
||||||
|
session_key: &str,
|
||||||
|
track: &LastfmTrackPayload,
|
||||||
|
) -> Result<(), LastfmApiError> {
|
||||||
|
let mut extra = vec![
|
||||||
|
("artist".to_string(), track.artist.clone()),
|
||||||
|
("track".to_string(), track.track.clone()),
|
||||||
|
];
|
||||||
|
push_optional(&mut extra, "album", track.album.as_deref());
|
||||||
|
push_optional(&mut extra, "albumArtist", track.album_artist.as_deref());
|
||||||
|
push_optional_i32(&mut extra, "trackNumber", track.track_number);
|
||||||
|
push_optional_i32(&mut extra, "duration", track.duration_seconds);
|
||||||
|
|
||||||
|
let params = self.signed_params("track.updateNowPlaying", Some(session_key), extra);
|
||||||
|
let body = self.post(params).await?;
|
||||||
|
check_lastfm_error(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scrobble_batch(
|
||||||
|
&self,
|
||||||
|
session_key: &str,
|
||||||
|
scrobbles: &[LastfmScrobblePayload],
|
||||||
|
) -> Result<(), LastfmApiError> {
|
||||||
|
let mut extra = Vec::new();
|
||||||
|
for (index, scrobble) in scrobbles.iter().take(MAX_BATCH_SIZE as usize).enumerate() {
|
||||||
|
let suffix = format!("[{index}]");
|
||||||
|
extra.push((format!("artist{suffix}"), scrobble.track.artist.clone()));
|
||||||
|
extra.push((format!("track{suffix}"), scrobble.track.track.clone()));
|
||||||
|
extra.push((format!("timestamp{suffix}"), scrobble.timestamp.to_string()));
|
||||||
|
push_optional(
|
||||||
|
&mut extra,
|
||||||
|
&format!("album{suffix}"),
|
||||||
|
scrobble.track.album.as_deref(),
|
||||||
|
);
|
||||||
|
push_optional(
|
||||||
|
&mut extra,
|
||||||
|
&format!("albumArtist{suffix}"),
|
||||||
|
scrobble.track.album_artist.as_deref(),
|
||||||
|
);
|
||||||
|
push_optional_i32(
|
||||||
|
&mut extra,
|
||||||
|
&format!("trackNumber{suffix}"),
|
||||||
|
scrobble.track.track_number,
|
||||||
|
);
|
||||||
|
push_optional_i32(
|
||||||
|
&mut extra,
|
||||||
|
&format!("duration{suffix}"),
|
||||||
|
scrobble.track.duration_seconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = self.signed_params("track.scrobble", Some(session_key), extra);
|
||||||
|
let body = self.post(params).await?;
|
||||||
|
check_lastfm_error(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_params(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
session_key: Option<&str>,
|
||||||
|
extra: Vec<(String, String)>,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let mut params = BTreeMap::new();
|
||||||
|
params.insert("api_key".to_string(), self.credentials.api_key.clone());
|
||||||
|
params.insert("method".to_string(), method.to_string());
|
||||||
|
if let Some(session_key) = session_key {
|
||||||
|
params.insert("sk".to_string(), session_key.to_string());
|
||||||
|
}
|
||||||
|
for (key, value) in extra {
|
||||||
|
params.insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut signature_input = String::new();
|
||||||
|
for (key, value) in ¶ms {
|
||||||
|
signature_input.push_str(key);
|
||||||
|
signature_input.push_str(value);
|
||||||
|
}
|
||||||
|
signature_input.push_str(&self.credentials.shared_secret);
|
||||||
|
let digest = Md5::digest(signature_input.as_bytes());
|
||||||
|
let api_sig = format!("{digest:x}");
|
||||||
|
|
||||||
|
let mut out = params.into_iter().collect::<Vec<_>>();
|
||||||
|
out.push(("api_sig".to_string(), api_sig));
|
||||||
|
out.push(("format".to_string(), "json".to_string()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(&self, params: Vec<(String, String)>) -> Result<String, LastfmApiError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(LASTFM_API_URL)
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| LastfmApiError::new(None, err.to_string()))?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| LastfmApiError::new(None, err.to_string()))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
if let Some(err) = parse_error(&body) {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
return Err(LastfmApiError::new(None, format!("HTTP {status}: {body}")));
|
||||||
|
}
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_pending_scrobbles(
|
||||||
|
pool: &PgPool,
|
||||||
|
config: &AppConfig,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
limit_per_user: i64,
|
||||||
|
) -> anyhow::Result<ScrobbleProcessSummary> {
|
||||||
|
let Some(credentials) = LastfmCredentials::from_config(config) else {
|
||||||
|
return Ok(ScrobbleProcessSummary::default());
|
||||||
|
};
|
||||||
|
let client = LastfmClient::new(credentials)?;
|
||||||
|
let user_ids = pending_user_ids(pool, user_id).await?;
|
||||||
|
let mut summary = ScrobbleProcessSummary::default();
|
||||||
|
|
||||||
|
for uid in user_ids {
|
||||||
|
let rows = fetch_pending_scrobbles(pool, uid, limit_per_user.min(MAX_BATCH_SIZE)).await?;
|
||||||
|
if rows.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
summary.considered += rows.len() as u64;
|
||||||
|
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
let mut attempt_rows = Vec::new();
|
||||||
|
let mut payloads = Vec::new();
|
||||||
|
for row in &rows {
|
||||||
|
match row.payload() {
|
||||||
|
Some(payload) => {
|
||||||
|
ids.push(row.id);
|
||||||
|
attempt_rows.push((row.id, row.attempt_count));
|
||||||
|
payloads.push(payload);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
mark_row_failed(pool, row.id, "track has no primary Last.fm artist").await?;
|
||||||
|
summary.skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match client.scrobble_batch(&rows[0].session_key, &payloads).await {
|
||||||
|
Ok(()) => {
|
||||||
|
mark_rows_sent(pool, &ids).await?;
|
||||||
|
clear_account_error(pool, uid).await?;
|
||||||
|
summary.sent += ids.len() as u64;
|
||||||
|
}
|
||||||
|
Err(err) if err.is_invalid_session() => {
|
||||||
|
mark_account_reauth_required(pool, uid, &err.to_string()).await?;
|
||||||
|
mark_rows_blocked(pool, &ids, &err.to_string()).await?;
|
||||||
|
summary.blocked += ids.len() as u64;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
mark_rows_retry_or_failed(pool, &attempt_rows, &err).await?;
|
||||||
|
summary.failed += ids.len() as u64;
|
||||||
|
if err.code == Some(29) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AuthSessionResponse {
|
||||||
|
session: Option<AuthSession>,
|
||||||
|
error: Option<i32>,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AuthSession {
|
||||||
|
name: String,
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ErrorEnvelope {
|
||||||
|
error: Option<i32>,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct PendingScrobbleRow {
|
||||||
|
id: i64,
|
||||||
|
started_at: i64,
|
||||||
|
duration_seconds: i32,
|
||||||
|
attempt_count: i32,
|
||||||
|
session_key: String,
|
||||||
|
title: String,
|
||||||
|
artist_name: Option<String>,
|
||||||
|
album_title: Option<String>,
|
||||||
|
album_artist_name: Option<String>,
|
||||||
|
track_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingScrobbleRow {
|
||||||
|
fn payload(&self) -> Option<LastfmScrobblePayload> {
|
||||||
|
let artist = self
|
||||||
|
.artist_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())?
|
||||||
|
.to_string();
|
||||||
|
Some(LastfmScrobblePayload {
|
||||||
|
track: LastfmTrackPayload {
|
||||||
|
artist,
|
||||||
|
track: self.title.clone(),
|
||||||
|
album: non_empty(self.album_title.as_deref()),
|
||||||
|
album_artist: non_empty(self.album_artist_name.as_deref()),
|
||||||
|
track_number: self.track_number,
|
||||||
|
duration_seconds: Some(self.duration_seconds),
|
||||||
|
},
|
||||||
|
timestamp: self.started_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: Option<&str>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_optional(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
params.push((key.to_string(), value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_optional_i32(params: &mut Vec<(String, String)>, key: &str, value: Option<i32>) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
params.push((key.to_string(), value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_lastfm_error(body: &str) -> Result<(), LastfmApiError> {
|
||||||
|
if let Some(err) = parse_error(body) {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_error(body: &str) -> Option<LastfmApiError> {
|
||||||
|
let envelope: ErrorEnvelope = serde_json::from_str(body).ok()?;
|
||||||
|
envelope
|
||||||
|
.error
|
||||||
|
.map(|code| LastfmApiError::new(Some(code), envelope.message.unwrap_or_default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_user_ids(pool: &PgPool, user_id: Option<i64>) -> anyhow::Result<Vec<i64>> {
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
return Ok(vec![user_id]);
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT DISTINCT o.user_id
|
||||||
|
FROM furumusic__lastfm_scrobble_outbox o
|
||||||
|
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
||||||
|
WHERE o.status IN ('pending', 'retry')
|
||||||
|
AND a.reauth_required = false
|
||||||
|
ORDER BY o.user_id"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_pending_scrobbles(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> anyhow::Result<Vec<PendingScrobbleRow>> {
|
||||||
|
let rows = sqlx::query_as::<_, PendingScrobbleRow>(
|
||||||
|
r#"SELECT o.id,
|
||||||
|
o.started_at,
|
||||||
|
o.duration_seconds,
|
||||||
|
o.attempt_count,
|
||||||
|
a.session_key::text AS session_key,
|
||||||
|
t.title::text AS title,
|
||||||
|
r.title::text AS album_title,
|
||||||
|
t.track_number,
|
||||||
|
(
|
||||||
|
SELECT ar.name::text
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||||
|
ORDER BY ta.position
|
||||||
|
LIMIT 1
|
||||||
|
) AS artist_name,
|
||||||
|
(
|
||||||
|
SELECT ar.name::text
|
||||||
|
FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__artist ar ON ar.id = ra.artist_id
|
||||||
|
WHERE ra.release_id = r.id
|
||||||
|
ORDER BY ra.position
|
||||||
|
LIMIT 1
|
||||||
|
) AS album_artist_name
|
||||||
|
FROM furumusic__lastfm_scrobble_outbox o
|
||||||
|
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
||||||
|
JOIN furumusic__track t ON t.id = o.track_id
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE o.user_id = $1
|
||||||
|
AND o.status IN ('pending', 'retry')
|
||||||
|
AND a.reauth_required = false
|
||||||
|
ORDER BY o.created_at, o.id
|
||||||
|
LIMIT $2"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_account_error(pool: &PgPool, user_id: i64) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__lastfm_account
|
||||||
|
SET last_error = NULL,
|
||||||
|
reauth_required = false,
|
||||||
|
updated_at = $2
|
||||||
|
WHERE user_id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_account_reauth_required(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
error: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__lastfm_account
|
||||||
|
SET reauth_required = true,
|
||||||
|
last_error = $2,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE user_id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(error)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_rows_sent(pool: &PgPool, ids: &[i64]) -> anyhow::Result<()> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let now = now_iso();
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__lastfm_scrobble_outbox
|
||||||
|
SET status = 'sent',
|
||||||
|
updated_at = $2,
|
||||||
|
scrobbled_at = $2,
|
||||||
|
last_error = NULL
|
||||||
|
WHERE id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_row_failed(pool: &PgPool, id: i64, error: &str) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__lastfm_scrobble_outbox
|
||||||
|
SET status = 'failed',
|
||||||
|
attempt_count = attempt_count + 1,
|
||||||
|
last_error = $2,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(error)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_rows_blocked(pool: &PgPool, ids: &[i64], error: &str) -> anyhow::Result<()> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__lastfm_scrobble_outbox
|
||||||
|
SET status = 'blocked',
|
||||||
|
attempt_count = attempt_count + 1,
|
||||||
|
last_error = $2,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.bind(error)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_rows_retry_or_failed(
|
||||||
|
pool: &PgPool,
|
||||||
|
rows: &[(i64, i32)],
|
||||||
|
error: &LastfmApiError,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ids: Vec<i64> = rows.iter().map(|(id, _)| *id).collect();
|
||||||
|
let next_status = if error.is_retryable()
|
||||||
|
&& rows
|
||||||
|
.iter()
|
||||||
|
.any(|(_, attempt_count)| attempt_count + 1 < MAX_ATTEMPTS)
|
||||||
|
{
|
||||||
|
"retry"
|
||||||
|
} else {
|
||||||
|
"failed"
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE furumusic__lastfm_scrobble_outbox
|
||||||
|
SET status = CASE
|
||||||
|
WHEN attempt_count + 1 >= $2 THEN 'failed'
|
||||||
|
ELSE $3
|
||||||
|
END,
|
||||||
|
attempt_count = attempt_count + 1,
|
||||||
|
last_error = $4,
|
||||||
|
updated_at = $5
|
||||||
|
WHERE id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(&ids)
|
||||||
|
.bind(MAX_ATTEMPTS)
|
||||||
|
.bind(next_status)
|
||||||
|
.bind(error.to_string())
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
+224
-11
@@ -3,8 +3,12 @@ mod agent;
|
|||||||
mod api;
|
mod api;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod federation;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod jobs;
|
mod jobs;
|
||||||
|
mod lastfm;
|
||||||
|
mod media_paths;
|
||||||
|
mod metrics;
|
||||||
mod music;
|
mod music;
|
||||||
mod oidc;
|
mod oidc;
|
||||||
mod player;
|
mod player;
|
||||||
@@ -26,7 +30,7 @@ use cot::form::{Form, FormResult};
|
|||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
use cot::project::RegisterAppsContext;
|
use cot::project::RegisterAppsContext;
|
||||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
use cot::request::extractors::{Path, RequestForm, UrlQuery};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::get;
|
use cot::router::method::get;
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
@@ -49,10 +53,11 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
||||||
registry.register(jobs::inbox_process::InboxProcessJob);
|
registry.register(jobs::inbox_process::InboxProcessJob);
|
||||||
registry.register(jobs::inbox_process::FileProcessJob);
|
registry.register(jobs::inbox_process::FileProcessJob);
|
||||||
registry.register(jobs::cover_backfill::CoverBackfillJob);
|
registry.register(jobs::archive_cleanup::ArchiveCleanupJob);
|
||||||
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
|
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
|
||||||
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
|
|
||||||
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
||||||
|
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
||||||
|
registry.register(jobs::lastfm_scrobble::LastfmScrobbleJob);
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +65,55 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result<cot::response::Response> {
|
#[derive(Deserialize)]
|
||||||
|
struct IndexQuery {
|
||||||
|
track: Option<i64>,
|
||||||
|
release: Option<i64>,
|
||||||
|
playlist_share: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
i18n: I18n,
|
||||||
|
UrlQuery(query): UrlQuery<IndexQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
let _user = match auth::get_session_user(&session, &db).await {
|
let _user = match auth::get_session_user(&session, &db).await {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(auth::redirect("/login")),
|
None => {
|
||||||
|
if let Some(location) = share_query_redirect(&query) {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
}
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let template = player::PlayerPageTemplate { t: i18n.t };
|
let template = player::PlayerPageTemplate { t: i18n.t };
|
||||||
Html::new(template.render()?).into_response()
|
Html::new(template.render()?).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn share_query_redirect(query: &IndexQuery) -> Option<String> {
|
||||||
|
if let Some(track_id) = query.track.filter(|id| *id > 0) {
|
||||||
|
return Some(format!("/?track={track_id}"));
|
||||||
|
}
|
||||||
|
if let Some(release_id) = query.release.filter(|id| *id > 0) {
|
||||||
|
return Some(format!("/?release={release_id}"));
|
||||||
|
}
|
||||||
|
let token = query.playlist_share.as_deref()?.trim();
|
||||||
|
if is_share_token(token) {
|
||||||
|
Some(format!("/?playlist_share={token}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_share_token(token: &str) -> bool {
|
||||||
|
!token.is_empty()
|
||||||
|
&& token.len() <= 64
|
||||||
|
&& token
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SetLangQuery {
|
struct SetLangQuery {
|
||||||
lang: String,
|
lang: String,
|
||||||
@@ -128,6 +173,21 @@ struct LoginForm {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginQuery {
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathId {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathToken {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logout
|
// Logout
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -137,12 +197,93 @@ async fn logout_handler(session: Session) -> cot::Result<cot::response::Response
|
|||||||
Ok(auth::redirect("/login"))
|
Ok(auth::redirect("/login"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn metrics_handler(
|
||||||
|
config: Arc<AppConfig>,
|
||||||
|
pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>>,
|
||||||
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
|
if config.database_url.is_empty() {
|
||||||
|
return Ok(cot::http::Response::builder()
|
||||||
|
.status(cot::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||||
|
.header(cot::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")
|
||||||
|
.body(Body::fixed("furumusic_metrics_unavailable 1\n"))
|
||||||
|
.expect("valid response"));
|
||||||
|
}
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(2)
|
||||||
|
.connect(&config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("metrics pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let body = metrics::render(pg_pool, &config).await;
|
||||||
|
Ok(cot::http::Response::builder()
|
||||||
|
.status(cot::http::StatusCode::OK)
|
||||||
|
.header(cot::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")
|
||||||
|
.body(Body::fixed(body))
|
||||||
|
.expect("valid response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_track_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let location = if path.id > 0 {
|
||||||
|
format!("/?track={}", path.id)
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_release_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let location = if path.id > 0 {
|
||||||
|
format!("/?release={}", path.id)
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn share_playlist_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<SharePathToken>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let token = path.token.trim();
|
||||||
|
let location = if is_share_token(token) {
|
||||||
|
format!("/?playlist_share={token}")
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
if auth::get_session_user(&session, &db).await.is_none() {
|
||||||
|
auth::remember_post_login_redirect(&session, &location).await?;
|
||||||
|
return Ok(auth::redirect("/login"));
|
||||||
|
}
|
||||||
|
Ok(auth::redirect(&location))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
struct FuruApp {
|
struct FuruApp {
|
||||||
config: Arc<AppConfig>,
|
config: Arc<AppConfig>,
|
||||||
|
pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App for FuruApp {
|
impl App for FuruApp {
|
||||||
@@ -164,23 +305,52 @@ impl App for FuruApp {
|
|||||||
),
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/",
|
"/",
|
||||||
|session: Session, db: Database, i18n: I18n| async move {
|
|session: Session, db: Database, i18n: I18n, query: UrlQuery<IndexQuery>| async move {
|
||||||
index(session, db, i18n).await
|
index(session, db, i18n, query).await
|
||||||
},
|
},
|
||||||
"index",
|
"index",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/track/{id}",
|
||||||
|
get(share_track_handler),
|
||||||
|
"share_track",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/release/{id}",
|
||||||
|
get(share_release_handler),
|
||||||
|
"share_release",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/share/playlist/{token}",
|
||||||
|
get(share_playlist_handler),
|
||||||
|
"share_playlist",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/metrics",
|
||||||
|
get({
|
||||||
|
let config = Arc::clone(&self.config);
|
||||||
|
let pool = Arc::clone(&self.pool);
|
||||||
|
move || {
|
||||||
|
let config = Arc::clone(&config);
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
async move { metrics_handler(config, pool).await }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"metrics",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/login",
|
"/login",
|
||||||
get({
|
get({
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
move |i18n: I18n, db: Database| {
|
move |i18n: I18n, db: Database, query: UrlQuery<LoginQuery>| {
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
async move {
|
async move {
|
||||||
// No users at all → redirect to first-run setup
|
// No users at all → redirect to first-run setup
|
||||||
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
||||||
return Ok(auth::redirect("/admin/setup"));
|
return Ok(auth::redirect("/admin/setup"));
|
||||||
}
|
}
|
||||||
login_page_handler(i18n, &config, db, String::new())
|
let message = query.0.error.unwrap_or_default();
|
||||||
|
login_page_handler(i18n, &config, db, message)
|
||||||
.await?
|
.await?
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -198,6 +368,11 @@ impl App for FuruApp {
|
|||||||
let data = match result {
|
let data = match result {
|
||||||
FormResult::Ok(data) => data,
|
FormResult::Ok(data) => data,
|
||||||
FormResult::ValidationError(_) => {
|
FormResult::ValidationError(_) => {
|
||||||
|
metrics::record_auth_attempt(
|
||||||
|
"password",
|
||||||
|
"failure",
|
||||||
|
"validation_error",
|
||||||
|
);
|
||||||
let msg = i18n.t.login_invalid.to_owned();
|
let msg = i18n.t.login_invalid.to_owned();
|
||||||
return login_page_handler(i18n, &config, db, msg)
|
return login_page_handler(i18n, &config, db, msg)
|
||||||
.await?
|
.await?
|
||||||
@@ -205,6 +380,15 @@ impl App for FuruApp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !live_config.auth_password_enabled {
|
||||||
|
metrics::record_auth_attempt("password", "failure", "disabled");
|
||||||
|
let msg = i18n.t.login_disabled.to_owned();
|
||||||
|
return login_page_handler(i18n, &config, db, msg)
|
||||||
|
.await?
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// Try to authenticate
|
// Try to authenticate
|
||||||
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
||||||
{
|
{
|
||||||
@@ -213,14 +397,24 @@ impl App for FuruApp {
|
|||||||
match hash.verify(&password) {
|
match hash.verify(&password) {
|
||||||
PasswordVerificationResult::Ok
|
PasswordVerificationResult::Ok
|
||||||
| PasswordVerificationResult::OkObsolete(_) => {
|
| PasswordVerificationResult::OkObsolete(_) => {
|
||||||
|
let redirect_to =
|
||||||
|
auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
auth::login(&session, user.id_val()).await?;
|
auth::login(&session, user.id_val()).await?;
|
||||||
return Ok(auth::redirect("/"));
|
auth::clear_post_login_redirect(&session).await?;
|
||||||
|
metrics::record_auth_attempt(
|
||||||
|
"password", "success", "ok",
|
||||||
|
);
|
||||||
|
metrics::record_session_created("password");
|
||||||
|
return Ok(auth::redirect(&redirect_to));
|
||||||
}
|
}
|
||||||
PasswordVerificationResult::Invalid => {}
|
PasswordVerificationResult::Invalid => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metrics::record_auth_attempt("password", "failure", "bad_credentials");
|
||||||
let msg = i18n.t.login_invalid.to_owned();
|
let msg = i18n.t.login_invalid.to_owned();
|
||||||
login_page_handler(i18n, &config, db, msg)
|
login_page_handler(i18n, &config, db, msg)
|
||||||
.await?
|
.await?
|
||||||
@@ -242,6 +436,16 @@ impl App for FuruApp {
|
|||||||
get(oidc::oidc_callback_handler),
|
get(oidc::oidc_callback_handler),
|
||||||
"oidc_callback",
|
"oidc_callback",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/auth/mobile/oidc/start",
|
||||||
|
get(oidc::oidc_mobile_start_handler),
|
||||||
|
"mobile_oidc_start",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/auth/mobile/oidc/callback",
|
||||||
|
get(oidc::oidc_mobile_callback_handler),
|
||||||
|
"mobile_oidc_callback",
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +534,7 @@ impl Project for FuruProject {
|
|||||||
context: &cot::project::MiddlewareContext,
|
context: &cot::project::MiddlewareContext,
|
||||||
) -> cot::project::RootHandler {
|
) -> cot::project::RootHandler {
|
||||||
handler
|
handler
|
||||||
|
.middleware(metrics::MetricsLayer)
|
||||||
.middleware(StaticFilesMiddleware::from_context(context))
|
.middleware(StaticFilesMiddleware::from_context(context))
|
||||||
.middleware(SessionMiddleware::from_context(context))
|
.middleware(SessionMiddleware::from_context(context))
|
||||||
.build()
|
.build()
|
||||||
@@ -355,10 +560,18 @@ impl Project for FuruProject {
|
|||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Join the federation at boot when it was left enabled (the settings
|
||||||
|
// live in the config KV table; changes apply live from the admin).
|
||||||
|
let fed_config = Arc::clone(&self.app_config);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
federation::handle().boot(&fed_config).await;
|
||||||
|
});
|
||||||
|
|
||||||
apps.register(cot::session::db::SessionApp::new());
|
apps.register(cot::session::db::SessionApp::new());
|
||||||
apps.register_with_views(
|
apps.register_with_views(
|
||||||
FuruApp {
|
FuruApp {
|
||||||
config: Arc::clone(&self.app_config),
|
config: Arc::clone(&self.app_config),
|
||||||
|
pool: Arc::new(tokio::sync::OnceCell::new()),
|
||||||
},
|
},
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn resolve_config_path(value: &str) -> String {
|
||||||
|
let path = resolve_config_path_buf(value);
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
path.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_config_path_buf(value: &str) -> PathBuf {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return PathBuf::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = normalize_slashes(trimmed);
|
||||||
|
if is_absolute_path(&normalized) {
|
||||||
|
PathBuf::from(normalized)
|
||||||
|
} else {
|
||||||
|
app_root().join(slash_path(&normalized))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_media_file_path(storage_dir: &str, file_path: &str) -> PathBuf {
|
||||||
|
resolve_path_from_root(storage_dir, file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn media_file_path_for_storage(storage_dir: &str, path: &Path) -> Option<String> {
|
||||||
|
path_for_root(storage_dir, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_path_from_root(root_dir: &str, stored_path: &str) -> PathBuf {
|
||||||
|
let normalized = normalize_slashes(stored_path.trim());
|
||||||
|
if is_absolute_path(&normalized) {
|
||||||
|
PathBuf::from(normalized)
|
||||||
|
} else {
|
||||||
|
resolve_config_path_buf(root_dir).join(slash_path(&normalized))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_for_root(root_dir: &str, path: &Path) -> Option<String> {
|
||||||
|
let root = resolve_config_path_buf(root_dir);
|
||||||
|
let normalized = normalize_slashes(&path.to_string_lossy());
|
||||||
|
if is_absolute_path(&normalized) {
|
||||||
|
return strip_root_prefix(&root, &normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
relative_path_string(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn normalize_media_file_paths(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
storage_dir: &str,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
normalize_table_paths(pool, "furumusic__media_file", "file_path", storage_dir).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn normalize_pending_review_paths(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
inbox_dir: &str,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
normalize_table_paths(pool, "furumusic__pending_review", "input_path", inbox_dir).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn normalize_table_paths(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
table: &str,
|
||||||
|
column: &str,
|
||||||
|
root_dir: &str,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let sql = format!("SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL ORDER BY id");
|
||||||
|
let rows: Vec<(i64, String)> = sqlx::query_as(&sql).fetch_all(pool).await?;
|
||||||
|
|
||||||
|
let mut updated = 0;
|
||||||
|
for (id, stored_path) in rows {
|
||||||
|
let Some(normalized) = normalize_stored_path(root_dir, &stored_path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if normalized == stored_path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = format!("UPDATE {table} SET {column} = $1 WHERE id = $2");
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&normalized)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_stored_path(root_dir: &str, stored_path: &str) -> Option<String> {
|
||||||
|
let normalized = normalize_slashes(stored_path);
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_absolute_path(&normalized) {
|
||||||
|
strip_root_prefix(&resolve_config_path_buf(root_dir), &normalized)
|
||||||
|
} else {
|
||||||
|
normalize_relative_path(&normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_root() -> PathBuf {
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_slashes(value: &str) -> String {
|
||||||
|
value.trim().replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_absolute_path(value: &str) -> bool {
|
||||||
|
value.starts_with('/') || Path::new(value).is_absolute() || looks_like_windows_absolute(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_windows_absolute(value: &str) -> bool {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/' && bytes[0].is_ascii_alphabetic()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slash_path(value: &str) -> PathBuf {
|
||||||
|
value
|
||||||
|
.split('/')
|
||||||
|
.filter(|part| !part.is_empty() && *part != ".")
|
||||||
|
.fold(PathBuf::new(), |mut path, part| {
|
||||||
|
path.push(part);
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_relative_path(value: &str) -> Option<String> {
|
||||||
|
let parts: Vec<&str> = value
|
||||||
|
.split('/')
|
||||||
|
.filter(|part| !part.is_empty() && *part != ".")
|
||||||
|
.collect();
|
||||||
|
if parts.is_empty() || parts.iter().any(|part| *part == "..") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(parts.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_root_prefix(root: &Path, normalized_path: &str) -> Option<String> {
|
||||||
|
let root_string = normalize_slashes(&root.to_string_lossy());
|
||||||
|
let root_trimmed = root_string.trim_end_matches('/');
|
||||||
|
let path_trimmed = normalized_path.trim();
|
||||||
|
|
||||||
|
let root_cmp = comparable_path(root_trimmed);
|
||||||
|
let path_cmp = comparable_path(path_trimmed);
|
||||||
|
if path_cmp == root_cmp {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = format!("{root_cmp}/");
|
||||||
|
if path_cmp.starts_with(&prefix) {
|
||||||
|
let tail = &path_trimmed[root_trimmed.len() + 1..];
|
||||||
|
return normalize_relative_path(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comparable_path(value: &str) -> String {
|
||||||
|
let normalized = normalize_slashes(value).trim_end_matches('/').to_owned();
|
||||||
|
if cfg!(windows) || looks_like_windows_absolute(&normalized) {
|
||||||
|
normalized.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_path_string(path: &Path) -> Option<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(value) => parts.push(value.to_string_lossy().to_string()),
|
||||||
|
Component::CurDir => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parts.join("/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_relative_config_path_from_app_root() {
|
||||||
|
let expected = app_root().join("media").join("library");
|
||||||
|
assert_eq!(resolve_config_path_buf("media/library"), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_absolute_config_path() {
|
||||||
|
assert_eq!(resolve_config_path_buf("/media"), PathBuf::from("/media"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_relative_media_file_under_storage_root() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_media_file_path("/media", "Buckethead/Pike/cover.jpg"),
|
||||||
|
PathBuf::from("/media")
|
||||||
|
.join("Buckethead")
|
||||||
|
.join("Pike")
|
||||||
|
.join("cover.jpg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_absolute_media_file_path() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_media_file_path("/media", "/media/Buckethead/Pike/cover.jpg"),
|
||||||
|
PathBuf::from("/media/Buckethead/Pike/cover.jpg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stores_path_relative_to_storage_root() {
|
||||||
|
let storage = app_root().join("media").join("library");
|
||||||
|
let path = storage.join("Artist").join("Album").join("track.flac");
|
||||||
|
assert_eq!(
|
||||||
|
media_file_path_for_storage(&storage.to_string_lossy(), &path).as_deref(),
|
||||||
|
Some("Artist/Album/track.flac")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stores_windows_path_relative_to_windows_storage_root() {
|
||||||
|
assert_eq!(
|
||||||
|
path_for_root(
|
||||||
|
r"C:\Users\ab\repos\furumusic\library",
|
||||||
|
Path::new(r"C:\Users\ab\repos\furumusic\library\Artist\Album\track.mp3"),
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some("Artist/Album/track.mp3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_relative_backslashes() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_stored_path("/media", r"Artist\Album\track.mp3").as_deref(),
|
||||||
|
Some("Artist/Album/track.mp3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1111
File diff suppressed because it is too large
Load Diff
@@ -1578,6 +1578,750 @@ 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()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0033: Last.fm scrobbling -----------------------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_lastfm_scrobbling(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__lastfm_account (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
lastfm_username VARCHAR(255) NOT NULL,
|
||||||
|
session_key TEXT NOT NULL,
|
||||||
|
connected_at VARCHAR(32) NOT NULL,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
last_error TEXT,
|
||||||
|
reauth_required BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__lastfm_auth_state (
|
||||||
|
state VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
created_at VARCHAR(32) NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__lastfm_scrobble_outbox (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
track_id BIGINT NOT NULL,
|
||||||
|
started_at BIGINT NOT NULL,
|
||||||
|
listened_seconds INTEGER NOT NULL,
|
||||||
|
duration_seconds INTEGER NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at VARCHAR(32) NOT NULL,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
scrobbled_at VARCHAR(32),
|
||||||
|
dedupe_key VARCHAR(128) NOT NULL UNIQUE
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_lastfm_scrobble_outbox_status
|
||||||
|
ON furumusic__lastfm_scrobble_outbox (status, created_at, id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_lastfm_scrobble_outbox_user
|
||||||
|
ON furumusic__lastfm_scrobble_outbox (user_id, status, created_at)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0033CreateLastfmScrobbling;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0033CreateLastfmScrobbling {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0033_create_lastfm_scrobbling";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0032_create_lastfm_track_popularity",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_lastfm_scrobbling).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0034: Artwork lookup state --------------------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_artwork_lookup_state(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__artwork_lookup_state (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
entity_kind VARCHAR(32) NOT NULL,
|
||||||
|
entity_id BIGINT NOT NULL,
|
||||||
|
source VARCHAR(32) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_attempt_at VARCHAR(32) NOT NULL,
|
||||||
|
last_error TEXT,
|
||||||
|
source_url TEXT,
|
||||||
|
UNIQUE(entity_kind, entity_id, source)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_artwork_lookup_state_retry
|
||||||
|
ON furumusic__artwork_lookup_state (entity_kind, source, status, last_attempt_at)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0034CreateArtworkLookupState;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0034CreateArtworkLookupState {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0034_create_artwork_lookup_state";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0033_create_lastfm_scrobbling",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_artwork_lookup_state).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0035: Weighted metadata tags for tracks, releases, and artists ----
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_entity_genre_tags(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__entity_genre_tag (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
entity_kind VARCHAR(32) NOT NULL,
|
||||||
|
entity_id BIGINT NOT NULL,
|
||||||
|
genre_id BIGINT NOT NULL,
|
||||||
|
source VARCHAR(32) NOT NULL,
|
||||||
|
weight DOUBLE PRECISION NOT NULL DEFAULT 1,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
UNIQUE(entity_kind, entity_id, genre_id, source)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_entity
|
||||||
|
ON furumusic__entity_genre_tag (entity_kind, entity_id, source)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_genre
|
||||||
|
ON furumusic__entity_genre_tag (genre_id, entity_kind)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0035CreateEntityGenreTags;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0035CreateEntityGenreTags {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0035_create_entity_genre_tags";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0034_create_artwork_lookup_state",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_entity_genre_tags).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0036: External metadata identifiers --------------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_external_metadata_ids(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__external_metadata_id (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
entity_kind VARCHAR(32) NOT NULL,
|
||||||
|
entity_id BIGINT NOT NULL,
|
||||||
|
source VARCHAR(32) NOT NULL,
|
||||||
|
id_kind VARCHAR(64) NOT NULL,
|
||||||
|
external_id VARCHAR(255) NOT NULL,
|
||||||
|
confidence DOUBLE PRECISION NOT NULL DEFAULT 1,
|
||||||
|
updated_at VARCHAR(32) NOT NULL,
|
||||||
|
UNIQUE(entity_kind, entity_id, source, id_kind)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_external_metadata_id_lookup
|
||||||
|
ON furumusic__external_metadata_id (source, id_kind, external_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0036CreateExternalMetadataIds;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0036CreateExternalMetadataIds {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0036_create_external_metadata_ids";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0035_create_entity_genre_tags",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_external_metadata_ids).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0037: Shared playlist snapshots ------------------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_playlist_share_links(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__playlist_share_link (
|
||||||
|
token VARCHAR(64) PRIMARY KEY,
|
||||||
|
creator_user_id BIGINT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
track_ids_json TEXT NOT NULL,
|
||||||
|
created_at VARCHAR(32) NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_playlist_share_link_creator
|
||||||
|
ON furumusic__playlist_share_link (creator_user_id, created_at DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0037CreatePlaylistShareLinks;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0037CreatePlaylistShareLinks {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0037_create_playlist_share_links";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0036_create_external_metadata_ids",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_playlist_share_links).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_fed_device_sync(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_identity (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
device_id TEXT NOT NULL UNIQUE,
|
||||||
|
group_id TEXT NOT NULL,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
local_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_hlc_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
local_seeded_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_sync TEXT,
|
||||||
|
last_error TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
client_version TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
endpoint_id TEXT NOT NULL DEFAULT '',
|
||||||
|
endpoint_ticket TEXT NOT NULL DEFAULT '',
|
||||||
|
trusted_at_ms BIGINT,
|
||||||
|
last_seen_ms BIGINT,
|
||||||
|
revoked_at_ms BIGINT,
|
||||||
|
revoked_by TEXT,
|
||||||
|
revoke_cutoff_seq BIGINT,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_device_single_user
|
||||||
|
ON furumusic__fed_device (device_id)
|
||||||
|
WHERE trusted_at_ms IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_invite (
|
||||||
|
invite_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
secret_hash TEXT NOT NULL,
|
||||||
|
expires_at_ms BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
used_at_ms BIGINT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_pending_pairing (
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
client_version TEXT NOT NULL,
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
endpoint_ticket TEXT NOT NULL,
|
||||||
|
invite_id TEXT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
answered_at_ms BIGINT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
requester_group_id TEXT,
|
||||||
|
requester_group_active_devices BIGINT NOT NULL DEFAULT 1,
|
||||||
|
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
use_requester_group BOOLEAN NOT NULL DEFAULT false
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_pending_pairing_user_status
|
||||||
|
ON furumusic__fed_pending_pairing (user_id, status, created_at_ms DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_ops (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
received_at_ms BIGINT NOT NULL,
|
||||||
|
tombstone BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_origin_seq
|
||||||
|
ON furumusic__fed_sync_ops (user_id, origin_device_id, seq)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_tombstone
|
||||||
|
ON furumusic__fed_sync_ops (user_id, tombstone)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_vector (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_peer_ack (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
peer_device_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, peer_device_id, origin_device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_like (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
liked BOOLEAN NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
local_playlist_id BIGINT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, playlist_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_state_playlist_local
|
||||||
|
ON furumusic__fed_state_playlist (user_id, local_playlist_id)
|
||||||
|
WHERE local_playlist_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist_item (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
present BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
position BIGINT NOT NULL DEFAULT 0,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, playlist_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_state_playlist_item_playlist
|
||||||
|
ON furumusic__fed_state_playlist_item
|
||||||
|
(user_id, playlist_id, present, position)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_playback_applied (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
applied_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0038CreateFedDeviceSync;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0038CreateFedDeviceSync {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0038_create_fed_device_sync";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0037_create_playlist_share_links",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_fed_device_sync).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn ensure_federation_content_id_cache(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0039EnsureFederationContentIdCache;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0039EnsureFederationContentIdCache {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0039_ensure_federation_content_id_cache";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0038_create_fed_device_sync",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(ensure_federation_content_id_cache).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_content_addressed_music_refs(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
// A track reference is durable user-facing identity. `local_track_id`
|
||||||
|
// is availability, not identity: it may become non-NULL after a
|
||||||
|
// federated track is materialized without changing likes/playlists.
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__track_ref (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
content_id TEXT NOT NULL UNIQUE,
|
||||||
|
local_track_id BIGINT UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
release_title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
duration_seconds DOUBLE PRECISION,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
metadata_authority TEXT NOT NULL DEFAULT 'local',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_track_ref_local_track
|
||||||
|
ON furumusic__track_ref (local_track_id)
|
||||||
|
WHERE local_track_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_track_source (
|
||||||
|
track_ref_id BIGINT NOT NULL REFERENCES furumusic__track_ref(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
owner_peer_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
last_seen_ms BIGINT NOT NULL,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (owner_peer_id, item_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_federation_track_source_ref
|
||||||
|
ON furumusic__federation_track_source (track_ref_id, last_seen_ms DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__user_liked_track
|
||||||
|
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||||
|
REFERENCES furumusic__track_ref(id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_liked_track_ref_uniq
|
||||||
|
ON furumusic__user_liked_track (user_id, track_ref_id)
|
||||||
|
WHERE track_ref_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__playlist_track
|
||||||
|
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||||
|
REFERENCES furumusic__track_ref(id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_playlist_track_ref
|
||||||
|
ON furumusic__playlist_track (track_ref_id)
|
||||||
|
WHERE track_ref_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// History deliberately remains local-track based. Only the web
|
||||||
|
// player's existing playback report records history and triggers
|
||||||
|
// Last.fm scrobbling.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0040CreateContentAddressedMusicRefs;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0040CreateContentAddressedMusicRefs {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0040_create_content_addressed_music_refs";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0039_ensure_federation_content_id_cache",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_content_addressed_music_refs).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1599,5 +2343,15 @@ pub mod db_migrations {
|
|||||||
&M0028AddModelNameColumns,
|
&M0028AddModelNameColumns,
|
||||||
&M0029AddPlaybackVolume,
|
&M0029AddPlaybackVolume,
|
||||||
&M0030AddMediaFileUploader,
|
&M0030AddMediaFileUploader,
|
||||||
|
&M0031CreateTorrentSession,
|
||||||
|
&M0032CreateLastfmTrackPopularity,
|
||||||
|
&M0033CreateLastfmScrobbling,
|
||||||
|
&M0034CreateArtworkLookupState,
|
||||||
|
&M0035CreateEntityGenreTags,
|
||||||
|
&M0036CreateExternalMetadataIds,
|
||||||
|
&M0037CreatePlaylistShareLinks,
|
||||||
|
&M0038CreateFedDeviceSync,
|
||||||
|
&M0039EnsureFederationContentIdCache,
|
||||||
|
&M0040CreateContentAddressedMusicRefs,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+693
-9
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
|
use cot::request::extractors::UrlQuery;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
@@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce";
|
|||||||
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
||||||
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
||||||
|
|
||||||
|
const SESSION_MOBILE_CSRF_STATE: &str = "mobile_oidc_csrf_state";
|
||||||
|
const SESSION_MOBILE_NONCE: &str = "mobile_oidc_nonce";
|
||||||
|
const SESSION_MOBILE_PKCE_VERIFIER: &str = "mobile_oidc_pkce_verifier";
|
||||||
|
const SESSION_MOBILE_PROVIDER_REDIRECT_URI: &str = "mobile_oidc_provider_redirect_uri";
|
||||||
|
const SESSION_MOBILE_APP_REDIRECT_URI: &str = "mobile_oidc_app_redirect_uri";
|
||||||
|
const DEFAULT_MOBILE_REDIRECT_URI: &str = "furumi://auth/callback";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider cache
|
// Provider cache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -172,6 +180,7 @@ pub async fn oidc_start_handler(
|
|||||||
|| config.oidc_client_secret.is_empty()
|
|| config.oidc_client_secret.is_empty()
|
||||||
{
|
{
|
||||||
tracing::warn!("OIDC start requested but SSO is not configured");
|
tracing::warn!("OIDC start requested but SSO is not configured");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "not_configured");
|
||||||
return redirect_login_with_error(i18n.t.login_sso_disabled);
|
return redirect_login_with_error(i18n.t.login_sso_disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +189,7 @@ pub async fn oidc_start_handler(
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("OIDC provider error: {e}");
|
tracing::error!("OIDC provider error: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "provider_error");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -245,13 +255,23 @@ pub struct OidcCallbackQuery {
|
|||||||
state: String,
|
state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MobileOidcStartQuery {
|
||||||
|
redirect_uri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MobileOidcCallbackQuery {
|
||||||
|
code: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn oidc_callback_handler(
|
pub async fn oidc_callback_handler(
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
db: Database,
|
db: Database,
|
||||||
session: Session,
|
session: Session,
|
||||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
|
UrlQuery(query): UrlQuery<OidcCallbackQuery>,
|
||||||
OidcCallbackQuery,
|
|
||||||
>,
|
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
@@ -276,19 +296,23 @@ pub async fn oidc_callback_handler(
|
|||||||
// Validate CSRF state.
|
// Validate CSRF state.
|
||||||
let Some(saved_csrf) = saved_csrf else {
|
let Some(saved_csrf) = saved_csrf else {
|
||||||
tracing::warn!("OIDC callback: no CSRF state in session");
|
tracing::warn!("OIDC callback: no CSRF state in session");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "missing_state");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
};
|
};
|
||||||
if query.state != saved_csrf {
|
if query.state != saved_csrf {
|
||||||
tracing::warn!("OIDC callback: CSRF state mismatch");
|
tracing::warn!("OIDC callback: CSRF state mismatch");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "csrf");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(nonce_str) = saved_nonce else {
|
let Some(nonce_str) = saved_nonce else {
|
||||||
tracing::warn!("OIDC callback: no nonce in session");
|
tracing::warn!("OIDC callback: no nonce in session");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "missing_nonce");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
};
|
};
|
||||||
let Some(pkce_str) = saved_pkce else {
|
let Some(pkce_str) = saved_pkce else {
|
||||||
tracing::warn!("OIDC callback: no PKCE verifier in session");
|
tracing::warn!("OIDC callback: no PKCE verifier in session");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "missing_pkce");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,6 +324,7 @@ pub async fn oidc_callback_handler(
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("OIDC provider error during callback: {e}");
|
tracing::error!("OIDC provider error during callback: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "provider_error");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -318,6 +343,7 @@ pub async fn oidc_callback_handler(
|
|||||||
Ok(req) => req,
|
Ok(req) => req,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("OIDC token endpoint not configured: {e}");
|
tracing::error!("OIDC token endpoint not configured: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "token_config");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -330,6 +356,7 @@ pub async fn oidc_callback_handler(
|
|||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("OIDC token exchange failed: {e}");
|
tracing::error!("OIDC token exchange failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "token_exchange");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -340,6 +367,7 @@ pub async fn oidc_callback_handler(
|
|||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => {
|
None => {
|
||||||
tracing::error!("OIDC response missing ID token");
|
tracing::error!("OIDC response missing ID token");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "missing_id_token");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -348,6 +376,7 @@ pub async fn oidc_callback_handler(
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("OIDC ID token verification failed: {e}");
|
tracing::error!("OIDC ID token verification failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "id_token_verify");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -389,16 +418,13 @@ 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,
|
||||||
config.oidc_admin_groups,
|
config.oidc_admin_groups,
|
||||||
);
|
);
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "not_in_group");
|
||||||
return redirect_login_with_error(i18n.t.login_access_denied);
|
return redirect_login_with_error(i18n.t.login_access_denied);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,12 +443,20 @@ pub async fn oidc_callback_handler(
|
|||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("OIDC user provisioning failed: {e}");
|
tracing::error!("OIDC user provisioning failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "failure", "provisioning");
|
||||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let redirect_to = auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
// Log the user in.
|
// Log the user in.
|
||||||
auth::login(&session, user.id_val()).await?;
|
auth::login(&session, user.id_val()).await?;
|
||||||
|
auth::clear_post_login_redirect(&session).await?;
|
||||||
|
crate::metrics::record_auth_attempt("oidc", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("oidc");
|
||||||
|
|
||||||
// Clear OIDC session keys.
|
// Clear OIDC session keys.
|
||||||
let _: Option<String> = session
|
let _: Option<String> = session
|
||||||
@@ -442,7 +476,297 @@ pub async fn oidc_callback_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(auth::redirect("/"))
|
Ok(auth::redirect(&redirect_to))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile OIDC flow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_start_handler(
|
||||||
|
origin: RequestOrigin,
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcStartQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(app_redirect_uri) = safe_mobile_redirect_uri(query.redirect_uri.as_deref()) else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "bad_redirect_uri");
|
||||||
|
return Ok(text_response(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"invalid mobile redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
tracing::warn!("Mobile OIDC start requested but SSO is not configured");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_redirect_uri = format!("{}/auth/mobile/oidc/callback", origin.0);
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri.clone())
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
let (auth_url, csrf_state, nonce) = client
|
||||||
|
.authorize_url(
|
||||||
|
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
)
|
||||||
|
.add_scope(Scope::new("email".to_string()))
|
||||||
|
.add_scope(Scope::new("profile".to_string()))
|
||||||
|
.set_pkce_challenge(pkce_challenge)
|
||||||
|
.url();
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_CSRF_STATE, csrf_state.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_NONCE, nonce.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_PKCE_VERIFIER, pkce_verifier.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(
|
||||||
|
SESSION_MOBILE_PROVIDER_REDIRECT_URI,
|
||||||
|
provider_redirect_uri.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_APP_REDIRECT_URI, app_redirect_uri)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
auth_url = %auth_url,
|
||||||
|
provider_redirect_uri = %provider_redirect_uri,
|
||||||
|
"Mobile OIDC start: redirecting to provider",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(auth::redirect(auth_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_callback_handler(
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcCallbackQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let app_redirect_uri = mobile_app_redirect_uri_from_session(&session).await?;
|
||||||
|
|
||||||
|
if query.error.is_some() {
|
||||||
|
tracing::warn!("Mobile OIDC callback returned provider error");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_denied");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(code) = query.code else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_code");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_code"));
|
||||||
|
};
|
||||||
|
let Some(state) = query.state else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let saved_csrf: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_nonce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_pkce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let provider_redirect_uri: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(saved_csrf) = saved_csrf else {
|
||||||
|
tracing::warn!("Mobile OIDC callback: no CSRF state in session");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
if state != saved_csrf {
|
||||||
|
tracing::warn!("Mobile OIDC callback: CSRF state mismatch");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "csrf");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "csrf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(nonce_str) = saved_nonce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_nonce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_nonce"));
|
||||||
|
};
|
||||||
|
let Some(pkce_str) = saved_pkce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_pkce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_pkce"));
|
||||||
|
};
|
||||||
|
let Some(provider_redirect_uri) = provider_redirect_uri else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_redirect_uri");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"missing_redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error during callback: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri)
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI from session: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let token_request = match client.exchange_code(AuthorizationCode::new(code)) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token endpoint not configured: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_config");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let token_response = token_request
|
||||||
|
.set_pkce_verifier(PkceCodeVerifier::new(pkce_str))
|
||||||
|
.request_async(&http)
|
||||||
|
.await;
|
||||||
|
let token_response = match token_response {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token exchange failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_exchange");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use openidconnect::TokenResponse;
|
||||||
|
let id_token = match token_response.id_token() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
tracing::error!("Mobile OIDC response missing ID token");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_id_token");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nonce = Nonce::new(nonce_str);
|
||||||
|
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC ID token verification failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "id_token_verify");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sub = claims.subject().to_string();
|
||||||
|
let issuer = claims.issuer().to_string();
|
||||||
|
let email = claims.email().map(|e| e.to_string());
|
||||||
|
let name = claims
|
||||||
|
.name()
|
||||||
|
.and_then(|n| n.get(None))
|
||||||
|
.map(|n| n.to_string());
|
||||||
|
let groups = extract_groups_from_jwt(&id_token.to_string());
|
||||||
|
|
||||||
|
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Mobile OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||||
|
config.oidc_user_groups,
|
||||||
|
config.oidc_admin_groups,
|
||||||
|
);
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_in_group");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "access_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = match provision_user(
|
||||||
|
&db,
|
||||||
|
&issuer,
|
||||||
|
&sub,
|
||||||
|
email.as_deref(),
|
||||||
|
name.as_deref(),
|
||||||
|
&groups,
|
||||||
|
&config.oidc_admin_groups,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC user provisioning failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provisioning");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let exchange_code = auth::create_mobile_exchange_code(&db, user.id_val())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("mobile_oidc");
|
||||||
|
Ok(mobile_redirect_success(&app_redirect_uri, &exchange_code))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -600,6 +924,307 @@ fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Respon
|
|||||||
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_response(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||||
|
cot::http::Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(cot::Body::fixed(message.to_owned()))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mobile_app_redirect_uri_from_session(session: &Session) -> cot::Result<String> {
|
||||||
|
let saved: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(safe_mobile_redirect_uri(saved.as_deref())
|
||||||
|
.unwrap_or_else(|| DEFAULT_MOBILE_REDIRECT_URI.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_mobile_oidc_session(session: &Session) -> cot::Result<()> {
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
|
||||||
|
let value = raw
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_MOBILE_REDIRECT_URI);
|
||||||
|
if value.len() > 2048 || value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = value.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
|
if is_loopback_http_redirect(&lower) {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 8252 §7.3: native apps without a custom URL scheme (the CLI client)
|
||||||
|
/// receive the callback on a loopback listener with an ephemeral port.
|
||||||
|
fn is_loopback_http_redirect(lower: &str) -> bool {
|
||||||
|
let Some(rest) = lower.strip_prefix("http://") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
|
||||||
|
let Some((host, port)) = host_port.rsplit_once(':') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
|
||||||
|
&& !port.is_empty()
|
||||||
|
&& port.len() <= 5
|
||||||
|
&& port.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
|
||||||
|
let deep_link = append_query_param(app_redirect_uri, "code", code);
|
||||||
|
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
|
||||||
|
return auth::redirect(&deep_link);
|
||||||
|
}
|
||||||
|
mobile_deep_link_page(
|
||||||
|
"success",
|
||||||
|
"Sign-in complete",
|
||||||
|
"Furumi should open automatically. If it doesn't, use the button or copy the code below.",
|
||||||
|
None,
|
||||||
|
Some(code),
|
||||||
|
&deep_link,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
|
||||||
|
let deep_link = append_query_param(app_redirect_uri, "error", error);
|
||||||
|
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
|
||||||
|
return auth::redirect(&deep_link);
|
||||||
|
}
|
||||||
|
mobile_deep_link_page(
|
||||||
|
"error",
|
||||||
|
"Sign-in failed",
|
||||||
|
"Furumi should open automatically and show the sign-in error.",
|
||||||
|
Some(error),
|
||||||
|
None,
|
||||||
|
&deep_link,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_deep_link_page(
|
||||||
|
state: &str,
|
||||||
|
title: &str,
|
||||||
|
message: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
code: Option<&str>,
|
||||||
|
deep_link: &str,
|
||||||
|
) -> cot::response::Response {
|
||||||
|
let state_class = html_escape(state);
|
||||||
|
let title_html = html_escape(title);
|
||||||
|
let message_html = html_escape(message);
|
||||||
|
let detail_html = detail
|
||||||
|
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let code_html = code
|
||||||
|
.map(|value| {
|
||||||
|
format!(
|
||||||
|
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
|
||||||
|
<input class="code" readonly value="{}" onclick="this.select()">"#,
|
||||||
|
html_escape(value)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let deep_link_html = html_escape(deep_link);
|
||||||
|
let deep_link_js =
|
||||||
|
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
|
||||||
|
|
||||||
|
let html = format!(
|
||||||
|
r#"<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{title_html}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #101114;
|
||||||
|
color: #f5f2ea;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
main {{
|
||||||
|
width: min(420px, 100%);
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.mark {{
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #2f7d52;
|
||||||
|
color: white;
|
||||||
|
}}
|
||||||
|
.mark.error {{
|
||||||
|
background: #9d3d42;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #c9c2b7;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}}
|
||||||
|
.detail {{
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #f1b3b7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}}
|
||||||
|
a {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e8d8a8;
|
||||||
|
color: #17150f;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}}
|
||||||
|
.hint {{
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #89847c;
|
||||||
|
}}
|
||||||
|
.code {{
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #3a3c42;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1c20;
|
||||||
|
color: #e8d8a8;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="mark {state_class}" aria-hidden="true">{mark}</div>
|
||||||
|
<h1>{title_html}</h1>
|
||||||
|
<p>{message_html}</p>
|
||||||
|
{detail_html}
|
||||||
|
<a href="{deep_link_html}">Open Furumi</a>
|
||||||
|
<p class="hint">If nothing happens, use the button above.</p>
|
||||||
|
{code_html}
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const deepLink = {deep_link_js};
|
||||||
|
window.setTimeout(() => {{
|
||||||
|
window.location.href = deepLink;
|
||||||
|
}}, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
mark = if state == "error" { "!" } else { "OK" }
|
||||||
|
);
|
||||||
|
|
||||||
|
cot::http::Response::builder()
|
||||||
|
.status(cot::http::StatusCode::OK)
|
||||||
|
.header(cot::http::header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.header(cot::http::header::CACHE_CONTROL, "no-store")
|
||||||
|
.body(cot::Body::fixed(html))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_query_param(uri: &str, key: &str, value: &str) -> String {
|
||||||
|
let (base, fragment) = uri.split_once('#').unwrap_or((uri, ""));
|
||||||
|
let separator = if base.contains('?') { '&' } else { '?' };
|
||||||
|
let mut out = format!("{base}{separator}{key}={}", urlencoded(value));
|
||||||
|
if !fragment.is_empty() {
|
||||||
|
out.push('#');
|
||||||
|
out.push_str(fragment);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_escape(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_groups_from_jwt(token: &str) -> Vec<String> {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let Some(payload_b64) = token.split('.').nth(1) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(payload_b64)
|
||||||
|
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(arr) = value.get("groups").and_then(|value| value.as_array()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|value| value.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimal percent-encoding for query parameter values.
|
/// Minimal percent-encoding for query parameter values.
|
||||||
fn urlencoded(s: &str) -> String {
|
fn urlencoded(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len() * 2);
|
let mut out = String::with_capacity(s.len() * 2);
|
||||||
@@ -616,3 +1241,62 @@ fn urlencoded(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_append_query_param_preserves_fragment() {
|
||||||
|
assert_eq!(
|
||||||
|
append_query_param("furumi://auth/callback#done", "code", "a b"),
|
||||||
|
"furumi://auth/callback?code=a%20b#done"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
append_query_param("furumi://auth/callback?desktop=1", "error", "oidc_error"),
|
||||||
|
"furumi://auth/callback?desktop=1&error=oidc_error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_html_escape_escapes_page_values() {
|
||||||
|
assert_eq!(
|
||||||
|
html_escape(r#"<tag attr="x&y">'text'</tag>"#),
|
||||||
|
"<tag attr="x&y">'text'</tag>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_redirect_uri_allows_only_furumi_schemes() {
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("furumi://auth/callback")).as_deref(),
|
||||||
|
Some("furumi://auth/callback")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("furumusic://auth/callback")).as_deref(),
|
||||||
|
Some("furumusic://auth/callback")
|
||||||
|
);
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_oidc_redirect_uri_allows_loopback_http() {
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
|
||||||
|
Some("http://127.0.0.1:8753/callback")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
|
||||||
|
Some("http://localhost:1234/callback")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
|
||||||
|
Some("http://[::1]:1234/callback")
|
||||||
|
);
|
||||||
|
// Non-loopback hosts, missing ports and https stay rejected.
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
|
||||||
|
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+418
-2
@@ -16,6 +16,7 @@ pub(super) struct Paginated<T: Serialize> {
|
|||||||
pub(super) total: i64,
|
pub(super) total: i64,
|
||||||
pub(super) page: i32,
|
pub(super) page: i32,
|
||||||
pub(super) per_page: i32,
|
pub(super) per_page: i32,
|
||||||
|
pub(super) has_more: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -36,25 +37,29 @@ pub(super) struct ArtistDetail {
|
|||||||
pub(super) image_url: Option<String>,
|
pub(super) image_url: Option<String>,
|
||||||
pub(super) total_track_count: i64,
|
pub(super) total_track_count: i64,
|
||||||
pub(super) total_play_count: i64,
|
pub(super) total_play_count: i64,
|
||||||
|
pub(super) top_tracks: Vec<TrackItem>,
|
||||||
pub(super) releases: Vec<ReleaseCard>,
|
pub(super) releases: Vec<ReleaseCard>,
|
||||||
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
|
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
pub(super) struct ArtistRef {
|
pub(super) struct ArtistRef {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) name: String,
|
pub(super) name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
pub(super) struct TrackItem {
|
pub(super) struct TrackItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
|
pub(super) content_id: Option<String>,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
pub(super) disc_number: Option<i32>,
|
pub(super) disc_number: Option<i32>,
|
||||||
pub(super) duration_seconds: f64,
|
pub(super) duration_seconds: f64,
|
||||||
pub(super) artists: Vec<ArtistRef>,
|
pub(super) artists: Vec<ArtistRef>,
|
||||||
pub(super) featured_artists: Vec<ArtistRef>,
|
pub(super) featured_artists: Vec<ArtistRef>,
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) release_title: String,
|
||||||
pub(super) release_year: Option<i32>,
|
pub(super) release_year: Option<i32>,
|
||||||
pub(super) cover_url: Option<String>,
|
pub(super) cover_url: Option<String>,
|
||||||
pub(super) stream_url: String,
|
pub(super) stream_url: String,
|
||||||
@@ -64,6 +69,17 @@ 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)]
|
||||||
|
pub(super) struct PlaylistTrackItem {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) track: TrackItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -84,6 +100,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)]
|
||||||
@@ -127,6 +147,161 @@ pub(super) struct PlaybackStateDto {
|
|||||||
pub(super) volume: f64,
|
pub(super) volume: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct DeviceHeartbeatRequest {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
pub(super) user_agent: Option<String>,
|
||||||
|
pub(super) current_jam_id: Option<String>,
|
||||||
|
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct DeviceSelectRequest {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
pub(super) current_device_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct DeviceCommandRequest {
|
||||||
|
pub(super) target_device_id: Option<String>,
|
||||||
|
pub(super) jam_id: Option<String>,
|
||||||
|
pub(super) command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerDeviceDto {
|
||||||
|
pub(super) id: String,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) kind: String,
|
||||||
|
pub(super) is_current: bool,
|
||||||
|
pub(super) is_active: bool,
|
||||||
|
pub(super) last_seen_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerJamDto {
|
||||||
|
pub(super) id: String,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) host_user_id: i64,
|
||||||
|
pub(super) host_name: String,
|
||||||
|
pub(super) is_owner: bool,
|
||||||
|
pub(super) is_member: bool,
|
||||||
|
pub(super) is_pending: bool,
|
||||||
|
pub(super) is_active: bool,
|
||||||
|
pub(super) member_count: i64,
|
||||||
|
pub(super) host_last_seen_ms: i64,
|
||||||
|
pub(super) host_device_online: bool,
|
||||||
|
pub(super) members: Vec<PlayerJamMemberDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerJamMemberDto {
|
||||||
|
pub(super) user_id: i64,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) is_joined: bool,
|
||||||
|
pub(super) is_current_user: bool,
|
||||||
|
pub(super) last_seen_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerJamCreateRequest {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) invitee_user_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerJamInviteRequest {
|
||||||
|
pub(super) jam_id: String,
|
||||||
|
pub(super) device_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) invitee_user_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerJamJoinRequest {
|
||||||
|
pub(super) jam_id: String,
|
||||||
|
pub(super) device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerJamLeaveRequest {
|
||||||
|
pub(super) jam_id: String,
|
||||||
|
pub(super) device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerJamUserDto {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) username: String,
|
||||||
|
pub(super) display_name: Option<String>,
|
||||||
|
pub(super) email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerDeviceCommandDto {
|
||||||
|
pub(super) id: String,
|
||||||
|
pub(super) command: String,
|
||||||
|
pub(super) payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerDevicePlaybackStateDto {
|
||||||
|
pub(super) track: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) tracks: Vec<serde_json::Value>,
|
||||||
|
pub(super) index: i32,
|
||||||
|
pub(super) position_seconds: f64,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) paused: bool,
|
||||||
|
pub(super) shuffle: bool,
|
||||||
|
pub(super) repeat_mode: String,
|
||||||
|
pub(super) volume: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) updated_at_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerDevicesResponse {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
pub(super) active_device_id: Option<String>,
|
||||||
|
pub(super) devices: Vec<PlayerDeviceDto>,
|
||||||
|
pub(super) jams: Vec<PlayerJamDto>,
|
||||||
|
pub(super) current_jam_id: Option<String>,
|
||||||
|
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDeviceConnectRequest {
|
||||||
|
pub(super) invite: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDevicePairingAnswerRequest {
|
||||||
|
pub(super) request_id: String,
|
||||||
|
pub(super) accept: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) use_requester_group: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FedDeviceRevokeRequest {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayerDevicePollResponse {
|
||||||
|
pub(super) device_id: String,
|
||||||
|
pub(super) active_device_id: Option<String>,
|
||||||
|
pub(super) devices: Vec<PlayerDeviceDto>,
|
||||||
|
pub(super) jams: Vec<PlayerJamDto>,
|
||||||
|
pub(super) current_jam_id: Option<String>,
|
||||||
|
pub(super) commands: Vec<PlayerDeviceCommandDto>,
|
||||||
|
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlaylistDetail {
|
pub(super) struct PlaylistDetail {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -137,6 +312,19 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) is_public: bool,
|
pub(super) is_public: bool,
|
||||||
pub(super) is_saved: bool,
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
|
pub(super) tracks: Vec<PlaylistTrackItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ShareLinkResponse {
|
||||||
|
pub(super) token: String,
|
||||||
|
pub(super) url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistShareDetail {
|
||||||
|
pub(super) token: String,
|
||||||
|
pub(super) title: String,
|
||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,17 +345,205 @@ pub(super) struct UserStats {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct UserProfile {
|
pub(super) struct UserProfile {
|
||||||
|
pub(super) id: i64,
|
||||||
pub(super) name: String,
|
pub(super) name: String,
|
||||||
pub(super) role: String,
|
pub(super) role: String,
|
||||||
pub(super) stats: UserStats,
|
pub(super) stats: UserStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflineManifestResponse {
|
||||||
|
pub(super) generated_at: String,
|
||||||
|
pub(super) tracks: Vec<OfflineTrackManifestItem>,
|
||||||
|
pub(super) playlists: Vec<OfflinePlaylistManifestItem>,
|
||||||
|
pub(super) liked_track_ids: Vec<i64>,
|
||||||
|
pub(super) followed_artist_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflineTrackManifestItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) stream_url: String,
|
||||||
|
pub(super) audio_file_id: i64,
|
||||||
|
pub(super) audio_hash: String,
|
||||||
|
pub(super) audio_size_bytes: i64,
|
||||||
|
pub(super) audio_mime_type: String,
|
||||||
|
pub(super) audio_updated_at: String,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) cover_url: Option<String>,
|
||||||
|
pub(super) cover_hash: Option<String>,
|
||||||
|
pub(super) cover_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflinePlaylistManifestItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: Option<String>,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
|
pub(super) kind: String,
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct LastfmStatus {
|
||||||
|
pub(super) configured: bool,
|
||||||
|
pub(super) connected: bool,
|
||||||
|
pub(super) username: Option<String>,
|
||||||
|
pub(super) reauth_required: bool,
|
||||||
|
pub(super) last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct LastfmActionResponse {
|
||||||
|
pub(super) ok: bool,
|
||||||
|
pub(super) queued: bool,
|
||||||
|
pub(super) sent: bool,
|
||||||
|
pub(super) message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct LastfmNowPlayingRequest {
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct LastfmScrobbleRequest {
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
pub(super) started_at: Option<i64>,
|
||||||
|
pub(super) listened_seconds: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct AgentQueueStatus {
|
||||||
|
pub(super) queued_count: i64,
|
||||||
|
pub(super) processing_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadTrack {
|
||||||
|
pub(super) track: TrackItem,
|
||||||
|
pub(super) media_file_id: i64,
|
||||||
|
pub(super) is_hidden: bool,
|
||||||
|
pub(super) release_is_hidden: bool,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) uploaded_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadRelease {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) is_hidden: bool,
|
||||||
|
pub(super) artists: Vec<ArtistRef>,
|
||||||
|
pub(super) tracks: Vec<UserUploadTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadReviewFields {
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) artist: String,
|
||||||
|
pub(super) album: String,
|
||||||
|
pub(super) year: String,
|
||||||
|
pub(super) track_number: String,
|
||||||
|
pub(super) genre: String,
|
||||||
|
pub(super) featured_artists: Vec<String>,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadReviewItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) status: String,
|
||||||
|
pub(super) filename: String,
|
||||||
|
pub(super) created_at: String,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) error_message: Option<String>,
|
||||||
|
pub(super) fields: UserUploadReviewFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadQueueItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) status: String,
|
||||||
|
pub(super) filename: String,
|
||||||
|
pub(super) created_at: String,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadsPage {
|
||||||
|
pub(super) tracks: Vec<UserUploadTrack>,
|
||||||
|
pub(super) releases: Vec<UserUploadRelease>,
|
||||||
|
pub(super) pending: Vec<UserUploadReviewItem>,
|
||||||
|
pub(super) queued: Vec<UserUploadQueueItem>,
|
||||||
|
pub(super) pending_total: i64,
|
||||||
|
pub(super) queued_total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadTrackUpdateRequest {
|
||||||
|
pub(super) title: Option<String>,
|
||||||
|
pub(super) artist_names: Option<Vec<String>>,
|
||||||
|
pub(super) featured_artist_names: Option<Vec<String>>,
|
||||||
|
pub(super) release_title: Option<String>,
|
||||||
|
pub(super) release_type: Option<String>,
|
||||||
|
pub(super) release_year: Option<String>,
|
||||||
|
pub(super) track_number: Option<String>,
|
||||||
|
pub(super) disc_number: Option<String>,
|
||||||
|
pub(super) is_hidden: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadReleaseUpdateRequest {
|
||||||
|
pub(super) title: Option<String>,
|
||||||
|
pub(super) artist_names: Option<Vec<String>>,
|
||||||
|
pub(super) release_type: Option<String>,
|
||||||
|
pub(super) year: Option<String>,
|
||||||
|
pub(super) is_hidden: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadBulkTrackUpdateRequest {
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
pub(super) artist_names: Option<Vec<String>>,
|
||||||
|
pub(super) featured_artist_names: Option<Vec<String>>,
|
||||||
|
pub(super) release_title: Option<String>,
|
||||||
|
pub(super) release_type: Option<String>,
|
||||||
|
pub(super) release_year: Option<String>,
|
||||||
|
pub(super) is_hidden: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct UserUploadReviewUpdateRequest {
|
||||||
|
pub(super) title: Option<String>,
|
||||||
|
pub(super) artist: Option<String>,
|
||||||
|
pub(super) album: Option<String>,
|
||||||
|
pub(super) year: Option<String>,
|
||||||
|
pub(super) track_number: Option<String>,
|
||||||
|
pub(super) genre: Option<String>,
|
||||||
|
pub(super) featured_artists: Option<Vec<String>>,
|
||||||
|
pub(super) release_type: Option<String>,
|
||||||
|
pub(super) notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayHistoryItem {
|
pub(super) struct PlayHistoryItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
pub(super) track_title: String,
|
pub(super) track_title: String,
|
||||||
pub(super) release_title: Option<String>,
|
pub(super) release_title: Option<String>,
|
||||||
|
pub(super) track: TrackItem,
|
||||||
pub(super) played_at: String,
|
pub(super) played_at: String,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
@@ -186,6 +562,46 @@ pub(super) struct LikeStatus {
|
|||||||
pub(super) liked: bool,
|
pub(super) liked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct ContentTrackMutation {
|
||||||
|
pub(super) content_id: String,
|
||||||
|
pub(super) liked: Option<bool>,
|
||||||
|
pub(super) playlist_id: Option<i64>,
|
||||||
|
pub(super) position: Option<i64>,
|
||||||
|
pub(super) federation: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PrepareFederatedTrackRequest {
|
||||||
|
pub(super) content_id: String,
|
||||||
|
pub(super) owner: String,
|
||||||
|
pub(super) item_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtworkQuery {
|
||||||
|
pub(super) owner: String,
|
||||||
|
pub(super) item_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtistQuery {
|
||||||
|
pub(super) name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationCatalogArtworkQuery {
|
||||||
|
pub(super) owner: String,
|
||||||
|
pub(super) artist: String,
|
||||||
|
pub(super) release: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtworkDiscoveryQuery {
|
||||||
|
pub(super) artist: String,
|
||||||
|
pub(super) release: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct LikedIds {
|
pub(super) struct LikedIds {
|
||||||
pub(super) track_ids: Vec<i64>,
|
pub(super) track_ids: Vec<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(
|
||||||
|
|||||||
+7345
-396
File diff suppressed because it is too large
Load Diff
+38
-1
@@ -3,6 +3,7 @@ use serde::Deserialize;
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct HistoryEntry {
|
pub(super) struct HistoryEntry {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
|
pub(super) started_at: Option<i64>,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
}
|
}
|
||||||
@@ -18,6 +19,11 @@ pub(super) struct TracksByIdsRequest {
|
|||||||
pub(super) ids: Vec<i64>,
|
pub(super) ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UserUploadsQuery {
|
||||||
|
pub(super) limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct CreatePlaylistRequest {
|
pub(super) struct CreatePlaylistRequest {
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
@@ -36,13 +42,26 @@ pub(super) struct AddTracksRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct RemoveTrackRequest {
|
pub(super) struct RemoveTrackRequest {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct ReorderPlaylistRequest {
|
||||||
|
pub(super) playlist_track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct CreatePlaylistShareRequest {
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
pub(super) title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct PaginationQuery {
|
pub(super) struct PaginationQuery {
|
||||||
pub(super) page: Option<i32>,
|
pub(super) page: Option<i32>,
|
||||||
pub(super) limit: Option<i32>,
|
pub(super) limit: Option<i32>,
|
||||||
|
pub(super) mine: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -55,12 +74,24 @@ pub(super) struct PathStringId {
|
|||||||
pub(super) id: String,
|
pub(super) id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathRadioSeed {
|
||||||
|
pub(super) kind: String,
|
||||||
|
pub(super) id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct SearchQuery {
|
pub(super) struct SearchQuery {
|
||||||
pub(super) q: String,
|
pub(super) q: String,
|
||||||
pub(super) limit: Option<i32>,
|
pub(super) limit: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct JamUserSearchQuery {
|
||||||
|
pub(super) q: Option<String>,
|
||||||
|
pub(super) limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct PathTrackId {
|
pub(super) struct PathTrackId {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
@@ -70,3 +101,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,
|
||||||
|
}
|
||||||
|
|||||||
+147
-23
@@ -12,6 +12,14 @@ pub(super) struct CountRow {
|
|||||||
pub(super) count: i64,
|
pub(super) count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct PlayerJamUserRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) username: String,
|
||||||
|
pub(super) display_name: Option<String>,
|
||||||
|
pub(super) email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct ReleaseRow {
|
pub(super) struct ReleaseRow {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -28,24 +36,6 @@ pub(super) struct ArtistBriefRow {
|
|||||||
pub(super) name: String,
|
pub(super) name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
pub(super) struct TrackRow {
|
|
||||||
pub(super) id: i64,
|
|
||||||
pub(super) title: String,
|
|
||||||
pub(super) track_number: Option<i32>,
|
|
||||||
pub(super) disc_number: Option<i32>,
|
|
||||||
pub(super) duration_seconds: f64,
|
|
||||||
pub(super) cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_year: Option<i32>,
|
|
||||||
pub(super) uploader_name: String,
|
|
||||||
pub(super) audio_format: Option<String>,
|
|
||||||
pub(super) audio_bitrate: Option<i32>,
|
|
||||||
pub(super) audio_sample_rate: Option<i32>,
|
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct TrackArtistRow {
|
pub(super) struct TrackArtistRow {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
@@ -54,11 +44,20 @@ pub(super) struct TrackArtistRow {
|
|||||||
pub(super) role: String,
|
pub(super) role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct ReleaseArtistRefRow {
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) artist_id: i64,
|
||||||
|
pub(super) artist_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct MediaFileRow {
|
pub(super) struct MediaFileRow {
|
||||||
pub(super) file_path: String,
|
pub(super) file_path: String,
|
||||||
pub(super) mime_type: String,
|
pub(super) mime_type: String,
|
||||||
pub(super) file_size_bytes: i64,
|
pub(super) file_size_bytes: i64,
|
||||||
|
pub(super) sha256_hash: String,
|
||||||
|
pub(super) created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -96,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
|
|||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct PlaylistTrackRow {
|
pub(super) struct PlaylistTrackRow {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
@@ -103,6 +103,8 @@ pub(super) struct PlaylistTrackRow {
|
|||||||
pub(super) duration_seconds: f64,
|
pub(super) duration_seconds: f64,
|
||||||
pub(super) cover_file_id: Option<i64>,
|
pub(super) cover_file_id: Option<i64>,
|
||||||
pub(super) release_cover_file_id: Option<i64>,
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) release_title: String,
|
||||||
pub(super) release_year: Option<i32>,
|
pub(super) release_year: Option<i32>,
|
||||||
pub(super) uploader_name: String,
|
pub(super) uploader_name: String,
|
||||||
pub(super) audio_format: Option<String>,
|
pub(super) audio_format: Option<String>,
|
||||||
@@ -110,6 +112,74 @@ 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)]
|
||||||
|
pub(super) struct UploadedTrackRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_number: Option<i32>,
|
||||||
|
pub(super) disc_number: Option<i32>,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) release_title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) release_year: Option<i32>,
|
||||||
|
pub(super) release_is_hidden: bool,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
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>,
|
||||||
|
pub(super) media_file_id: i64,
|
||||||
|
pub(super) is_hidden: bool,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) uploaded_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct UserUploadQueueRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) status: String,
|
||||||
|
pub(super) input_path: Option<String>,
|
||||||
|
pub(super) created_at: String,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct UserUploadReviewRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) status: String,
|
||||||
|
pub(super) input_path: Option<String>,
|
||||||
|
pub(super) result_json: Option<String>,
|
||||||
|
pub(super) context_json: Option<String>,
|
||||||
|
pub(super) created_at: String,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct UploadTrackEditRow {
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_number: Option<i32>,
|
||||||
|
pub(super) disc_number: Option<i32>,
|
||||||
|
pub(super) is_hidden: bool,
|
||||||
|
pub(super) release_title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) release_year: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -128,6 +198,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)]
|
||||||
@@ -158,6 +232,8 @@ pub(super) struct SearchTrackRow {
|
|||||||
pub(super) duration_seconds: f64,
|
pub(super) duration_seconds: f64,
|
||||||
pub(super) cover_file_id: Option<i64>,
|
pub(super) cover_file_id: Option<i64>,
|
||||||
pub(super) release_cover_file_id: Option<i64>,
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) release_title: String,
|
||||||
pub(super) release_year: Option<i32>,
|
pub(super) release_year: Option<i32>,
|
||||||
pub(super) uploader_name: String,
|
pub(super) uploader_name: String,
|
||||||
pub(super) audio_format: Option<String>,
|
pub(super) audio_format: Option<String>,
|
||||||
@@ -165,6 +241,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)]
|
||||||
@@ -175,14 +255,31 @@ pub(super) struct ReleaseUploaderRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct PlayHistoryRow {
|
pub(super) struct PlayHistoryTrackRow {
|
||||||
pub(super) id: i64,
|
pub(super) history_id: i64,
|
||||||
pub(super) track_id: i64,
|
|
||||||
pub(super) track_title: String,
|
|
||||||
pub(super) release_title: Option<String>,
|
|
||||||
pub(super) played_at: String,
|
pub(super) played_at: String,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_number: Option<i32>,
|
||||||
|
pub(super) disc_number: Option<i32>,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) release_title: String,
|
||||||
|
pub(super) release_year: Option<i32>,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
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)]
|
||||||
@@ -193,3 +290,30 @@ pub(super) struct ReleaseInfoRow {
|
|||||||
pub(super) year: Option<i32>,
|
pub(super) year: Option<i32>,
|
||||||
pub(super) cover_file_id: Option<i64>,
|
pub(super) cover_file_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct OfflineTrackManifestRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) audio_file_id: i64,
|
||||||
|
pub(super) audio_hash: String,
|
||||||
|
pub(super) audio_size_bytes: i64,
|
||||||
|
pub(super) audio_mime_type: String,
|
||||||
|
pub(super) audio_updated_at: String,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) cover_hash: Option<String>,
|
||||||
|
pub(super) cover_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct OfflinePlaylistManifestRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: String,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|||||||
+71
-9
@@ -200,6 +200,12 @@ impl JobRun {
|
|||||||
duration_ms: i64,
|
duration_ms: i64,
|
||||||
log: &str,
|
log: &str,
|
||||||
) -> cot::db::Result<()> {
|
) -> cot::db::Result<()> {
|
||||||
|
crate::metrics::record_scheduler_job(
|
||||||
|
self.job_name.as_str(),
|
||||||
|
self.trigger.as_str(),
|
||||||
|
"completed",
|
||||||
|
duration_ms,
|
||||||
|
);
|
||||||
self.status = LimitedString::new("completed").unwrap();
|
self.status = LimitedString::new("completed").unwrap();
|
||||||
self.finished_at = Some(now_iso().to_string());
|
self.finished_at = Some(now_iso().to_string());
|
||||||
self.duration_ms = Some(duration_ms);
|
self.duration_ms = Some(duration_ms);
|
||||||
@@ -214,6 +220,12 @@ impl JobRun {
|
|||||||
log: &str,
|
log: &str,
|
||||||
error: &str,
|
error: &str,
|
||||||
) -> cot::db::Result<()> {
|
) -> cot::db::Result<()> {
|
||||||
|
crate::metrics::record_scheduler_job(
|
||||||
|
self.job_name.as_str(),
|
||||||
|
self.trigger.as_str(),
|
||||||
|
"failed",
|
||||||
|
duration_ms,
|
||||||
|
);
|
||||||
self.status = LimitedString::new("failed").unwrap();
|
self.status = LimitedString::new("failed").unwrap();
|
||||||
self.finished_at = Some(now_iso().to_string());
|
self.finished_at = Some(now_iso().to_string());
|
||||||
self.duration_ms = Some(duration_ms);
|
self.duration_ms = Some(duration_ms);
|
||||||
@@ -460,6 +472,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());
|
||||||
@@ -474,14 +496,24 @@ impl PendingReview {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn exists_for_path(db: &Database, path: &str) -> cot::db::Result<bool> {
|
/// Latest review row for an inbox path: `(id, status, updated_at)`.
|
||||||
let all = Self::objects().all(db).await?;
|
///
|
||||||
let exists = all.iter().any(|r| {
|
/// Used by inbox_discover to decide whether a file needs a new review,
|
||||||
let s = r.status.as_str();
|
/// a requeue of its existing row, or nothing at all — without creating
|
||||||
// "rejected" and "failed" reviews should not block re-discovery
|
/// a fresh row per retry.
|
||||||
s != "rejected" && s != "failed" && r.input_path.as_deref() == Some(path)
|
pub async fn latest_for_path(
|
||||||
});
|
pool: &sqlx::PgPool,
|
||||||
Ok(exists)
|
path: &str,
|
||||||
|
) -> anyhow::Result<Option<(i64, String, String)>> {
|
||||||
|
let row: Option<(i64, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, status::text, updated_at::text \
|
||||||
|
FROM furumusic__pending_review WHERE input_path = $1 \
|
||||||
|
ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(path)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark all "processing" reviews as "failed" — called at scheduler
|
/// Mark all "processing" reviews as "failed" — called at scheduler
|
||||||
@@ -1347,7 +1379,12 @@ 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"
|
||||||
|
&& job_name != "lastfm_scrobble"
|
||||||
|
&& job_name != "archive_cleanup"
|
||||||
|
&& job_name != "artwork_backfill"
|
||||||
|
{
|
||||||
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1457,6 +1494,31 @@ pub async fn start_scheduler(
|
|||||||
Err(e) => tracing::error!("Failed to recover stale reviews: {e}"),
|
Err(e) => tracing::error!("Failed to recover stale reviews: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !live_config.agent_storage_dir.trim().is_empty() {
|
||||||
|
match crate::media_paths::normalize_media_file_paths(&pool, &live_config.agent_storage_dir)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!("Normalized {n} media file path(s) to relative storage paths"),
|
||||||
|
Err(e) => tracing::warn!("Failed to normalize media file paths: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !live_config.agent_inbox_dir.trim().is_empty() {
|
||||||
|
match crate::media_paths::normalize_pending_review_paths(
|
||||||
|
&pool,
|
||||||
|
&live_config.agent_inbox_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => {
|
||||||
|
tracing::info!("Normalized {n} pending review path(s) to relative inbox paths")
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("Failed to normalize pending review paths: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert ScheduledJob rows
|
// Upsert ScheduledJob rows
|
||||||
for job in registry.all_jobs() {
|
for job in registry.all_jobs() {
|
||||||
ScheduledJob::upsert(&db, job.name(), job.description(), job.default_cron())
|
ScheduledJob::upsert(&db, job.name(), job.description(), job.default_cron())
|
||||||
|
|||||||
+989
-131
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,12 @@
|
|||||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||||
<input type="checkbox" name="duration_seconds" checked> duration_seconds
|
<input type="checkbox" name="duration_seconds" checked> duration_seconds
|
||||||
</label>
|
</label>
|
||||||
|
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||||
|
<input type="checkbox" name="local_genres" checked> local genres from files
|
||||||
|
</label>
|
||||||
|
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||||
|
<input type="checkbox" name="lastfm_tags" checked> Last.fm tags
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
|
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
|
||||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -85,6 +85,16 @@
|
|||||||
<td><input type="checkbox" name="swagger_enabled" id="swagger_enabled" value="on"{% if swagger_enabled %} checked{% endif %}></td>
|
<td><input type="checkbox" name="swagger_enabled" id="swagger_enabled" value="on"{% if swagger_enabled %} checked{% endif %}></td>
|
||||||
<td><span class="badge badge-{{ swagger_enabled_source }}">{{ swagger_enabled_source }}</span></td>
|
<td><span class="badge badge-{{ swagger_enabled_source }}">{{ swagger_enabled_source }}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="lastfm_api_key">{{ t.settings_lastfm_api_key }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_api_key_help }}</span></td>
|
||||||
|
<td><input type="password" name="lastfm_api_key" id="lastfm_api_key" value="{{ lastfm_api_key }}"></td>
|
||||||
|
<td><span class="badge badge-{{ lastfm_api_key_source }}">{{ lastfm_api_key_source }}</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="lastfm_shared_secret">{{ t.settings_lastfm_shared_secret }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_lastfm_shared_secret_help }}</span></td>
|
||||||
|
<td><input type="password" name="lastfm_shared_secret" id="lastfm_shared_secret" value="{{ lastfm_shared_secret }}"></td>
|
||||||
|
<td><span class="badge badge-{{ lastfm_shared_secret_source }}">{{ lastfm_shared_secret_source }}</span></td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2>{{ t.settings_agent }}</h2>
|
<h2>{{ t.settings_agent }}</h2>
|
||||||
|
|||||||
+2789
-137
File diff suppressed because it is too large
Load Diff
+4
-5117
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,891 @@
|
|||||||
|
<!-- 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>
|
||||||
|
<div class="info-actions" x-show="$store.info.modal.actions && $store.info.modal.actions.length">
|
||||||
|
<template x-for="(action, idx) in $store.info.modal.actions" :key="action.label + '-' + idx">
|
||||||
|
<button class="info-action-btn" type="button" @click.stop.prevent="$store.info.runAction(idx)" :disabled="action.busy === true">
|
||||||
|
<span x-text="action.busy ? '{{ t.player_resolving }}' : action.label"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="info-modal-body">
|
||||||
|
<template x-if="$store.info.modal.rows && $store.info.modal.rows.length">
|
||||||
|
<table class="info-table">
|
||||||
|
<tbody>
|
||||||
|
<template x-for="(row, idx) in $store.info.modal.rows" :key="row.label + '-' + idx">
|
||||||
|
<tr>
|
||||||
|
<th x-text="row.label"></th>
|
||||||
|
<td>
|
||||||
|
<template x-if="row.links && row.links.length">
|
||||||
|
<div class="info-link-list">
|
||||||
|
<template x-for="link in row.links" :key="link.type + '-' + link.id + '-' + link.label">
|
||||||
|
<button class="info-link" type="button" @click="$store.info.navigate(link)" x-text="link.label"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!row.links || !row.links.length">
|
||||||
|
<span x-text="row.value"></span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<pre class="info-modal-plain" x-show="!$store.info.modal.rows || !$store.info.modal.rows.length" x-text="$store.info.modal.body"></pre>
|
||||||
|
</div>
|
||||||
|
</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-tabs">
|
||||||
|
<button class="torrent-tab-btn"
|
||||||
|
:class="{ active: $store.torrents.activeTab === 'import' }"
|
||||||
|
@click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
|
||||||
|
<button class="torrent-tab-btn"
|
||||||
|
:class="{ active: $store.torrents.activeTab === 'uploads' }"
|
||||||
|
@click="$store.torrents.showUploadsTab()">
|
||||||
|
<span>{{ t.player_my_uploads }}</span>
|
||||||
|
<span class="torrent-tab-count"
|
||||||
|
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
|
||||||
|
x-text="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.activeTab === 'import'">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.activeTab === 'uploads'">
|
||||||
|
<section class="upload-manager-panel">
|
||||||
|
<div class="upload-manager-head">
|
||||||
|
<div>
|
||||||
|
<h4>{{ t.player_my_uploaded_tracks }}</h4>
|
||||||
|
<p x-text="$store.torrents.uploadSummary()"></p>
|
||||||
|
</div>
|
||||||
|
<button class="modal-btn modal-btn-ghost"
|
||||||
|
@click="$store.torrents.loadUploads()"
|
||||||
|
:disabled="$store.torrents.uploadHasEditorOpen()">{{ t.player_refresh }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.uploadLoaded && $store.torrents.uploadTracks.length === 0 && $store.torrents.uploadPending.length === 0 && $store.torrents.uploadQueued.length === 0">
|
||||||
|
<div class="empty-state torrent-workspace-empty">
|
||||||
|
<p>{{ t.player_no_uploaded_tracks }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="upload-manager-grid">
|
||||||
|
<aside class="upload-review-column">
|
||||||
|
<div class="upload-panel-card">
|
||||||
|
<div class="upload-panel-title">{{ t.player_needs_approval }}</div>
|
||||||
|
<p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' {{ t.player_pending_or_failed }}'"></p>
|
||||||
|
<template x-if="$store.torrents.uploadPending.length === 0">
|
||||||
|
<div class="upload-mini-empty">{{ t.player_no_tracks_need_approval }}</div>
|
||||||
|
</template>
|
||||||
|
<div class="upload-review-list">
|
||||||
|
<template x-for="item in $store.torrents.uploadPending" :key="item.id">
|
||||||
|
<button class="upload-review-row" :class="{ active: $store.torrents.uploadReviewEditId === item.id, failed: item.status === 'failed' }" @click="$store.torrents.editUploadReview(item)">
|
||||||
|
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
|
||||||
|
<span class="upload-review-name" x-text="item.filename"></span>
|
||||||
|
<span class="upload-review-error" x-show="item.error_message" x-text="item.error_message"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template x-if="$store.torrents.uploadQueuedTotal > 0">
|
||||||
|
<div class="upload-panel-card upload-queue-panel">
|
||||||
|
<div class="upload-panel-title upload-panel-title-row">
|
||||||
|
<span>{{ t.player_queued_processing }}</span>
|
||||||
|
<div class="upload-queue-nav" x-show="$store.torrents.uploadQueued.length > $store.torrents.uploadQueuePageSize">
|
||||||
|
<span class="upload-queue-range" x-text="$store.torrents.uploadQueueRangeText()"></span>
|
||||||
|
<button type="button"
|
||||||
|
class="upload-queue-nav-btn"
|
||||||
|
@click="$store.torrents.uploadQueuePrev()"
|
||||||
|
:disabled="!$store.torrents.uploadQueueCanPrev()"
|
||||||
|
title="{{ t.player_previous }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="upload-queue-nav-btn"
|
||||||
|
@click="$store.torrents.uploadQueueNext()"
|
||||||
|
:disabled="!$store.torrents.uploadQueueCanNext()"
|
||||||
|
title="{{ t.player_next }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template x-for="item in $store.torrents.compactQueuedUploads()" :key="item.id">
|
||||||
|
<div class="upload-queue-row" :title="$store.torrents.uploadQueueTooltip(item)">
|
||||||
|
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
|
||||||
|
<span class="upload-queue-name" x-text="item.filename"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="upload-mini-empty" x-show="$store.torrents.uploadQueuedTotal > $store.torrents.uploadQueued.length" x-text="$store.torrents.uploadQueueOverflowText()"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
<section class="upload-library-column">
|
||||||
|
<div class="upload-bulk-bar" x-show="$store.torrents.uploadSelectedCount() > 0">
|
||||||
|
<div class="upload-bulk-title" x-text="$store.torrents.uploadSelectedCount() + ' {{ t.player_selected }}'"></div>
|
||||||
|
<input type="text" placeholder="{{ t.player_artists }}" x-model="$store.torrents.uploadBulkDraft.artists">
|
||||||
|
<input type="text" placeholder="{{ t.player_featured }}" x-model="$store.torrents.uploadBulkDraft.featured_artists">
|
||||||
|
<input type="text" placeholder="{{ t.player_album }}" x-model="$store.torrents.uploadBulkDraft.release_title">
|
||||||
|
<input type="number" placeholder="{{ t.player_year }}" x-model="$store.torrents.uploadBulkDraft.release_year">
|
||||||
|
<select x-model="$store.torrents.uploadBulkDraft.release_type">
|
||||||
|
<option value="">{{ t.player_type_unchanged }}</option><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
|
||||||
|
</select>
|
||||||
|
<select x-model="$store.torrents.uploadBulkDraft.hidden">
|
||||||
|
<option value="">{{ t.player_visibility_unchanged }}</option><option value="false">{{ t.player_visible }}</option><option value="true">{{ t.player_hidden }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadBulkEdit()" :disabled="$store.torrents.uploadBulkSaving">{{ t.player_apply }}</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">{{ t.player_clear }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="upload-release-tree">
|
||||||
|
<template x-for="group in $store.torrents.uploadArtistGroups()" :key="group.key">
|
||||||
|
<section class="upload-artist-group">
|
||||||
|
<div class="upload-artist-row">
|
||||||
|
<div class="upload-artist-name" x-text="group.name"></div>
|
||||||
|
<div class="upload-artist-meta" x-text="group.releases.length + ' {{ t.player_releases_count }} - ' + group.trackCount + ' {{ t.player_tracks_count }}'"></div>
|
||||||
|
</div>
|
||||||
|
<template x-for="release in group.releases" :key="release.id">
|
||||||
|
<div class="upload-release-node" :class="{ hidden: release.is_hidden }">
|
||||||
|
<div class="upload-release-row">
|
||||||
|
<button class="torrent-tree-check" :class="$store.torrents.uploadReleaseSelectionState(release)" @click="$store.torrents.toggleUploadReleaseSelection(release)">
|
||||||
|
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === '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.uploadReleaseSelectionState(release) === '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>
|
||||||
|
<button class="torrent-tree-toggle" :class="{ expanded: $store.torrents.uploadReleaseExpanded(release.id) }" @click="$store.torrents.toggleUploadRelease(release.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
|
||||||
|
<div class="upload-release-main">
|
||||||
|
<div class="upload-release-title"><span x-text="release.title"></span><span class="upload-hidden-pill" x-show="release.is_hidden">{{ t.player_hidden }}</span></div>
|
||||||
|
<div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || T.noYear"></span><span>-</span><span x-text="release.tracks.length + ' {{ t.player_tracks_count }}'"></span></div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">{{ t.player_edit_release }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="upload-track-children" x-show="$store.torrents.uploadReleaseExpanded(release.id)">
|
||||||
|
<template x-for="item in release.tracks" :key="item.track.id">
|
||||||
|
<div class="upload-tree-track" :class="{ hidden: item.is_hidden, selected: $store.torrents.selectedUploadTracks.has(item.track.id) }">
|
||||||
|
<button class="torrent-tree-check" :class="{ checked: $store.torrents.selectedUploadTracks.has(item.track.id) }" @click="$store.torrents.toggleUploadTrackSelection(item.track.id)">
|
||||||
|
<template x-if="$store.torrents.selectedUploadTracks.has(item.track.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
|
||||||
|
</button>
|
||||||
|
<div class="upload-track-main">
|
||||||
|
<div class="upload-track-title"><span x-text="item.track.track_number ? item.track.track_number + '. ' + item.track.title : item.track.title"></span><span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span></div>
|
||||||
|
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="T.featuredShort"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="$store.torrents.uploadFeaturedArtistsText(item)"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-track-actions">
|
||||||
|
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
|
||||||
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.uploadHasEditorOpen()">
|
||||||
|
<div class="upload-editor-backdrop" @click.self="$store.torrents.closeUploadEditor()">
|
||||||
|
<aside class="upload-editor-drawer">
|
||||||
|
<div class="upload-editor-head">
|
||||||
|
<div>
|
||||||
|
<div class="upload-panel-title" x-text="$store.torrents.uploadEditorKicker()"></div>
|
||||||
|
<h4 x-text="$store.torrents.uploadEditorTitle()"></h4>
|
||||||
|
</div>
|
||||||
|
<button class="track-action-btn" @click="$store.torrents.closeUploadEditor()" title="{{ t.player_close }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.uploadReviewDraft">
|
||||||
|
<div class="upload-editor-form">
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_artist }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="{{ t.player_artists_placeholder }}"></label>
|
||||||
|
<label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label>
|
||||||
|
<label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label>
|
||||||
|
<label class="upload-field upload-field-compact"><span>{{ t.player_genre }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label>
|
||||||
|
<label class="upload-field upload-field-compact">
|
||||||
|
<span>{{ t.player_type }}</span>
|
||||||
|
<select x-model="$store.torrents.uploadReviewDraft.release_type">
|
||||||
|
<option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="upload-field upload-field-wide"><span>{{ t.player_notes }}</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label>
|
||||||
|
<div class="upload-editor-actions">
|
||||||
|
<button class="modal-btn modal-btn-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_delete_review }}</button>
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_approve }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.uploadReleaseDraft">
|
||||||
|
<div class="upload-editor-form">
|
||||||
|
<label class="upload-field upload-field-wide"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label>
|
||||||
|
<label class="upload-field upload-field-wide"><span>{{ t.player_album_artists }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
|
||||||
|
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
|
||||||
|
<div class="upload-editor-actions">
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadReleaseEdit()" :disabled="$store.torrents.uploadReleaseSavingId === $store.torrents.uploadReleaseEditId">{{ t.player_save_release }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.uploadDraft">
|
||||||
|
<div class="upload-editor-form">
|
||||||
|
<label class="upload-field upload-field-wide"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_artists }}</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label>
|
||||||
|
<label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
|
||||||
|
<label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label>
|
||||||
|
<label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label>
|
||||||
|
<label class="upload-field upload-field-compact"><span>{{ t.player_disc_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"></label>
|
||||||
|
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
|
||||||
|
<div class="upload-editor-actions">
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadEdit()" :disabled="$store.torrents.uploadSavingId === $store.torrents.uploadEditId">{{ t.player_save_track }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="upload-track-list" x-show="false">
|
||||||
|
<template x-for="item in $store.torrents.uploadTracks" :key="item.track.id">
|
||||||
|
<div class="upload-track-card" :class="{ hidden: item.is_hidden }">
|
||||||
|
<template x-if="$store.torrents.uploadEditId !== item.track.id">
|
||||||
|
<div class="upload-track-display">
|
||||||
|
<div class="upload-track-main">
|
||||||
|
<div class="upload-track-title">
|
||||||
|
<span x-text="item.track.title"></span>
|
||||||
|
<span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="upload-track-meta">
|
||||||
|
<span x-text="$store.torrents.uploadArtistsText(item)"></span>
|
||||||
|
<span>·</span>
|
||||||
|
<span x-text="item.track.release_title"></span>
|
||||||
|
<span x-show="item.track.track_number">·</span>
|
||||||
|
<span x-show="item.track.track_number" x-text="'#' + item.track.track_number"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-track-actions">
|
||||||
|
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="$store.torrents.uploadEditId === item.track.id">
|
||||||
|
<div class="upload-edit-form">
|
||||||
|
<label>
|
||||||
|
<span>{{ t.player_title }}</span>
|
||||||
|
<input type="text" x-model="$store.torrents.uploadDraft.title">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t.player_artists }}</span>
|
||||||
|
<input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="{{ t.player_artist_featured_placeholder }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t.player_release }}</span>
|
||||||
|
<input type="text" x-model="$store.torrents.uploadDraft.release_title">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t.player_type }}</span>
|
||||||
|
<select x-model="$store.torrents.uploadDraft.release_type">
|
||||||
|
<option value="album">{{ t.player_release_type_album }}</option>
|
||||||
|
<option value="single">{{ t.player_release_type_single }}</option>
|
||||||
|
<option value="ep">{{ t.player_release_type_ep }}</option>
|
||||||
|
<option value="compilation">{{ t.player_release_type_compilation }}</option>
|
||||||
|
<option value="mixtape">{{ t.player_release_type_mixtape }}</option>
|
||||||
|
<option value="live">{{ t.player_release_type_live }}</option>
|
||||||
|
<option value="soundtrack">{{ t.player_release_type_soundtrack }}</option>
|
||||||
|
<option value="remix">{{ t.player_release_type_remix }}</option>
|
||||||
|
<option value="demo">{{ t.player_release_type_demo }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t.player_year }}</span>
|
||||||
|
<input type="number" x-model="$store.torrents.uploadDraft.release_year">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t.player_track_number }}</span>
|
||||||
|
<input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t.player_disc_number }}</span>
|
||||||
|
<input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number">
|
||||||
|
</label>
|
||||||
|
<label class="upload-hidden-toggle">
|
||||||
|
<input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden">
|
||||||
|
<span>{{ t.player_hidden }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="upload-edit-actions">
|
||||||
|
<button class="modal-btn modal-btn-primary"
|
||||||
|
@click="$store.torrents.saveUploadEdit()"
|
||||||
|
:disabled="$store.torrents.uploadSavingId === item.track.id">{{ t.player_save }}</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">{{ t.player_cancel }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- User Settings Modal -->
|
||||||
|
<template x-if="$store.user.settingsOpen">
|
||||||
|
<div class="modal-overlay" @click.self="$store.user.closeSettings()">
|
||||||
|
<div class="modal-box user-settings-modal">
|
||||||
|
<div class="user-settings-head">
|
||||||
|
<div>
|
||||||
|
<h3>User settings</h3>
|
||||||
|
<p>Personal services, listening history and trusted devices.</p>
|
||||||
|
</div>
|
||||||
|
<button class="mobile-list-action" @click="$store.user.closeSettings()" 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>
|
||||||
|
|
||||||
|
<section class="user-settings-section">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Listening history</h4>
|
||||||
|
<p>Review plays recorded by this web player.</p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn" @click="$store.user.openHistoryFromSettings()">Open history</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-settings-section">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Last.fm</h4>
|
||||||
|
<p x-text="$store.user.lastfmStatusLabel()"></p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:class="$store.user.lastfmClass()"
|
||||||
|
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||||
|
@click="$store.user.handleLastfm()"
|
||||||
|
x-text="$store.user.lastfm?.connected && !$store.user.lastfm?.reauth_required ? 'Disconnect' : 'Connect'"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-settings-section user-settings-devices">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Connected devices</h4>
|
||||||
|
<p x-text="$store.devices.fedSummary()"></p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.syncFedDevices()">Sync now</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="$store.devices.fedError">
|
||||||
|
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="settings-device-group">
|
||||||
|
<div class="settings-device-label">Web player sessions</div>
|
||||||
|
<template x-for="device in $store.devices.webDevices()" :key="'settings-web-' + device.id">
|
||||||
|
<div class="settings-device-row">
|
||||||
|
<span class="device-row-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2"/>
|
||||||
|
<path d="M8 20h8M12 16v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="fed-device-main">
|
||||||
|
<span class="fed-device-name" x-text="device.name"></span>
|
||||||
|
<span class="fed-device-meta"
|
||||||
|
x-text="device.is_current ? 'This browser session' : (device.is_active ? 'Active web session' : 'Web session')"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-device-group">
|
||||||
|
<div class="settings-device-label">Trusted federation devices</div>
|
||||||
|
<template x-for="request in $store.devices.fedPending()" :key="request.request_id">
|
||||||
|
<div class="fed-pairing-card">
|
||||||
|
<div class="fed-pairing-title" x-text="request.name || request.device_id"></div>
|
||||||
|
<div class="fed-pairing-meta"
|
||||||
|
x-text="request.requester_group_id ? 'Already belongs to another sync group' : (request.client_version || 'Waiting for approval')"></div>
|
||||||
|
<div class="fed-device-actions">
|
||||||
|
<button class="fed-action-btn primary"
|
||||||
|
@click="$store.devices.answerFedPairing(request, true, !!request.requester_group_id)"
|
||||||
|
x-text="request.requester_group_id ? 'Use existing group' : 'Approve'"></button>
|
||||||
|
<button class="fed-action-btn"
|
||||||
|
@click="$store.devices.answerFedPairing(request, false, false)">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="device in $store.devices.fedDevices()" :key="'settings-fed-' + device.device_id">
|
||||||
|
<div class="settings-device-row federation">
|
||||||
|
<span class="device-row-icon federation-device-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
|
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="fed-device-main">
|
||||||
|
<span class="fed-device-name" x-text="device.name || device.device_id"></span>
|
||||||
|
<span class="fed-device-meta"
|
||||||
|
x-text="device.is_self ? 'This web player' : (device.client_version || 'Trusted device')"></span>
|
||||||
|
</span>
|
||||||
|
<button class="fed-revoke-btn"
|
||||||
|
x-show="!device.is_self"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-pairing-actions">
|
||||||
|
<button class="settings-primary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.generateFedInvite()">Invite a device</button>
|
||||||
|
<template x-if="$store.devices.fedInvite">
|
||||||
|
<input class="fed-device-input"
|
||||||
|
readonly
|
||||||
|
:value="$store.devices.fedInvite"
|
||||||
|
@focus="$event.target.select()">
|
||||||
|
</template>
|
||||||
|
<div class="fed-connect-row">
|
||||||
|
<input class="fed-device-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Paste frid:// invite"
|
||||||
|
x-model="$store.devices.fedInviteInput"
|
||||||
|
@keydown.enter.prevent="$store.devices.connectFedInvite()">
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
|
||||||
|
@click="$store.devices.connectFedInvite()">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</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">
|
||||||
|
<div class="history-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ t.player_play_history }}</h3>
|
||||||
|
<p class="torrent-message" :class="{ error: $store.history.error }"
|
||||||
|
x-text="$store.history.message"></p>
|
||||||
|
</div>
|
||||||
|
<button class="mobile-list-action" @click="$store.history.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>
|
||||||
|
<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-if="$store.history.items.length > 0">
|
||||||
|
<div class="history-table-head">
|
||||||
|
<span></span>
|
||||||
|
<span>{{ t.player_title }}</span>
|
||||||
|
<span>{{ t.player_played_at }}</span>
|
||||||
|
<span></span>
|
||||||
|
<span style="text-align:right">{{ t.player_listened }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="(item, idx) in $store.history.items" :key="item.id">
|
||||||
|
<div class="history-row track-row"
|
||||||
|
:class="{ playing: $store.player.currentTrack && item.track && $store.player.currentTrack.id === item.track.id }"
|
||||||
|
@dblclick="$store.history.playFrom(idx)">
|
||||||
|
<button class="history-cover"
|
||||||
|
@click.stop="$store.history.playFrom(idx)"
|
||||||
|
:title="item.track?.title || item.track_title">
|
||||||
|
<template x-if="item.track && item.track.cover_url">
|
||||||
|
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||||
|
</template>
|
||||||
|
<template x-if="!item.track || !item.track.cover_url">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
|
||||||
|
<div class="track-artists-inline">
|
||||||
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||||
|
<span>
|
||||||
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||||
|
<a class="artist-link" @click.stop="$store.history.close(); $store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="!item.track || !$store.library.trackArtistLinks(item.track).length">
|
||||||
|
<span x-text="item.release_title || '{{ t.player_unknown_release }}'"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="history-release-line">
|
||||||
|
<a class="artist-link"
|
||||||
|
x-show="item.track && item.track.release_id"
|
||||||
|
@click.stop="$store.history.close(); $store.library.openRelease(item.track.release_id)"
|
||||||
|
x-text="item.track?.release_title || item.release_title || '{{ t.player_unknown_release }}'"></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-date" x-text="$store.history.date(item.played_at)"></div>
|
||||||
|
<div class="track-actions">
|
||||||
|
<button class="track-action-btn info-btn popularity-info-btn"
|
||||||
|
:class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
|
||||||
|
:style="$store.library.popularityStyle(item.track)"
|
||||||
|
@click.stop="$store.library.openTrackInfo(item.track)"
|
||||||
|
:title="$store.library.trackInfoTitle(item.track)"
|
||||||
|
aria-label="{{ t.player_track_info }}">
|
||||||
|
<span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
|
||||||
|
<span x-show="!$store.library.hasPopularity(item.track)" class="info-letter">i</span>
|
||||||
|
</button>
|
||||||
|
<button class="like-btn" :class="{ liked: $store.likes.has(item.track_id) }" @click.stop="$store.likes.toggle(item.track_id)" title="{{ t.player_like }}">
|
||||||
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(item.track_id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(item.track || item.track_id, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([item.track_id])" title="{{ t.player_add_to_playlist }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="history-duration" x-text="$store.history.duration(item.duration_listened)"></span>
|
||||||
|
</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