Compare commits

..
52 Commits
Author SHA1 Message Date
Ultradesu 3a75c7d848 Fixed history DTO view
Build and Publish / Build and Publish Docker Image (push) Successful in 4m6s
2026-07-28 08:34:29 +01:00
Ultradesu dc1fac1a94 hotfix
Build and Publish / Build and Publish Docker Image (push) Successful in 5m51s
2026-07-28 00:01:36 +01:00
Ultradesu 122214a896 Fixed playback history window
Build and Publish / Build and Publish Docker Image (push) Successful in 5m20s
2026-07-27 23:41:29 +01:00
Ultradesu 134f89fc9d Bump libs
Build and Publish / Build and Publish Docker Image (push) Successful in 5m18s
2026-07-27 23:17:07 +01:00
Ultradesu 232c171aac Add synced listening history 2026-07-27 23:16:03 +01:00
Ultradesu 845df4e031 Fixed autoplay after fed download 2026-07-27 17:33:48 +01:00
Ultradesu b0d8929b4c Fixed liked track sorting
Build and Publish / Build and Publish Docker Image (push) Successful in 10m14s
2026-07-27 17:09:59 +01:00
Ultradesu 291265be7d hotfix search
Build and Publish / Build and Publish Docker Image (push) Successful in 7m32s
2026-07-27 17:02:20 +01:00
Ultradesu 624e75839d Reworked federation. Added settings page. Fixed connected devices.
Build and Publish / Build and Publish Docker Image (push) Successful in 5m21s
2026-07-27 16:44:16 +01:00
Ultradesu fd766cda24 Added catalog slices support
Build and Publish / Build and Publish Docker Image (push) Successful in 7m6s
2026-07-25 22:39:36 +03:00
Ultradesu 49000f716c Bump
Build and Publish / Build and Publish Docker Image (push) Successful in 4m3s
2026-07-25 00:47:16 +03:00
Ultradesu ee4990e2f1 Fixed control mode tracks art 2026-07-25 00:47:00 +03:00
Ultradesu e64b61c167 Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m11s
2026-07-24 17:08:00 +03:00
Ultradesu b737ced3fc Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m1s
2026-07-24 16:54:41 +03:00
Ultradesu c2bdd62a51 Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m10s
2026-07-24 16:45:11 +03:00
Ultradesu d1370c6a28 Improved UI. Added playlist manager
Build and Publish / Build and Publish Docker Image (push) Successful in 3m56s
2026-07-23 17:24:28 +03:00
Ultradesu 2fc5fd7960 Improved DHT publication. Adjust batching, added validation per key
Build and Publish / Build and Publish Docker Image (push) Successful in 4m9s
2026-07-23 16:44:35 +03:00
Ultradesu 63506e3af2 Aupdated FRID to v4 protocol
Build and Publish / Build and Publish Docker Image (push) Successful in 5m4s
2026-07-23 15:52:42 +03:00
Ultradesu 5b339aa921 Fixed content_id persistence
Build and Publish / Build and Publish Docker Image (push) Successful in 7m30s
2026-07-20 18:40:32 +03:00
Ultradesu 42c772f735 Fixed blake3 computation
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s
2026-07-20 18:24:39 +03:00
Ultradesu e738086573 Extend DHT scheme with content_id
Build and Publish / Build and Publish Docker Image (push) Successful in 5m6s
2026-07-20 18:05:31 +03:00
Ultradesu 4b7756c36e Extend DHT scheme with content_id 2026-07-20 18:05:16 +03:00
Ultradesu 4381750c6e Extend DHT scheme
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s
2026-07-20 16:29:21 +03:00
Ultradesu 3485f643f4 Fixed DHT republish-after-ttl
Build and Publish / Build and Publish Docker Image (push) Successful in 4m58s
2026-07-20 14:19:11 +03:00
Ultradesu bca0f5e2f0 added psql support
Build and Publish / Build and Publish Docker Image (push) Successful in 9m24s
2026-07-20 02:03:44 +03:00
Ultradesu 53b2ff29f8 Moved DHT state in psql 2026-07-20 01:59:45 +03:00
Ultradesu c349512fb0 fixed gitignore 2026-07-17 14:01:13 +03:00
Ultradesu 0615356785 Added federation
Build and Publish / Build and Publish Docker Image (push) Successful in 4m55s
2026-07-17 13:30:55 +03:00
Ultradesu 184371afca Added offline mode support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m46s
2026-07-15 18:13:40 +03:00
Ultradesu 716da908c9 Fixed inbox_process task
Build and Publish / Build and Publish Docker Image (push) Successful in 5m34s
2026-07-08 15:22:21 +03:00
Ultradesu 0c120c0868 Added support of TUI player
Build and Publish / Build and Publish Docker Image (push) Successful in 2m38s
2026-06-10 23:20:18 +01:00
Ultradesu d9d0fbb7d1 Added cli client SSO login support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s
2026-06-10 13:34:38 +01:00
Ultradesu 71d6556ba8 Improved artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m37s
2026-06-09 17:18:55 +01:00
Ultradesu 0ac59eb0ca Improved release editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m35s
2026-06-09 15:06:01 +01:00
Ultradesu 652c6a470d Fix queue display
Build and Publish / Build and Publish Docker Image (push) Successful in 2m33s
2026-06-08 17:59:56 +01:00
Ultradesu 1c54782dd7 Fix queue display 2026-06-08 17:59:46 +01:00
Ultradesu 8fa06038fe Added MacOS client support
Build and Publish / Build and Publish Docker Image (push) Successful in 2m32s
2026-06-08 16:36:30 +01:00
Ultradesu 6b69cc0fc0 Bump~
Build and Publish / Build and Publish Docker Image (push) Successful in 2m34s
2026-06-08 11:23:10 +01:00
Ultradesu 624cadab64 Fixed sone jobs 2026-06-08 11:22:52 +01:00
ab a8756c95de Bump
Build and Publish / Build and Publish Docker Image (push) Successful in 4m9s
2026-06-05 13:21:55 +03:00
Ultradesu 952d11e6f5 added mobile auth support 2026-06-05 03:37:51 +03:00
Ultradesu df16713aa2 bump
Build and Publish / Build and Publish Docker Image (push) Successful in 2m37s
2026-06-03 17:49:13 +03:00
Ultradesu 3a9240b82c Add music sharing and mobile player polish
Add track, release, and queue sharing with post-login redirects; support shared playlist links and highlighted shared tracks.

Add local synchronized playback for jams, constrain HTTP metrics to known routes, and refine mobile player controls/layout.
2026-06-03 17:48:30 +03:00
Ultradesu d31dce3ece Improved player
Build and Publish / Build and Publish Docker Image (push) Successful in 2m34s
2026-06-03 03:45:23 +03:00
Ultradesu 1e1453e465 Update metadata jobs and player library 2026-06-03 03:39:16 +03:00
Ultradesu d2a8f301b8 pump ver
Build and Publish / Build and Publish Docker Image (push) Successful in 2m31s
2026-06-03 02:03:29 +03:00
Ultradesu 0a4f78acfa Improve player library and admin user stats 2026-06-03 02:02:23 +03:00
Ultradesu f716c22f86 Added user page. fixed player.
Build and Publish / Build and Publish Docker Image (push) Successful in 3m32s
2026-06-02 20:45:00 +03:00
Ultradesu a1dafaa5f2 JOBS: fixed metadata_backfill.rs 2026-06-01 18:49:44 +03:00
Ultradesu c244b3d4d8 PLAYER: fixe i8n
Build and Publish / Build and Publish Docker Image (push) Successful in 3m23s
2026-06-01 18:33:39 +03:00
ab 27ee56c5b7 CORE: added prom metrics on /metrics
Build and Publish / Build and Publish Docker Image (push) Successful in 2m2s
2026-06-01 16:08:49 +03:00
ab 88b5c7f7d1 UI: fixed some ui issues
Build and Publish / Build and Publish Docker Image (push) Successful in 3m23s
2026-06-01 14:53:51 +03:00
42 changed files with 27534 additions and 2159 deletions
+1
View File
@@ -2,3 +2,4 @@
/nul
/.claude
/media
/federation
Generated
+2522 -620
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1,17 +1,24 @@
[package]
name = "furumusic"
version = "0.2.10"
version = "0.9.6"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
[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"] }
serde = { version = "1", features = ["derive"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
async-stream = "0.3"
bytes = "1"
tower = "0.5"
base64 = "0.22"
blake3 = "1"
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -27,5 +34,9 @@ anyhow = "1.0"
tokio-cron-scheduler = "0.15"
croner = "3"
async-trait = "0.1"
postcard = { version = "1", features = ["alloc"] }
uuid = "1"
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" }
+136
View File
@@ -227,6 +227,55 @@ impl App for AdminApp {
},
"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",
{
@@ -308,6 +357,32 @@ impl App for AdminApp {
}),
"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(
"/v2/api/jobs/{name}/run",
cot::router::method::post({
@@ -340,6 +415,38 @@ impl App for AdminApp {
}),
"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(
"/v2/api/jobs/{name}/toggle",
cot::router::method::post({
@@ -480,6 +587,32 @@ impl App for AdminApp {
},
"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",
{
@@ -1249,6 +1382,9 @@ impl App for AdminApp {
all.extend(cot::db::migrations::wrap_migrations(
crate::scheduler::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::auth::db_migrations::MIGRATIONS,
));
all
}
}
+1067 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1472,6 +1472,7 @@ pub async fn metadata_backfill_run(
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"),
};
+11 -1
View File
@@ -128,13 +128,21 @@ pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<C
match tokio::fs::read(img_path).await {
Ok(data) if !data.is_empty() => {
let mime = mime_for_image(img_path);
crate::metrics::record_agent_cover_lookup("folder", "ok", data.len());
return Some(CoverImage {
data,
mime_type: mime,
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;
}
}
}
@@ -143,10 +151,12 @@ pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<C
let path = audio_path.to_path_buf();
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
if let Ok(Some(cover)) = result {
crate::metrics::record_agent_cover_lookup("embedded", "ok", cover.data.len());
return Some(cover);
}
}
crate::metrics::record_agent_cover_lookup("none", "not_found", 0);
None
}
+12 -2
View File
@@ -74,6 +74,7 @@ fn generate_missing_variants_sync(
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;
@@ -88,12 +89,21 @@ fn generate_missing_variants_sync(
.to_rgb8();
let mut output = Vec::new();
let mut encoder = JpegEncoder::new_with_quality(&mut output, variant.quality);
encoder.encode(
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;
}
+30 -2
View File
@@ -330,6 +330,7 @@ pub async fn normalize_batch(
// If over 80% of context limit and more than 1 file, split
let limit_80 = context_limit * 80 / 100;
if estimated > limit_80 && files.len() > 1 {
crate::metrics::record_agent_llm_split("estimated_context");
tracing::info!(
estimated_tokens = estimated,
context_limit,
@@ -419,6 +420,7 @@ pub async fn normalize_batch(
|| err_str.contains("length")
|| err_str.contains("token");
if is_context_error {
crate::metrics::record_agent_llm_split("context_error");
tracing::warn!(
file_count = files.len(),
"LLM error suggests context overflow, splitting batch: {e}"
@@ -466,14 +468,40 @@ pub async fn normalize_batch(
}
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 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
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 {
results,
+525 -11
View File
@@ -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::http::StatusCode;
use cot::http::header::CONTENT_TYPE;
use cot::json::Json;
use cot::openapi::{AsApiOperation, RouteContext};
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::session::Session;
use cot::{App, Body};
use schemars::JsonSchema;
use serde::Serialize;
use cot::{App, Body, RequestHandler};
use schemars::{JsonSchema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use crate::auth;
use crate::config::AppConfig;
use crate::user::User;
// ---------------------------------------------------------------------------
// JSON error helper
@@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
.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
// ---------------------------------------------------------------------------
@@ -34,8 +240,85 @@ struct MeResponse {
role: String,
}
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_session_user(&session, &db).await else {
#[derive(Debug, Serialize, JsonSchema)]
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(
cot::http::StatusCode::UNAUTHORIZED,
"not authenticated",
@@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::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
// ---------------------------------------------------------------------------
@@ -62,10 +485,101 @@ impl App for ApiApp {
}
fn router(&self) -> Router {
Router::with_urls([Route::with_api_handler_and_name(
"/me",
api_get(me_handler),
"api_me",
)])
Router::with_urls([
Route::with_api_handler_and_name(
"/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
View File
@@ -1,7 +1,13 @@
use chrono::{Duration, Utc};
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::session::Session;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::user::User;
@@ -37,6 +43,7 @@ impl Role {
// ---------------------------------------------------------------------------
const SESSION_USER_ID: &str = "user_id";
const SESSION_POST_LOGIN_REDIRECT: &str = "post_login_redirect";
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
@@ -45,11 +52,7 @@ pub struct AuthenticatedUser {
pub role: Role,
}
/// 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()??;
fn authenticated_user_from_user(user: User) -> Option<AuthenticatedUser> {
if !user.is_active() {
return None;
}
@@ -61,6 +64,7 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
display
}
};
crate::metrics::record_active_user(user.id_val());
Some(AuthenticatedUser {
id: user.id_val(),
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
/// `Err(response)` — a redirect to `/login` or a 403.
pub async fn require_admin_or_redirect(
@@ -75,9 +435,11 @@ pub async fn require_admin_or_redirect(
db: &Database,
) -> Result<AuthenticatedUser, cot::response::Response> {
let Some(user) = get_session_user(session, db).await else {
crate::metrics::record_authorization_denied("unauthenticated");
return Err(redirect("/login"));
};
if user.role != Role::Admin {
crate::metrics::record_authorization_denied("forbidden");
return Err("Forbidden"
.with_status(cot::http::StatusCode::FORBIDDEN)
.into_response()
@@ -96,9 +458,47 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
.insert(SESSION_USER_ID, user_id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
crate::metrics::record_active_user(user_id);
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.
pub async fn logout(session: &Session) -> cot::Result<()> {
session
@@ -117,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::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
// ---------------------------------------------------------------------------
+66
View File
@@ -135,6 +135,9 @@ pub struct ConfigSources {
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 {
@@ -162,6 +165,9 @@ impl Default for ConfigSources {
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,
}
}
}
@@ -270,6 +276,15 @@ pub struct AppConfig {
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 {
@@ -297,6 +312,9 @@ impl Default for AppConfig {
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,
}
}
}
@@ -325,6 +343,9 @@ impl_env_overrides!(
agent_concurrency,
lastfm_api_key,
lastfm_shared_secret,
federation_enabled,
federation_network_id,
federation_save_on_listen,
);
impl AppConfig {
@@ -338,10 +359,52 @@ impl AppConfig {
pub fn load() -> Self {
let mut cfg = Self::default();
cfg.apply_env_overrides();
cfg.apply_startup_db_overrides();
cfg.apply_env_overrides();
cfg.normalize_host_paths();
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
/// track the source of each field.
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
@@ -410,6 +473,9 @@ impl AppConfig {
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);
}
}
+536
View File
@@ -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
+991
View File
@@ -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");
}
File diff suppressed because it is too large Load Diff
+773
View File
@@ -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))))
}
+349
View File
@@ -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())
}
+75
View File
@@ -272,6 +272,8 @@ translations! {
// 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" , "Треки";
@@ -298,6 +300,7 @@ translations! {
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" , "Переименовать плейлист";
@@ -306,6 +309,7 @@ translations! {
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" , "Переименовать";
@@ -355,6 +359,11 @@ translations! {
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" , "Назад";
@@ -365,6 +374,7 @@ translations! {
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" , "Синглы";
@@ -386,7 +396,71 @@ translations! {
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-файл";
@@ -414,6 +488,7 @@ translations! {
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" , "История прослушиваний";
+431
View File
@@ -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"
);
}
}
+576 -77
View File
@@ -15,12 +15,19 @@ pub struct ArtworkBackfillJob;
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
const MAX_LASTFM_RELEASE_LOOKUPS: i64 = 200;
const MAX_LASTFM_ARTIST_LOOKUPS: i64 = 200;
const MAX_CAA_RELEASE_LOOKUPS: i64 = 200;
#[derive(Debug, Clone, Copy)]
pub struct ArtworkBackfillOptions {
pub overwrite_existing: bool,
}
#[derive(Debug, sqlx::FromRow)]
struct ReleaseCandidate {
id: i64,
title: String,
artist_name: Option<String>,
track_title: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
@@ -36,6 +43,13 @@ struct ArtworkRefCandidate {
file_path: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
struct ArtistImageRepairCandidate {
id: i64,
name: String,
media_file_id: i64,
}
#[derive(Debug, Deserialize)]
struct LastfmAlbumResponse {
album: Option<LastfmImageContainer>,
@@ -85,7 +99,10 @@ struct ArtworkStats {
broken_release_refs_cleared: u64,
broken_track_refs_cleared: u64,
broken_artist_refs_cleared: u64,
remote_artist_album_fallback_refs_cleared: u64,
release_local_assigned: u64,
release_caa_assigned: u64,
release_caa_not_found: u64,
release_lastfm_assigned: u64,
release_lastfm_not_found: u64,
release_skipped_no_audio: u64,
@@ -114,66 +131,107 @@ impl Job for ArtworkBackfillJob {
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let storage_dir = ctx.config.agent_storage_dir.trim();
if storage_dir.is_empty() {
log.warn("agent_storage_dir is not configured, skipping artwork backfill");
return Ok(());
}
let client = Client::builder()
.user_agent(format!(
"furumusic-artwork-backfill/{}",
env!("CARGO_PKG_VERSION")
))
.timeout(std::time::Duration::from_secs(20))
.build()?;
let mut stats = ArtworkStats::default();
let normalized_paths =
crate::media_paths::normalize_media_file_paths(&ctx.pool, storage_dir).await?;
if normalized_paths > 0 {
log.info(&format!(
"Media path normalization pass: rewrote {normalized_paths} media file path(s) to relative storage paths"
));
} else {
log.info("Media path normalization pass: all media file paths are already relative");
}
repair_missing_artwork_refs(ctx, log, storage_dir, &mut stats).await?;
backfill_release_local(ctx, log, storage_dir, &mut stats).await?;
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 artwork fallback");
} else {
backfill_release_lastfm(ctx, log, storage_dir, api_key, &client, &mut stats).await?;
backfill_artist_lastfm(ctx, log, storage_dir, api_key, &client, &mut stats).await?;
}
backfill_artist_album_fallbacks(ctx, log, &mut stats).await?;
repair_cover_variants(ctx, log, storage_dir, &mut stats).await?;
log.info(&format!(
"Artwork backfill complete: broken_release_refs_cleared={}, broken_track_refs_cleared={}, broken_artist_refs_cleared={}, release_local_assigned={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, artist_album_fallback_assigned={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}",
stats.broken_release_refs_cleared,
stats.broken_track_refs_cleared,
stats.broken_artist_refs_cleared,
stats.release_local_assigned,
stats.release_lastfm_assigned,
stats.release_lastfm_not_found,
stats.release_skipped_no_audio,
stats.artist_lastfm_assigned,
stats.artist_lastfm_not_found,
stats.artist_album_fallback_assigned,
stats.variants_created,
stats.variants_unchanged,
stats.variants_missing_original,
stats.failed
));
Ok(())
run_with_options(
ctx,
log,
ArtworkBackfillOptions {
overwrite_existing: false,
},
)
.await
}
}
pub async fn run_with_options(
ctx: &JobContext,
log: &mut JobLog,
options: ArtworkBackfillOptions,
) -> anyhow::Result<()> {
let storage_dir = ctx.config.agent_storage_dir.trim();
if storage_dir.is_empty() {
log.warn("agent_storage_dir is not configured, skipping artwork backfill");
return Ok(());
}
log.info(&format!(
"Artwork backfill options: mode={}",
if options.overwrite_existing {
"overwrite_existing"
} else {
"missing_only"
}
));
let client = Client::builder()
.user_agent(format!(
"furumusic-artwork-backfill/{}",
env!("CARGO_PKG_VERSION")
))
.timeout(std::time::Duration::from_secs(20))
.build()?;
let musicbrainz_client =
crate::jobs::musicbrainz::MusicBrainzClient::new("furumusic-artwork-backfill")?;
let mut stats = ArtworkStats::default();
let normalized_paths =
crate::media_paths::normalize_media_file_paths(&ctx.pool, storage_dir).await?;
if normalized_paths > 0 {
log.info(&format!(
"Media path normalization pass: rewrote {normalized_paths} media file path(s) to relative storage paths"
));
} else {
log.info("Media path normalization pass: all media file paths are already relative");
}
repair_missing_artwork_refs(ctx, log, storage_dir, &mut stats).await?;
repair_remote_artist_album_fallback_refs(ctx, log, &mut stats).await?;
backfill_release_local(ctx, log, storage_dir, options, &mut stats).await?;
backfill_release_coverartarchive(
ctx,
log,
storage_dir,
&musicbrainz_client,
options,
&mut stats,
)
.await?;
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 artwork fallback");
} else {
backfill_release_lastfm(ctx, log, storage_dir, api_key, &client, options, &mut stats)
.await?;
backfill_artist_lastfm(ctx, log, storage_dir, api_key, &client, options, &mut stats)
.await?;
}
backfill_artist_album_fallbacks(ctx, log, options, &mut stats).await?;
repair_cover_variants(ctx, log, storage_dir, &mut stats).await?;
log.info(&format!(
"Artwork backfill complete: broken_release_refs_cleared={}, broken_track_refs_cleared={}, broken_artist_refs_cleared={}, remote_artist_album_fallback_refs_cleared={}, release_local_assigned={}, release_caa_assigned={}, release_caa_not_found={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, artist_album_fallback_assigned={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}",
stats.broken_release_refs_cleared,
stats.broken_track_refs_cleared,
stats.broken_artist_refs_cleared,
stats.remote_artist_album_fallback_refs_cleared,
stats.release_local_assigned,
stats.release_caa_assigned,
stats.release_caa_not_found,
stats.release_lastfm_assigned,
stats.release_lastfm_not_found,
stats.release_skipped_no_audio,
stats.artist_lastfm_assigned,
stats.artist_lastfm_not_found,
stats.artist_album_fallback_assigned,
stats.variants_created,
stats.variants_unchanged,
stats.variants_missing_original,
stats.failed
));
Ok(())
}
async fn repair_missing_artwork_refs(
ctx: &JobContext,
log: &mut JobLog,
@@ -186,6 +244,71 @@ async fn repair_missing_artwork_refs(
Ok(())
}
async fn repair_remote_artist_album_fallback_refs(
ctx: &JobContext,
log: &mut JobLog,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let rows = sqlx::query_as::<_, ArtistImageRepairCandidate>(
r#"SELECT DISTINCT a.id,
a.name::text AS name,
a.image_file_id AS media_file_id
FROM furumusic__artist a
JOIN furumusic__media_file mf ON mf.id = a.image_file_id
WHERE a.image_file_id IS NOT NULL
AND a.is_hidden = false
AND mf.file_path NOT LIKE '%/__artist_image__/%'
AND EXISTS (
SELECT 1
FROM furumusic__release r
JOIN furumusic__artwork_lookup_state s
ON s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
WHERE r.cover_file_id = a.image_file_id
AND r.is_hidden = false
)
ORDER BY a.id"#,
)
.fetch_all(&ctx.pool)
.await?;
if rows.is_empty() {
return Ok(());
}
log.info(&format!(
"Artist fallback repair pass: clearing {} remote release cover fallback(s)",
rows.len()
));
for row in rows {
let result = sqlx::query(
r#"UPDATE furumusic__artist
SET image_file_id = NULL,
updated_at = $3
WHERE id = $1
AND image_file_id = $2"#,
)
.bind(row.id)
.bind(row.media_file_id)
.bind(now_iso())
.execute(&ctx.pool)
.await?;
if result.rows_affected() > 0 {
stats.remote_artist_album_fallback_refs_cleared += 1;
log.warn(&format!(
"Artist {} \"{}\": cleared remote release cover fallback (media_file_id={})",
row.id, row.name, row.media_file_id
));
}
}
Ok(())
}
async fn repair_missing_release_cover_refs(
ctx: &JobContext,
log: &mut JobLog,
@@ -360,6 +483,7 @@ async fn backfill_release_local(
ctx: &JobContext,
log: &mut JobLog,
storage_dir: &str,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let releases = sqlx::query_as::<_, ReleaseCandidate>(
@@ -372,17 +496,26 @@ async fn backfill_release_local(
WHERE ra.release_id = r.id
ORDER BY ra.position
LIMIT 1
) AS artist_name
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
WHERE r.cover_file_id IS NULL
WHERE ($1 OR r.cover_file_id IS NULL)
AND r.is_hidden = false
ORDER BY r.id"#,
)
.bind(options.overwrite_existing)
.fetch_all(&ctx.pool)
.await?;
if releases.is_empty() {
log.info("Release local artwork pass: all visible releases already have covers");
log.info("Release local artwork pass: no eligible releases need local cover lookup");
return Ok(());
}
log.info(&format!(
@@ -451,7 +584,7 @@ async fn backfill_release_local(
.await
{
Ok(cover_file_id) => {
cover_art::assign_cover_to_release(&ctx.pool, release.id, cover_file_id).await?;
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options).await?;
stats.release_local_assigned += 1;
log.info(&format!(
"Release {} \"{}\": assigned local cover from {source_desc}",
@@ -471,12 +604,12 @@ async fn backfill_release_local(
Ok(())
}
async fn backfill_release_lastfm(
async fn backfill_release_coverartarchive(
ctx: &JobContext,
log: &mut JobLog,
storage_dir: &str,
api_key: &str,
client: &Client,
client: &crate::jobs::musicbrainz::MusicBrainzClient,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let failed_cutoff = cutoff_iso(1);
@@ -502,25 +635,285 @@ async fn backfill_release_lastfm(
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, ta.position
LIMIT 1
)
) AS artist_name
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
LEFT JOIN furumusic__artwork_lookup_state s
ON s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source = 'lastfm'
WHERE r.cover_file_id IS NULL
AND s.source = 'coverartarchive'
WHERE ($3 OR r.cover_file_id IS NULL)
AND r.is_hidden = false
AND (
s.entity_id IS NULL
$3
OR s.entity_id IS NULL
OR s.status = 'failed' AND s.last_attempt_at < $1
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
OR s.status = 'found' AND s.last_attempt_at < $1
)
ORDER BY s.last_attempt_at NULLS FIRST, r.id
LIMIT $3"#,
LIMIT $4"#,
)
.bind(&failed_cutoff)
.bind(&not_found_cutoff)
.bind(options.overwrite_existing)
.bind(MAX_CAA_RELEASE_LOOKUPS)
.fetch_all(&ctx.pool)
.await?;
if releases.is_empty() {
log.info("Release Cover Art Archive pass: no eligible releases need lookup");
return Ok(());
}
log.info(&format!(
"Release Cover Art Archive pass: looking up {} release(s)",
releases.len()
));
for (index, release) in releases.iter().enumerate() {
let Some(artist_name) = release
.artist_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
stats.release_caa_not_found += 1;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"not_found",
Some("release has no primary artist for MusicBrainz lookup"),
None,
)
.await?;
continue;
};
log.info(&format!(
"Release Cover Art Archive {}/{}: release {} \"{}\" by \"{}\"",
index + 1,
releases.len(),
release.id,
release.title,
artist_name
));
let release_mbid = crate::jobs::musicbrainz::load_or_search_release_mbid(
&ctx.pool,
client,
release.id,
artist_name,
&release.title,
release.track_title.as_deref(),
)
.await;
let (release_mbid, release_group_mbid) = match release_mbid {
Ok(value) => value,
Err(err) => {
stats.failed += 1;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"failed",
Some(&err.to_string()),
None,
)
.await?;
log.warn(&format!(
"Release {} \"{}\": MusicBrainz lookup for cover art failed: {err}",
release.id, release.title
));
continue;
}
};
if release_mbid.is_none() && release_group_mbid.is_none() {
stats.release_caa_not_found += 1;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"not_found",
Some("MusicBrainz release match not found"),
None,
)
.await?;
continue;
}
match client
.fetch_cover_art_front_url(release_mbid.as_deref(), release_group_mbid.as_deref())
.await
{
Ok(Some(image_url)) => {
match download_remote_cover(client.http_client(), &image_url).await {
Ok(cover) => match cover_art::save_cover_to_storage(
&ctx.db,
&ctx.pool,
storage_dir,
artist_name,
&release.title,
&cover,
)
.await
{
Ok(cover_file_id) => {
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options)
.await?;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"found",
None,
Some(&image_url),
)
.await?;
stats.release_caa_assigned += 1;
log.info(&format!(
"Release {} \"{}\": assigned Cover Art Archive cover",
release.id, release.title
));
}
Err(err) => {
stats.failed += 1;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"failed",
Some(&err.to_string()),
Some(&image_url),
)
.await?;
log.warn(&format!(
"Release {} \"{}\": failed to save Cover Art Archive cover: {err}",
release.id, release.title
));
}
},
Err(err) => {
stats.failed += 1;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"failed",
Some(&err.to_string()),
Some(&image_url),
)
.await?;
log.warn(&format!(
"Release {} \"{}\": failed to download Cover Art Archive cover: {err}",
release.id, release.title
));
}
}
}
Ok(None) => {
stats.release_caa_not_found += 1;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"not_found",
None,
None,
)
.await?;
}
Err(err) => {
stats.failed += 1;
record_coverartarchive_lookup_state(
&ctx.pool,
"release",
release.id,
"failed",
Some(&err.to_string()),
None,
)
.await?;
log.warn(&format!(
"Release {} \"{}\": Cover Art Archive lookup failed: {err}",
release.id, release.title
));
}
}
}
Ok(())
}
async fn backfill_release_lastfm(
ctx: &JobContext,
log: &mut JobLog,
storage_dir: &str,
api_key: &str,
client: &Client,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let failed_cutoff = cutoff_iso(1);
let not_found_cutoff = cutoff_iso(30);
let releases = sqlx::query_as::<_, ReleaseCandidate>(
r#"SELECT r.id,
r.title::text AS title,
COALESCE(
(
SELECT a.name::text
FROM furumusic__release_artist ra
JOIN furumusic__artist a ON a.id = ra.artist_id
WHERE ra.release_id = r.id
ORDER BY ra.position
LIMIT 1
),
(
SELECT a.name::text
FROM furumusic__track t
JOIN furumusic__track_artist ta ON ta.track_id = t.id
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE t.release_id = r.id AND ta.role <> 'featuring'
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, ta.position
LIMIT 1
)
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
LEFT JOIN furumusic__artwork_lookup_state s
ON s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source = 'lastfm'
WHERE ($3 OR r.cover_file_id IS NULL)
AND r.is_hidden = false
AND (
$3
OR s.entity_id IS NULL
OR s.status = 'failed' AND s.last_attempt_at < $1
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
OR s.status = 'found' AND s.last_attempt_at < $1
)
ORDER BY s.last_attempt_at NULLS FIRST, r.id
LIMIT $4"#,
)
.bind(&failed_cutoff)
.bind(&not_found_cutoff)
.bind(options.overwrite_existing)
.bind(MAX_LASTFM_RELEASE_LOOKUPS)
.fetch_all(&ctx.pool)
.await?;
@@ -580,7 +973,7 @@ async fn backfill_release_lastfm(
.await
{
Ok(cover_file_id) => {
cover_art::assign_cover_to_release(&ctx.pool, release.id, cover_file_id)
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options)
.await?;
record_lookup_state(
&ctx.pool,
@@ -680,12 +1073,37 @@ async fn backfill_release_lastfm(
Ok(())
}
async fn assign_cover_to_release(
pool: &sqlx::PgPool,
release_id: i64,
cover_file_id: i64,
options: ArtworkBackfillOptions,
) -> anyhow::Result<()> {
if options.overwrite_existing {
sqlx::query(
r#"UPDATE furumusic__release
SET cover_file_id = $1,
updated_at = $3
WHERE id = $2"#,
)
.bind(cover_file_id)
.bind(release_id)
.bind(now_iso())
.execute(pool)
.await?;
} else {
cover_art::assign_cover_to_release(pool, release_id, cover_file_id).await?;
}
Ok(())
}
async fn backfill_artist_lastfm(
ctx: &JobContext,
log: &mut JobLog,
storage_dir: &str,
api_key: &str,
client: &Client,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let failed_cutoff = cutoff_iso(1);
@@ -699,21 +1117,25 @@ async fn backfill_artist_lastfm(
AND s.entity_id = a.id
AND s.source = 'lastfm'
WHERE (
$3
OR
a.image_file_id IS NULL
OR mf.file_path NOT LIKE '%/__artist_image__/%'
)
AND a.is_hidden = false
AND (
s.entity_id IS NULL
$3
OR s.entity_id IS NULL
OR s.status = 'failed' AND s.last_attempt_at < $1
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
OR s.status = 'found' AND s.last_attempt_at < $1
)
ORDER BY s.last_attempt_at NULLS FIRST, a.id
LIMIT $3"#,
LIMIT $4"#,
)
.bind(&failed_cutoff)
.bind(&not_found_cutoff)
.bind(options.overwrite_existing)
.bind(MAX_LASTFM_ARTIST_LOOKUPS)
.fetch_all(&ctx.pool)
.await?;
@@ -755,6 +1177,8 @@ async fn backfill_artist_lastfm(
updated_at = $3
WHERE id = $2
AND (
$4
OR
image_file_id IS NULL
OR EXISTS (
SELECT 1
@@ -767,6 +1191,7 @@ async fn backfill_artist_lastfm(
.bind(image_file_id)
.bind(artist.id)
.bind(now_iso())
.bind(options.overwrite_existing)
.execute(&ctx.pool)
.await?;
record_lookup_state(
@@ -826,7 +1251,7 @@ async fn backfill_artist_lastfm(
artist.id, artist.name
));
stats.artist_lastfm_not_found += 1;
match assign_artist_album_fallback(ctx, artist.id).await {
match assign_artist_album_fallback(ctx, artist.id, options).await {
Ok(Some(media_file_id)) => {
stats.artist_album_fallback_assigned += 1;
log.info(&format!(
@@ -892,31 +1317,53 @@ async fn backfill_artist_lastfm(
async fn backfill_artist_album_fallbacks(
ctx: &JobContext,
log: &mut JobLog,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let artists = sqlx::query_as::<_, ArtistCandidate>(
r#"SELECT a.id, a.name::text AS name
FROM furumusic__artist a
WHERE a.image_file_id IS NULL
WHERE ($1 OR a.image_file_id IS NULL)
AND a.is_hidden = false
AND EXISTS (
SELECT 1
FROM furumusic__release_artist ra
JOIN furumusic__release r ON r.id = ra.release_id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ra.artist_id = a.id
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
UNION
SELECT 1
FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id
JOIN furumusic__release r ON r.id = t.release_id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ta.artist_id = a.id
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
)
ORDER BY a.id"#,
)
.bind(options.overwrite_existing)
.fetch_all(&ctx.pool)
.await?;
@@ -931,7 +1378,7 @@ async fn backfill_artist_album_fallbacks(
));
for artist in artists {
match assign_artist_album_fallback(ctx, artist.id).await {
match assign_artist_album_fallback(ctx, artist.id, options).await {
Ok(Some(media_file_id)) => {
stats.artist_album_fallback_assigned += 1;
log.info(&format!(
@@ -1016,6 +1463,7 @@ async fn repair_cover_variants(
async fn assign_artist_album_fallback(
ctx: &JobContext,
artist_id: i64,
options: ArtworkBackfillOptions,
) -> anyhow::Result<Option<i64>> {
let media_file_id: Option<i64> = sqlx::query_scalar(
r#"SELECT media_file_id
@@ -1023,17 +1471,37 @@ async fn assign_artist_album_fallback(
SELECT DISTINCT r.cover_file_id AS media_file_id
FROM furumusic__release r
JOIN furumusic__release_artist ra ON ra.release_id = r.id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ra.artist_id = $1
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
UNION
SELECT DISTINCT r.cover_file_id AS media_file_id
FROM furumusic__release r
JOIN furumusic__track t ON t.release_id = r.id
JOIN furumusic__track_artist ta ON ta.track_id = t.id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ta.artist_id = $1
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
) covers
ORDER BY random()
LIMIT 1"#,
@@ -1051,11 +1519,12 @@ async fn assign_artist_album_fallback(
SET image_file_id = $1,
updated_at = $3
WHERE id = $2
AND image_file_id IS NULL"#,
AND ($4 OR image_file_id IS NULL)"#,
)
.bind(media_file_id)
.bind(artist_id)
.bind(now_iso())
.bind(options.overwrite_existing)
.execute(&ctx.pool)
.await?;
@@ -1324,6 +1793,36 @@ async fn record_lookup_state(
Ok(())
}
async fn record_coverartarchive_lookup_state(
pool: &sqlx::PgPool,
entity_kind: &str,
entity_id: i64,
status: &str,
error: Option<&str>,
source_url: Option<&str>,
) -> anyhow::Result<()> {
sqlx::query(
r#"INSERT INTO furumusic__artwork_lookup_state
(entity_kind, entity_id, source, status, attempt_count, last_attempt_at, last_error, source_url)
VALUES ($1, $2, 'coverartarchive', $3, 1, $4, $5, $6)
ON CONFLICT (entity_kind, entity_id, source) DO UPDATE SET
status = EXCLUDED.status,
attempt_count = furumusic__artwork_lookup_state.attempt_count + 1,
last_attempt_at = EXCLUDED.last_attempt_at,
last_error = EXCLUDED.last_error,
source_url = EXCLUDED.source_url"#,
)
.bind(entity_kind)
.bind(entity_id)
.bind(status)
.bind(now_iso())
.bind(error)
.bind(source_url)
.execute(pool)
.await?;
Ok(())
}
async fn reset_lookup_state(
pool: &sqlx::PgPool,
entity_kind: &str,
@@ -1333,7 +1832,7 @@ async fn reset_lookup_state(
r#"DELETE FROM furumusic__artwork_lookup_state
WHERE entity_kind = $1
AND entity_id = $2
AND source = 'lastfm'"#,
AND source IN ('lastfm', 'coverartarchive')"#,
)
.bind(entity_kind)
.bind(entity_id)
+159 -10
View File
@@ -12,6 +12,21 @@ const AUDIO_EXTENSIONS: &[&str] = &[
"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;
#[async_trait::async_trait]
@@ -29,6 +44,8 @@ impl Job for InboxDiscoverJob {
}
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
if DISCOVER_RUNNING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
@@ -44,6 +61,19 @@ impl Job for InboxDiscoverJob {
}
}
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;
@@ -61,6 +91,10 @@ impl Job for InboxDiscoverJob {
let mut audio_files = Vec::new();
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()));
if audio_files.is_empty() {
return Ok(());
@@ -72,6 +106,7 @@ impl Job for InboxDiscoverJob {
let mut discovered = 0u64;
let mut skipped_hash = 0u64;
let mut skipped_existing = 0u64;
let mut requeued = 0u64;
for (_folder, files) in &groups {
for file_path in files {
@@ -79,13 +114,34 @@ impl Job for InboxDiscoverJob {
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
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
Ok(true) => {
skipped_existing += 1;
// One review row per path: any existing row blocks creating a
// new one. A stale "failed" row is requeued in place instead,
// so retries don't multiply rows. "rejected" stays rejected.
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;
}
Ok(false) => {}
Err(e) => {
log.warn(&format!(
"Error checking existing review for {}: {e}",
@@ -97,6 +153,7 @@ impl Job for InboxDiscoverJob {
// Compute SHA-256 hash
let path_clone = file_path.to_path_buf();
let hash_start = std::time::Instant::now();
let (hash, file_size) =
match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
let data = std::fs::read(&path_clone)?;
@@ -107,8 +164,16 @@ impl Job for InboxDiscoverJob {
})
.await?
{
Ok(v) => v,
Ok(v) => {
crate::metrics::record_agent_file_hash(hash_start.elapsed(), v.1, "ok");
v
}
Err(e) => {
crate::metrics::record_agent_file_hash(
hash_start.elapsed(),
0,
"error",
);
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
continue;
}
@@ -125,13 +190,18 @@ impl Job for InboxDiscoverJob {
// Extract raw metadata
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 || {
crate::agent::metadata::extract(&path_for_meta)
})
.await?
{
Ok(m) => m,
Ok(m) => {
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "ok");
m
}
Err(e) => {
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "error");
log.warn(&format!(
"Failed to extract metadata from {}: {e}",
file_path.display()
@@ -186,13 +256,19 @@ impl Job for InboxDiscoverJob {
}
log.info(&format!(
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
discovered, skipped_hash, skipped_existing
"Discovered {} new files, requeued {} failed, skipped {} (hash known), skipped {} (already tracked)",
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
// and no orchestrator is already running
if discovered > 0 {
if discovered + requeued > 0 {
if crate::jobs::inbox_process::is_orchestrator_running() {
log.info(
"New files discovered but inbox_process already running, it will pick them up",
@@ -219,6 +295,7 @@ impl Job for InboxDiscoverJob {
}
}
metrics_guard.outcome = run_outcome;
Ok(())
}
}
@@ -263,3 +340,75 @@ pub fn is_audio_file(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
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
}
+216 -58
View File
@@ -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.
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).
pub fn is_orchestrator_running() -> bool {
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
}
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
/// Returns true if the lock was acquired (no other orchestrator is running).
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)
.fetch_one(pool)
.await
{
Ok(acquired) => acquired,
Err(e) => {
tracing::error!("Failed to acquire advisory lock: {e}");
false
}
struct OrchestratorAdvisoryGuard {
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
}
impl Drop for OrchestratorAdvisoryGuard {
fn drop(&mut self) {
let Some(mut conn) = self.conn.take() else {
return;
};
tokio::spawn(async move {
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 release_orchestrator_lock(pool: &sqlx::PgPool) {
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
async fn connection_holds_orchestrator_lock(
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
) -> 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)
.execute(pool)
.await;
.fetch_one(&mut *conn)
.await?;
if acquired {
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
} else {
Ok(None)
}
}
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
@@ -83,10 +159,10 @@ impl Job for InboxProcessJob {
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
log.info(
log.warn(
"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;
impl Drop for AtomicGuard {
@@ -98,27 +174,16 @@ impl Job for InboxProcessJob {
let _atomic_guard = AtomicGuard;
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
if !try_acquire_orchestrator_lock(&ctx.pool).await {
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
return Ok(());
}
tracing::info!("inbox_process: advisory lock acquired");
let pool_for_unlock = ctx.pool.clone();
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 = match try_acquire_orchestrator_lock(&ctx.pool).await? {
Some(guard) => guard,
None => {
log.warn("Another inbox_process orchestrator holds the advisory lock");
anyhow::bail!(
"inbox_process advisory lock is held by another database session; no in-process orchestrator is running"
);
}
}
let _advisory_guard = AdvisoryGuard {
pool: pool_for_unlock,
};
tracing::info!("inbox_process: advisory lock acquired");
let config = Arc::clone(&ctx.config);
let mut total_ok = 0u64;
@@ -154,14 +219,25 @@ impl Job for InboxProcessJob {
folder_rel, file_count,
));
let (ok, fail) =
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
.await;
// Large folders are split into chunks: a single LLM call for
// 100+ files overflows the completion window and the whole
// 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_fail += fail;
total_ok += ok;
total_fail += fail;
}
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"
));
}
}
@@ -284,6 +360,7 @@ async fn process_folder_batch(
log.info("Phase 1: extracting metadata...");
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
let mut failed_reviews: Vec<PendingReview> = Vec::new();
let mut merged_count = 0u64;
for mut review in reviews {
let stored_input_path = review.input_path_str().to_owned();
@@ -295,9 +372,6 @@ async fn process_folder_batch(
.unwrap_or("unknown")
.to_owned();
// Set status → processing
let _ = review.set_processing(db).await;
// Parse context_json
let mut context: serde_json::Value = review
.context_json
@@ -305,14 +379,56 @@ async fn process_folder_batch(
.and_then(|s| serde_json::from_str(s).ok())
.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)
let path_for_meta = file_path.to_path_buf();
let metadata_start = std::time::Instant::now();
let meta_future =
tokio::task::spawn_blocking(move || crate::agent::metadata::extract(&path_for_meta));
let raw_meta =
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))) => {
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "error");
crate::metrics::record_agent_failed("metadata");
let msg = format!("{filename}: metadata error: {e}");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
@@ -320,6 +436,8 @@ async fn process_folder_batch(
continue;
}
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}");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
@@ -327,6 +445,8 @@ async fn process_folder_batch(
continue;
}
Err(_) => {
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "timeout");
crate::metrics::record_agent_failed("metadata");
let msg = format!("{filename}: metadata timeout (60s)");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
@@ -374,15 +494,16 @@ async fn process_folder_batch(
}
log.info(&format!(
"Phase 1 done: {} prepared, {} failed metadata",
"Phase 1 done: {} prepared, {} merged duplicates, {} failed",
prepared.len(),
merged_count,
failed_reviews.len(),
));
if prepared.is_empty() {
let duration_ms = batch_start.elapsed().as_millis() as i64;
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)
@@ -416,6 +537,7 @@ async fn process_folder_batch(
// Lookup all unique artist queries
let mut all_similar_artists = Vec::new();
for q in &artist_queries {
let rag_start = std::time::Instant::now();
match tokio::time::timeout(
std::time::Duration::from_secs(30),
crate::agent::rag::find_similar_artists(pool, q, 5),
@@ -423,6 +545,12 @@ async fn process_folder_batch(
.await
{
Ok(Ok(results)) => {
crate::metrics::record_agent_rag(
"artist",
"ok",
rag_start.elapsed(),
results.len(),
);
for a in results {
if !all_similar_artists
.iter()
@@ -432,13 +560,20 @@ async fn process_folder_batch(
}
}
}
Ok(Err(e)) => log.warn(&format!("RAG artist lookup failed for \"{q}\": {e}")),
Err(_) => log.warn(&format!("RAG artist lookup timed out for \"{q}\"")),
Ok(Err(e)) => {
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();
for q in &album_queries {
let rag_start = std::time::Instant::now();
match tokio::time::timeout(
std::time::Duration::from_secs(30),
crate::agent::rag::find_similar_releases(pool, q, 5),
@@ -446,6 +581,12 @@ async fn process_folder_batch(
.await
{
Ok(Ok(results)) => {
crate::metrics::record_agent_rag(
"release",
"ok",
rag_start.elapsed(),
results.len(),
);
for r in results {
if !all_similar_releases
.iter()
@@ -455,8 +596,14 @@ async fn process_folder_batch(
}
}
}
Ok(Err(e)) => log.warn(&format!("RAG release lookup failed for \"{q}\": {e}")),
Err(_) => log.warn(&format!("RAG release lookup timed out for \"{q}\"")),
Ok(Err(e)) => {
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}\""))
}
}
}
@@ -547,18 +694,22 @@ async fn process_folder_batch(
let batch_result = match llm_result {
Ok(r) => r,
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}");
log.error(&err_msg);
// Mark all files as failed
let prepared_count = prepared.len() as u64;
for mut p in prepared {
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 _ = run
.set_failed(db, duration_ms, &log.output(), &err_msg)
.await;
return (0, total_fail_count);
return (merged_count, total_fail_count);
}
};
@@ -582,7 +733,7 @@ async fn process_folder_batch(
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 mut ok_count = 0u64;
let mut ok_count = merged_count;
let mut fail_count = failed_reviews.len() as u64;
for mut p in prepared {
@@ -612,6 +763,7 @@ async fn process_folder_batch(
let result_json = serde_json::to_string(normalized).unwrap_or_default();
let confidence = normalized.confidence.unwrap_or(0.0);
crate::metrics::observe_agent_confidence(confidence);
let feat = if normalized.featured_artists.is_empty() {
String::new()
@@ -655,9 +807,12 @@ async fn process_folder_batch(
{
Ok(()) => {
let _ = p.review.set_auto_approved(db).await;
crate::metrics::record_agent_file_processed("ok", "auto_approved");
ok_count += 1;
}
Err(e) => {
crate::metrics::record_agent_failed("finalize");
crate::metrics::record_agent_file_processed("failed", "failed");
let msg = format!("{filename}: finalize failed: {e}");
log.error(&msg);
let _ = p.review.set_failed(db, &msg).await;
@@ -671,6 +826,7 @@ async fn process_folder_batch(
)
.unwrap();
let _ = p.review.save(db).await;
crate::metrics::record_agent_file_processed("ok", "pending_review");
log.info(&format!(
"{filename}: manual review (confidence {confidence} < {})",
config.agent_confidence_threshold,
@@ -682,9 +838,11 @@ async fn process_folder_batch(
let duration_ms = batch_start.elapsed().as_millis() as i64;
if fail_count == 0 {
let _ = run.set_completed(db, duration_ms, &log.output()).await;
crate::metrics::record_agent_folder_batch("completed", file_count, batch_start.elapsed());
} else {
let msg = format!("{fail_count} file(s) failed");
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)
+521 -75
View File
@@ -15,6 +15,7 @@ pub struct MetadataBackfillOptions {
pub duration_seconds: bool,
pub local_genres: bool,
pub lastfm_tags: bool,
pub musicbrainz_tags: bool,
pub overwrite: bool,
}
@@ -26,6 +27,7 @@ impl MetadataBackfillOptions {
|| self.duration_seconds
|| self.local_genres
|| self.lastfm_tags
|| self.musicbrainz_tags
}
fn needs_file_scan(self) -> bool {
@@ -59,6 +61,7 @@ struct LastfmReleaseTagRow {
id: i64,
title: String,
artist_name: Option<String>,
track_title: Option<String>,
}
#[derive(sqlx::FromRow)]
@@ -84,6 +87,22 @@ struct LastfmTagStats {
failed: u64,
}
#[derive(Debug, Default)]
struct MusicBrainzTagStats {
considered: u64,
updated_entities: u64,
tags_saved: u64,
skipped_existing: u64,
not_found: u64,
failed: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LastfmTagPassResult {
Completed,
RateLimited,
}
pub struct MetadataBackfillJob;
#[async_trait::async_trait]
@@ -111,6 +130,7 @@ impl Job for MetadataBackfillJob {
duration_seconds: true,
local_genres: true,
lastfm_tags: true,
musicbrainz_tags: true,
overwrite: false,
},
)
@@ -137,10 +157,11 @@ pub async fn run_with_options(
let mut failed = 0u64;
log.info(&format!(
"Metadata backfill options: file_scan={}, local_genres={}, lastfm_tags={}, mode={}",
"Metadata backfill options: file_scan={}, local_genres={}, lastfm_tags={}, musicbrainz_tags={}, mode={}",
options.needs_file_scan(),
options.local_genres,
options.lastfm_tags,
options.musicbrainz_tags,
if options.overwrite {
"overwrite"
} else {
@@ -245,14 +266,9 @@ pub async fn run_with_options(
let mut changed_tags = false;
if options.local_genres {
if let (Some(track_id), Some(genre)) = (row.track_id, raw_meta.genre.as_deref()) {
let saved = save_track_tag_text(
&ctx.pool,
track_id,
genre,
"file",
options.overwrite,
)
.await?;
let saved =
save_track_tag_text(&ctx.pool, track_id, genre, "file", options.overwrite)
.await?;
if saved > 0 {
local_tags_updated += saved;
changed_tags = true;
@@ -306,14 +322,28 @@ pub async fn run_with_options(
LastfmTagStats::default()
};
let musicbrainz_stats = if options.musicbrainz_tags {
log.info("Starting MusicBrainz tag backfill");
backfill_musicbrainz_tags(ctx, log, options.overwrite).await?
} else {
log.info("MusicBrainz tag backfill disabled for this run");
MusicBrainzTagStats::default()
};
log.info(&format!(
"Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {local_tags_updated} local tags saved, {unchanged} unchanged, {missing} missing, {failed} failed; Last.fm tags: considered={}, updated_entities={}, tags_saved={}, skipped_existing={}, not_found={}, failed={}",
"Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {local_tags_updated} local tags saved, {unchanged} unchanged, {missing} missing, {failed} failed; Last.fm tags: considered={}, updated_entities={}, tags_saved={}, skipped_existing={}, not_found={}, failed={}; MusicBrainz tags: considered={}, updated_entities={}, tags_saved={}, skipped_existing={}, not_found={}, failed={}",
lastfm_stats.considered,
lastfm_stats.updated_entities,
lastfm_stats.tags_saved,
lastfm_stats.skipped_existing,
lastfm_stats.not_found,
lastfm_stats.failed,
musicbrainz_stats.considered,
musicbrainz_stats.updated_entities,
musicbrainz_stats.tags_saved,
musicbrainz_stats.skipped_existing,
musicbrainz_stats.not_found,
musicbrainz_stats.failed,
));
Ok(())
}
@@ -345,12 +375,277 @@ async fn backfill_lastfm_tags(
.build()?;
let mut stats = LastfmTagStats::default();
backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
if backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
== LastfmTagPassResult::RateLimited
{
return Ok(stats);
}
if backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
== LastfmTagPassResult::RateLimited
{
return Ok(stats);
}
if backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
== LastfmTagPassResult::RateLimited
{
return Ok(stats);
}
Ok(stats)
}
async fn backfill_musicbrainz_tags(
ctx: &JobContext,
log: &mut JobLog,
overwrite: bool,
) -> anyhow::Result<MusicBrainzTagStats> {
log.info("MusicBrainz tag backfill started");
let client = crate::jobs::musicbrainz::MusicBrainzClient::new("furumusic-metadata-backfill")?;
let mut stats = MusicBrainzTagStats::default();
backfill_musicbrainz_artist_tags(ctx, log, &client, overwrite, &mut stats).await?;
backfill_musicbrainz_release_tags(ctx, log, &client, overwrite, &mut stats).await?;
Ok(stats)
}
async fn backfill_musicbrainz_artist_tags(
ctx: &JobContext,
log: &mut JobLog,
client: &crate::jobs::musicbrainz::MusicBrainzClient,
overwrite: bool,
stats: &mut MusicBrainzTagStats,
) -> anyhow::Result<()> {
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
r#"SELECT DISTINCT a.id, a.name::text AS name
FROM furumusic__artist a
JOIN furumusic__track_artist ta ON ta.artist_id = a.id
JOIN furumusic__track t ON t.id = ta.track_id
WHERE a.is_hidden = false AND t.is_hidden = false
ORDER BY a.id"#,
)
.fetch_all(&ctx.pool)
.await?;
log.info(&format!(
"MusicBrainz artist tag pass: checking {} artist(s)",
rows.len()
));
let total = rows.len();
for (index, row) in rows.into_iter().enumerate() {
if should_log_lastfm_item(index + 1, total, 25) {
log.info(&format!(
"MusicBrainz artist tags {}/{}: artist {} \"{}\"",
index + 1,
total,
row.id,
row.name
));
}
if should_skip_source_entity(&ctx.pool, "artist", row.id, "musicbrainz", overwrite).await? {
stats.skipped_existing += 1;
continue;
}
stats.considered += 1;
let mbid =
match crate::jobs::musicbrainz::load_external_id(&ctx.pool, "artist", row.id, "artist")
.await?
{
Some(mbid) => Some(mbid),
None => match client.search_artist(&row.name).await {
Ok(Some(found)) => {
crate::jobs::musicbrainz::save_external_id(
&ctx.pool,
"artist",
row.id,
"artist",
&found.mbid,
found.score as f64 / 100.0,
)
.await?;
Some(found.mbid)
}
Ok(None) => None,
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"MusicBrainz artist search failed for artist {} \"{}\": {err}",
row.id, row.name
));
None
}
},
};
let Some(mbid) = mbid else {
stats.not_found += 1;
continue;
};
match client.lookup_artist_tags(&mbid).await {
Ok(tags) if !tags.is_empty() => {
let tags = musicbrainz_tags_to_candidates(&tags);
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "musicbrainz", false)
.await
{
Ok(saved) => {
stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"MusicBrainz artist tags save failed for artist {} \"{}\": {err}",
row.id, row.name
));
}
}
}
Ok(_) => {
stats.not_found += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"MusicBrainz artist tags failed for artist {} \"{}\" mbid={}: {err}",
row.id, row.name, mbid
));
}
}
}
Ok(())
}
async fn backfill_musicbrainz_release_tags(
ctx: &JobContext,
log: &mut JobLog,
client: &crate::jobs::musicbrainz::MusicBrainzClient,
overwrite: bool,
stats: &mut MusicBrainzTagStats,
) -> anyhow::Result<()> {
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
r#"SELECT r.id,
r.title::text AS title,
(
SELECT a.name::text
FROM furumusic__release_artist ra
JOIN furumusic__artist a ON a.id = ra.artist_id
WHERE ra.release_id = r.id
ORDER BY ra.position
LIMIT 1
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
WHERE r.is_hidden = false
ORDER BY r.id"#,
)
.fetch_all(&ctx.pool)
.await?;
log.info(&format!(
"MusicBrainz release tag pass: checking {} release(s)",
rows.len()
));
let total = rows.len();
for (index, row) in rows.into_iter().enumerate() {
if should_log_lastfm_item(index + 1, total, 25) {
log.info(&format!(
"MusicBrainz release tags {}/{}: release {} \"{}\"",
index + 1,
total,
row.id,
row.title
));
}
if should_skip_source_entity(&ctx.pool, "release", row.id, "musicbrainz", overwrite).await?
{
stats.skipped_existing += 1;
continue;
}
let Some(artist) = row
.artist_name
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
stats.not_found += 1;
continue;
};
stats.considered += 1;
let mbid = match crate::jobs::musicbrainz::load_or_search_release_mbid(
&ctx.pool,
client,
row.id,
artist,
&row.title,
row.track_title.as_deref(),
)
.await
{
Ok((release_mbid, _release_group_mbid)) => release_mbid,
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"MusicBrainz release search failed for release {} \"{}\" / \"{}\": {err}",
row.id, artist, row.title
));
None
}
};
let Some(mbid) = mbid else {
stats.not_found += 1;
continue;
};
match client.lookup_release_tags(&mbid).await {
Ok(result) if !result.tags.is_empty() => {
if let Some(group_mbid) = result.release_group_mbid.as_deref() {
crate::jobs::musicbrainz::save_external_id(
&ctx.pool,
"release",
row.id,
"release_group",
group_mbid,
1.0,
)
.await?;
}
let tags = musicbrainz_tags_to_candidates(&result.tags);
match replace_entity_tags(&ctx.pool, "release", row.id, &tags, "musicbrainz", false)
.await
{
Ok(saved) => {
stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"MusicBrainz release tags save failed for release {} \"{}\" / \"{}\": {err}",
row.id, artist, row.title
));
}
}
}
Ok(_) => {
stats.not_found += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"MusicBrainz release tags failed for release {} \"{}\" mbid={}: {err}",
row.id, row.title, mbid
));
}
}
}
Ok(())
}
async fn backfill_lastfm_artist_tags(
ctx: &JobContext,
log: &mut JobLog,
@@ -358,7 +653,7 @@ async fn backfill_lastfm_artist_tags(
api_key: &str,
overwrite: bool,
stats: &mut LastfmTagStats,
) -> anyhow::Result<()> {
) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
r#"SELECT DISTINCT a.id, a.name::text AS name
FROM furumusic__artist a
@@ -376,31 +671,65 @@ async fn backfill_lastfm_artist_tags(
));
let total = rows.len();
for (index, row) in rows.into_iter().enumerate() {
if should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await? {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
log.info(&format!(
"Last.fm artist tags progress: {}/{}",
index + 1,
total
if should_log_lastfm_item(index + 1, total, 25) {
log.info(&format!(
"Last.fm artist tags {}/{}: artist {} \"{}\"",
index + 1,
total,
row.id,
row.name
));
}
match should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await {
Ok(true) => {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
log.info(&format!(
"Last.fm artist tags progress: {}/{}",
index + 1,
total
));
}
continue;
}
Ok(false) => {}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm artist tags skip check failed for artist {} \"{}\": {err}",
row.id, row.name
));
continue;
}
continue;
}
stats.considered += 1;
match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
Ok(tags) if !tags.is_empty() => {
let saved =
replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false)
.await?;
stats.tags_saved += saved;
stats.updated_entities += 1;
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false).await
{
Ok(saved) => {
stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm artist tags save failed for artist {} \"{}\": {err}",
row.id, row.name
));
}
}
}
Ok(_) => {
stats.not_found += 1;
}
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
return Err(err);
Err(err) if is_lastfm_rate_limit_error(&err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm rate limit reached while fetching artist tags for artist {} \"{}\"; stopping Last.fm tag backfill for this run",
row.id, row.name
));
return Ok(LastfmTagPassResult::RateLimited);
}
Err(err) => {
stats.failed += 1;
@@ -419,7 +748,7 @@ async fn backfill_lastfm_artist_tags(
}
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
}
Ok(())
Ok(LastfmTagPassResult::Completed)
}
async fn backfill_lastfm_release_tags(
@@ -429,7 +758,7 @@ async fn backfill_lastfm_release_tags(
api_key: &str,
overwrite: bool,
stats: &mut LastfmTagStats,
) -> anyhow::Result<()> {
) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
r#"SELECT r.id,
r.title::text AS title,
@@ -440,7 +769,15 @@ async fn backfill_lastfm_release_tags(
WHERE ra.release_id = r.id
ORDER BY ra.position
LIMIT 1
) AS artist_name
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
WHERE r.is_hidden = false
ORDER BY r.id"#,
@@ -454,18 +791,41 @@ async fn backfill_lastfm_release_tags(
));
let total = rows.len();
for (index, row) in rows.into_iter().enumerate() {
if should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await? {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
log.info(&format!(
"Last.fm release tags progress: {}/{}",
index + 1,
total
));
}
continue;
if should_log_lastfm_item(index + 1, total, 25) {
log.info(&format!(
"Last.fm release tags {}/{}: release {} \"{}\"",
index + 1,
total,
row.id,
row.title
));
}
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
match should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await {
Ok(true) => {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
log.info(&format!(
"Last.fm release tags progress: {}/{}",
index + 1,
total
));
}
continue;
}
Ok(false) => {}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm release tags skip check failed for release {} \"{}\": {err}",
row.id, row.title
));
continue;
}
}
let Some(artist) = row
.artist_name
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
stats.not_found += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
@@ -480,17 +840,32 @@ async fn backfill_lastfm_release_tags(
stats.considered += 1;
match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await {
Ok(tags) if !tags.is_empty() => {
let saved =
replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
.await?;
stats.tags_saved += saved;
stats.updated_entities += 1;
match replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
.await
{
Ok(saved) => {
stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm release tags save failed for release {} \"{}\" / \"{}\": {err}",
row.id, artist, row.title
));
}
}
}
Ok(_) => {
stats.not_found += 1;
}
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
return Err(err);
Err(err) if is_lastfm_rate_limit_error(&err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm rate limit reached while fetching release tags for release {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
row.id, artist, row.title
));
return Ok(LastfmTagPassResult::RateLimited);
}
Err(err) => {
stats.failed += 1;
@@ -509,7 +884,7 @@ async fn backfill_lastfm_release_tags(
}
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
}
Ok(())
Ok(LastfmTagPassResult::Completed)
}
async fn backfill_lastfm_track_tags(
@@ -519,7 +894,7 @@ async fn backfill_lastfm_track_tags(
api_key: &str,
overwrite: bool,
stats: &mut LastfmTagStats,
) -> anyhow::Result<()> {
) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmTrackTagRow>(
r#"SELECT t.id,
t.title::text AS title,
@@ -544,18 +919,41 @@ async fn backfill_lastfm_track_tags(
));
let total = rows.len();
for (index, row) in rows.into_iter().enumerate() {
if should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await? {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 50) {
log.info(&format!(
"Last.fm track tags progress: {}/{}",
index + 1,
total
));
}
continue;
if should_log_lastfm_item(index + 1, total, 50) {
log.info(&format!(
"Last.fm track tags {}/{}: track {} \"{}\"",
index + 1,
total,
row.id,
row.title
));
}
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
match should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await {
Ok(true) => {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 50) {
log.info(&format!(
"Last.fm track tags progress: {}/{}",
index + 1,
total
));
}
continue;
}
Ok(false) => {}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm track tags skip check failed for track {} \"{}\": {err}",
row.id, row.title
));
continue;
}
}
let Some(artist) = row
.artist_name
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
stats.not_found += 1;
if should_log_lastfm_progress(index + 1, total, 50) {
@@ -570,16 +968,30 @@ async fn backfill_lastfm_track_tags(
stats.considered += 1;
match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await {
Ok(tags) if !tags.is_empty() => {
let saved =
replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await?;
stats.tags_saved += saved;
stats.updated_entities += 1;
match replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await {
Ok(saved) => {
stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm track tags save failed for track {} \"{}\" / \"{}\": {err}",
row.id, artist, row.title
));
}
}
}
Ok(_) => {
stats.not_found += 1;
}
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
return Err(err);
Err(err) if is_lastfm_rate_limit_error(&err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm rate limit reached while fetching track tags for track {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
row.id, artist, row.title
));
return Ok(LastfmTagPassResult::RateLimited);
}
Err(err) => {
stats.failed += 1;
@@ -598,18 +1010,36 @@ async fn backfill_lastfm_track_tags(
}
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
}
Ok(())
Ok(LastfmTagPassResult::Completed)
}
fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool {
total > 0 && (done == total || done % every == 0)
}
fn should_log_lastfm_item(done: usize, total: usize, every: usize) -> bool {
total > 0 && (done == 1 || done == total || done % every == 0)
}
fn is_lastfm_rate_limit_error(err: &anyhow::Error) -> bool {
err.to_string().contains("Last.fm rate limit exceeded")
}
async fn should_skip_lastfm_entity(
pool: &sqlx::PgPool,
entity_kind: &str,
entity_id: i64,
overwrite: bool,
) -> anyhow::Result<bool> {
should_skip_source_entity(pool, entity_kind, entity_id, "lastfm", overwrite).await
}
async fn should_skip_source_entity(
pool: &sqlx::PgPool,
entity_kind: &str,
entity_id: i64,
source: &str,
overwrite: bool,
) -> anyhow::Result<bool> {
if overwrite {
return Ok(false);
@@ -617,16 +1047,28 @@ async fn should_skip_lastfm_entity(
let exists: Option<i64> = sqlx::query_scalar(
r#"SELECT 1
FROM furumusic__entity_genre_tag
WHERE entity_kind = $1 AND entity_id = $2 AND source = 'lastfm'
WHERE entity_kind = $1 AND entity_id = $2 AND source = $3
LIMIT 1"#,
)
.bind(entity_kind)
.bind(entity_id)
.bind(source)
.fetch_optional(pool)
.await?;
Ok(exists.is_some())
}
fn musicbrainz_tags_to_candidates(
tags: &[crate::jobs::musicbrainz::MusicBrainzTag],
) -> Vec<TagCandidate> {
tags.iter()
.map(|tag| TagCandidate {
name: tag.name.clone(),
weight: tag.weight,
})
.collect()
}
async fn fetch_lastfm_artist_tags(
client: &reqwest::Client,
api_key: &str,
@@ -723,7 +1165,11 @@ async fn fetch_lastfm_top_tags(
Value::Object(_) => tag_from_value(tag_value).into_iter().collect::<Vec<_>>(),
_ => Vec::new(),
};
tags.sort_by(|a, b| b.weight.total_cmp(&a.weight).then_with(|| a.name.cmp(&b.name)));
tags.sort_by(|a, b| {
b.weight
.total_cmp(&a.weight)
.then_with(|| a.name.cmp(&b.name))
});
tags.truncate(LASTFM_TAG_LIMIT);
Ok(tags)
}
@@ -896,9 +1342,9 @@ fn tags_from_text(value: &str) -> Vec<TagCandidate> {
for raw in normalized_separators.split([';', ',']) {
if let Some(name) = clean_tag_name(raw) {
if !is_ignored_tag(&normalize_tag_name(&name))
&& !tags
.iter()
.any(|tag: &TagCandidate| normalize_tag_name(&tag.name) == normalize_tag_name(&name))
&& !tags.iter().any(|tag: &TagCandidate| {
normalize_tag_name(&tag.name) == normalize_tag_name(&name)
})
{
tags.push(TagCandidate { name, weight: 1.0 });
}
+2
View File
@@ -1,9 +1,11 @@
pub mod archive_cleanup;
pub mod artwork_backfill;
pub mod inbox_discover;
pub mod inbox_process;
pub mod lastfm_popularity;
pub mod lastfm_scrobble;
pub mod metadata_backfill;
pub mod musicbrainz;
use std::path::{Component, Path, PathBuf};
+587
View File
@@ -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())
}
+5 -5
View File
@@ -447,17 +447,17 @@ async fn fetch_pending_scrobbles(
o.duration_seconds,
o.attempt_count,
a.session_key::text AS session_key,
t.title::text AS title,
r.title::text AS album_title,
COALESCE(o.track_title, t.title::text) AS title,
COALESCE(o.album_title, r.title::text) AS album_title,
t.track_number,
(
COALESCE(o.artist_name, (
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,
)) AS artist_name,
(
SELECT ar.name::text
FROM furumusic__release_artist ra
@@ -468,7 +468,7 @@ async fn fetch_pending_scrobbles(
) 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__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')
+219 -8
View File
@@ -3,10 +3,12 @@ mod agent;
mod api;
mod auth;
mod config;
mod federation;
mod i18n;
mod jobs;
mod lastfm;
mod media_paths;
mod metrics;
mod music;
mod oidc;
mod player;
@@ -28,7 +30,7 @@ use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::middleware::SessionMiddleware;
use cot::project::RegisterAppsContext;
use cot::request::extractors::{RequestForm, UrlQuery};
use cot::request::extractors::{Path, RequestForm, UrlQuery};
use cot::response::IntoResponse;
use cot::router::method::get;
use cot::router::{Route, Router};
@@ -51,6 +53,7 @@ fn build_registry() -> Arc<JobRegistry> {
registry.register(jobs::inbox_discover::InboxDiscoverJob);
registry.register(jobs::inbox_process::InboxProcessJob);
registry.register(jobs::inbox_process::FileProcessJob);
registry.register(jobs::archive_cleanup::ArchiveCleanupJob);
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
@@ -62,15 +65,55 @@ fn build_registry() -> Arc<JobRegistry> {
// 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 {
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 };
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)]
struct SetLangQuery {
lang: String,
@@ -130,6 +173,21 @@ struct LoginForm {
password: String,
}
#[derive(Deserialize)]
struct LoginQuery {
error: Option<String>,
}
#[derive(Deserialize)]
struct SharePathId {
id: i64,
}
#[derive(Deserialize)]
struct SharePathToken {
token: String,
}
// ---------------------------------------------------------------------------
// Logout
// ---------------------------------------------------------------------------
@@ -139,12 +197,93 @@ async fn logout_handler(session: Session) -> cot::Result<cot::response::Response
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
// ---------------------------------------------------------------------------
struct FuruApp {
config: Arc<AppConfig>,
pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>>,
}
impl App for FuruApp {
@@ -166,23 +305,52 @@ impl App for FuruApp {
),
Route::with_handler_and_name(
"/",
|session: Session, db: Database, i18n: I18n| async move {
index(session, db, i18n).await
|session: Session, db: Database, i18n: I18n, query: UrlQuery<IndexQuery>| async move {
index(session, db, i18n, query).await
},
"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(
"/login",
get({
let config = Arc::clone(&self.config);
move |i18n: I18n, db: Database| {
move |i18n: I18n, db: Database, query: UrlQuery<LoginQuery>| {
let config = Arc::clone(&config);
async move {
// No users at all → redirect to first-run setup
if User::count_all(&db).await.unwrap_or(0) == 0 {
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?
.into_response()
}
@@ -200,6 +368,11 @@ impl App for FuruApp {
let data = match result {
FormResult::Ok(data) => data,
FormResult::ValidationError(_) => {
metrics::record_auth_attempt(
"password",
"failure",
"validation_error",
);
let msg = i18n.t.login_invalid.to_owned();
return login_page_handler(i18n, &config, db, msg)
.await?
@@ -207,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
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
{
@@ -215,14 +397,24 @@ impl App for FuruApp {
match hash.verify(&password) {
PasswordVerificationResult::Ok
| PasswordVerificationResult::OkObsolete(_) => {
let redirect_to =
auth::get_post_login_redirect(&session)
.await?
.unwrap_or_else(|| "/".to_string());
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 => {}
}
}
}
metrics::record_auth_attempt("password", "failure", "bad_credentials");
let msg = i18n.t.login_invalid.to_owned();
login_page_handler(i18n, &config, db, msg)
.await?
@@ -244,6 +436,16 @@ impl App for FuruApp {
get(oidc::oidc_callback_handler),
"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",
),
])
}
}
@@ -332,6 +534,7 @@ impl Project for FuruProject {
context: &cot::project::MiddlewareContext,
) -> cot::project::RootHandler {
handler
.middleware(metrics::MetricsLayer)
.middleware(StaticFilesMiddleware::from_context(context))
.middleware(SessionMiddleware::from_context(context))
.build()
@@ -357,10 +560,18 @@ impl Project for FuruProject {
.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_with_views(
FuruApp {
config: Arc::clone(&self.app_config),
pool: Arc::new(tokio::sync::OnceCell::new()),
},
"",
);
+1111
View File
File diff suppressed because it is too large Load Diff
+618
View File
@@ -1865,6 +1865,617 @@ pub mod db_migrations {
&[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()];
}
#[cot::db::migrations::migration_op]
async fn create_synced_listen_history(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__listen_event (
user_id BIGINT NOT NULL,
listen_id TEXT NOT NULL,
content_id TEXT NOT NULL,
local_track_id BIGINT,
origin_device_id TEXT NOT NULL,
started_at_ms BIGINT NOT NULL,
listened_ms BIGINT NOT NULL,
track_duration_ms BIGINT,
ended_reason TEXT NOT NULL,
qualified BOOLEAN NOT NULL,
metadata_json JSONB NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, listen_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_listen_event_user_time
ON furumusic__listen_event (user_id, started_at_ms DESC, listen_id)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_listen_event_content
ON furumusic__listen_event (user_id, content_id)",
)
.await?;
ctx.db
.raw(
"INSERT INTO furumusic__listen_event
(user_id, listen_id, content_id, local_track_id,
origin_device_id, started_at_ms, listened_ms,
track_duration_ms, ended_reason, qualified,
metadata_json, created_at)
SELECT ph.user_id,
'legacy-web:' || ph.id::text,
tr.content_id,
ph.track_id,
ident.device_id,
(EXTRACT(EPOCH FROM ph.played_at::timestamptz) * 1000)::bigint,
COALESCE(ph.duration_listened, 0)::bigint * 1000,
(t.duration_seconds * 1000)::bigint,
CASE WHEN ph.completed THEN '\"finished\"' ELSE '\"unknown\"' END,
ph.completed,
jsonb_build_object(
'title', t.title::text,
'artist_names', COALESCE((
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
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'
), '[]'::jsonb),
'featured_artist_names', COALESCE((
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
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'
), '[]'::jsonb),
'release_title', r.title::text
),
ph.played_at::text
FROM furumusic__play_history ph
JOIN furumusic__track t ON t.id = ph.track_id
JOIN furumusic__track_ref tr ON tr.local_track_id = ph.track_id
JOIN furumusic__fed_device_identity ident
ON ident.user_id = ph.user_id
LEFT JOIN furumusic__release r ON r.id = t.release_id
ON CONFLICT (user_id, listen_id) DO NOTHING",
)
.await?;
ctx.db
.raw(
"ALTER TABLE furumusic__lastfm_scrobble_outbox
ALTER COLUMN track_id DROP NOT NULL",
)
.await?;
ctx.db
.raw(
"ALTER TABLE furumusic__lastfm_scrobble_outbox
ADD COLUMN IF NOT EXISTS track_title TEXT,
ADD COLUMN IF NOT EXISTS artist_name TEXT,
ADD COLUMN IF NOT EXISTS album_title TEXT",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0041CreateSyncedListenHistory;
impl migrations::Migration for M0041CreateSyncedListenHistory {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0041_create_synced_listen_history";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0040_create_content_addressed_music_refs",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_synced_listen_history).build()];
}
#[cot::db::migrations::migration_op]
async fn repair_legacy_listen_qualification(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"UPDATE furumusic__listen_event le
SET qualified = (
ph.completed
OR (
COALESCE(ph.duration_listened, 0) >= 5
AND COALESCE(t.duration_seconds, 0) > 0
AND COALESCE(ph.duration_listened, 0) >= LEAST(
COALESCE(t.duration_seconds, 0) / 2.0,
240.0
)
)
)
FROM furumusic__play_history ph
JOIN furumusic__track t ON t.id = ph.track_id
WHERE le.user_id = ph.user_id
AND le.listen_id = 'legacy-web:' || ph.id::text",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0042RepairLegacyListenQualification;
impl migrations::Migration for M0042RepairLegacyListenQualification {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0042_repair_legacy_listen_qualification";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0041_create_synced_listen_history",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(repair_legacy_listen_qualification).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -1891,5 +2502,12 @@ pub mod db_migrations {
&M0033CreateLastfmScrobbling,
&M0034CreateArtworkLookupState,
&M0035CreateEntityGenreTags,
&M0036CreateExternalMetadataIds,
&M0037CreatePlaylistShareLinks,
&M0038CreateFedDeviceSync,
&M0039EnsureFederationContentIdCache,
&M0040CreateContentAddressedMusicRefs,
&M0041CreateSyncedListenHistory,
&M0042RepairLegacyListenQualification,
];
}
+692 -4
View File
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
use std::time::Instant;
use cot::db::Database;
use cot::request::extractors::UrlQuery;
use cot::session::Session;
use openidconnect::core::{CoreClient, CoreProviderMetadata};
use openidconnect::{
@@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce";
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
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
// ---------------------------------------------------------------------------
@@ -172,6 +180,7 @@ pub async fn oidc_start_handler(
|| config.oidc_client_secret.is_empty()
{
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);
}
@@ -180,6 +189,7 @@ pub async fn oidc_start_handler(
Ok(c) => c,
Err(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);
}
};
@@ -245,13 +255,23 @@ pub struct OidcCallbackQuery {
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(
i18n: I18n,
db: Database,
session: Session,
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
OidcCallbackQuery,
>,
UrlQuery(query): UrlQuery<OidcCallbackQuery>,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(&db).await;
@@ -276,19 +296,23 @@ pub async fn oidc_callback_handler(
// Validate CSRF state.
let Some(saved_csrf) = saved_csrf else {
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);
};
if query.state != saved_csrf {
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);
}
let Some(nonce_str) = saved_nonce else {
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);
};
let Some(pkce_str) = saved_pkce else {
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);
};
@@ -300,6 +324,7 @@ pub async fn oidc_callback_handler(
Ok(c) => c,
Err(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);
}
};
@@ -318,6 +343,7 @@ pub async fn oidc_callback_handler(
Ok(req) => req,
Err(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);
}
};
@@ -330,6 +356,7 @@ pub async fn oidc_callback_handler(
Ok(t) => t,
Err(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);
}
};
@@ -340,6 +367,7 @@ pub async fn oidc_callback_handler(
Some(t) => t,
None => {
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);
}
};
@@ -348,6 +376,7 @@ pub async fn oidc_callback_handler(
Ok(c) => c,
Err(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);
}
};
@@ -395,6 +424,7 @@ pub async fn oidc_callback_handler(
config.oidc_user_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);
}
@@ -413,12 +443,20 @@ pub async fn oidc_callback_handler(
Ok(u) => u,
Err(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);
}
};
let redirect_to = auth::get_post_login_redirect(&session)
.await?
.unwrap_or_else(|| "/".to_string());
// Log the user in.
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.
let _: Option<String> = session
@@ -438,7 +476,297 @@ pub async fn oidc_callback_handler(
.await
.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))
}
// ---------------------------------------------------------------------------
@@ -596,6 +924,307 @@ fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Respon
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => 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.
fn urlencoded(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
@@ -612,3 +1241,62 @@ fn urlencoded(s: &str) -> String {
}
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>"#),
"&lt;tag attr=&quot;x&amp;y&quot;&gt;&#39;text&#39;&lt;/tag&gt;"
);
}
#[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());
}
}
+126 -3
View File
@@ -16,6 +16,7 @@ pub(super) struct Paginated<T: Serialize> {
pub(super) total: i64,
pub(super) page: i32,
pub(super) per_page: i32,
pub(super) has_more: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -50,6 +51,7 @@ pub(super) struct ArtistRef {
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub(super) struct TrackItem {
pub(super) id: i64,
pub(super) content_id: Option<String>,
pub(super) title: String,
pub(super) track_number: Option<i32>,
pub(super) disc_number: Option<i32>,
@@ -73,6 +75,15 @@ pub(super) struct TrackItem {
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlaylistTrackItem {
pub(super) playlist_track_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) sort_key: Option<i64>,
#[serde(flatten)]
pub(super) track: TrackItem,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct ArtistAppearanceTrack {
pub(super) id: i64,
@@ -264,6 +275,24 @@ pub(super) struct PlayerDevicesResponse {
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,
@@ -285,6 +314,19 @@ pub(super) struct PlaylistDetail {
pub(super) is_public: bool,
pub(super) is_saved: bool,
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>,
}
@@ -311,6 +353,45 @@ pub(super) struct UserProfile {
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,
@@ -460,14 +541,16 @@ pub(super) struct UserUploadReviewUpdateRequest {
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayHistoryItem {
pub(super) id: i64,
pub(super) track_id: i64,
pub(super) id: String,
pub(super) track_id: Option<i64>,
pub(super) track_title: String,
pub(super) release_title: Option<String>,
pub(super) track: TrackItem,
pub(super) track: serde_json::Value,
pub(super) played_at: String,
pub(super) duration_listened: Option<i32>,
pub(super) completed: bool,
pub(super) device_id: String,
pub(super) device_name: String,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -483,6 +566,46 @@ pub(super) struct LikeStatus {
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: Option<String>,
pub(super) item_id: Option<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)]
pub(super) struct LikedIds {
pub(super) track_ids: Vec<i64>,
+3039 -566
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -3,9 +3,11 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(super) struct HistoryEntry {
pub(super) track_id: i64,
pub(super) listen_id: Option<String>,
pub(super) started_at: Option<i64>,
pub(super) duration_listened: Option<i32>,
pub(super) completed: bool,
pub(super) ended_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -42,13 +44,26 @@ pub(super) struct AddTracksRequest {
#[derive(Debug, Deserialize)]
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)]
pub(super) struct PaginationQuery {
pub(super) page: Option<i32>,
pub(super) limit: Option<i32>,
pub(super) mine: Option<bool>,
}
#[derive(Debug, Deserialize)]
+30 -28
View File
@@ -56,6 +56,8 @@ pub(super) struct MediaFileRow {
pub(super) file_path: String,
pub(super) mime_type: String,
pub(super) file_size_bytes: i64,
pub(super) sha256_hash: String,
pub(super) created_at: String,
}
#[derive(sqlx::FromRow)]
@@ -93,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
#[derive(sqlx::FromRow)]
pub(super) struct PlaylistTrackRow {
pub(super) playlist_track_id: Option<i64>,
pub(super) id: i64,
pub(super) title: String,
pub(super) track_number: Option<i32>,
@@ -251,34 +254,6 @@ pub(super) struct ReleaseUploaderRow {
pub(super) track_count: i64,
}
#[derive(sqlx::FromRow)]
pub(super) struct PlayHistoryTrackRow {
pub(super) history_id: i64,
pub(super) played_at: String,
pub(super) duration_listened: Option<i32>,
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)]
pub(super) struct ReleaseInfoRow {
pub(super) id: i64,
@@ -287,3 +262,30 @@ pub(super) struct ReleaseInfoRow {
pub(super) year: Option<i32>,
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>,
}
+31 -8
View File
@@ -200,6 +200,12 @@ impl JobRun {
duration_ms: i64,
log: &str,
) -> 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.finished_at = Some(now_iso().to_string());
self.duration_ms = Some(duration_ms);
@@ -214,6 +220,12 @@ impl JobRun {
log: &str,
error: &str,
) -> 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.finished_at = Some(now_iso().to_string());
self.duration_ms = Some(duration_ms);
@@ -484,14 +496,24 @@ impl PendingReview {
.await
}
pub async fn exists_for_path(db: &Database, path: &str) -> cot::db::Result<bool> {
let all = Self::objects().all(db).await?;
let exists = all.iter().any(|r| {
let s = r.status.as_str();
// "rejected" and "failed" reviews should not block re-discovery
s != "rejected" && s != "failed" && r.input_path.as_deref() == Some(path)
});
Ok(exists)
/// Latest review row for an inbox path: `(id, status, updated_at)`.
///
/// Used by inbox_discover to decide whether a file needs a new review,
/// a requeue of its existing row, or nothing at all — without creating
/// a fresh row per retry.
pub async fn latest_for_path(
pool: &sqlx::PgPool,
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
@@ -1360,6 +1382,7 @@ async fn run_scheduled_job(
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");
+23 -2
View File
@@ -777,7 +777,7 @@ impl TorrentService {
self.ensure_memory_job(pool, uploader_user_id, id).await?;
let (torrent_bytes, output_dir) = {
let (torrent_bytes, output_dir, selected_size) = {
let mut jobs = self.jobs.lock().await;
let job = jobs.get_mut(id).context("torrent job not found")?;
if job.user_id != uploader_user_id {
@@ -800,7 +800,11 @@ impl TorrentService {
job.error = None;
job.completed_at = None;
job.updated_at = now_string();
(job.torrent_bytes.clone(), job.output_dir.clone())
(
job.torrent_bytes.clone(),
job.output_dir.clone(),
job.selected_size(),
)
};
tokio::fs::create_dir_all(&output_dir).await?;
@@ -846,6 +850,7 @@ impl TorrentService {
let service = Arc::clone(self);
let pool = pool.clone();
let id = id.to_string();
let download_start = std::time::Instant::now();
tokio::spawn(async move {
if let Err(err) = handle.wait_until_completed().await {
if service.is_paused(&id).await {
@@ -853,6 +858,11 @@ impl TorrentService {
}
service.stop_torrent(&handle).await;
service.fail_job(&pool, &id, err.to_string()).await;
crate::metrics::record_torrent_download(
"failed",
selected_size,
download_start.elapsed(),
);
return;
}
service.stop_torrent(&handle).await;
@@ -861,6 +871,17 @@ impl TorrentService {
.await
{
service.fail_job(&pool, &id, err.to_string()).await;
crate::metrics::record_torrent_download(
"failed",
selected_size,
download_start.elapsed(),
);
} else {
crate::metrics::record_torrent_download(
"completed",
selected_size,
download_start.elapsed(),
);
}
});
+1039 -27
View File
File diff suppressed because it is too large Load Diff
+275 -102
View File
@@ -122,11 +122,11 @@
<div class="torrent-tabs">
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'import' }"
@click="$store.torrents.showImportTab()">Import</button>
@click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'uploads' }"
@click="$store.torrents.showUploadsTab()">
<span>My uploads</span>
<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>
@@ -347,7 +347,7 @@
<section class="upload-manager-panel">
<div class="upload-manager-head">
<div>
<h4>My uploaded tracks</h4>
<h4>{{ t.player_my_uploaded_tracks }}</h4>
<p x-text="$store.torrents.uploadSummary()"></p>
</div>
<button class="modal-btn modal-btn-ghost"
@@ -357,22 +357,22 @@
<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>No uploaded tracks yet</p>
<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">Needs approval</div>
<p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' pending or failed'"></p>
<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">No tracks need approval</div>
<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="item.status"></span>
<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>
@@ -381,67 +381,94 @@
</div>
<template x-if="$store.torrents.uploadQueuedTotal > 0">
<div class="upload-panel-card upload-queue-panel">
<div class="upload-panel-title">Queued / processing</div>
<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">
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="item.status"></span>
<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="'Showing ' + $store.torrents.uploadQueued.length + ' of ' + $store.torrents.uploadQueuedTotal"></div>
<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() + ' selected'"></div>
<input type="text" placeholder="Artists" x-model="$store.torrents.uploadBulkDraft.artists">
<input type="text" placeholder="Featured" x-model="$store.torrents.uploadBulkDraft.featured_artists">
<input type="text" placeholder="Album" x-model="$store.torrents.uploadBulkDraft.release_title">
<input type="number" placeholder="Year" x-model="$store.torrents.uploadBulkDraft.release_year">
<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="">Type unchanged</option><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option>
<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="">Visibility unchanged</option><option value="false">Visible</option><option value="true">Hidden</option>
<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">Apply</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">Clear</button>
<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="release in $store.torrents.uploadReleases" :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">hidden</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || 'no year'"></span><span>-</span><span x-text="release.tracks.length + ' tracks'"></span></div>
</div>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">Edit release</button>
<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>
<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>
<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>
<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">hidden</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)">feat.</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)">Edit</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>
</template>
</div>
</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>
@@ -455,7 +482,7 @@
<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="Close">
<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>
@@ -464,53 +491,53 @@
<template x-if="$store.torrents.uploadReviewDraft">
<div class="upload-editor-form">
<label class="upload-field upload-field-half"><span>Title</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artist</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="Artist, Artist"></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Genre</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label>
<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>Type</span>
<span>{{ t.player_type }}</span>
<select x-model="$store.torrents.uploadReviewDraft.release_type">
<option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</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>
</label>
<label class="upload-field upload-field-wide"><span>Notes</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></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">Delete review</button>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Approve</button>
<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>Album</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label>
<label class="upload-field upload-field-wide"><span>Album artists</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Year</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label>
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>Hidden</span></label>
<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">Save release</button>
<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>Title</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artists</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Disc #</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>Hidden</span></label>
<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">Save track</button>
<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>
@@ -526,7 +553,7 @@
<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">hidden</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>
@@ -543,7 +570,7 @@
<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)">Edit</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
</div>
</div>
</template>
@@ -551,52 +578,52 @@
<template x-if="$store.torrents.uploadEditId === item.track.id">
<div class="upload-edit-form">
<label>
<span>Title</span>
<span>{{ t.player_title }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.title">
</label>
<label>
<span>Artists</span>
<input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="Artist, Featured Artist">
<span>{{ t.player_artists }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="{{ t.player_artist_featured_placeholder }}">
</label>
<label>
<span>Release</span>
<span>{{ t.player_release }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.release_title">
</label>
<label>
<span>Type</span>
<span>{{ t.player_type }}</span>
<select x-model="$store.torrents.uploadDraft.release_type">
<option value="album">Album</option>
<option value="single">Single</option>
<option value="ep">EP</option>
<option value="compilation">Compilation</option>
<option value="mixtape">Mixtape</option>
<option value="live">Live</option>
<option value="soundtrack">Soundtrack</option>
<option value="remix">Remix</option>
<option value="demo">Demo</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>
</label>
<label>
<span>Year</span>
<span>{{ t.player_year }}</span>
<input type="number" x-model="$store.torrents.uploadDraft.release_year">
</label>
<label>
<span>Track #</span>
<span>{{ t.player_track_number }}</span>
<input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number">
</label>
<label>
<span>Disc #</span>
<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>Hidden</span>
<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">Save</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">Cancel</button>
: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>
@@ -609,6 +636,144 @@
</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()">
@@ -656,7 +821,12 @@
</template>
</button>
<div class="track-info">
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
<div class="track-title">
<span x-text="item.track?.title || item.track_title"></span>
<span class="history-device-badge"
:title="item.device_id"
x-text="item.device_name"></span>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
@@ -695,6 +865,9 @@
<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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1303 -132
View File
File diff suppressed because it is too large Load Diff