Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd766cda24 | ||
|
|
49000f716c | ||
|
|
ee4990e2f1 | ||
|
|
e64b61c167 | ||
|
|
b737ced3fc | ||
|
|
c2bdd62a51 | ||
|
|
d1370c6a28 | ||
|
|
2fc5fd7960 | ||
|
|
63506e3af2 | ||
|
|
5b339aa921 | ||
|
|
42c772f735 | ||
|
|
e738086573 | ||
|
|
4b7756c36e | ||
|
|
4381750c6e | ||
|
|
3485f643f4 | ||
|
|
bca0f5e2f0 | ||
|
|
53b2ff29f8 | ||
|
|
c349512fb0 | ||
|
|
0615356785 | ||
|
|
184371afca | ||
|
|
716da908c9 | ||
|
|
0c120c0868 | ||
|
|
d9d0fbb7d1 | ||
|
|
71d6556ba8 | ||
|
|
0ac59eb0ca | ||
|
|
652c6a470d | ||
|
|
1c54782dd7 | ||
|
|
8fa06038fe | ||
|
|
6b69cc0fc0 | ||
|
|
624cadab64 | ||
|
|
a8756c95de | ||
|
|
952d11e6f5 | ||
|
|
df16713aa2 | ||
|
|
3a9240b82c | ||
|
|
d31dce3ece | ||
|
|
1e1453e465 | ||
|
|
d2a8f301b8 | ||
|
|
0a4f78acfa |
@@ -2,3 +2,4 @@
|
||||
/nul
|
||||
/.claude
|
||||
/media
|
||||
/federation
|
||||
|
||||
Generated
+2512
-603
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -1,11 +1,14 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.2.14"
|
||||
version = "0.8.4"
|
||||
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"
|
||||
@@ -13,6 +16,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||
tower = "0.5"
|
||||
base64 = "0.22"
|
||||
blake3 = "1"
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -28,5 +32,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" }
|
||||
|
||||
@@ -357,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({
|
||||
@@ -389,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({
|
||||
@@ -529,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",
|
||||
{
|
||||
@@ -1298,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
|
||||
}
|
||||
}
|
||||
|
||||
+581
-25
@@ -83,10 +83,18 @@ pub struct MetadataBackfillRunRequest {
|
||||
local_genres: bool,
|
||||
#[serde(default = "default_true")]
|
||||
lastfm_tags: bool,
|
||||
#[serde(default = "default_true")]
|
||||
musicbrainz_tags: bool,
|
||||
#[serde(default)]
|
||||
overwrite: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtworkBackfillRunRequest {
|
||||
#[serde(default)]
|
||||
overwrite_existing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct UpdateLibraryItemRequest {
|
||||
kind: String,
|
||||
@@ -102,6 +110,7 @@ pub(super) struct UpdateLibraryItemRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||
disc_number: Option<String>,
|
||||
artist_ids: Option<Vec<i64>>,
|
||||
release_tracks: Option<Vec<ReleaseTrackUpdateRequest>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -110,6 +119,21 @@ pub(super) struct LibraryItemDetailQuery {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct TrackSearchQuery {
|
||||
search: Option<String>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseTrackUpdateRequest {
|
||||
id: i64,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||
track_number: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||
disc_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SetLibraryImageRequest {
|
||||
kind: String,
|
||||
@@ -191,6 +215,7 @@ struct AdminUserRowDto {
|
||||
struct AdminUserDetailDto {
|
||||
user: AdminUserRowDto,
|
||||
stats: AdminUserStatsDto,
|
||||
recent_plays: Vec<AdminUserPlayDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -207,6 +232,25 @@ struct AdminUserStatsDto {
|
||||
lastfm_connected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct AdminUserPlayDto {
|
||||
history_id: i64,
|
||||
played_at: String,
|
||||
duration_listened: Option<i32>,
|
||||
completed: bool,
|
||||
track_id: i64,
|
||||
title: String,
|
||||
artists: String,
|
||||
release_id: i64,
|
||||
release_title: String,
|
||||
release_year: Option<i32>,
|
||||
cover_url: Option<String>,
|
||||
track_duration_seconds: f64,
|
||||
uploader_name: String,
|
||||
audio_format: Option<String>,
|
||||
audio_bitrate: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct OverviewStatsDto {
|
||||
tracks: i64,
|
||||
@@ -276,6 +320,7 @@ struct MetadataTagDto {
|
||||
struct ReviewPageDto {
|
||||
items: Vec<ReviewDto>,
|
||||
total: i64,
|
||||
total_all: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
status: Option<String>,
|
||||
@@ -403,6 +448,10 @@ struct AdminSettingsValues {
|
||||
agent_confidence_threshold: String,
|
||||
agent_context_limit: String,
|
||||
agent_concurrency: String,
|
||||
#[serde(default)]
|
||||
federation_enabled: bool,
|
||||
#[serde(default)]
|
||||
federation_network_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
@@ -427,6 +476,8 @@ struct AdminSettingsSources {
|
||||
agent_confidence_threshold: &'static str,
|
||||
agent_context_limit: &'static str,
|
||||
agent_concurrency: &'static str,
|
||||
federation_enabled: &'static str,
|
||||
federation_network_id: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -451,6 +502,10 @@ pub(super) struct UpdateSettingsRequest {
|
||||
agent_confidence_threshold: String,
|
||||
agent_context_limit: String,
|
||||
agent_concurrency: String,
|
||||
#[serde(default)]
|
||||
federation_enabled: bool,
|
||||
#[serde(default)]
|
||||
federation_network_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -509,6 +564,7 @@ struct LibraryItemDetailDto {
|
||||
selected_artist_ids: Vec<i64>,
|
||||
artists: Vec<ArtistOptionDto>,
|
||||
releases: Vec<ReleaseOptionDto>,
|
||||
release_tracks: Vec<ReleaseTrackDto>,
|
||||
available_covers: Vec<AvailableCoverDto>,
|
||||
metadata_tags: Vec<MetadataTagDto>,
|
||||
}
|
||||
@@ -526,6 +582,19 @@ struct ReleaseOptionDto {
|
||||
subtitle: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct ReleaseTrackDto {
|
||||
id: i64,
|
||||
title: String,
|
||||
artists: String,
|
||||
release_id: Option<i64>,
|
||||
release_title: Option<String>,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
duration_seconds: f64,
|
||||
is_hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct AvailableCoverDto {
|
||||
media_file_id: i64,
|
||||
@@ -622,6 +691,19 @@ struct LibraryItemRow {
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct ReleaseTrackRow {
|
||||
id: i64,
|
||||
title: String,
|
||||
artists: String,
|
||||
release_id: Option<i64>,
|
||||
release_title: Option<String>,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
duration_seconds: f64,
|
||||
is_hidden: bool,
|
||||
}
|
||||
|
||||
pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = AdminV2Template {
|
||||
t: i18n.t,
|
||||
@@ -906,6 +988,11 @@ pub async fn update_settings(
|
||||
"agent_concurrency",
|
||||
body.agent_concurrency.trim().to_string(),
|
||||
),
|
||||
("federation_enabled", body.federation_enabled.to_string()),
|
||||
(
|
||||
"federation_network_id",
|
||||
body.federation_network_id.trim().to_string(),
|
||||
),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||
@@ -914,9 +1001,78 @@ pub async fn update_settings(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
// Federation applies on the fly: (re)start or stop the node to match
|
||||
// the freshly saved settings — no server restart involved.
|
||||
let (fresh, _) = AppConfig::load_with_db(&db).await;
|
||||
tokio::spawn(async move {
|
||||
crate::federation::handle().apply(&fresh).await;
|
||||
});
|
||||
Json(serde_json::json!({ "ok": true })).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Federation (status + manual controls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn federation_status(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
Json(crate::federation::handle().status().await).into_response()
|
||||
}
|
||||
|
||||
pub async fn federation_sync(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let fed = crate::federation::handle();
|
||||
if let Err(err) = fed.sync_now().await {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("sync failed: {err:#}"),
|
||||
));
|
||||
}
|
||||
Json(fed.status().await).into_response()
|
||||
}
|
||||
|
||||
pub async fn federation_ticket(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
match crate::federation::handle().ticket().await {
|
||||
Ok(ticket) => Json(serde_json::json!({ "ticket": ticket })).into_response(),
|
||||
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FederationConnectRequest {
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
pub async fn federation_connect(
|
||||
session: Session,
|
||||
db: Database,
|
||||
Json(body): Json<FederationConnectRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
match crate::federation::handle().connect(&body.ticket).await {
|
||||
Ok(peer) => Json(serde_json::json!({ "connected": peer })).into_response(),
|
||||
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn settings_probe(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -985,6 +1141,8 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
|
||||
agent_context_limit: config.agent_context_limit.to_string(),
|
||||
agent_concurrency: config.agent_concurrency.to_string(),
|
||||
federation_enabled: config.federation_enabled,
|
||||
federation_network_id: config.federation_network_id,
|
||||
},
|
||||
sources: AdminSettingsSources {
|
||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||
@@ -1007,6 +1165,8 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
agent_confidence_threshold: sources.agent_confidence_threshold.code(),
|
||||
agent_context_limit: sources.agent_context_limit.code(),
|
||||
agent_concurrency: sources.agent_concurrency.code(),
|
||||
federation_enabled: sources.federation_enabled.code(),
|
||||
federation_network_id: sources.federation_network_id.code(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1056,6 +1216,7 @@ pub async fn run_metadata_backfill(
|
||||
duration_seconds: body.duration_seconds,
|
||||
local_genres: body.local_genres,
|
||||
lastfm_tags: body.lastfm_tags,
|
||||
musicbrainz_tags: body.musicbrainz_tags,
|
||||
overwrite: body.overwrite,
|
||||
};
|
||||
if !options.any_field() {
|
||||
@@ -1103,6 +1264,56 @@ pub async fn run_metadata_backfill(
|
||||
Json(JobRunStartedDto { ok: true, run_id }).into_response()
|
||||
}
|
||||
|
||||
pub async fn run_artwork_backfill(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &PgPool,
|
||||
Json(body): Json<ArtworkBackfillRunRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let options = crate::jobs::artwork_backfill::ArtworkBackfillOptions {
|
||||
overwrite_existing: body.overwrite_existing,
|
||||
};
|
||||
let mut run = JobRun::create_running(&db, "artwork_backfill", "manual")
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create job run: {e}")))?;
|
||||
let run_id = run.id_val();
|
||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||
let db_for_task = db.clone();
|
||||
let pool_for_task = pool.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let start = std::time::Instant::now();
|
||||
let ctx = scheduler::JobContext {
|
||||
config: std::sync::Arc::new(live_config),
|
||||
db: db_for_task.clone(),
|
||||
pool: pool_for_task.clone(),
|
||||
run_id,
|
||||
registry: std::sync::Arc::new(JobRegistry::new()),
|
||||
};
|
||||
let mut log = scheduler::JobLog::with_live_flush(pool_for_task.clone(), run_id);
|
||||
let result = crate::jobs::artwork_backfill::run_with_options(&ctx, &mut log, options).await;
|
||||
let duration_ms = start.elapsed().as_millis() as i64;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = run
|
||||
.set_completed(&db_for_task, duration_ms, &log.output())
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = run
|
||||
.set_failed(&db_for_task, duration_ms, &log.output(), &err.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Json(JobRunStartedDto { ok: true, run_id }).into_response()
|
||||
}
|
||||
|
||||
pub async fn toggle_job(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1209,6 +1420,21 @@ pub async fn library_item_detail(
|
||||
return Ok(response);
|
||||
}
|
||||
let kind = normalize_library_kind(Some(query.kind.as_str()));
|
||||
if kind == "releases" && query.id == 0 {
|
||||
let item = LibraryItemDto {
|
||||
id: 0,
|
||||
kind: kind.clone(),
|
||||
title: String::new(),
|
||||
subtitle: String::new(),
|
||||
is_hidden: Some(false),
|
||||
tags: Vec::new(),
|
||||
updated_at: None,
|
||||
};
|
||||
let detail = load_library_item_detail(pool, &kind, item)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
return Json(detail).into_response();
|
||||
}
|
||||
let Some(item) = fetch_library_item(pool, &kind, query.id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
@@ -1221,6 +1447,25 @@ pub async fn library_item_detail(
|
||||
Json(detail).into_response()
|
||||
}
|
||||
|
||||
pub async fn track_search(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &PgPool,
|
||||
query: TrackSearchQuery,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let Some(search) = clean_search(query.search.as_deref()) else {
|
||||
return Json(Vec::<ReleaseTrackDto>::new()).into_response();
|
||||
};
|
||||
let tracks = search_tracks(pool, &search, query.limit.unwrap_or(16).clamp(1, 40))
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Json(tracks).into_response()
|
||||
}
|
||||
|
||||
pub async fn update_library_item(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1238,6 +1483,19 @@ pub async fn update_library_item(
|
||||
}
|
||||
|
||||
let now = now_string();
|
||||
if kind == "releases" && body.id == 0 {
|
||||
let release_id = create_release_library_item(pool, &body, title, &now)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let Some(item) = fetch_library_item(pool, &kind, release_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
else {
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "library item not found"));
|
||||
};
|
||||
return Json(item).into_response();
|
||||
}
|
||||
|
||||
let affected = match kind.as_str() {
|
||||
"artists" => {
|
||||
sqlx::query(
|
||||
@@ -1341,22 +1599,9 @@ pub async fn update_library_item(
|
||||
let mut seen_artist_ids = HashSet::new();
|
||||
artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
|
||||
if kind == "releases" {
|
||||
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
|
||||
.bind(body.id)
|
||||
.execute(pool)
|
||||
set_release_artists(pool, body.id, &artist_ids)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
for (position, artist_id) in artist_ids.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(body.id)
|
||||
.bind(*artist_id)
|
||||
.bind(position as i32)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
} else {
|
||||
sqlx::query(
|
||||
"DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'",
|
||||
@@ -1380,6 +1625,14 @@ pub async fn update_library_item(
|
||||
}
|
||||
}
|
||||
|
||||
if kind == "releases" {
|
||||
if let Some(release_tracks) = body.release_tracks.as_deref() {
|
||||
update_release_tracks(pool, body.id, release_tracks, &now)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(item) = fetch_library_item(pool, &kind, body.id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
@@ -1778,7 +2031,7 @@ async fn load_admin_user_detail(
|
||||
.bind(user_id)
|
||||
.fetch_one(pool),
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.media_file_id WHERE mf.uploaded_by_user_id = $1"
|
||||
"SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE mf.uploaded_by_user_id = $1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool),
|
||||
@@ -1793,6 +2046,7 @@ async fn load_admin_user_detail(
|
||||
.bind(user_id)
|
||||
.fetch_one(pool),
|
||||
)?;
|
||||
let recent_plays = load_admin_user_recent_plays(pool, user_id, 30).await?;
|
||||
|
||||
Ok(Some(AdminUserDetailDto {
|
||||
user,
|
||||
@@ -1808,9 +2062,98 @@ async fn load_admin_user_detail(
|
||||
torrent_sessions,
|
||||
lastfm_connected,
|
||||
},
|
||||
recent_plays,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn load_admin_user_recent_plays(
|
||||
pool: &PgPool,
|
||||
user_id: i64,
|
||||
limit: i64,
|
||||
) -> anyhow::Result<Vec<AdminUserPlayDto>> {
|
||||
let rows = sqlx::query_as::<_, AdminUserPlaySqlRow>(
|
||||
r#"SELECT ph.id AS history_id,
|
||||
ph.played_at::text AS played_at,
|
||||
ph.duration_listened,
|
||||
ph.completed,
|
||||
t.id AS track_id,
|
||||
t.title::text AS title,
|
||||
COALESCE(NULLIF(STRING_AGG(a.name::text, ', ' ORDER BY ta.position), ''), 'Unknown artist') AS artists,
|
||||
t.release_id,
|
||||
COALESCE(r.title::text, '') AS release_title,
|
||||
r.year AS release_year,
|
||||
t.cover_file_id,
|
||||
r.cover_file_id AS release_cover_file_id,
|
||||
t.duration_seconds AS track_duration_seconds,
|
||||
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||
mf.audio_format,
|
||||
mf.audio_bitrate
|
||||
FROM furumusic__play_history ph
|
||||
JOIN furumusic__track t ON t.id = ph.track_id
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main'
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ph.user_id = $1
|
||||
GROUP BY ph.id, ph.played_at, ph.duration_listened, ph.completed, t.id, t.title,
|
||||
t.release_id, r.title, r.year, t.cover_file_id, r.cover_file_id,
|
||||
t.duration_seconds, mf.uploader_name, mf.audio_format, mf.audio_bitrate
|
||||
ORDER BY ph.played_at DESC, ph.id DESC
|
||||
LIMIT $2"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| AdminUserPlayDto {
|
||||
history_id: row.history_id,
|
||||
played_at: row.played_at,
|
||||
duration_listened: row.duration_listened,
|
||||
completed: row.completed,
|
||||
track_id: row.track_id,
|
||||
title: row.title,
|
||||
artists: row.artists,
|
||||
release_id: row.release_id,
|
||||
release_title: row.release_title,
|
||||
release_year: row.release_year,
|
||||
cover_url: admin_track_cover_url(row.cover_file_id, row.release_cover_file_id),
|
||||
track_duration_seconds: row.track_duration_seconds,
|
||||
uploader_name: row.uploader_name,
|
||||
audio_format: row.audio_format,
|
||||
audio_bitrate: row.audio_bitrate,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct AdminUserPlaySqlRow {
|
||||
history_id: i64,
|
||||
played_at: String,
|
||||
duration_listened: Option<i32>,
|
||||
completed: bool,
|
||||
track_id: i64,
|
||||
title: String,
|
||||
artists: String,
|
||||
release_id: i64,
|
||||
release_title: String,
|
||||
release_year: Option<i32>,
|
||||
cover_file_id: Option<i64>,
|
||||
release_cover_file_id: Option<i64>,
|
||||
track_duration_seconds: f64,
|
||||
uploader_name: String,
|
||||
audio_format: Option<String>,
|
||||
audio_bitrate: Option<i32>,
|
||||
}
|
||||
|
||||
fn admin_track_cover_url(track_cover: Option<i64>, release_cover: Option<i64>) -> Option<String> {
|
||||
track_cover
|
||||
.or(release_cover)
|
||||
.map(|id| format!("/api/player/cover/{id}/medium"))
|
||||
}
|
||||
|
||||
fn admin_user_row(
|
||||
row: AdminUserSqlRow,
|
||||
active: &HashMap<i64, i64>,
|
||||
@@ -1999,6 +2342,7 @@ async fn load_review_page(pool: &PgPool, query: ReviewsQuery) -> anyhow::Result<
|
||||
let search_pattern = search.as_ref().map(|s| format!("%{s}%"));
|
||||
|
||||
let total = count_reviews(pool, status.clone(), search_pattern.clone()).await?;
|
||||
let total_all = count_reviews(pool, None, search_pattern.clone()).await?;
|
||||
let status_counts = load_review_status_counts(pool, None, search_pattern.clone()).await?;
|
||||
|
||||
let mut qb = QueryBuilder::<Postgres>::new(
|
||||
@@ -2024,6 +2368,7 @@ async fn load_review_page(pool: &PgPool, query: ReviewsQuery) -> anyhow::Result<
|
||||
Ok(ReviewPageDto {
|
||||
items,
|
||||
total,
|
||||
total_all,
|
||||
limit,
|
||||
offset,
|
||||
status,
|
||||
@@ -2475,13 +2820,13 @@ async fn fetch_library_item(
|
||||
"tracks" => {
|
||||
sqlx::query_as::<_, LibraryItemRow>(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
|
||||
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
||||
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
||||
t.updated_at::text AS updated_at \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
||||
LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \
|
||||
@@ -2532,6 +2877,7 @@ async fn load_library_item_detail(
|
||||
selected_artist_ids: Vec::new(),
|
||||
artists: Vec::new(),
|
||||
releases: Vec::new(),
|
||||
release_tracks: Vec::new(),
|
||||
available_covers: Vec::new(),
|
||||
metadata_tags: load_metadata_tags(pool, kind, item.id).await?,
|
||||
item,
|
||||
@@ -2572,16 +2918,22 @@ async fn load_library_item_detail(
|
||||
.map(|row| row.id)
|
||||
.collect();
|
||||
detail.artists = load_artist_options(pool).await?;
|
||||
if detail.item.id > 0 {
|
||||
detail.release_tracks = load_release_tracks(pool, detail.item.id).await?;
|
||||
}
|
||||
}
|
||||
"tracks" => {
|
||||
let row: Option<(i64, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
|
||||
"SELECT release_id, track_number, disc_number, year FROM furumusic__track WHERE id = $1",
|
||||
let row: Option<(Option<i64>, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
|
||||
"SELECT r.id AS release_id, t.track_number, t.disc_number, t.year \
|
||||
FROM furumusic__track t \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(detail.item.id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if let Some((release_id, track_number, disc_number, year)) = row {
|
||||
detail.release_id = Some(release_id);
|
||||
detail.release_id = release_id;
|
||||
detail.track_number = track_number;
|
||||
detail.disc_number = disc_number;
|
||||
detail.year = year;
|
||||
@@ -2729,6 +3081,210 @@ async fn load_release_options(pool: &PgPool) -> anyhow::Result<Vec<ReleaseOption
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_release_library_item(
|
||||
pool: &PgPool,
|
||||
body: &UpdateLibraryItemRequest,
|
||||
title: &str,
|
||||
now: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
let release_type = body
|
||||
.release_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("album");
|
||||
let year = body
|
||||
.year
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| value.parse::<i32>().ok());
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO furumusic__release \
|
||||
(title, title_sort, release_type, year, cover_file_id, total_tracks, total_discs, is_hidden, model_name, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, NULL, NULL, NULL, $5, NULL, $6, $6) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(normalize_name(title))
|
||||
.bind(release_type)
|
||||
.bind(year)
|
||||
.bind(body.hidden)
|
||||
.bind(now)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(artist_ids) = body.artist_ids.as_deref() {
|
||||
set_release_artists(pool, release_id, artist_ids).await?;
|
||||
}
|
||||
if let Some(release_tracks) = body.release_tracks.as_deref() {
|
||||
update_release_tracks(pool, release_id, release_tracks, now).await?;
|
||||
}
|
||||
|
||||
Ok(release_id)
|
||||
}
|
||||
|
||||
async fn set_release_artists(
|
||||
pool: &PgPool,
|
||||
release_id: i64,
|
||||
artist_ids: &[i64],
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
|
||||
.bind(release_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let mut seen_artist_ids = HashSet::new();
|
||||
let unique_artist_ids = artist_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| *id > 0 && seen_artist_ids.insert(*id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (position, artist_id) in unique_artist_ids.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(*artist_id)
|
||||
.bind(position as i32)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_release_tracks(
|
||||
pool: &PgPool,
|
||||
release_id: i64,
|
||||
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
|
||||
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
|
||||
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
|
||||
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
|
||||
FROM furumusic__track t \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
||||
WHERE t.release_id = $1 \
|
||||
GROUP BY t.id, r.id, r.title \
|
||||
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title ASC, t.id ASC",
|
||||
)
|
||||
.bind(release_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(release_track_dto).collect())
|
||||
}
|
||||
|
||||
async fn search_tracks(
|
||||
pool: &PgPool,
|
||||
search: &str,
|
||||
limit: i64,
|
||||
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
|
||||
let pattern = format!("%{search}%");
|
||||
let starts_with = format!("{search}%");
|
||||
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
|
||||
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
|
||||
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
|
||||
FROM furumusic__track t \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
||||
WHERE t.title ILIKE $1 OR COALESCE(r.title::text, '') ILIKE $1 OR COALESCE(a.name::text, '') ILIKE $1 \
|
||||
GROUP BY t.id, r.id, r.title \
|
||||
ORDER BY CASE \
|
||||
WHEN LOWER(t.title::text) = LOWER($2) THEN 0 \
|
||||
WHEN t.title ILIKE $3 THEN 1 \
|
||||
ELSE 2 \
|
||||
END, \
|
||||
t.title_sort ASC, t.id ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(pattern)
|
||||
.bind(search)
|
||||
.bind(starts_with)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(release_track_dto).collect())
|
||||
}
|
||||
|
||||
async fn update_release_tracks(
|
||||
pool: &PgPool,
|
||||
release_id: i64,
|
||||
tracks: &[ReleaseTrackUpdateRequest],
|
||||
now: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut seen_ids = HashSet::new();
|
||||
let selected = tracks
|
||||
.iter()
|
||||
.filter(|track| track.id > 0 && seen_ids.insert(track.id))
|
||||
.collect::<Vec<_>>();
|
||||
let selected_ids = selected.iter().map(|track| track.id).collect::<Vec<_>>();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
if selected_ids.is_empty() {
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__track \
|
||||
SET release_id = 0, updated_at = $2 \
|
||||
WHERE release_id = $1",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__track \
|
||||
SET release_id = 0, updated_at = $2 \
|
||||
WHERE release_id = $1 AND id <> ALL($3)",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(now)
|
||||
.bind(&selected_ids)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for track in selected {
|
||||
let track_number = parse_optional_admin_i32(track.track_number.as_deref(), 1, 9999);
|
||||
let disc_number = parse_optional_admin_i32(track.disc_number.as_deref(), 1, 999);
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__track \
|
||||
SET release_id = $1, track_number = $2, disc_number = $3, updated_at = $4 \
|
||||
WHERE id = $5",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(track_number)
|
||||
.bind(disc_number)
|
||||
.bind(now)
|
||||
.bind(track.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_track_dto(row: ReleaseTrackRow) -> ReleaseTrackDto {
|
||||
ReleaseTrackDto {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
artists: row.artists,
|
||||
release_id: row.release_id,
|
||||
release_title: row.release_title,
|
||||
track_number: row.track_number,
|
||||
disc_number: row.disc_number,
|
||||
duration_seconds: row.duration_seconds,
|
||||
is_hidden: row.is_hidden,
|
||||
}
|
||||
}
|
||||
|
||||
async fn artist_available_covers(
|
||||
pool: &PgPool,
|
||||
artist_id: i64,
|
||||
@@ -2771,7 +3327,7 @@ async fn library_ids_by_filter(
|
||||
"tracks" => QueryBuilder::<Postgres>::new(
|
||||
"SELECT DISTINCT t.id \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
||||
),
|
||||
@@ -3010,7 +3566,7 @@ async fn count_library(
|
||||
"tracks" => QueryBuilder::<Postgres>::new(
|
||||
"SELECT COUNT(DISTINCT t.id) AS count \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
||||
),
|
||||
@@ -3147,13 +3703,13 @@ async fn load_track_items(
|
||||
) -> anyhow::Result<Vec<LibraryItemRow>> {
|
||||
let mut qb = QueryBuilder::<Postgres>::new(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
|
||||
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
||||
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
||||
t.updated_at::text AS updated_at \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
||||
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
||||
@@ -3169,7 +3725,7 @@ async fn load_track_items(
|
||||
qb.push_bind(pattern);
|
||||
qb.push(")");
|
||||
}
|
||||
qb.push(" GROUP BY t.id, r.title ORDER BY r.title ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT ");
|
||||
qb.push(" GROUP BY t.id, r.title ORDER BY COALESCE(r.title::text, '') ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT ");
|
||||
qb.push_bind(limit);
|
||||
qb.push(" OFFSET ");
|
||||
qb.push_bind(offset);
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
|
||||
@@ -96,17 +96,11 @@ fn generate_missing_variants_sync(
|
||||
image::ExtendedColorType::Rgb8,
|
||||
);
|
||||
match result {
|
||||
Ok(()) => crate::metrics::record_agent_cover_variant(
|
||||
variant.name,
|
||||
"ok",
|
||||
start.elapsed(),
|
||||
),
|
||||
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(),
|
||||
);
|
||||
crate::metrics::record_agent_cover_variant(variant.name, "error", start.elapsed());
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
|
||||
+525
-11
@@ -1,14 +1,27 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use cot::aide::openapi::{
|
||||
MediaType, Operation, ReferenceOr, RequestBody, Response as OpenApiResponse, SchemaObject,
|
||||
StatusCode as OpenApiStatusCode,
|
||||
};
|
||||
use cot::auth::PasswordVerificationResult;
|
||||
use cot::common_types::Password;
|
||||
use cot::db::Database;
|
||||
use cot::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());
|
||||
}
|
||||
}
|
||||
|
||||
+588
-6
@@ -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;
|
||||
}
|
||||
@@ -69,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(
|
||||
@@ -103,6 +462,43 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
||||
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
|
||||
@@ -121,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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -135,6 +135,8 @@ 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,
|
||||
}
|
||||
|
||||
impl Default for ConfigSources {
|
||||
@@ -162,6 +164,8 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +274,12 @@ 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,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -297,6 +307,8 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +337,8 @@ impl_env_overrides!(
|
||||
agent_concurrency,
|
||||
lastfm_api_key,
|
||||
lastfm_shared_secret,
|
||||
federation_enabled,
|
||||
federation_network_id,
|
||||
);
|
||||
|
||||
impl AppConfig {
|
||||
@@ -338,10 +352,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 +466,8 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,954 @@
|
||||
//! 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`) and apply on the fly — saving the settings
|
||||
//! starts, stops or re-joins the node without a server restart.
|
||||
|
||||
pub mod devices;
|
||||
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(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>,
|
||||
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
|
||||
content_pending: std::sync::Mutex<HashSet<i64>>,
|
||||
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()),
|
||||
content_cache: std::sync::Mutex::new(Default::default()),
|
||||
content_pending: 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', '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,
|
||||
"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();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_content_id(
|
||||
pool: &PgPool,
|
||||
media_file_id: i64,
|
||||
sha256_hash: &str,
|
||||
content_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_content_id_cache
|
||||
(media_file_id, sha256_hash, content_id, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (media_file_id) DO UPDATE SET
|
||||
sha256_hash = EXCLUDED.sha256_hash,
|
||||
content_id = EXCLUDED.content_id,
|
||||
updated_at = EXCLUDED.updated_at",
|
||||
)
|
||||
.bind(media_file_id)
|
||||
.bind(sha256_hash)
|
||||
.bind(content_id)
|
||||
.bind(now_iso())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_content_id(storage_dir: &str, file_path: &str) -> Option<String> {
|
||||
if storage_dir.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||
let mut file = std::fs::File::open(path).ok()?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
std::io::copy(&mut file, &mut hasher).ok()?;
|
||||
Some(format!("b3:{}", hasher.finalize().to_hex()))
|
||||
}
|
||||
|
||||
async fn stop_running(running: Option<Running>) {
|
||||
let Some(running) = running else { return };
|
||||
for task in &running.tasks {
|
||||
task.abort();
|
||||
}
|
||||
if let Err(err) = running.service.shutdown().await {
|
||||
tracing::warn!("federation node shutdown reported an error: {err}");
|
||||
}
|
||||
tracing::info!("federation stopped");
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! Serve side of the federation wire protocols (audio + catalog), backed by
|
||||
//! the PostgreSQL library and the media storage directory. Wire compatible
|
||||
//! with the furumi TUI client and any other furumi peer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
pub use music_dht::catalog::CATALOG_ALPN;
|
||||
use music_dht::catalog::{
|
||||
CatalogArtist, CatalogArtistPreview, CatalogImageHeader as ImageHeader, CatalogRelease,
|
||||
CatalogRequest, CatalogResponse, CatalogTrack,
|
||||
};
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor, normalize_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row as _;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
use super::{TransportStats, record_stream_transport};
|
||||
|
||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
|
||||
/// Maximum size of a JSON protocol line (request or response header).
|
||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||
/// Images above this size are skipped rather than transferred.
|
||||
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire shapes (shared with the furumi TUI client)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AudioRequest {
|
||||
item_id: String,
|
||||
#[serde(default)]
|
||||
offset: u64,
|
||||
#[serde(default)]
|
||||
want_cover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct AudioResponseHeader {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
mime_type: String,
|
||||
total_size: u64,
|
||||
offset: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
metadata: Option<TrackMetadata>,
|
||||
cover_size: u64,
|
||||
cover_mime: String,
|
||||
artist_image_size: u64,
|
||||
artist_image_mime: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
struct TrackMetadata {
|
||||
title: String,
|
||||
artists: Vec<String>,
|
||||
featured_artists: Vec<String>,
|
||||
album_artists: Vec<String>,
|
||||
release_title: String,
|
||||
release_type: Option<String>,
|
||||
year: Option<i32>,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn hex_decode_item_id(value: &str) -> Option<ItemId> {
|
||||
if value.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(ItemId::from_bytes(bytes))
|
||||
}
|
||||
|
||||
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = reader.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("stream ended before the protocol line was complete");
|
||||
}
|
||||
if byte[0] == b'\n' {
|
||||
return Ok(line);
|
||||
}
|
||||
line.push(byte[0]);
|
||||
if line.len() > MAX_PROTOCOL_LINE {
|
||||
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||
writer: &mut W,
|
||||
value: &impl Serialize,
|
||||
) -> Result<()> {
|
||||
let mut line = serde_json::to_vec(value)?;
|
||||
line.push(b'\n');
|
||||
writer.write_all(&line).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn item_id_of(own: &EndpointId, track_id: i64) -> String {
|
||||
hex_encode(ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")).as_bytes())
|
||||
}
|
||||
|
||||
fn resolve_media_path(storage_dir: &str, file_path: &str) -> PathBuf {
|
||||
crate::media_paths::resolve_media_file_path(storage_dir, file_path)
|
||||
}
|
||||
|
||||
fn guess_mime(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"mp3" => "audio/mpeg",
|
||||
"flac" => "audio/flac",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"opus" => "audio/opus",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" | "mp4" | "alac" => "audio/mp4",
|
||||
"aac" => "audio/aac",
|
||||
"aiff" | "aif" => "audio/aiff",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an image media file from disk, bounded by [`MAX_IMAGE_BYTES`].
|
||||
async fn read_image(
|
||||
storage_dir: &str,
|
||||
media: Option<(String, String)>,
|
||||
) -> Option<(Vec<u8>, String)> {
|
||||
let (file_path, mime) = media?;
|
||||
let path = resolve_media_path(storage_dir, &file_path);
|
||||
let size = tokio::fs::metadata(&path).await.ok()?.len();
|
||||
if size == 0 || size > MAX_IMAGE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let bytes = tokio::fs::read(&path).await.ok()?;
|
||||
let mime = if mime.trim().is_empty() {
|
||||
"image/jpeg".to_string()
|
||||
} else {
|
||||
mime
|
||||
};
|
||||
Some((bytes, mime))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library lookups (PostgreSQL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Finds the visible track whose derived DHT item id matches `item_id`.
|
||||
async fn resolve_track_id(pool: &PgPool, own: &EndpointId, item_id: ItemId) -> Result<Option<i64>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT t.id FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let track_id: i64 = row.get(0);
|
||||
if ItemId::derive(own, ItemKind::Track, &format!("track:{track_id}")) == item_id {
|
||||
return Ok(Some(track_id));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// (file_path, mime_type) of the track's audio media file.
|
||||
async fn track_audio_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
/// Track cover (falling back to the release cover) as (file_path, mime).
|
||||
async fn track_cover_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
JOIN furumusic__media_file m ON m.id = COALESCE(t.cover_file_id, r.cover_file_id)
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
/// The main artist's image of a track as (file_path, mime).
|
||||
async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||
WHERE ta.track_id = $1 AND ta.role = 'main'
|
||||
ORDER BY ta.position LIMIT 1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
async fn track_catalog_artist_names(
|
||||
pool: &PgPool,
|
||||
track_id: i64,
|
||||
) -> Result<(Vec<String>, Vec<String>)> {
|
||||
let mut artists = Vec::new();
|
||||
let mut featured = Vec::new();
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let name: String = row.get(0);
|
||||
match row.get::<String, _>(1).as_str() {
|
||||
"featuring" => featured.push(name),
|
||||
"main" => artists.push(name),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((artists, featured))
|
||||
}
|
||||
|
||||
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
||||
let Some(track) = sqlx::query(
|
||||
"SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year),
|
||||
t.release_id, r.title, r.release_type
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let release_id: i64 = track.get(4);
|
||||
|
||||
let mut artists = Vec::new();
|
||||
let mut featured = Vec::new();
|
||||
let artist_rows = sqlx::query(
|
||||
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in artist_rows {
|
||||
let name: String = row.get(0);
|
||||
match row.get::<String, _>(1).as_str() {
|
||||
"featuring" => featured.push(name),
|
||||
"main" => artists.push(name),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let album_artists: Vec<String> = sqlx::query(
|
||||
"SELECT a.name FROM furumusic__release_artist ra
|
||||
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||
WHERE ra.release_id = $1 ORDER BY ra.position",
|
||||
)
|
||||
.bind(release_id)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| row.get(0))
|
||||
.collect();
|
||||
|
||||
Ok(Some(TrackMetadata {
|
||||
title: track.get(0),
|
||||
artists,
|
||||
featured_artists: featured,
|
||||
album_artists,
|
||||
release_title: track.get(5),
|
||||
release_type: Some(track.get(6)),
|
||||
year: track.get(3),
|
||||
track_number: track.get(1),
|
||||
disc_number: track.get(2),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs the audio accept loop until the acceptor closes. Every visible
|
||||
/// track of the library is streamable by every peer of the network.
|
||||
pub async fn serve_audio(
|
||||
mut acceptor: StreamAcceptor,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
let storage_dir = storage_dir.clone();
|
||||
let transport_stats = Arc::clone(&transport_stats);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own, transport_stats).await
|
||||
{
|
||||
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_audio_one(
|
||||
mut stream: ByteStream,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
record_stream_transport(&transport_stats, "audio", "inbound", "open", &stream);
|
||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
item = %request.item_id,
|
||||
offset = request.offset,
|
||||
"federation peer requested audio"
|
||||
);
|
||||
|
||||
let track_id = match hex_decode_item_id(&request.item_id) {
|
||||
Some(item_id) => match resolve_track_id(&pool, &own, item_id).await {
|
||||
Ok(Some(track_id)) => track_id,
|
||||
Ok(None) => return refuse_audio(stream, "track not found in the library").await,
|
||||
Err(err) => {
|
||||
return refuse_audio(stream, &format!("library lookup failed: {err:#}")).await;
|
||||
}
|
||||
},
|
||||
None => return refuse_audio(stream, "malformed item_id").await,
|
||||
};
|
||||
|
||||
let Some((file_path, mime_type)) = track_audio_file(&pool, track_id).await? else {
|
||||
return refuse_audio(stream, "audio file record is missing").await;
|
||||
};
|
||||
let path = resolve_media_path(&storage_dir, &file_path);
|
||||
let mut file = match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
return refuse_audio(stream, &format!("audio file is not readable: {err}")).await;
|
||||
}
|
||||
};
|
||||
let total_size = file.metadata().await?.len();
|
||||
let offset = request.offset.min(total_size);
|
||||
if offset > 0 {
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
}
|
||||
|
||||
let metadata = match track_metadata(&pool, track_id).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
tracing::warn!(track_id, "federation metadata lookup failed: {err:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let (cover, artist_image) = if request.want_cover {
|
||||
(
|
||||
read_image(
|
||||
&storage_dir,
|
||||
track_cover_file(&pool, track_id).await.ok().flatten(),
|
||||
)
|
||||
.await,
|
||||
read_image(
|
||||
&storage_dir,
|
||||
track_artist_image_file(&pool, track_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mime_type = if mime_type.trim().is_empty() {
|
||||
guess_mime(&path).to_string()
|
||||
} else {
|
||||
mime_type
|
||||
};
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type,
|
||||
total_size,
|
||||
offset,
|
||||
metadata,
|
||||
cover_size: cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||
cover_mime: cover
|
||||
.as_ref()
|
||||
.map(|(_, mime)| mime.clone())
|
||||
.unwrap_or_default(),
|
||||
artist_image_size: artist_image
|
||||
.as_ref()
|
||||
.map_or(0, |(bytes, _)| bytes.len() as u64),
|
||||
artist_image_mime: artist_image
|
||||
.as_ref()
|
||||
.map(|(_, mime)| mime.clone())
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some((bytes, _)) = &cover {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
if let Some((bytes, _)) = &artist_image {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||
stream.send.finish()?;
|
||||
// Wait until the peer read everything before dropping the stream,
|
||||
// otherwise the tail of the file is lost.
|
||||
let _ = stream.send.stopped().await;
|
||||
record_stream_transport(&transport_stats, "audio", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refuse_audio(mut stream: ByteStream, message: &str) -> Result<()> {
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: false,
|
||||
error: Some(message.to_string()),
|
||||
..AudioResponseHeader::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
anyhow::bail!("refused audio request: {message}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs the catalog accept loop until the acceptor closes.
|
||||
pub async fn serve_catalog(
|
||||
mut acceptor: StreamAcceptor,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
let storage_dir = storage_dir.clone();
|
||||
let transport_stats = Arc::clone(&transport_stats);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) =
|
||||
serve_catalog_one(stream, pool, storage_dir, own, transport_stats).await
|
||||
{
|
||||
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_catalog_one(
|
||||
mut stream: ByteStream,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
record_stream_transport(&transport_stats, "catalog", "inbound", "open", &stream);
|
||||
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
artist = %request.artist,
|
||||
want = request.want.as_deref().unwrap_or("catalog"),
|
||||
"federation peer requested a catalog"
|
||||
);
|
||||
|
||||
match request.want.as_deref() {
|
||||
None | Some("catalog") => {
|
||||
let response = match build_catalog(&pool, &own, &request.artist).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||
..CatalogResponse::default()
|
||||
},
|
||||
};
|
||||
stream
|
||||
.send
|
||||
.write_all(&serde_json::to_vec(&response)?)
|
||||
.await?;
|
||||
}
|
||||
Some("artists") => {
|
||||
let cursor = request.cursor.clone();
|
||||
let limit = request.limit.unwrap_or(64).clamp(1, 200);
|
||||
let response = match build_artist_slice(&pool, cursor, limit).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("artist slice lookup failed: {err:#}")),
|
||||
..CatalogResponse::default()
|
||||
},
|
||||
};
|
||||
stream
|
||||
.send
|
||||
.write_all(&serde_json::to_vec(&response)?)
|
||||
.await?;
|
||||
}
|
||||
Some(want @ ("artist_image" | "release_cover")) => {
|
||||
let media = if want == "release_cover" {
|
||||
release_cover_by_names(
|
||||
&pool,
|
||||
&request.artist,
|
||||
request.release.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
artist_image_by_name(&pool, &request.artist).await?
|
||||
};
|
||||
let image = read_image(&storage_dir, media).await;
|
||||
let header = match &image {
|
||||
Some((bytes, mime)) => ImageHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type: mime.clone(),
|
||||
size: bytes.len() as u64,
|
||||
},
|
||||
None => ImageHeader {
|
||||
ok: false,
|
||||
error: Some("no image".to_string()),
|
||||
..ImageHeader::default()
|
||||
},
|
||||
};
|
||||
write_line(&mut stream.send, &header).await?;
|
||||
if let Some((bytes, _)) = &image {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
let response = CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("unknown request kind '{other}'")),
|
||||
..CatalogResponse::default()
|
||||
};
|
||||
stream
|
||||
.send
|
||||
.write_all(&serde_json::to_vec(&response)?)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||
let Some(artist_row) = sqlx::query(
|
||||
"SELECT id, name FROM furumusic__artist
|
||||
WHERE LOWER(name) = LOWER($1) AND is_hidden = false
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(artist)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
else {
|
||||
return Ok(CatalogResponse {
|
||||
ok: false,
|
||||
error: Some("artist not found in the library".to_string()),
|
||||
..CatalogResponse::default()
|
||||
});
|
||||
};
|
||||
let artist_id: i64 = artist_row.get(0);
|
||||
|
||||
let release_rows = sqlx::query(
|
||||
"SELECT r.id, r.title, r.release_type, r.year
|
||||
FROM furumusic__release r
|
||||
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||
WHERE ra.artist_id = $1 AND r.is_hidden = false
|
||||
ORDER BY r.year NULLS LAST, r.title",
|
||||
)
|
||||
.bind(artist_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut releases = Vec::new();
|
||||
for release_row in release_rows {
|
||||
let release_id: i64 = release_row.get(0);
|
||||
let track_rows = sqlx::query(
|
||||
"SELECT t.id, t.title, t.track_number, t.disc_number, t.duration_seconds,
|
||||
c.content_id
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__federation_content_id_cache c
|
||||
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||
WHERE t.release_id = $1 AND t.is_hidden = false
|
||||
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title",
|
||||
)
|
||||
.bind(release_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||
for row in track_rows {
|
||||
let track_id: i64 = row.get(0);
|
||||
let duration: f64 = row.get(4);
|
||||
let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?;
|
||||
tracks.push(CatalogTrack {
|
||||
title: row.get(1),
|
||||
artists,
|
||||
featured_artists,
|
||||
track_number: row.get(2),
|
||||
disc_number: row.get(3),
|
||||
duration_seconds: (duration > 0.0).then_some(duration),
|
||||
content_id: row.get(5),
|
||||
item_id: item_id_of(own, track_id),
|
||||
});
|
||||
}
|
||||
releases.push(CatalogRelease {
|
||||
title: release_row.get(1),
|
||||
release_type: release_row.get(2),
|
||||
year: release_row.get(3),
|
||||
tracks,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CatalogResponse {
|
||||
ok: true,
|
||||
artist: Some(CatalogArtist {
|
||||
name: artist_row.get(1),
|
||||
releases,
|
||||
appears_on: Vec::new(),
|
||||
}),
|
||||
..CatalogResponse::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_artist_slice(
|
||||
pool: &PgPool,
|
||||
cursor: Option<String>,
|
||||
limit: usize,
|
||||
) -> Result<CatalogResponse> {
|
||||
let offset = cursor
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT a.name::text AS name,
|
||||
mf.file_path::text AS image_path,
|
||||
COALESCE(s.release_count, 0)::bigint AS release_count,
|
||||
COALESCE(s.track_count, 0)::bigint AS track_count
|
||||
FROM furumusic__artist a
|
||||
LEFT JOIN furumusic__media_file mf ON mf.id = a.image_file_id
|
||||
LEFT JOIN (
|
||||
SELECT appearance.artist_id,
|
||||
COUNT(DISTINCT appearance.release_id) FILTER (WHERE appearance.is_primary_release_artist) AS release_count,
|
||||
COUNT(DISTINCT appearance.track_id) AS track_count
|
||||
FROM (
|
||||
SELECT ta.artist_id,
|
||||
t.id AS track_id,
|
||||
r.id AS release_id,
|
||||
primary_release.artist_id IS NOT NULL AS is_primary_release_artist
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
|
||||
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||
LEFT JOIN furumusic__release_artist primary_release
|
||||
ON primary_release.release_id = r.id
|
||||
AND primary_release.artist_id = ta.artist_id
|
||||
AND primary_release.position = 0
|
||||
) appearance
|
||||
GROUP BY appearance.artist_id
|
||||
) s ON s.artist_id = a.id
|
||||
WHERE a.is_hidden = false
|
||||
AND COALESCE(s.track_count, 0) > 0
|
||||
ORDER BY (COALESCE(s.release_count, 0) > 0) DESC,
|
||||
COALESCE(s.release_count, 0) DESC,
|
||||
COALESCE(s.track_count, 0) DESC,
|
||||
a.name_sort
|
||||
LIMIT $1 OFFSET $2"#,
|
||||
)
|
||||
.bind(limit as i64 + 1)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut artists = Vec::with_capacity(rows.len().min(limit));
|
||||
let has_more = rows.len() > limit;
|
||||
for row in rows.into_iter().take(limit) {
|
||||
let name: String = row.get(0);
|
||||
artists.push(CatalogArtistPreview {
|
||||
artist_key: normalize_name(&name),
|
||||
name,
|
||||
image_path: row.get(1),
|
||||
release_count: row.get(2),
|
||||
track_count: row.get(3),
|
||||
});
|
||||
}
|
||||
let next_cursor = has_more.then(|| (offset + artists.len() as i64).to_string());
|
||||
Ok(CatalogResponse {
|
||||
ok: true,
|
||||
artists,
|
||||
next_cursor,
|
||||
..CatalogResponse::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn artist_image_by_name(pool: &PgPool, artist: &str) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__artist a
|
||||
JOIN furumusic__media_file m ON m.id = a.image_file_id
|
||||
WHERE LOWER(a.name) = LOWER($1) AND a.is_hidden = false
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(artist)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
async fn release_cover_by_names(
|
||||
pool: &PgPool,
|
||||
artist: &str,
|
||||
release: &str,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__release r
|
||||
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||
JOIN furumusic__media_file m ON m.id = r.cover_file_id
|
||||
WHERE LOWER(a.name) = LOWER($1) AND LOWER(r.title) = LOWER($2)
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(artist)
|
||||
.bind(release)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use music_dht::{
|
||||
DhtKey, EndpointId, LibraryItem, MAX_RECORDS_PER_RESPONSE, MusicDhtError, MusicDhtStorage,
|
||||
NodeContact, NodeId, SecretKey, StoreDecision, StoredRecord, decide_store,
|
||||
};
|
||||
use sqlx::{PgPool, Row as _};
|
||||
|
||||
const IDENTITY_NAME: &str = "default";
|
||||
|
||||
const SCHEMA: &[&str] = &[
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_identity (
|
||||
name TEXT PRIMARY KEY,
|
||||
secret_key BYTEA NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_local_item (
|
||||
id BYTEA PRIMARY KEY,
|
||||
normalized_name TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
payload BYTEA NOT NULL
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_local_item_normalized_name
|
||||
ON furumusic__federation_local_item(normalized_name)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_dht_record (
|
||||
dht_key BYTEA NOT NULL,
|
||||
item_id BYTEA NOT NULL,
|
||||
owner_peer_id TEXT NOT NULL,
|
||||
payload BYTEA NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
deleted BOOLEAN NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (dht_key, item_id, owner_peer_id)
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_dht_record_expires_at
|
||||
ON furumusic__federation_dht_record(expires_at_ms)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_known_peer (
|
||||
peer_id TEXT PRIMARY KEY,
|
||||
node_id BYTEA NOT NULL,
|
||||
ticket TEXT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||
media_file_id BIGINT PRIMARY KEY,
|
||||
sha256_hash TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||
ON furumusic__federation_content_id_cache(content_id)",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostgresFederationStorage {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresFederationStorage {
|
||||
pub async fn new(pool: PgPool) -> music_dht::Result<Self> {
|
||||
let storage = Self { pool };
|
||||
storage.ensure_schema().await?;
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
pub async fn load_or_create_secret_key(&self) -> music_dht::Result<SecretKey> {
|
||||
if let Some(bytes) = sqlx::query_scalar::<_, Vec<u8>>(
|
||||
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||
)
|
||||
.bind(IDENTITY_NAME)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
{
|
||||
return secret_from_bytes(bytes);
|
||||
}
|
||||
|
||||
let key = SecretKey::generate();
|
||||
let key_bytes = key.to_bytes();
|
||||
let now = now_iso();
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO furumusic__federation_identity
|
||||
(name, secret_key, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (name) DO NOTHING",
|
||||
)
|
||||
.bind(IDENTITY_NAME)
|
||||
.bind(key_bytes.as_slice())
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.rows_affected();
|
||||
if inserted == 1 {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let bytes = sqlx::query_scalar::<_, Vec<u8>>(
|
||||
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||
)
|
||||
.bind(IDENTITY_NAME)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
secret_from_bytes(bytes)
|
||||
}
|
||||
|
||||
async fn ensure_schema(&self) -> music_dht::Result<()> {
|
||||
for sql in SCHEMA {
|
||||
sqlx::query(sql)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicDhtStorage for PostgresFederationStorage {
|
||||
async fn upsert_local_item(&self, item: &LibraryItem) -> music_dht::Result<()> {
|
||||
let payload = postcard::to_stdvec(item).map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_local_item
|
||||
(id, normalized_name, revision, deleted, updated_at_ms, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
normalized_name = EXCLUDED.normalized_name,
|
||||
revision = EXCLUDED.revision,
|
||||
deleted = EXCLUDED.deleted,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms,
|
||||
payload = EXCLUDED.payload",
|
||||
)
|
||||
.bind(item.id.as_bytes().as_slice())
|
||||
.bind(&item.normalized_name)
|
||||
.bind(item.revision as i64)
|
||||
.bind(item.deleted)
|
||||
.bind(item.updated_at_ms as i64)
|
||||
.bind(payload)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_local_items(&self, include_deleted: bool) -> music_dht::Result<Vec<LibraryItem>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT payload
|
||||
FROM furumusic__federation_local_item
|
||||
WHERE $1 OR deleted = false
|
||||
ORDER BY normalized_name",
|
||||
)
|
||||
.bind(include_deleted)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| postcard::from_bytes::<LibraryItem>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn store_dht_record(&self, key: DhtKey, record: StoredRecord) -> music_dht::Result<bool> {
|
||||
let mut conn = self.pool.acquire().await.map_err(db_error)?;
|
||||
store_record_in_conn(&mut conn, &key, &record).await
|
||||
}
|
||||
|
||||
async fn store_dht_records(
|
||||
&self,
|
||||
entries: Vec<(DhtKey, StoredRecord)>,
|
||||
) -> music_dht::Result<Vec<bool>> {
|
||||
let mut tx = self.pool.begin().await.map_err(db_error)?;
|
||||
let mut stored = Vec::with_capacity(entries.len());
|
||||
for (key, record) in &entries {
|
||||
stored.push(store_record_in_conn(&mut tx, key, record).await?);
|
||||
}
|
||||
tx.commit().await.map_err(db_error)?;
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
async fn dht_records_by_key(
|
||||
&self,
|
||||
key: DhtKey,
|
||||
now_ms: u64,
|
||||
) -> music_dht::Result<Vec<StoredRecord>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT payload
|
||||
FROM furumusic__federation_dht_record
|
||||
WHERE dht_key = $1 AND expires_at_ms > $2
|
||||
ORDER BY expires_at_ms DESC, item_id
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(now_ms as i64)
|
||||
.bind(MAX_RECORDS_PER_RESPONSE as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| postcard::from_bytes::<StoredRecord>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete_expired_records(&self, now_ms: u64) -> music_dht::Result<usize> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM furumusic__federation_dht_record WHERE expires_at_ms <= $1")
|
||||
.bind(now_ms as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
async fn upsert_known_peer(&self, contact: &NodeContact) -> music_dht::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_known_peer
|
||||
(peer_id, node_id, ticket, last_seen_ms)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (peer_id) DO UPDATE SET
|
||||
node_id = EXCLUDED.node_id,
|
||||
ticket = EXCLUDED.ticket,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms",
|
||||
)
|
||||
.bind(contact.peer_id.to_string())
|
||||
.bind(contact.node_id.as_bytes().as_slice())
|
||||
.bind(&contact.ticket)
|
||||
.bind(contact.last_seen_ms as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_known_peer(&self, peer_id: EndpointId) -> music_dht::Result<()> {
|
||||
sqlx::query("DELETE FROM furumusic__federation_known_peer WHERE peer_id = $1")
|
||||
.bind(peer_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_known_peers(&self) -> music_dht::Result<Vec<NodeContact>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT peer_id, node_id, ticket, last_seen_ms
|
||||
FROM furumusic__federation_known_peer",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
let peer_id: String = row.get(0);
|
||||
let node_id: Vec<u8> = row.get(1);
|
||||
let ticket: String = row.get(2);
|
||||
let last_seen_ms: i64 = row.get(3);
|
||||
let Ok(peer_id) = EndpointId::from_str(&peer_id) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(node_id) = <[u8; 32]>::try_from(node_id.as_slice()) else {
|
||||
continue;
|
||||
};
|
||||
contacts.push(NodeContact {
|
||||
node_id: NodeId::from_bytes(node_id),
|
||||
peer_id,
|
||||
ticket,
|
||||
last_seen_ms: last_seen_ms as u64,
|
||||
});
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
}
|
||||
|
||||
fn secret_from_bytes(bytes: Vec<u8>) -> music_dht::Result<SecretKey> {
|
||||
let bytes: [u8; 32] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| MusicDhtError::Database("stored federation identity is corrupted".into()))?;
|
||||
Ok(SecretKey::from_bytes(&bytes))
|
||||
}
|
||||
|
||||
/// Applies one validated record following the revision/tombstone rules.
|
||||
/// Returns `true` if the record was written or refreshed. Runs against a
|
||||
/// pooled connection or an open transaction.
|
||||
async fn store_record_in_conn(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
key: &DhtKey,
|
||||
record: &StoredRecord,
|
||||
) -> music_dht::Result<bool> {
|
||||
let existing = sqlx::query(
|
||||
"SELECT revision, deleted, expires_at_ms
|
||||
FROM furumusic__federation_dht_record
|
||||
WHERE dht_key = $1 AND item_id = $2 AND owner_peer_id = $3",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.map(|row| {
|
||||
(
|
||||
row.get::<i64, _>(0) as u64,
|
||||
row.get::<bool, _>(1),
|
||||
row.get::<i64, _>(2) as u64,
|
||||
)
|
||||
});
|
||||
|
||||
match decide_store(existing, record) {
|
||||
StoreDecision::Ignore => return Ok(false),
|
||||
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {}
|
||||
}
|
||||
|
||||
let payload = postcard::to_stdvec(record).map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_dht_record
|
||||
(dht_key, item_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (dht_key, item_id, owner_peer_id) DO UPDATE SET
|
||||
payload = EXCLUDED.payload,
|
||||
revision = EXCLUDED.revision,
|
||||
deleted = EXCLUDED.deleted,
|
||||
expires_at_ms = EXCLUDED.expires_at_ms",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.bind(payload)
|
||||
.bind(record.item.revision as i64)
|
||||
.bind(record.item.deleted)
|
||||
.bind(record.expires_at_ms as i64)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
|
||||
MusicDhtError::Database(err.to_string())
|
||||
}
|
||||
@@ -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" , "Треки";
|
||||
@@ -307,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" , "Переименовать";
|
||||
@@ -356,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" , "Назад";
|
||||
@@ -366,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" , "Синглы";
|
||||
@@ -479,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" , "История прослушиваний";
|
||||
|
||||
@@ -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
@@ -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(¬_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(¬_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(¬_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)
|
||||
|
||||
+126
-9
@@ -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]
|
||||
@@ -76,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(());
|
||||
@@ -87,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 {
|
||||
@@ -94,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}",
|
||||
@@ -128,7 +169,11 @@ impl Job for InboxDiscoverJob {
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
crate::metrics::record_agent_file_hash(hash_start.elapsed(), 0, "error");
|
||||
crate::metrics::record_agent_file_hash(
|
||||
hash_start.elapsed(),
|
||||
0,
|
||||
"error",
|
||||
);
|
||||
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
||||
continue;
|
||||
}
|
||||
@@ -211,8 +256,8 @@ 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,
|
||||
@@ -223,7 +268,7 @@ impl Job for InboxDiscoverJob {
|
||||
|
||||
// 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",
|
||||
@@ -295,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
|
||||
}
|
||||
|
||||
+177
-55
@@ -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,6 +379,42 @@ 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();
|
||||
@@ -384,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)
|
||||
@@ -434,7 +545,12 @@ async fn process_folder_batch(
|
||||
.await
|
||||
{
|
||||
Ok(Ok(results)) => {
|
||||
crate::metrics::record_agent_rag("artist", "ok", rag_start.elapsed(), results.len());
|
||||
crate::metrics::record_agent_rag(
|
||||
"artist",
|
||||
"ok",
|
||||
rag_start.elapsed(),
|
||||
results.len(),
|
||||
);
|
||||
for a in results {
|
||||
if !all_similar_artists
|
||||
.iter()
|
||||
@@ -465,7 +581,12 @@ async fn process_folder_batch(
|
||||
.await
|
||||
{
|
||||
Ok(Ok(results)) => {
|
||||
crate::metrics::record_agent_rag("release", "ok", rag_start.elapsed(), results.len());
|
||||
crate::metrics::record_agent_rag(
|
||||
"release",
|
||||
"ok",
|
||||
rag_start.elapsed(),
|
||||
results.len(),
|
||||
);
|
||||
for r in results {
|
||||
if !all_similar_releases
|
||||
.iter()
|
||||
@@ -578,16 +699,17 @@ async fn process_folder_batch(
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -611,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 {
|
||||
|
||||
+337
-22
@@ -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,16 @@ 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,
|
||||
@@ -117,6 +130,7 @@ impl Job for MetadataBackfillJob {
|
||||
duration_seconds: true,
|
||||
local_genres: true,
|
||||
lastfm_tags: true,
|
||||
musicbrainz_tags: true,
|
||||
overwrite: false,
|
||||
},
|
||||
)
|
||||
@@ -143,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 {
|
||||
@@ -251,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;
|
||||
@@ -312,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(())
|
||||
}
|
||||
@@ -369,6 +393,259 @@ async fn backfill_lastfm_tags(
|
||||
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,
|
||||
@@ -428,8 +705,7 @@ async fn backfill_lastfm_artist_tags(
|
||||
stats.considered += 1;
|
||||
match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
|
||||
Ok(tags) if !tags.is_empty() => {
|
||||
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false)
|
||||
.await
|
||||
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false).await
|
||||
{
|
||||
Ok(saved) => {
|
||||
stats.tags_saved += saved;
|
||||
@@ -493,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"#,
|
||||
@@ -538,7 +822,10 @@ async fn backfill_lastfm_release_tags(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
||||
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) {
|
||||
@@ -663,7 +950,10 @@ async fn backfill_lastfm_track_tags(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
||||
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) {
|
||||
@@ -678,8 +968,7 @@ 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() => {
|
||||
match replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await
|
||||
{
|
||||
match replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await {
|
||||
Ok(saved) => {
|
||||
stats.tags_saved += saved;
|
||||
stats.updated_entities += 1;
|
||||
@@ -741,6 +1030,16 @@ async fn should_skip_lastfm_entity(
|
||||
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);
|
||||
@@ -748,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,
|
||||
@@ -854,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)
|
||||
}
|
||||
@@ -1027,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 });
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+165
-13
@@ -3,6 +3,7 @@ mod agent;
|
||||
mod api;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod federation;
|
||||
mod i18n;
|
||||
mod jobs;
|
||||
mod lastfm;
|
||||
@@ -29,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};
|
||||
@@ -52,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);
|
||||
@@ -63,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,
|
||||
@@ -131,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,6 +225,58 @@ async fn metrics_handler(
|
||||
.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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,11 +305,26 @@ 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({
|
||||
@@ -218,14 +342,15 @@ impl App for FuruApp {
|
||||
"/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()
|
||||
}
|
||||
@@ -255,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
|
||||
{
|
||||
@@ -263,23 +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?;
|
||||
auth::clear_post_login_redirect(&session).await?;
|
||||
metrics::record_auth_attempt(
|
||||
"password", "success", "ok",
|
||||
);
|
||||
metrics::record_session_created("password");
|
||||
return Ok(auth::redirect("/"));
|
||||
return Ok(auth::redirect(&redirect_to));
|
||||
}
|
||||
PasswordVerificationResult::Invalid => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics::record_auth_attempt(
|
||||
"password",
|
||||
"failure",
|
||||
"bad_credentials",
|
||||
);
|
||||
metrics::record_auth_attempt("password", "failure", "bad_credentials");
|
||||
let msg = i18n.t.login_invalid.to_owned();
|
||||
login_page_handler(i18n, &config, db, msg)
|
||||
.await?
|
||||
@@ -301,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",
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -415,6 +560,13 @@ 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 {
|
||||
|
||||
+320
-60
@@ -6,11 +6,11 @@ use std::sync::{LazyLock, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cot::http::header::CONTENT_LENGTH;
|
||||
use cot::Error;
|
||||
use cot::http::Method;
|
||||
use cot::http::header::CONTENT_LENGTH;
|
||||
use cot::request::Request;
|
||||
use cot::response::Response;
|
||||
use cot::Error;
|
||||
use sqlx::PgPool;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
@@ -80,28 +80,33 @@ where
|
||||
|
||||
fn call(&mut self, request: Request) -> Self::Future {
|
||||
let method = request.method().clone();
|
||||
let route = normalize_route(request.uri().path());
|
||||
let route = known_http_route(request.uri().path()).map(str::to_owned);
|
||||
let request_bytes = request
|
||||
.headers()
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
let labels = http_labels(&method, &route, "in_flight");
|
||||
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_http_request_body_bytes_total",
|
||||
vec![
|
||||
("method", method.as_str().to_owned()),
|
||||
("route", route.clone()),
|
||||
],
|
||||
request_bytes,
|
||||
);
|
||||
if let Some(route) = &route {
|
||||
let labels = http_labels(&method, route, "in_flight");
|
||||
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_http_request_body_bytes_total",
|
||||
vec![
|
||||
("method", method.as_str().to_owned()),
|
||||
("route", route.clone()),
|
||||
],
|
||||
request_bytes,
|
||||
);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let fut = self.inner.call(request);
|
||||
Box::pin(async move {
|
||||
let result = fut.await;
|
||||
let Some(route) = route else {
|
||||
return result;
|
||||
};
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
REGISTRY.inc_gauge(
|
||||
"furumusic_http_in_flight_requests",
|
||||
@@ -236,8 +241,17 @@ pub fn record_agent_discover_run(outcome: &'static str, duration: Duration) {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn record_agent_discover_files(seen: u64, queued: u64, skipped_hash: u64, skipped_existing: u64) {
|
||||
REGISTRY.inc_counter("furumusic_agent_discover_files_seen_total", Vec::new(), seen as f64);
|
||||
pub fn record_agent_discover_files(
|
||||
seen: u64,
|
||||
queued: u64,
|
||||
skipped_hash: u64,
|
||||
skipped_existing: u64,
|
||||
) {
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_agent_discover_files_seen_total",
|
||||
Vec::new(),
|
||||
seen as f64,
|
||||
);
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_agent_discover_files_queued_total",
|
||||
Vec::new(),
|
||||
@@ -340,18 +354,12 @@ pub fn record_agent_llm(
|
||||
let model = normalize_model_label(model);
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_agent_llm_requests_total",
|
||||
vec![
|
||||
("model", model.clone()),
|
||||
("outcome", outcome.to_owned()),
|
||||
],
|
||||
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||
1.0,
|
||||
);
|
||||
REGISTRY.observe_histogram(
|
||||
"furumusic_agent_llm_duration_seconds",
|
||||
vec![
|
||||
("model", model.clone()),
|
||||
("outcome", outcome.to_owned()),
|
||||
],
|
||||
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||
duration.as_secs_f64(),
|
||||
JOB_BUCKETS,
|
||||
);
|
||||
@@ -362,10 +370,7 @@ pub fn record_agent_llm(
|
||||
);
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_agent_llm_tokens_total",
|
||||
vec![
|
||||
("model", model.clone()),
|
||||
("type", "completion".to_owned()),
|
||||
],
|
||||
vec![("model", model.clone()), ("type", "completion".to_owned())],
|
||||
completion_tokens as f64,
|
||||
);
|
||||
REGISTRY.observe_histogram(
|
||||
@@ -400,7 +405,12 @@ pub fn record_agent_llm_parse_failure(model: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Duration, results: usize) {
|
||||
pub fn record_agent_rag(
|
||||
kind: &'static str,
|
||||
outcome: &'static str,
|
||||
duration: Duration,
|
||||
results: usize,
|
||||
) {
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_agent_rag_queries_total",
|
||||
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
|
||||
@@ -423,7 +433,10 @@ pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Dur
|
||||
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_agent_cover_lookup_total",
|
||||
vec![("source", source.to_owned()), ("outcome", outcome.to_owned())],
|
||||
vec![
|
||||
("source", source.to_owned()),
|
||||
("outcome", outcome.to_owned()),
|
||||
],
|
||||
1.0,
|
||||
);
|
||||
REGISTRY.inc_counter(
|
||||
@@ -433,7 +446,11 @@ pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, by
|
||||
);
|
||||
}
|
||||
|
||||
pub fn record_agent_cover_variant(variant: &'static str, outcome: &'static str, duration: Duration) {
|
||||
pub fn record_agent_cover_variant(
|
||||
variant: &'static str,
|
||||
outcome: &'static str,
|
||||
duration: Duration,
|
||||
) {
|
||||
REGISTRY.inc_counter(
|
||||
"furumusic_agent_cover_variant_generation_total",
|
||||
vec![
|
||||
@@ -489,7 +506,12 @@ pub fn record_torrent_download(outcome: &'static str, selected_bytes: u64, durat
|
||||
|
||||
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
|
||||
let mut out = String::new();
|
||||
emit_static_gauge(&mut out, "furumusic_build_info", &[("version", env!("CARGO_PKG_VERSION"))], 1.0);
|
||||
emit_static_gauge(
|
||||
&mut out,
|
||||
"furumusic_build_info",
|
||||
&[("version", env!("CARGO_PKG_VERSION"))],
|
||||
1.0,
|
||||
);
|
||||
render_active_users(&mut out);
|
||||
render_storage(&mut out, config);
|
||||
render_db_metrics(&mut out, pool).await;
|
||||
@@ -538,11 +560,42 @@ fn render_storage(out: &mut String, config: &AppConfig) {
|
||||
}
|
||||
|
||||
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
||||
render_group_counts(out, pool, "furumusic_users_total", "SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role", "role").await;
|
||||
render_single_count(out, pool, "furumusic_library_tracks_total", "SELECT COUNT(*) FROM furumusic__track").await;
|
||||
render_single_count(out, pool, "furumusic_library_releases_total", "SELECT COUNT(*) FROM furumusic__release").await;
|
||||
render_single_count(out, pool, "furumusic_library_artists_total", "SELECT COUNT(*) FROM furumusic__artist").await;
|
||||
render_single_count(out, pool, "furumusic_library_playlists_total", "SELECT COUNT(*) FROM furumusic__playlist").await;
|
||||
render_group_counts(
|
||||
out,
|
||||
pool,
|
||||
"furumusic_users_total",
|
||||
"SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role",
|
||||
"role",
|
||||
)
|
||||
.await;
|
||||
render_single_count(
|
||||
out,
|
||||
pool,
|
||||
"furumusic_library_tracks_total",
|
||||
"SELECT COUNT(*) FROM furumusic__track",
|
||||
)
|
||||
.await;
|
||||
render_single_count(
|
||||
out,
|
||||
pool,
|
||||
"furumusic_library_releases_total",
|
||||
"SELECT COUNT(*) FROM furumusic__release",
|
||||
)
|
||||
.await;
|
||||
render_single_count(
|
||||
out,
|
||||
pool,
|
||||
"furumusic_library_artists_total",
|
||||
"SELECT COUNT(*) FROM furumusic__artist",
|
||||
)
|
||||
.await;
|
||||
render_single_count(
|
||||
out,
|
||||
pool,
|
||||
"furumusic_library_playlists_total",
|
||||
"SELECT COUNT(*) FROM furumusic__playlist",
|
||||
)
|
||||
.await;
|
||||
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
|
||||
@@ -550,7 +603,13 @@ async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
||||
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
|
||||
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
|
||||
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
|
||||
render_single_count(out, pool, "furumusic_play_history_total", "SELECT COUNT(*) FROM furumusic__play_history").await;
|
||||
render_single_count(
|
||||
out,
|
||||
pool,
|
||||
"furumusic_play_history_total",
|
||||
"SELECT COUNT(*) FROM furumusic__play_history",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
|
||||
@@ -566,7 +625,10 @@ async fn render_group_counts(
|
||||
sql: &str,
|
||||
label_name: &'static str,
|
||||
) {
|
||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
for (label, count) in rows {
|
||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
|
||||
}
|
||||
@@ -580,7 +642,10 @@ async fn render_group_sums(
|
||||
sql: &str,
|
||||
label_name: &'static str,
|
||||
) {
|
||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
for (label, value) in rows {
|
||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
|
||||
}
|
||||
@@ -641,7 +706,12 @@ impl Registry {
|
||||
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
|
||||
let mut labels = key.labels.clone();
|
||||
labels.push(("le", bucket.to_string()));
|
||||
emit_metric(&mut out, &format!("{}_bucket", key.name), &labels, *count as f64);
|
||||
emit_metric(
|
||||
&mut out,
|
||||
&format!("{}_bucket", key.name),
|
||||
&labels,
|
||||
*count as f64,
|
||||
);
|
||||
}
|
||||
let mut inf_labels = key.labels.clone();
|
||||
inf_labels.push(("le", "+Inf".to_owned()));
|
||||
@@ -683,31 +753,221 @@ fn http_labels(method: &Method, route: &str, status: &str) -> Vec<(&'static str,
|
||||
]
|
||||
}
|
||||
|
||||
fn normalize_route(path: &str) -> String {
|
||||
let mut route = String::with_capacity(path.len());
|
||||
for segment in path.split('/') {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
route.push('/');
|
||||
if segment.parse::<i64>().is_ok() || looks_like_uuid(segment) {
|
||||
route.push_str("{id}");
|
||||
} else {
|
||||
route.push_str(segment);
|
||||
}
|
||||
}
|
||||
if route.is_empty() {
|
||||
fn known_http_route(path: &str) -> Option<&'static str> {
|
||||
let path = canonicalize_http_path(path);
|
||||
KNOWN_HTTP_ROUTES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|pattern| route_pattern_matches(pattern, &path))
|
||||
}
|
||||
|
||||
fn canonicalize_http_path(path: &str) -> String {
|
||||
let without_trailing = path.trim_end_matches('/');
|
||||
if without_trailing.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
route
|
||||
without_trailing.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_uuid(value: &str) -> bool {
|
||||
value.len() == 36
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_hexdigit() || ch == '-')
|
||||
fn route_pattern_matches(pattern: &str, path: &str) -> bool {
|
||||
if pattern == "/" {
|
||||
return path == "/";
|
||||
}
|
||||
|
||||
let mut pattern_segments = pattern.trim_start_matches('/').split('/');
|
||||
let mut path_segments = path.trim_start_matches('/').split('/');
|
||||
|
||||
loop {
|
||||
match (pattern_segments.next(), path_segments.next()) {
|
||||
(None, None) => return true,
|
||||
(Some(pattern_segment), Some(path_segment)) => {
|
||||
if path_segment.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if is_route_param(pattern_segment) {
|
||||
continue;
|
||||
}
|
||||
if pattern_segment != path_segment {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_route_param(segment: &str) -> bool {
|
||||
segment.starts_with('{') && segment.ends_with('}')
|
||||
}
|
||||
|
||||
const KNOWN_HTTP_ROUTES: &[&str] = &[
|
||||
// Keep this allowlist in sync with Cot route declarations. Unknown paths are
|
||||
// intentionally skipped so bot traffic cannot create high-cardinality labels.
|
||||
"/",
|
||||
"/admin",
|
||||
"/swagger",
|
||||
"/swagger/openapi.json",
|
||||
"/share/track/{id}",
|
||||
"/share/release/{id}",
|
||||
"/share/playlist/{token}",
|
||||
"/metrics",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/set-lang",
|
||||
"/auth/oidc/start",
|
||||
"/auth/oidc/callback",
|
||||
"/api/me",
|
||||
"/admin/setup",
|
||||
"/admin/v2",
|
||||
"/admin/v2/api/dashboard",
|
||||
"/admin/v2/api/reviews",
|
||||
"/admin/v2/api/reviews/bulk",
|
||||
"/admin/v2/api/users",
|
||||
"/admin/v2/api/users/{id}",
|
||||
"/admin/v2/api/reviews/{id}/approve",
|
||||
"/admin/v2/api/jobs",
|
||||
"/admin/v2/api/jobs/metadata_backfill/run-options",
|
||||
"/admin/v2/api/jobs/artwork_backfill/run-options",
|
||||
"/admin/v2/api/jobs/{name}/run",
|
||||
"/admin/v2/api/settings",
|
||||
"/admin/v2/api/settings/probe",
|
||||
"/admin/v2/api/jobs/{name}/toggle",
|
||||
"/admin/v2/api/jobs/{name}/runs",
|
||||
"/admin/v2/api/jobs/{name}/runs/{run_id}",
|
||||
"/admin/v2/api/library",
|
||||
"/admin/v2/api/library/item",
|
||||
"/admin/v2/api/library/item/detail",
|
||||
"/admin/v2/api/library/item/image",
|
||||
"/admin/v2/api/library/item/upload-image",
|
||||
"/admin/v2/api/library/bulk",
|
||||
"/admin/debug",
|
||||
"/admin/settings",
|
||||
"/admin/settings/probe",
|
||||
"/admin/users",
|
||||
"/admin/users/new",
|
||||
"/admin/users/{id}/edit",
|
||||
"/admin/users/{id}/delete",
|
||||
"/admin/artists",
|
||||
"/admin/artists/new",
|
||||
"/admin/artists/{id}/edit",
|
||||
"/admin/artists/{id}/delete",
|
||||
"/admin/artists/{id}/available-covers",
|
||||
"/admin/artists/{id}/set-image",
|
||||
"/admin/artists/{id}/upload-image",
|
||||
"/admin/releases",
|
||||
"/admin/releases/new",
|
||||
"/admin/releases/{id}/edit",
|
||||
"/admin/releases/{id}/delete",
|
||||
"/admin/media-files",
|
||||
"/admin/media-files/{id}/delete",
|
||||
"/admin/jobs",
|
||||
"/admin/jobs/metadata_backfill/run-options",
|
||||
"/admin/jobs/{name}/run",
|
||||
"/admin/jobs/{name}/toggle",
|
||||
"/admin/jobs/{name}/cron",
|
||||
"/admin/jobs/{name}/runs/{run_id}",
|
||||
"/admin/jobs/{name}",
|
||||
"/admin/reviews/clear",
|
||||
"/admin/reviews/bulk",
|
||||
"/admin/reviews",
|
||||
"/admin/reviews/{id}",
|
||||
"/admin/reviews/{id}/approve",
|
||||
"/admin/reviews/{id}/reject",
|
||||
"/admin/reviews/{id}/requeue",
|
||||
"/api/player/me",
|
||||
"/api/player/lastfm/status",
|
||||
"/api/player/lastfm/connect",
|
||||
"/api/player/lastfm/callback",
|
||||
"/api/player/lastfm/disconnect",
|
||||
"/api/player/lastfm/now-playing",
|
||||
"/api/player/lastfm/scrobble",
|
||||
"/api/player/agent-queue",
|
||||
"/api/player/offline/manifest",
|
||||
"/api/player/torrents",
|
||||
"/api/player/torrents/session/{id}",
|
||||
"/api/player/torrents/preview",
|
||||
"/api/player/uploads/local",
|
||||
"/api/player/uploads/tracks",
|
||||
"/api/player/uploads/tracks/{track_id}",
|
||||
"/api/player/uploads/bulk-tracks",
|
||||
"/api/player/uploads/releases/{id}",
|
||||
"/api/player/uploads/reviews/{id}",
|
||||
"/api/player/uploads/reviews/{id}/approve",
|
||||
"/api/player/torrents/{id}/start",
|
||||
"/api/player/torrents/{id}/pause",
|
||||
"/api/player/torrents/{id}/status",
|
||||
"/api/player/artists",
|
||||
"/api/player/artists/{id}",
|
||||
"/api/player/releases/{id}",
|
||||
"/api/player/radio/{kind}/{id}",
|
||||
"/api/player/playlists",
|
||||
"/api/player/share-playlist",
|
||||
"/api/player/share-playlist/{id}",
|
||||
"/api/player/playlists/{id}",
|
||||
"/api/player/playlists/{id}/tracks",
|
||||
"/api/player/likes",
|
||||
"/api/player/likes/toggle/{track_id}",
|
||||
"/api/player/likes/release/{id}",
|
||||
"/api/player/follows",
|
||||
"/api/player/follows/toggle/{id}",
|
||||
"/api/player/stream/{track_id}",
|
||||
"/api/player/cover/{media_file_id}/{variant}",
|
||||
"/api/player/cover/{media_file_id}",
|
||||
"/api/player/devices/heartbeat",
|
||||
"/api/player/devices/poll",
|
||||
"/api/player/devices/active",
|
||||
"/api/player/devices/command",
|
||||
"/api/player/jams/users",
|
||||
"/api/player/jams",
|
||||
"/api/player/jams/join",
|
||||
"/api/player/jams/invite",
|
||||
"/api/player/jams/leave",
|
||||
"/api/player/state",
|
||||
"/api/player/history",
|
||||
"/api/player/search",
|
||||
"/api/player/tracks-by-ids",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::known_http_route;
|
||||
|
||||
#[test]
|
||||
fn known_http_route_matches_declared_dynamic_routes() {
|
||||
assert_eq!(
|
||||
known_http_route("/api/player/stream/42"),
|
||||
Some("/api/player/stream/{track_id}")
|
||||
);
|
||||
assert_eq!(
|
||||
known_http_route("/admin/jobs/metadata_backfill/runs/123"),
|
||||
Some("/admin/jobs/{name}/runs/{run_id}")
|
||||
);
|
||||
assert_eq!(
|
||||
known_http_route("/share/playlist/abcDEF123"),
|
||||
Some("/share/playlist/{token}")
|
||||
);
|
||||
assert_eq!(
|
||||
known_http_route("/share/release/42"),
|
||||
Some("/share/release/{id}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_http_route_skips_unknown_bot_paths() {
|
||||
assert_eq!(known_http_route("/wp-login.php"), None);
|
||||
assert_eq!(
|
||||
known_http_route("/api/player/not-a-real-endpoint/123"),
|
||||
None
|
||||
);
|
||||
assert_eq!(known_http_route("/static/random-bot-path.js"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_http_route_uses_stable_canonical_labels() {
|
||||
assert_eq!(known_http_route("/admin/"), Some("/admin"));
|
||||
assert_eq!(known_http_route("/login/"), Some("/login"));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_label(value: &str) -> String {
|
||||
|
||||
@@ -1865,6 +1865,364 @@ 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()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -1891,5 +2249,9 @@ pub mod db_migrations {
|
||||
&M0033CreateLastfmScrobbling,
|
||||
&M0034CreateArtworkLookupState,
|
||||
&M0035CreateEntityGenreTags,
|
||||
&M0036CreateExternalMetadataIds,
|
||||
&M0037CreatePlaylistShareLinks,
|
||||
&M0038CreateFedDeviceSync,
|
||||
&M0039EnsureFederationContentIdCache,
|
||||
];
|
||||
}
|
||||
|
||||
+677
-4
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -247,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;
|
||||
|
||||
@@ -430,8 +448,13 @@ pub async fn oidc_callback_handler(
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
|
||||
@@ -453,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))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -611,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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn extract_groups_from_jwt(token: &str) -> Vec<String> {
|
||||
use base64::Engine;
|
||||
|
||||
let Some(payload_b64) = token.split('.').nth(1) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(arr) = value.get("groups").and_then(|value| value.as_array()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
arr.iter()
|
||||
.filter_map(|value| value.as_str().map(String::from))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Minimal percent-encoding for query parameter values.
|
||||
fn urlencoded(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 2);
|
||||
@@ -627,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>"#),
|
||||
"<tag attr="x&y">'text'</tag>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_oidc_redirect_uri_allows_only_furumi_schemes() {
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("furumi://auth/callback")).as_deref(),
|
||||
Some("furumi://auth/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("furumusic://auth/callback")).as_deref(),
|
||||
Some("furumusic://auth/callback")
|
||||
);
|
||||
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_oidc_redirect_uri_allows_loopback_http() {
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
|
||||
Some("http://127.0.0.1:8753/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
|
||||
Some("http://localhost:1234/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
|
||||
Some("http://[::1]:1234/callback")
|
||||
);
|
||||
// Non-loopback hosts, missing ports and https stay rejected.
|
||||
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
|
||||
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
|
||||
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
|
||||
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
@@ -73,6 +74,13 @@ 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(flatten)]
|
||||
pub(super) track: TrackItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistAppearanceTrack {
|
||||
pub(super) id: i64,
|
||||
@@ -264,6 +272,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 +311,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 +350,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,
|
||||
|
||||
+1915
-356
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -42,13 +42,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)]
|
||||
|
||||
@@ -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>,
|
||||
@@ -287,3 +290,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>,
|
||||
}
|
||||
|
||||
+19
-8
@@ -496,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
|
||||
@@ -1372,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");
|
||||
|
||||
+672
-22
@@ -1193,6 +1193,77 @@ tbody tr:hover {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.release-track-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.release-track-list {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.release-track-head,
|
||||
.release-track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 82px minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, .9fr) 70px 36px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.release-track-head {
|
||||
min-height: 34px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-subdued);
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-track-row {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.release-track-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.release-track-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.release-track-row input {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.release-track-title,
|
||||
.release-track-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-track-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.release-track-meta {
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1332,6 +1403,89 @@ tbody tr:hover {
|
||||
.user-stats-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-activity-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-activity-row {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-activity-cover {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-subdued);
|
||||
}
|
||||
|
||||
.user-activity-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-activity-cover svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.user-activity-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-activity-title,
|
||||
.user-activity-meta,
|
||||
.user-activity-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-activity-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.user-activity-meta {
|
||||
margin-top: 3px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-activity-sub {
|
||||
margin-top: 3px;
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-activity-time {
|
||||
min-width: 118px;
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-activity-time strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
@@ -1351,7 +1505,7 @@ tbody tr:hover {
|
||||
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
|
||||
<i data-lucide="inbox"></i>
|
||||
<span>Review Queue</span>
|
||||
<span class="nav-count" x-text="reviews.total || 0"></span>
|
||||
<span class="nav-count" x-text="reviewTotalAll()"></span>
|
||||
</button>
|
||||
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
|
||||
<i data-lucide="calendar-clock"></i>
|
||||
@@ -1443,7 +1597,7 @@ tbody tr:hover {
|
||||
<template x-for="status in reviewStatuses" :key="status.value">
|
||||
<button class="seg-btn" :class="{active: reviewFilter.status === status.value}" @click="setReviewStatus(status.value)">
|
||||
<span x-text="status.label"></span>
|
||||
<span class="muted" x-text="status.value ? statusCount(status.value) : reviews.total"></span>
|
||||
<span class="muted" x-text="status.value ? statusCount(status.value) : reviewTotalAll()"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1459,7 +1613,7 @@ tbody tr:hover {
|
||||
</button>
|
||||
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
|
||||
<i data-lucide="list-checks"></i>
|
||||
Select filter
|
||||
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
|
||||
</button>
|
||||
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
|
||||
<i data-lucide="x"></i>
|
||||
@@ -1632,12 +1786,19 @@ tbody tr:hover {
|
||||
<label><input type="checkbox" x-model="metadataBackfillOptions.duration_seconds" /> duration_seconds</label>
|
||||
<label><input type="checkbox" x-model="metadataBackfillOptions.local_genres" /> local genres from files</label>
|
||||
<label><input type="checkbox" x-model="metadataBackfillOptions.lastfm_tags" /> Last.fm tags</label>
|
||||
<label><input type="checkbox" x-model="metadataBackfillOptions.musicbrainz_tags" /> MusicBrainz tags</label>
|
||||
</div>
|
||||
<div class="mode-row">
|
||||
<label><input type="radio" value="fill_missing" x-model="metadataBackfillOptions.mode" /> Fill missing only</label>
|
||||
<label><input type="radio" value="overwrite" x-model="metadataBackfillOptions.mode" /> Overwrite existing values</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-backfill-options" x-show="isArtworkBackfillJob(activeJob)">
|
||||
<div class="mode-row">
|
||||
<label><input type="radio" value="missing" x-model="artworkBackfillOptions.mode" /> Missing images only</label>
|
||||
<label><input type="radio" value="overwrite" x-model="artworkBackfillOptions.mode" /> Search all and replace existing</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-param-note" x-show="activeJob && !jobHasParameterForm(activeJob)">
|
||||
This task has no manual parameters.
|
||||
</div>
|
||||
@@ -1746,6 +1907,10 @@ tbody tr:hover {
|
||||
<i data-lucide="square-pen"></i>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn primary" x-show="libraryKind === 'releases'" @click="openReleaseCreator()">
|
||||
<i data-lucide="plus"></i>
|
||||
New release
|
||||
</button>
|
||||
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
|
||||
<i data-lucide="git-merge"></i>
|
||||
Merge
|
||||
@@ -2100,6 +2265,96 @@ tbody tr:hover {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Federation</strong>
|
||||
<span>Publish this library into the furumi P2P network</span>
|
||||
</div>
|
||||
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-toggle">
|
||||
<label>
|
||||
<span>Federation enabled</span>
|
||||
<span class="source-pill" :class="sourceClass('federation_enabled')" x-text="settingSource('federation_enabled')"></span>
|
||||
</label>
|
||||
<div class="setting-toggle-row">
|
||||
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
|
||||
</div>
|
||||
<div class="setting-help">Applies immediately on save — no restart needed. Peers can browse and stream every visible track.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
<span>Network ID (shared secret)</span>
|
||||
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
|
||||
<div class="setting-help">Every peer using the same id finds the others automatically.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="probe-body" x-show="federationStatus.node">
|
||||
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
|
||||
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
|
||||
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
|
||||
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
|
||||
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
|
||||
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
||||
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
||||
</div>
|
||||
<div class="probe-table" x-show="fedTransport().total_samples > 0" style="margin-top:10px">
|
||||
<div class="probe-row">
|
||||
<span>Transport path</span>
|
||||
<strong>
|
||||
<span class="badge" :class="fedPathBadge(fedTransport().last_path)" x-text="fedTransport().last_path || 'unknown'"></span>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></div>
|
||||
<div class="probe-row"><span>Path samples</span><strong x-text="`${fedTransport().direct_samples || 0} direct · ${fedTransport().relay_samples || 0} relay · ${fedTransport().custom_samples || 0} custom · ${fedTransport().unknown_samples || 0} unknown`"></strong></div>
|
||||
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
||||
</div>
|
||||
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
|
||||
<template x-for="(sample, index) in fedTransport().last.slice(0, 5)" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
|
||||
<div class="probe-row">
|
||||
<span x-text="`${sample.protocol} · ${sample.direction} · ${sample.phase}`"></span>
|
||||
<strong>
|
||||
<span class="badge" :class="fedPathBadge(sample.selected_path)" x-text="sample.selected_path || 'unknown'"></span>
|
||||
<span x-text="` ${fedRtt(sample.selected_rtt_ms)} · tx ${formatBytes(sample.total_tx_bytes || 0)} · rx ${formatBytes(sample.total_rx_bytes || 0)} · lost ${formatBytes(sample.lost_bytes || 0)}`"></span>
|
||||
</strong>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
||||
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
||||
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
||||
<i data-lucide="upload-cloud"></i>
|
||||
Publish now
|
||||
</button>
|
||||
<button class="btn" type="button" @click="fedShowTicket()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
||||
<i data-lucide="ticket"></i>
|
||||
Show ticket
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-field settings-wide" x-show="federationTicket" style="margin-top:10px">
|
||||
<label>Connection ticket (share with a peer)</label>
|
||||
<textarea readonly rows="3" style="width:100%; font-family:monospace; font-size:11px" x-text="federationTicket"></textarea>
|
||||
</div>
|
||||
<div class="setting-field settings-wide" x-show="federationStatus.node && federationStatus.node.running" style="margin-top:10px">
|
||||
<label>Connect to a peer by ticket</label>
|
||||
<div style="display:flex; gap:8px">
|
||||
<input x-model="fedConnectTicket" placeholder="fnet..." style="flex:1" autocomplete="off" />
|
||||
<button class="btn" type="button" @click="fedConnect()" :disabled="federationLoading || !fedConnectTicket.trim()">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
@@ -2160,7 +2415,7 @@ tbody tr:hover {
|
||||
<div class="modal-body" x-show="activeUserDetail">
|
||||
<div class="segmented user-modal-tabs">
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'overview'}" @click="userModalTab = 'overview'">Overview</button>
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'" disabled>Activity</button>
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'">Activity</button>
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'library'}" @click="userModalTab = 'library'" disabled>Library</button>
|
||||
</div>
|
||||
<div x-show="userModalTab === 'overview'">
|
||||
@@ -2200,6 +2455,32 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="userModalTab === 'activity'">
|
||||
<div class="user-activity-list">
|
||||
<template x-for="play in (activeUserDetail?.recent_plays || [])" :key="play.history_id">
|
||||
<div class="user-activity-row">
|
||||
<div class="user-activity-cover">
|
||||
<template x-if="play.cover_url">
|
||||
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!play.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div class="user-activity-main">
|
||||
<div class="user-activity-title" x-text="play.title"></div>
|
||||
<div class="user-activity-meta" x-text="play.artists"></div>
|
||||
<div class="user-activity-sub" x-text="userPlayMeta(play)"></div>
|
||||
</div>
|
||||
<div class="user-activity-time">
|
||||
<strong x-text="userPlayListened(play)"></strong>
|
||||
<span x-text="shortDate(play.played_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="empty" x-show="activeUserDetail && (!activeUserDetail.recent_plays || activeUserDetail.recent_plays.length === 0)">No play history for this user yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -2303,8 +2584,8 @@ tbody tr:hover {
|
||||
<section class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="panel-title">
|
||||
<strong x-text="activeLibraryItem?.title || 'Editor'"></strong>
|
||||
<span x-text="activeLibraryItem?.kind || 'Library entity'"></span>
|
||||
<strong x-text="editorTitle()"></strong>
|
||||
<span x-text="editorSubtitle()"></span>
|
||||
</div>
|
||||
<button class="icon-btn" @click="editorOpen = false">
|
||||
<i data-lucide="x"></i>
|
||||
@@ -2338,6 +2619,54 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" x-show="isReleaseEditor()">
|
||||
<label>Release tracks</label>
|
||||
<div class="release-track-search-row">
|
||||
<div class="artist-picker">
|
||||
<input class="search" placeholder="Search track" x-model="releaseTrackSearch" @input.debounce.300ms="searchReleaseTracks()" @keydown.enter.prevent="addBestReleaseTrack()" @keydown.escape="clearReleaseTrackSearch()" />
|
||||
<div class="artist-results" x-show="releaseTrackSearchOpen()" x-transition>
|
||||
<template x-for="track in availableReleaseTrackResults()" :key="track.id">
|
||||
<button class="artist-result" type="button" @click="addReleaseTrack(track)">
|
||||
<span x-text="track.title"></span>
|
||||
<small x-text="releaseTrackSearchMeta(track)"></small>
|
||||
</button>
|
||||
</template>
|
||||
<div class="artist-result muted" x-show="releaseTrackSearchLoading">Searching...</div>
|
||||
<div class="artist-result muted" x-show="!releaseTrackSearchLoading && availableReleaseTrackResults().length === 0">No matching tracks</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="addBestReleaseTrack()" :disabled="!releaseTrackSearch.trim()">
|
||||
<i data-lucide="plus"></i>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="release-track-list" x-show="releaseTracks().length">
|
||||
<div class="release-track-head">
|
||||
<span>Disc</span>
|
||||
<span>Track #</span>
|
||||
<span>Title</span>
|
||||
<span>Artists</span>
|
||||
<span>Current release</span>
|
||||
<span>Time</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<template x-for="track in releaseTracks()" :key="track.id">
|
||||
<div class="release-track-row">
|
||||
<input type="number" min="1" max="999" x-model="track.disc_number" />
|
||||
<input type="number" min="1" max="9999" x-model="track.track_number" />
|
||||
<div class="release-track-title" x-text="track.title"></div>
|
||||
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
|
||||
<div class="release-track-meta" x-text="releaseTrackOrigin(track)"></div>
|
||||
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
|
||||
<button class="icon-btn" type="button" @click="removeReleaseTrack(track.id)" title="Remove from release">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="empty" x-show="!releaseTracks().length">No tracks attached</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid" x-show="isTrackEditor()">
|
||||
<div class="field">
|
||||
<label>Track #</label>
|
||||
@@ -2469,9 +2798,9 @@ tbody tr:hover {
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()">
|
||||
<i :data-lucide="editorSaving ? 'loader-circle' : 'save'"></i>
|
||||
<span x-text="editorSaving ? 'Saving...' : 'Save'"></span>
|
||||
<span x-text="editorSaving ? 'Saving...' : (editorIsNewRelease() ? 'Create' : 'Save')"></span>
|
||||
</button>
|
||||
<button class="btn danger" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
|
||||
<button class="btn danger" x-show="!editorIsNewRelease()" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
|
||||
<i data-lucide="trash-2"></i>
|
||||
Delete
|
||||
</button>
|
||||
@@ -2500,7 +2829,7 @@ function adminV2() {
|
||||
stats: {},
|
||||
runtime: { agent: {}, storage: [], node: {} },
|
||||
libraryOverview: {},
|
||||
reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] },
|
||||
reviews: { items: [], total: 0, total_all: 0, limit: 80, offset: 0, status_counts: [] },
|
||||
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
|
||||
usersLoading: false,
|
||||
userSearch: '',
|
||||
@@ -2547,8 +2876,12 @@ function adminV2() {
|
||||
duration_seconds: true,
|
||||
local_genres: true,
|
||||
lastfm_tags: true,
|
||||
musicbrainz_tags: true,
|
||||
mode: 'fill_missing'
|
||||
},
|
||||
artworkBackfillOptions: {
|
||||
mode: 'missing'
|
||||
},
|
||||
libraryKind: 'artists',
|
||||
librarySearch: '',
|
||||
library: { items: [], total: 0, limit: 40, offset: 0 },
|
||||
@@ -2562,8 +2895,12 @@ function adminV2() {
|
||||
editorImageFile: null,
|
||||
editorArtistToAdd: '',
|
||||
editorReleaseToAdd: '',
|
||||
releaseTrackSearch: '',
|
||||
releaseTrackSearchResults: [],
|
||||
releaseTrackSearchLoading: false,
|
||||
releaseTrackSearchToken: 0,
|
||||
editorDetail: null,
|
||||
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [] },
|
||||
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
|
||||
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
||||
settingsDraft: {
|
||||
auth_password_enabled: false,
|
||||
@@ -2585,10 +2922,16 @@ function adminV2() {
|
||||
agent_llm_auth: '',
|
||||
agent_confidence_threshold: '',
|
||||
agent_context_limit: '',
|
||||
agent_concurrency: ''
|
||||
agent_concurrency: '',
|
||||
federation_enabled: false,
|
||||
federation_network_id: ''
|
||||
},
|
||||
settingsProbe: { status: 'idle', ok: false },
|
||||
settingsProbeLoading: false,
|
||||
federationStatus: {},
|
||||
federationLoading: false,
|
||||
federationTicket: '',
|
||||
fedConnectTicket: '',
|
||||
settingsSaving: false,
|
||||
routeReady: false,
|
||||
poller: null,
|
||||
@@ -2837,7 +3180,7 @@ function adminV2() {
|
||||
async openUser(user) {
|
||||
if (!user) return;
|
||||
this.userModalTab = 'overview';
|
||||
this.activeUserDetail = { user, stats: {} };
|
||||
this.activeUserDetail = { user, stats: {}, recent_plays: [] };
|
||||
this.userModalOpen = true;
|
||||
try {
|
||||
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
|
||||
@@ -2868,6 +3211,7 @@ function adminV2() {
|
||||
body: JSON.stringify(this.settingsDraft)
|
||||
});
|
||||
await this.loadSettings(false);
|
||||
await this.loadFederation(false);
|
||||
this.showToast('Settings saved');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
@@ -2881,11 +3225,87 @@ function adminV2() {
|
||||
this.activeView = 'settings';
|
||||
this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await this.loadFederation(false);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
},
|
||||
|
||||
async loadFederation(showErrors = true) {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
this.federationStatus = await this.request(`${this.apiBase}/federation`);
|
||||
} catch (error) {
|
||||
if (showErrors) this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async fedSyncNow() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
this.federationStatus = await this.request(`${this.apiBase}/federation/sync`, { method: 'POST', body: '{}' });
|
||||
this.showToast('Library published to the federation');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async fedShowTicket() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
const data = await this.request(`${this.apiBase}/federation/ticket`);
|
||||
this.federationTicket = data.ticket || '';
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async fedConnect() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
const data = await this.request(`${this.apiBase}/federation/connect`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticket: this.fedConnectTicket.trim() })
|
||||
});
|
||||
this.fedConnectTicket = '';
|
||||
this.showToast(`Connected to ${(data.connected || '').slice(0, 12)}…`);
|
||||
await this.loadFederation(false);
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.federationLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
fedShort(id) {
|
||||
return id ? `${id.slice(0, 12)}…` : '-';
|
||||
},
|
||||
|
||||
fedTransport() {
|
||||
return (this.federationStatus.node && this.federationStatus.node.transport) || {};
|
||||
},
|
||||
|
||||
fedPathBadge(path) {
|
||||
if (path === 'direct') return 'ok';
|
||||
if (path === 'relay') return 'pending';
|
||||
if (path === 'custom') return 'running';
|
||||
return 'disabled';
|
||||
},
|
||||
|
||||
fedRtt(ms) {
|
||||
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
|
||||
},
|
||||
|
||||
async loadSettingsProbe(showErrors = true) {
|
||||
this.settingsProbeLoading = true;
|
||||
try {
|
||||
@@ -3071,6 +3491,11 @@ function adminV2() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.metadataBackfillPayload())
|
||||
})
|
||||
: this.isArtworkBackfillJob(job)
|
||||
? await this.request(`${this.apiBase}/jobs/artwork_backfill/run-options`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.artworkBackfillPayload())
|
||||
})
|
||||
: await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/run`, { method: 'POST' });
|
||||
this.showToast(`Run #${result.run_id} started`);
|
||||
this.activeJobName = job.name;
|
||||
@@ -3090,8 +3515,12 @@ function adminV2() {
|
||||
return job && job.name === 'metadata_backfill';
|
||||
},
|
||||
|
||||
isArtworkBackfillJob(job) {
|
||||
return job && job.name === 'artwork_backfill';
|
||||
},
|
||||
|
||||
jobHasParameterForm(job) {
|
||||
return this.isMetadataBackfillJob(job);
|
||||
return this.isMetadataBackfillJob(job) || this.isArtworkBackfillJob(job);
|
||||
},
|
||||
|
||||
metadataBackfillPayload() {
|
||||
@@ -3103,10 +3532,18 @@ function adminV2() {
|
||||
duration_seconds: Boolean(options.duration_seconds),
|
||||
local_genres: Boolean(options.local_genres),
|
||||
lastfm_tags: Boolean(options.lastfm_tags),
|
||||
musicbrainz_tags: Boolean(options.musicbrainz_tags),
|
||||
overwrite: options.mode === 'overwrite'
|
||||
};
|
||||
},
|
||||
|
||||
artworkBackfillPayload() {
|
||||
const options = this.artworkBackfillOptions || {};
|
||||
return {
|
||||
overwrite_existing: options.mode === 'overwrite'
|
||||
};
|
||||
},
|
||||
|
||||
async toggleJob(job) {
|
||||
try {
|
||||
const result = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/toggle`, { method: 'POST' });
|
||||
@@ -3259,16 +3696,30 @@ function adminV2() {
|
||||
release_id: null,
|
||||
track_number: '',
|
||||
disc_number: '',
|
||||
artist_ids: []
|
||||
artist_ids: [],
|
||||
release_tracks: []
|
||||
};
|
||||
this.editorDetail = null;
|
||||
this.editorImageFile = null;
|
||||
this.editorArtistToAdd = '';
|
||||
this.editorReleaseToAdd = '';
|
||||
this.clearReleaseTrackSearch();
|
||||
this.editorOpen = true;
|
||||
this.loadEditorDetail(item);
|
||||
},
|
||||
|
||||
openReleaseCreator() {
|
||||
this.libraryKind = 'releases';
|
||||
this.openEditor({
|
||||
id: 0,
|
||||
kind: 'releases',
|
||||
title: '',
|
||||
subtitle: 'New release',
|
||||
is_hidden: false,
|
||||
tags: []
|
||||
});
|
||||
},
|
||||
|
||||
async loadEditorDetail(item) {
|
||||
const key = `${item.kind}:${item.id}`;
|
||||
this.editorLoading = true;
|
||||
@@ -3285,11 +3736,13 @@ function adminV2() {
|
||||
release_id: detail.release_id || null,
|
||||
track_number: detail.track_number || '',
|
||||
disc_number: detail.disc_number || '',
|
||||
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : []
|
||||
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : [],
|
||||
release_tracks: Array.isArray(detail.release_tracks) ? detail.release_tracks.map(track => this.normalizeReleaseTrack(track)) : []
|
||||
};
|
||||
this.editorImageFile = null;
|
||||
this.editorArtistToAdd = '';
|
||||
this.editorReleaseToAdd = '';
|
||||
this.clearReleaseTrackSearch();
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
@@ -3312,8 +3765,22 @@ function adminV2() {
|
||||
return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks';
|
||||
},
|
||||
|
||||
editorIsNewRelease() {
|
||||
return this.isReleaseEditor() && Number(this.activeLibraryItem.id || 0) === 0;
|
||||
},
|
||||
|
||||
editorTitle() {
|
||||
if (this.editorIsNewRelease()) return 'New release';
|
||||
return this.activeLibraryItem?.title || 'Editor';
|
||||
},
|
||||
|
||||
editorSubtitle() {
|
||||
if (this.editorIsNewRelease()) return 'Create release and attach tracks';
|
||||
return this.activeLibraryItem?.kind || 'Library entity';
|
||||
},
|
||||
|
||||
canEditLibraryImage() {
|
||||
return this.isArtistEditor() || this.isReleaseEditor();
|
||||
return this.isArtistEditor() || (this.isReleaseEditor() && !this.editorIsNewRelease());
|
||||
},
|
||||
|
||||
canShowMetadataTags() {
|
||||
@@ -3360,6 +3827,7 @@ function adminV2() {
|
||||
|
||||
editorCanSave() {
|
||||
if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false;
|
||||
if (!String(this.editorDraft.title || '').trim()) return false;
|
||||
if (this.isTrackEditor() && !this.editorDraft.release_id) return false;
|
||||
return true;
|
||||
},
|
||||
@@ -3468,6 +3936,146 @@ function adminV2() {
|
||||
return true;
|
||||
},
|
||||
|
||||
normalizeReleaseTrack(track = {}) {
|
||||
const trackNumber = track.track_number;
|
||||
const discNumber = track.disc_number;
|
||||
return {
|
||||
id: Number(track.id),
|
||||
title: track.title || `Track #${track.id}`,
|
||||
artists: track.artists || '',
|
||||
release_id: track.release_id == null ? null : Number(track.release_id),
|
||||
release_title: track.release_title || '',
|
||||
track_number: trackNumber == null ? '' : String(trackNumber),
|
||||
disc_number: discNumber == null ? '' : String(discNumber),
|
||||
duration_seconds: Number(track.duration_seconds || 0),
|
||||
is_hidden: Boolean(track.is_hidden)
|
||||
};
|
||||
},
|
||||
|
||||
releaseTracks() {
|
||||
if (!Array.isArray(this.editorDraft.release_tracks)) {
|
||||
this.editorDraft.release_tracks = [];
|
||||
}
|
||||
return this.editorDraft.release_tracks;
|
||||
},
|
||||
|
||||
releaseTrackPayload() {
|
||||
return this.releaseTracks().map(track => ({
|
||||
id: Number(track.id),
|
||||
track_number: track.track_number || '',
|
||||
disc_number: track.disc_number || ''
|
||||
}));
|
||||
},
|
||||
|
||||
releaseTrackIds() {
|
||||
return new Set(this.releaseTracks().map(track => Number(track.id)));
|
||||
},
|
||||
|
||||
releaseTrackSearchOpen() {
|
||||
return this.isReleaseEditor() && String(this.releaseTrackSearch || '').trim().length > 0;
|
||||
},
|
||||
|
||||
availableReleaseTrackResults() {
|
||||
const selected = this.releaseTrackIds();
|
||||
return (this.releaseTrackSearchResults || []).filter(track => !selected.has(Number(track.id)));
|
||||
},
|
||||
|
||||
clearReleaseTrackSearch() {
|
||||
this.releaseTrackSearch = '';
|
||||
this.releaseTrackSearchResults = [];
|
||||
this.releaseTrackSearchLoading = false;
|
||||
this.releaseTrackSearchToken += 1;
|
||||
},
|
||||
|
||||
async searchReleaseTracks() {
|
||||
const query = String(this.releaseTrackSearch || '').trim();
|
||||
if (!query) {
|
||||
this.releaseTrackSearchResults = [];
|
||||
this.releaseTrackSearchLoading = false;
|
||||
return;
|
||||
}
|
||||
const token = this.releaseTrackSearchToken + 1;
|
||||
this.releaseTrackSearchToken = token;
|
||||
this.releaseTrackSearchLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ search: query, limit: '16' });
|
||||
const rows = await this.request(`${this.apiBase}/library/tracks/search?${params.toString()}`);
|
||||
if (this.releaseTrackSearchToken !== token) return;
|
||||
this.releaseTrackSearchResults = Array.isArray(rows) ? rows.map(track => this.normalizeReleaseTrack(track)) : [];
|
||||
} catch (error) {
|
||||
if (this.releaseTrackSearchToken === token) this.showToast(error.message);
|
||||
} finally {
|
||||
if (this.releaseTrackSearchToken === token) {
|
||||
this.releaseTrackSearchLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async addBestReleaseTrack() {
|
||||
if (!String(this.releaseTrackSearch || '').trim()) return;
|
||||
if (!this.availableReleaseTrackResults().length && !this.releaseTrackSearchLoading) {
|
||||
await this.searchReleaseTracks();
|
||||
}
|
||||
const track = this.availableReleaseTrackResults()[0];
|
||||
if (!track) {
|
||||
this.showToast('Choose a track from search results');
|
||||
return;
|
||||
}
|
||||
this.addReleaseTrack(track);
|
||||
},
|
||||
|
||||
addReleaseTrack(track) {
|
||||
if (!track) return;
|
||||
const normalized = this.normalizeReleaseTrack(track);
|
||||
if (this.releaseTrackIds().has(Number(normalized.id))) {
|
||||
this.showToast('Track already in release');
|
||||
return;
|
||||
}
|
||||
this.editorDraft.release_tracks = this.releaseTracks().concat([normalized]);
|
||||
this.clearReleaseTrackSearch();
|
||||
this.$nextTick(() => this.icons());
|
||||
},
|
||||
|
||||
removeReleaseTrack(id) {
|
||||
this.editorDraft.release_tracks = this.releaseTracks().filter(track => Number(track.id) !== Number(id));
|
||||
},
|
||||
|
||||
releaseTrackOrigin(track) {
|
||||
const releaseId = Number(track && track.release_id ? track.release_id : 0);
|
||||
const currentId = Number(this.activeLibraryItem && this.activeLibraryItem.id ? this.activeLibraryItem.id : 0);
|
||||
if (releaseId && releaseId === currentId) return this.editorDraft.title || track.release_title || 'This release';
|
||||
if (track && track.release_title) return track.release_title;
|
||||
if (releaseId) return `Missing release #${releaseId}`;
|
||||
return 'No release';
|
||||
},
|
||||
|
||||
releaseTrackSearchMeta(track) {
|
||||
const parts = [];
|
||||
if (track.artists) parts.push(track.artists);
|
||||
parts.push(this.releaseTrackOrigin(track));
|
||||
const number = this.releaseTrackNumberLabel(track);
|
||||
if (number) parts.push(number);
|
||||
return parts.join(' / ');
|
||||
},
|
||||
|
||||
releaseTrackNumberLabel(track) {
|
||||
const disc = String((track && track.disc_number) || '').trim();
|
||||
const number = String((track && track.track_number) || '').trim();
|
||||
if (disc && number) return `D${disc} #${number}`;
|
||||
if (number) return `#${number}`;
|
||||
if (disc) return `D${disc}`;
|
||||
return '';
|
||||
},
|
||||
|
||||
trackDuration(seconds) {
|
||||
const total = Math.round(Number(seconds || 0));
|
||||
if (!total) return '-';
|
||||
const minutes = Math.floor(total / 60);
|
||||
const rest = String(total % 60).padStart(2, '0');
|
||||
return `${minutes}:${rest}`;
|
||||
},
|
||||
|
||||
setEditorImageFile(event) {
|
||||
this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null;
|
||||
},
|
||||
@@ -3592,6 +4200,7 @@ function adminV2() {
|
||||
if (!this.editorCanSave()) return;
|
||||
this.editorSaving = true;
|
||||
try {
|
||||
const wasNewRelease = this.editorIsNewRelease();
|
||||
const updated = await this.request(`${this.apiBase}/library/item`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -3604,13 +4213,15 @@ function adminV2() {
|
||||
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
|
||||
track_number: this.editorDraft.track_number || '',
|
||||
disc_number: this.editorDraft.disc_number || '',
|
||||
artist_ids: this.editorDraft.artist_ids || []
|
||||
artist_ids: this.editorDraft.artist_ids || [],
|
||||
release_tracks: this.isReleaseEditor() ? this.releaseTrackPayload() : null
|
||||
})
|
||||
});
|
||||
this.replaceLibraryItem(updated);
|
||||
this.activeLibraryItem = updated;
|
||||
if (this.editorDetail) this.editorDetail.item = updated;
|
||||
this.showToast('Saved');
|
||||
if (this.isReleaseEditor()) await this.loadEditorDetail(updated);
|
||||
this.showToast(wasNewRelease ? 'Release created' : 'Saved');
|
||||
await this.refreshCountsOnly();
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
@@ -3631,9 +4242,18 @@ function adminV2() {
|
||||
},
|
||||
|
||||
replaceLibraryItem(updated) {
|
||||
this.library.items = this.library.items.map(item =>
|
||||
item.kind === updated.kind && item.id === updated.id ? updated : item
|
||||
);
|
||||
let replaced = false;
|
||||
this.library.items = this.library.items.map(item => {
|
||||
if (item.kind === updated.kind && Number(item.id) === Number(updated.id)) {
|
||||
replaced = true;
|
||||
return updated;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (!replaced && updated.kind === this.libraryKind) {
|
||||
this.library.items = [updated].concat(this.library.items || []);
|
||||
this.library.total = Number(this.library.total || 0) + 1;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshCountsOnly() {
|
||||
@@ -3735,7 +4355,11 @@ function adminV2() {
|
||||
},
|
||||
|
||||
reviewPanelSubtitle() {
|
||||
return `${this.fmt(this.reviews.total || 0)} rows · ${this.reviewFilter.status || 'all statuses'}`;
|
||||
const total = this.reviews.total || 0;
|
||||
const totalAll = this.reviewTotalAll();
|
||||
const status = this.reviewFilter.status || 'all statuses';
|
||||
if (this.reviewFilter.status) return `${this.fmt(total)} ${status} · ${this.fmt(totalAll)} total`;
|
||||
return `${this.fmt(totalAll)} rows · ${status}`;
|
||||
},
|
||||
|
||||
jobPanelSubtitle() {
|
||||
@@ -3788,6 +4412,26 @@ function adminV2() {
|
||||
];
|
||||
},
|
||||
|
||||
userPlayListened(play) {
|
||||
const listened = play?.duration_listened;
|
||||
const duration = play?.track_duration_seconds;
|
||||
const listenedText = listened == null ? 'unknown' : this.durationApprox(listened);
|
||||
const durationText = duration ? this.durationApprox(duration) : null;
|
||||
return durationText ? `${listenedText} / ${durationText}` : listenedText;
|
||||
},
|
||||
|
||||
userPlayMeta(play) {
|
||||
const parts = [];
|
||||
if (play.release_title) {
|
||||
parts.push(play.release_year ? `${play.release_title} (${play.release_year})` : play.release_title);
|
||||
}
|
||||
if (play.audio_format) parts.push(String(play.audio_format).toUpperCase());
|
||||
if (play.audio_bitrate) parts.push(`${play.audio_bitrate} kbps`);
|
||||
if (play.uploader_name) parts.push(`uploaded by ${play.uploader_name}`);
|
||||
parts.push(play.completed ? 'completed' : 'partial');
|
||||
return parts.join(' · ');
|
||||
},
|
||||
|
||||
durationApprox(seconds) {
|
||||
const value = Math.max(0, Number(seconds || 0));
|
||||
if (value < 60) return `${Math.floor(value)}s`;
|
||||
@@ -3826,6 +4470,12 @@ function adminV2() {
|
||||
return row ? row.count : 0;
|
||||
},
|
||||
|
||||
reviewTotalAll() {
|
||||
const explicit = Number(this.reviews.total_all || 0);
|
||||
if (explicit > 0 || Object.prototype.hasOwnProperty.call(this.reviews || {}, 'total_all')) return explicit;
|
||||
return (this.reviews.status_counts || []).reduce((sum, row) => sum + Number(row.count || 0), 0);
|
||||
},
|
||||
|
||||
formatConfidence(value) {
|
||||
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
|
||||
},
|
||||
|
||||
@@ -722,6 +722,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>
|
||||
|
||||
+899
-19
File diff suppressed because it is too large
Load Diff
+370
-113
@@ -60,7 +60,13 @@
|
||||
:class="{ active: $store.library.view === 'artists' }"
|
||||
@click="$store.library.goArtists()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
{{ t.player_artists }}
|
||||
{{ t.player_global_library }}
|
||||
</div>
|
||||
<div class="sidebar-nav-item"
|
||||
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||
@click="$store.library.goMyUploads()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
{{ t.player_my_uploads }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
@@ -168,7 +174,13 @@
|
||||
:class="{ active: $store.library.view === 'artists' }"
|
||||
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
{{ t.player_artists }}
|
||||
{{ t.player_global_library }}
|
||||
</div>
|
||||
<div class="sidebar-nav-item"
|
||||
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||
@click="$store.library.goMyUploads(); $store.mobile.closeLibrary()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
{{ t.player_my_uploads }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,17 +411,6 @@
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
</template>
|
||||
<button class="artist-follow-card-btn"
|
||||
:class="{ followed: $store.follows.has(artist.id) }"
|
||||
@click.stop="$store.follows.toggle(artist.id)"
|
||||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-artist-name" x-text="artist.name"></div>
|
||||
</div>
|
||||
@@ -492,6 +493,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([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(track, $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([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>
|
||||
@@ -507,39 +511,59 @@
|
||||
</template>
|
||||
|
||||
<!-- Artists Grid -->
|
||||
<template x-if="$store.library.view === 'artists'">
|
||||
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
|
||||
<div>
|
||||
<h1 class="section-title">{{ t.player_artists }}</h1>
|
||||
<h1 class="section-title" x-text="$store.library.view === 'my_uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></h1>
|
||||
<div class="card-grid">
|
||||
<template x-for="artist in $store.library.artists" :key="artist.id">
|
||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
||||
</template>
|
||||
<button class="artist-follow-card-btn"
|
||||
:class="{ followed: $store.follows.has(artist.id) }"
|
||||
@click.stop="$store.follows.toggle(artist.id)"
|
||||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<template x-for="(artist, idx) in $store.library.artists" :key="artist.id">
|
||||
<div class="artist-grid-entry">
|
||||
<div class="artist-section-divider"
|
||||
x-show="$store.library.shouldShowFeaturedSeparator(idx)"
|
||||
x-cloak>
|
||||
<span>{{ t.player_featured_only_artists }}</span>
|
||||
</div>
|
||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
||||
</template>
|
||||
<button class="artist-follow-card-btn"
|
||||
:class="{ followed: $store.follows.has(artist.id) }"
|
||||
@click.stop="$store.follows.toggle(artist.id)"
|
||||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-title" x-text="artist.name"></div>
|
||||
<div class="card-subtitle" x-text="artist.release_count + ' {{ t.player_releases_count }} · ' + artist.track_count + ' {{ t.player_tracks_count }}'"></div>
|
||||
</div>
|
||||
<div class="card-title" x-text="artist.name"></div>
|
||||
<div class="card-subtitle" x-text="artist.release_count + ' {{ t.player_releases_count }} · ' + artist.track_count + ' {{ t.player_tracks_count }}'"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="$store.library.loading">
|
||||
<template x-if="!$store.library.loading && $store.library.artists.length === 0">
|
||||
<div class="empty-state">
|
||||
<p x-text="$store.library.view === 'my_uploads' ? '{{ t.player_no_uploaded_tracks }}' : '{{ t.artists_empty }}'"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.library.loading && $store.library.artists.length === 0">
|
||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||
</template>
|
||||
<div class="scroll-load-indicator"
|
||||
x-show="$store.library.loading && $store.library.artists.length > 0"
|
||||
x-cloak
|
||||
role="status"
|
||||
aria-live="polite">
|
||||
<div class="scroll-load-spinner"></div>
|
||||
<span>{{ t.player_loading_more }}</span>
|
||||
</div>
|
||||
<div id="artist-sentinel" style="height:1px"></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -548,7 +572,8 @@
|
||||
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
||||
<div>
|
||||
<div class="breadcrumb">
|
||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||||
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
|
||||
<span>/</span>
|
||||
<span x-text="$store.library.currentArtist.name"></span>
|
||||
</div>
|
||||
@@ -595,6 +620,83 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
||||
<div class="track-list-header">
|
||||
<span>#</span>
|
||||
<span>{{ t.player_title }}</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
|
||||
<div class="track-row artist-appearance-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<button class="artist-appearance-cover"
|
||||
type="button"
|
||||
@click.stop="$store.library.openRelease(track.release_id)"
|
||||
:title="track.release_title"
|
||||
aria-label="{{ t.player_release }}">
|
||||
<template x-if="track.cover_url">
|
||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<div class="artist-appearance-copy">
|
||||
<div class="track-title">
|
||||
<span x-text="track.title"></span>
|
||||
<span style="color:var(--text-subdued)"> · </span>
|
||||
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
|
||||
</div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||||
:style="$store.library.popularityStyle(track)"
|
||||
@click.stop="$store.library.openTrackInfo(track)"
|
||||
:title="$store.library.trackInfoTitle(track)"
|
||||
aria-label="{{ t.player_track_info }}">
|
||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([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(track, $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([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title" x-text="group.label"></h2>
|
||||
@@ -692,6 +794,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([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(track, $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([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>
|
||||
@@ -708,7 +813,8 @@
|
||||
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
||||
<div>
|
||||
<div class="breadcrumb">
|
||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||||
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
|
||||
<span>/</span>
|
||||
<template x-if="$store.library.currentRelease.artists.length > 0">
|
||||
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
||||
@@ -746,25 +852,36 @@
|
||||
</div>
|
||||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||||
<div class="release-actions">
|
||||
<button class="release-action-btn secondary"
|
||||
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
||||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||
aria-label="{{ t.player_release_info }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
{{ t.player_info }}
|
||||
</button>
|
||||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
{{ t.player_play }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ t.player_queue }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
{{ t.player_next }}
|
||||
</button>
|
||||
<div class="release-action-row release-meta-actions">
|
||||
<button class="release-action-btn secondary"
|
||||
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
||||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||
aria-label="{{ t.player_release_info }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
{{ t.player_info }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary release-share-btn"
|
||||
@click.stop="$store.sharing.copyRelease($store.library.currentRelease, $event.currentTarget)"
|
||||
title="{{ t.player_share }}"
|
||||
aria-label="{{ t.player_share }}">
|
||||
<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>
|
||||
{{ t.player_share }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="release-action-row release-playback-actions">
|
||||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
{{ t.player_play }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ t.player_queue }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
{{ t.player_next }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -778,7 +895,8 @@
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
||||
<div class="track-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
:data-shared-track-id="track.id"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, 'shared-target': $store.sharing.isSharedTrack(track) }"
|
||||
@dblclick="$store.queue.playRelease([track], 0)">
|
||||
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
||||
<div class="track-info">
|
||||
@@ -812,6 +930,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([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(track, $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([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>
|
||||
@@ -830,7 +951,23 @@
|
||||
<span>/</span>
|
||||
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
||||
</div>
|
||||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||||
<div class="playlist-detail-heading">
|
||||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||||
<button class="release-action-btn secondary playlist-edit-toggle"
|
||||
x-show="$store.playlists.canEditCurrent()"
|
||||
x-cloak
|
||||
@click="$store.playlists.toggleEditCurrent()"
|
||||
:title="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'">
|
||||
<svg x-show="!$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||||
<path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
<svg x-show="$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span x-text="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="playlist-detail-meta"
|
||||
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
||||
<span x-show="$store.library.currentPlaylist.owner_name"
|
||||
@@ -842,27 +979,61 @@
|
||||
<template x-if="$store.library.currentPlaylist.description">
|
||||
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
||||
</template>
|
||||
<div class="track-list-header">
|
||||
<div class="track-list-header playlist-track-list-header"
|
||||
:class="{ editing: $store.playlists.isEditingCurrent() }">
|
||||
<span class="playlist-edit-cell"
|
||||
x-show="$store.playlists.isEditingCurrent()"
|
||||
x-cloak></span>
|
||||
<span>#</span>
|
||||
<span>{{ t.player_title }}</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
|
||||
<div class="track-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
|
||||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.playlist_track_id || (track.id + '-' + idx)">
|
||||
<div class="track-row playlist-track-row"
|
||||
:data-playlist-index="idx"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, editing: $store.playlists.isEditingCurrent(), dragging: $store.playlists._dragIdx === idx }"
|
||||
:draggable="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||
@dblclick="if (!$store.playlists.isEditingCurrent()) $store.queue.playRelease($store.library.currentPlaylist.tracks, idx)"
|
||||
@dragstart="if (!$store.playlists.startDrag($event, idx)) $event.preventDefault()"
|
||||
@dragend="$store.playlists.endDrag()"
|
||||
@dragover.prevent="$store.playlists.dragOver($event, idx)"
|
||||
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||||
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); $store.playlists.dropOn(idx)">
|
||||
<button class="playlist-track-remove"
|
||||
x-show="$store.playlists.isEditingCurrent()"
|
||||
x-cloak
|
||||
@click.stop="$store.playlists.removeCurrentTrack(track, idx)"
|
||||
title="{{ t.player_remove }}"
|
||||
aria-label="{{ t.player_remove }}">
|
||||
<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>
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
<div class="track-info playlist-track-info">
|
||||
<button class="playlist-drag-handle"
|
||||
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||
x-cloak
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
@pointerdown.stop="$store.playlists.startPointerReorder($event, idx)"
|
||||
title="{{ t.player_edit }}"
|
||||
aria-label="{{ t.player_edit }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||
</button>
|
||||
<div class="playlist-track-copy">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
@@ -885,6 +1056,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([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(track, $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([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>
|
||||
@@ -904,45 +1078,51 @@
|
||||
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
||||
<div class="queue-header">
|
||||
<h3>{{ t.player_queue }}</h3>
|
||||
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
||||
<div class="queue-header-actions">
|
||||
<button class="queue-share-btn" @click="$store.sharing.copyQueue($event.currentTarget)" :disabled="$store.queue.tracks.length === 0" title="{{ t.player_share_queue }}" aria-label="{{ t.player_share_queue }}">
|
||||
<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="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-tracks">
|
||||
<template x-if="$store.queue.tracks.length === 0">
|
||||
<template x-if="$store.queue.displayItems().length === 0">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<p>{{ t.player_queue_empty }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id">
|
||||
<template x-for="item in $store.queue.displayItems()" :key="item.key">
|
||||
<div class="queue-track"
|
||||
:data-queue-index="idx"
|
||||
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx, 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
|
||||
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
|
||||
@click="$store.queue.playFromIndex(idx)"
|
||||
draggable="true"
|
||||
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'"
|
||||
:data-queue-index="item.index"
|
||||
:class="{ active: item.state === 'current', current: item.state === 'current', played: item.state === 'played', dragging: $store.queue._dragIdx === item.index, synthetic: item.synthetic, 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
|
||||
:style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
|
||||
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)"
|
||||
:draggable="!item.synthetic"
|
||||
@dragstart="if (item.synthetic) { $event.preventDefault(); } else { $store.queue._dragIdx = item.index; $event.dataTransfer.effectAllowed = 'move'; }"
|
||||
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
|
||||
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
|
||||
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||||
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }">
|
||||
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if (!item.synthetic && $store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, item.index); $store.queue._dragIdx = null; }">
|
||||
<div class="queue-drag-handle"
|
||||
x-show="!item.synthetic"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
@pointerdown.stop="$store.queue.startPointerReorder($event, idx)">
|
||||
@pointerdown.stop="$store.queue.startPointerReorder($event, item.index)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||
</div>
|
||||
<div class="queue-track-cover">
|
||||
<template x-if="track.cover_url">
|
||||
<img :src="track.cover_url" :alt="track.title" loading="lazy">
|
||||
<template x-if="item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!track.cover_url">
|
||||
<template x-if="!item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div class="queue-track-info">
|
||||
<div class="queue-track-title" x-text="track.title"></div>
|
||||
<div class="queue-track-title" x-text="item.track.title"></div>
|
||||
<div class="queue-track-artist">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
@@ -952,15 +1132,15 @@
|
||||
</div>
|
||||
<div class="queue-track-actions">
|
||||
<button class="queue-track-remove info-btn popularity-info-btn"
|
||||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||||
:style="$store.library.popularityStyle(track)"
|
||||
@click.stop="$store.library.openTrackInfo(track)"
|
||||
:title="$store.library.trackInfoTitle(track)"
|
||||
:class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
|
||||
:style="$store.library.popularityStyle(item.track)"
|
||||
@click.stop="$store.library.openTrackInfo(item.track)"
|
||||
:title="$store.library.trackInfoTitle(item.track)"
|
||||
aria-label="{{ t.player_track_info }}">
|
||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||
<span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
|
||||
<span x-show="!$store.library.hasPopularity(item.track)" class="info-letter">i</span>
|
||||
</button>
|
||||
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}">
|
||||
<button class="queue-track-remove" x-show="!item.synthetic" @click.stop="$store.queue.remove(item.index)" title="{{ t.player_remove }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -981,7 +1161,7 @@
|
||||
</button>
|
||||
<div class="player-now-playing">
|
||||
<template x-if="$store.player.currentTrack">
|
||||
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
|
||||
<div class="player-current-track">
|
||||
<div class="player-cover"
|
||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||
<template x-if="$store.player.currentTrack.cover_url">
|
||||
@@ -1001,6 +1181,12 @@
|
||||
aria-label="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has($store.player.currentTrack.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn player-current-share"
|
||||
@click.stop="$store.sharing.copyTrack($store.player.currentTrack, $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>
|
||||
</div>
|
||||
<div class="player-track-artist">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
@@ -1026,7 +1212,7 @@
|
||||
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||
</button>
|
||||
<button class="player-btn" @click="$store.player.prev()" title="{{ t.player_previous }}">
|
||||
<button class="player-btn player-btn-prev" @click="$store.player.prev()" title="{{ t.player_previous }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
||||
@@ -1037,7 +1223,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<button class="player-btn" @click="$store.player.next()" title="{{ t.player_next }}">
|
||||
<button class="player-btn player-btn-next" @click="$store.player.next()" title="{{ t.player_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
|
||||
@@ -1051,7 +1237,9 @@
|
||||
</div>
|
||||
<div class="player-timeline">
|
||||
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
|
||||
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
|
||||
<div class="progress-bar"
|
||||
@pointerdown.prevent="$store.player.startProgressDrag($event)"
|
||||
aria-label="{{ t.player_duration }}">
|
||||
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
|
||||
<div class="progress-bar-thumb"></div>
|
||||
</div>
|
||||
@@ -1147,6 +1335,67 @@
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="device-section-label fed-section-label">Fed Clients</div>
|
||||
<div class="fed-device-panel">
|
||||
<div class="fed-device-status" x-text="$store.devices.fedSummary()"></div>
|
||||
<template x-if="$store.devices.fedError">
|
||||
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||
</template>
|
||||
<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 in another sync group' : (request.client_version || 'waiting for approval')"></div>
|
||||
<template x-if="request.requester_group_id">
|
||||
<div class="fed-pairing-note">Recommended keeps the existing group intact.</div>
|
||||
</template>
|
||||
<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 ? 'Recommended' : 'Approve'"></button>
|
||||
<button class="fed-action-btn"
|
||||
@click="$store.devices.answerFedPairing(request, false, false)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="device in $store.devices.fedDevices()" :key="device.device_id">
|
||||
<div class="fed-device-row" :class="{ self: device.is_self }">
|
||||
<span class="fed-device-dot" :class="{ self: device.is_self }"></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 ? 'WEB' : (device.client_version || 'unknown'))"></span>
|
||||
</span>
|
||||
<button class="fed-revoke-btn"
|
||||
x-show="!device.is_self"
|
||||
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fed-device-actions">
|
||||
<button class="fed-action-btn primary"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.generateFedInvite()">Invite</button>
|
||||
<button class="fed-action-btn"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.syncFedDevices()">Sync</button>
|
||||
</div>
|
||||
<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="fed-action-btn"
|
||||
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
|
||||
@click="$store.devices.connectFedInvite()">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.devices.jams.length > 0">
|
||||
<div class="device-section-label jam-section-label">Jams</div>
|
||||
</template>
|
||||
@@ -1188,6 +1437,14 @@
|
||||
</button>
|
||||
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
|
||||
<div class="jam-panel-title" x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite listeners' : 'Start Jam'"></div>
|
||||
<label class="jam-local-playback-toggle"
|
||||
x-show="$store.devices.jamPanelMode === 'manage' && $store.devices.canPlayJamLocally()"
|
||||
x-cloak>
|
||||
<input type="checkbox"
|
||||
:checked="$store.devices.jamLocalPlayback"
|
||||
@change="$store.devices.setJamLocalPlayback($event.target.checked)">
|
||||
<span>{{ t.player_jam_play_on_this_device }}</span>
|
||||
</label>
|
||||
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
|
||||
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
|
||||
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
|
||||
@@ -1224,27 +1481,27 @@
|
||||
</div>
|
||||
<div class="mobile-expanded-queue">
|
||||
<div class="mobile-expanded-queue-title">{{ t.player_queue }}</div>
|
||||
<template x-if="$store.queue.upcoming().length === 0">
|
||||
<template x-if="$store.queue.displayItems().length === 0">
|
||||
<div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div>
|
||||
</template>
|
||||
<template x-for="(track, idx) in $store.queue.upcoming()" :key="'mobile-expanded-queue-' + track.id + '-' + idx">
|
||||
<template x-for="item in $store.queue.displayItems()" :key="'mobile-expanded-queue-' + item.key">
|
||||
<button class="mobile-expanded-queue-row"
|
||||
:class="{ 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
|
||||
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
|
||||
:class="{ current: item.state === 'current', played: item.state === 'played', 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
|
||||
:style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
|
||||
type="button"
|
||||
@click="$store.queue.playFromIndex($store.queue.currentIndex + idx + 1)">
|
||||
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
|
||||
<div class="mobile-expanded-queue-cover">
|
||||
<template x-if="track.cover_url">
|
||||
<img :src="track.cover_url" :alt="track.title" loading="lazy">
|
||||
<template x-if="item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!track.cover_url">
|
||||
<template x-if="!item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mobile-expanded-queue-info">
|
||||
<div class="mobile-expanded-queue-name" x-text="track.title"></div>
|
||||
<div class="mobile-expanded-queue-name" x-text="item.track.title"></div>
|
||||
<div class="mobile-expanded-queue-artist">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<span x-text="artist.label"></span>
|
||||
@@ -1252,7 +1509,7 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mobile-expanded-queue-time" x-text="formatTime(track.duration_seconds)"></span>
|
||||
<span class="mobile-expanded-queue-time" x-text="formatTime(item.track.duration_seconds)"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
+708
-49
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user