Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d68724f3 | ||
|
|
d71845509d | ||
|
|
4efcfdc539 | ||
|
|
d61d7a6bac | ||
|
|
cad8b2280f | ||
|
|
3a75c7d848 | ||
|
|
dc1fac1a94 | ||
|
|
122214a896 | ||
|
|
134f89fc9d | ||
|
|
232c171aac | ||
|
|
845df4e031 | ||
|
|
b0d8929b4c | ||
|
|
291265be7d | ||
|
|
624e75839d | ||
|
|
fd766cda24 | ||
|
|
49000f716c | ||
|
|
ee4990e2f1 | ||
|
|
e64b61c167 | ||
|
|
b737ced3fc | ||
|
|
c2bdd62a51 | ||
|
|
d1370c6a28 | ||
|
|
2fc5fd7960 | ||
|
|
63506e3af2 | ||
|
|
5b339aa921 | ||
|
|
42c772f735 | ||
|
|
e738086573 | ||
|
|
4b7756c36e | ||
|
|
4381750c6e | ||
|
|
3485f643f4 | ||
|
|
bca0f5e2f0 | ||
|
|
53b2ff29f8 | ||
|
|
c349512fb0 | ||
|
|
0615356785 | ||
|
|
184371afca | ||
|
|
716da908c9 | ||
|
|
0c120c0868 | ||
|
|
d9d0fbb7d1 | ||
|
|
71d6556ba8 | ||
|
|
0ac59eb0ca | ||
|
|
652c6a470d | ||
|
|
1c54782dd7 |
@@ -2,3 +2,4 @@
|
|||||||
/nul
|
/nul
|
||||||
/.claude
|
/.claude
|
||||||
/media
|
/media
|
||||||
|
/federation
|
||||||
|
|||||||
Generated
+2523
-620
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -1,18 +1,24 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.4.2"
|
version = "0.9.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cot = { version = "0.6.0", features = ["postgres", "json", "openapi", "swagger-ui"] }
|
# default-features off: cot's defaults include the sqlite backend, whose old
|
||||||
|
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
|
||||||
|
# binary). This server only ever talks PostgreSQL.
|
||||||
|
cot = { version = "0.6.0", default-features = false, features = ["postgres", "json", "openapi", "swagger-ui"] }
|
||||||
schemars = { version = "0.9", features = ["derive"] }
|
schemars = { version = "0.9", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
openidconnect = "4.0"
|
openidconnect = "4.0"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||||
|
async-stream = "0.3"
|
||||||
|
bytes = "1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
blake3 = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -28,5 +34,9 @@ anyhow = "1.0"
|
|||||||
tokio-cron-scheduler = "0.15"
|
tokio-cron-scheduler = "0.15"
|
||||||
croner = "3"
|
croner = "3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
postcard = { version = "1", features = ["alloc"] }
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
|
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||||
|
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||||
|
music-dht = "0.3"
|
||||||
|
|||||||
@@ -415,6 +415,38 @@ impl App for AdminApp {
|
|||||||
}),
|
}),
|
||||||
"admin_v2_settings_probe",
|
"admin_v2_settings_probe",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_status(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_status",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/sync",
|
||||||
|
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_sync(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_sync",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/ticket",
|
||||||
|
get(move |session: Session, db: Database| async move {
|
||||||
|
v2::federation_ticket(session, db).await
|
||||||
|
}),
|
||||||
|
"admin_v2_federation_ticket",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/federation/connect",
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::FederationConnectRequest>| async move {
|
||||||
|
v2::federation_connect(session, db, json).await
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"admin_v2_federation_connect",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/jobs/{name}/toggle",
|
"/v2/api/jobs/{name}/toggle",
|
||||||
cot::router::method::post({
|
cot::router::method::post({
|
||||||
@@ -555,6 +587,32 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_library_item_detail",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/library/item/image",
|
"/v2/api/library/item/image",
|
||||||
{
|
{
|
||||||
|
|||||||
+419
-24
@@ -110,6 +110,7 @@ pub(super) struct UpdateLibraryItemRequest {
|
|||||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||||
disc_number: Option<String>,
|
disc_number: Option<String>,
|
||||||
artist_ids: Option<Vec<i64>>,
|
artist_ids: Option<Vec<i64>>,
|
||||||
|
release_tracks: Option<Vec<ReleaseTrackUpdateRequest>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -118,6 +119,21 @@ pub(super) struct LibraryItemDetailQuery {
|
|||||||
id: i64,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct SetLibraryImageRequest {
|
pub(super) struct SetLibraryImageRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
@@ -432,6 +448,12 @@ struct AdminSettingsValues {
|
|||||||
agent_confidence_threshold: String,
|
agent_confidence_threshold: String,
|
||||||
agent_context_limit: String,
|
agent_context_limit: String,
|
||||||
agent_concurrency: String,
|
agent_concurrency: String,
|
||||||
|
#[serde(default)]
|
||||||
|
federation_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
federation_network_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
federation_save_on_listen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
@@ -456,6 +478,9 @@ struct AdminSettingsSources {
|
|||||||
agent_confidence_threshold: &'static str,
|
agent_confidence_threshold: &'static str,
|
||||||
agent_context_limit: &'static str,
|
agent_context_limit: &'static str,
|
||||||
agent_concurrency: &'static str,
|
agent_concurrency: &'static str,
|
||||||
|
federation_enabled: &'static str,
|
||||||
|
federation_network_id: &'static str,
|
||||||
|
federation_save_on_listen: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -480,6 +505,12 @@ pub(super) struct UpdateSettingsRequest {
|
|||||||
agent_confidence_threshold: String,
|
agent_confidence_threshold: String,
|
||||||
agent_context_limit: String,
|
agent_context_limit: String,
|
||||||
agent_concurrency: String,
|
agent_concurrency: String,
|
||||||
|
#[serde(default)]
|
||||||
|
federation_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
federation_network_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
federation_save_on_listen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -538,6 +569,7 @@ struct LibraryItemDetailDto {
|
|||||||
selected_artist_ids: Vec<i64>,
|
selected_artist_ids: Vec<i64>,
|
||||||
artists: Vec<ArtistOptionDto>,
|
artists: Vec<ArtistOptionDto>,
|
||||||
releases: Vec<ReleaseOptionDto>,
|
releases: Vec<ReleaseOptionDto>,
|
||||||
|
release_tracks: Vec<ReleaseTrackDto>,
|
||||||
available_covers: Vec<AvailableCoverDto>,
|
available_covers: Vec<AvailableCoverDto>,
|
||||||
metadata_tags: Vec<MetadataTagDto>,
|
metadata_tags: Vec<MetadataTagDto>,
|
||||||
}
|
}
|
||||||
@@ -555,6 +587,19 @@ struct ReleaseOptionDto {
|
|||||||
subtitle: String,
|
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)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct AvailableCoverDto {
|
struct AvailableCoverDto {
|
||||||
media_file_id: i64,
|
media_file_id: i64,
|
||||||
@@ -651,6 +696,19 @@ struct LibraryItemRow {
|
|||||||
updated_at: Option<String>,
|
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> {
|
pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||||
let template = AdminV2Template {
|
let template = AdminV2Template {
|
||||||
t: i18n.t,
|
t: i18n.t,
|
||||||
@@ -935,6 +993,15 @@ pub async fn update_settings(
|
|||||||
"agent_concurrency",
|
"agent_concurrency",
|
||||||
body.agent_concurrency.trim().to_string(),
|
body.agent_concurrency.trim().to_string(),
|
||||||
),
|
),
|
||||||
|
("federation_enabled", body.federation_enabled.to_string()),
|
||||||
|
(
|
||||||
|
"federation_network_id",
|
||||||
|
body.federation_network_id.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"federation_save_on_listen",
|
||||||
|
body.federation_save_on_listen.to_string(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||||
@@ -943,9 +1010,78 @@ pub async fn update_settings(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.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()
|
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(
|
pub async fn settings_probe(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -1014,6 +1150,9 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
|
agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
|
||||||
agent_context_limit: config.agent_context_limit.to_string(),
|
agent_context_limit: config.agent_context_limit.to_string(),
|
||||||
agent_concurrency: config.agent_concurrency.to_string(),
|
agent_concurrency: config.agent_concurrency.to_string(),
|
||||||
|
federation_enabled: config.federation_enabled,
|
||||||
|
federation_network_id: config.federation_network_id,
|
||||||
|
federation_save_on_listen: config.federation_save_on_listen,
|
||||||
},
|
},
|
||||||
sources: AdminSettingsSources {
|
sources: AdminSettingsSources {
|
||||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||||
@@ -1036,6 +1175,9 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
agent_confidence_threshold: sources.agent_confidence_threshold.code(),
|
agent_confidence_threshold: sources.agent_confidence_threshold.code(),
|
||||||
agent_context_limit: sources.agent_context_limit.code(),
|
agent_context_limit: sources.agent_context_limit.code(),
|
||||||
agent_concurrency: sources.agent_concurrency.code(),
|
agent_concurrency: sources.agent_concurrency.code(),
|
||||||
|
federation_enabled: sources.federation_enabled.code(),
|
||||||
|
federation_network_id: sources.federation_network_id.code(),
|
||||||
|
federation_save_on_listen: sources.federation_save_on_listen.code(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1289,6 +1431,21 @@ pub async fn library_item_detail(
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
let kind = normalize_library_kind(Some(query.kind.as_str()));
|
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)
|
let Some(item) = fetch_library_item(pool, &kind, query.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
@@ -1301,6 +1458,25 @@ pub async fn library_item_detail(
|
|||||||
Json(detail).into_response()
|
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(
|
pub async fn update_library_item(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -1318,6 +1494,19 @@ pub async fn update_library_item(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = now_string();
|
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() {
|
let affected = match kind.as_str() {
|
||||||
"artists" => {
|
"artists" => {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -1421,22 +1610,9 @@ pub async fn update_library_item(
|
|||||||
let mut seen_artist_ids = HashSet::new();
|
let mut seen_artist_ids = HashSet::new();
|
||||||
artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
|
artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
|
||||||
if kind == "releases" {
|
if kind == "releases" {
|
||||||
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
|
set_release_artists(pool, body.id, &artist_ids)
|
||||||
.bind(body.id)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.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 {
|
} else {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'",
|
"DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'",
|
||||||
@@ -1460,6 +1636,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)
|
let Some(item) = fetch_library_item(pool, &kind, body.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
@@ -2647,13 +2831,13 @@ async fn fetch_library_item(
|
|||||||
"tracks" => {
|
"tracks" => {
|
||||||
sqlx::query_as::<_, LibraryItemRow>(
|
sqlx::query_as::<_, LibraryItemRow>(
|
||||||
"SELECT t.id, t.title::text AS title, \
|
"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, \
|
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
|
||||||
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
||||||
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
||||||
t.updated_at::text AS updated_at \
|
t.updated_at::text AS updated_at \
|
||||||
FROM furumusic__track t \
|
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__track_artist ta ON ta.track_id = t.id \
|
||||||
LEFT JOIN furumusic__play_history ph ON ph.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 \
|
LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \
|
||||||
@@ -2704,6 +2888,7 @@ async fn load_library_item_detail(
|
|||||||
selected_artist_ids: Vec::new(),
|
selected_artist_ids: Vec::new(),
|
||||||
artists: Vec::new(),
|
artists: Vec::new(),
|
||||||
releases: Vec::new(),
|
releases: Vec::new(),
|
||||||
|
release_tracks: Vec::new(),
|
||||||
available_covers: Vec::new(),
|
available_covers: Vec::new(),
|
||||||
metadata_tags: load_metadata_tags(pool, kind, item.id).await?,
|
metadata_tags: load_metadata_tags(pool, kind, item.id).await?,
|
||||||
item,
|
item,
|
||||||
@@ -2744,16 +2929,22 @@ async fn load_library_item_detail(
|
|||||||
.map(|row| row.id)
|
.map(|row| row.id)
|
||||||
.collect();
|
.collect();
|
||||||
detail.artists = load_artist_options(pool).await?;
|
detail.artists = load_artist_options(pool).await?;
|
||||||
|
if detail.item.id > 0 {
|
||||||
|
detail.release_tracks = load_release_tracks(pool, detail.item.id).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"tracks" => {
|
"tracks" => {
|
||||||
let row: Option<(i64, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
|
let row: Option<(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",
|
"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)
|
.bind(detail.item.id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some((release_id, track_number, disc_number, year)) = row {
|
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.track_number = track_number;
|
||||||
detail.disc_number = disc_number;
|
detail.disc_number = disc_number;
|
||||||
detail.year = year;
|
detail.year = year;
|
||||||
@@ -2901,6 +3092,210 @@ async fn load_release_options(pool: &PgPool) -> anyhow::Result<Vec<ReleaseOption
|
|||||||
.collect())
|
.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(
|
async fn artist_available_covers(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
artist_id: i64,
|
artist_id: i64,
|
||||||
@@ -2943,7 +3338,7 @@ async fn library_ids_by_filter(
|
|||||||
"tracks" => QueryBuilder::<Postgres>::new(
|
"tracks" => QueryBuilder::<Postgres>::new(
|
||||||
"SELECT DISTINCT t.id \
|
"SELECT DISTINCT t.id \
|
||||||
FROM furumusic__track t \
|
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__track_artist ta ON ta.track_id = t.id \
|
||||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
||||||
),
|
),
|
||||||
@@ -3182,7 +3577,7 @@ async fn count_library(
|
|||||||
"tracks" => QueryBuilder::<Postgres>::new(
|
"tracks" => QueryBuilder::<Postgres>::new(
|
||||||
"SELECT COUNT(DISTINCT t.id) AS count \
|
"SELECT COUNT(DISTINCT t.id) AS count \
|
||||||
FROM furumusic__track t \
|
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__track_artist ta ON ta.track_id = t.id \
|
||||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
||||||
),
|
),
|
||||||
@@ -3319,13 +3714,13 @@ async fn load_track_items(
|
|||||||
) -> anyhow::Result<Vec<LibraryItemRow>> {
|
) -> anyhow::Result<Vec<LibraryItemRow>> {
|
||||||
let mut qb = QueryBuilder::<Postgres>::new(
|
let mut qb = QueryBuilder::<Postgres>::new(
|
||||||
"SELECT t.id, t.title::text AS title, \
|
"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, \
|
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
|
||||||
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
||||||
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
||||||
t.updated_at::text AS updated_at \
|
t.updated_at::text AS updated_at \
|
||||||
FROM furumusic__track t \
|
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__track_artist ta ON ta.track_id = t.id \
|
||||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
||||||
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
||||||
@@ -3341,7 +3736,7 @@ async fn load_track_items(
|
|||||||
qb.push_bind(pattern);
|
qb.push_bind(pattern);
|
||||||
qb.push(")");
|
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_bind(limit);
|
||||||
qb.push(" OFFSET ");
|
qb.push(" OFFSET ");
|
||||||
qb.push_bind(offset);
|
qb.push_bind(offset);
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ pub struct ConfigSources {
|
|||||||
pub agent_concurrency: ConfigSource,
|
pub agent_concurrency: ConfigSource,
|
||||||
pub lastfm_api_key: ConfigSource,
|
pub lastfm_api_key: ConfigSource,
|
||||||
pub lastfm_shared_secret: ConfigSource,
|
pub lastfm_shared_secret: ConfigSource,
|
||||||
|
pub federation_enabled: ConfigSource,
|
||||||
|
pub federation_network_id: ConfigSource,
|
||||||
|
pub federation_save_on_listen: ConfigSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigSources {
|
impl Default for ConfigSources {
|
||||||
@@ -162,6 +165,9 @@ impl Default for ConfigSources {
|
|||||||
agent_concurrency: ConfigSource::Default,
|
agent_concurrency: ConfigSource::Default,
|
||||||
lastfm_api_key: ConfigSource::Default,
|
lastfm_api_key: ConfigSource::Default,
|
||||||
lastfm_shared_secret: ConfigSource::Default,
|
lastfm_shared_secret: ConfigSource::Default,
|
||||||
|
federation_enabled: ConfigSource::Default,
|
||||||
|
federation_network_id: ConfigSource::Default,
|
||||||
|
federation_save_on_listen: ConfigSource::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +276,15 @@ pub struct AppConfig {
|
|||||||
pub lastfm_api_key: String,
|
pub lastfm_api_key: String,
|
||||||
/// Last.fm shared secret for authenticated scrobbling calls.
|
/// Last.fm shared secret for authenticated scrobbling calls.
|
||||||
pub lastfm_shared_secret: String,
|
pub lastfm_shared_secret: String,
|
||||||
|
/// Whether this server participates in the furumi federation (publishes
|
||||||
|
/// its library into the shared DHT and serves audio to peers).
|
||||||
|
pub federation_enabled: bool,
|
||||||
|
/// Federation network id — the shared secret every peer of the network
|
||||||
|
/// uses to find the others.
|
||||||
|
pub federation_network_id: String,
|
||||||
|
/// Whether a federated track requested for playback is imported into the
|
||||||
|
/// shared local library. This is a server-wide administrator policy.
|
||||||
|
pub federation_save_on_listen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -297,6 +312,9 @@ impl Default for AppConfig {
|
|||||||
agent_concurrency: 2,
|
agent_concurrency: 2,
|
||||||
lastfm_api_key: String::new(),
|
lastfm_api_key: String::new(),
|
||||||
lastfm_shared_secret: String::new(),
|
lastfm_shared_secret: String::new(),
|
||||||
|
federation_enabled: false,
|
||||||
|
federation_network_id: String::new(),
|
||||||
|
federation_save_on_listen: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +343,9 @@ impl_env_overrides!(
|
|||||||
agent_concurrency,
|
agent_concurrency,
|
||||||
lastfm_api_key,
|
lastfm_api_key,
|
||||||
lastfm_shared_secret,
|
lastfm_shared_secret,
|
||||||
|
federation_enabled,
|
||||||
|
federation_network_id,
|
||||||
|
federation_save_on_listen,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -452,6 +473,9 @@ impl AppConfig {
|
|||||||
apply_db_field!(agent_concurrency);
|
apply_db_field!(agent_concurrency);
|
||||||
apply_db_field!(lastfm_api_key);
|
apply_db_field!(lastfm_api_key);
|
||||||
apply_db_field!(lastfm_shared_secret);
|
apply_db_field!(lastfm_shared_secret);
|
||||||
|
apply_db_field!(federation_enabled);
|
||||||
|
apply_db_field!(federation_network_id);
|
||||||
|
apply_db_field!(federation_save_on_listen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Informational publication of the protocol versions exposed by this peer.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use music_dht::StreamAcceptor;
|
||||||
|
use music_dht::capabilities::{
|
||||||
|
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, read_message,
|
||||||
|
write_message,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::serve::AUDIO_PROTOCOL_VERSION;
|
||||||
|
|
||||||
|
fn local_manifest() -> CapabilityManifest {
|
||||||
|
CapabilityManifest::frid("furumusic", env!("CARGO_PKG_VERSION"))
|
||||||
|
// The web server does not expose federation Jam yet.
|
||||||
|
.without_protocol(JAM_ID)
|
||||||
|
.with_protocol("audio", AUDIO_PROTOCOL_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(mut acceptor: StreamAcceptor) {
|
||||||
|
while let Some(stream) = acceptor.accept().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(error) = serve_one(stream).await {
|
||||||
|
tracing::debug!("capability stream failed: {error:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_one(mut stream: music_dht::ByteStream) -> Result<()> {
|
||||||
|
let response = match read_message(&mut stream).await? {
|
||||||
|
CapabilityMessage::Get {
|
||||||
|
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||||
|
} => CapabilityMessage::Manifest {
|
||||||
|
manifest: local_manifest(),
|
||||||
|
},
|
||||||
|
CapabilityMessage::Get { version } => CapabilityMessage::Error {
|
||||||
|
message: format!("unsupported capability protocol {version}"),
|
||||||
|
},
|
||||||
|
_ => CapabilityMessage::Error {
|
||||||
|
message: "expected capability request".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write_message(&mut stream, &response).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_describes_only_supported_player_protocols() {
|
||||||
|
let manifest = local_manifest();
|
||||||
|
assert_eq!(manifest.application, "furumusic");
|
||||||
|
assert_eq!(
|
||||||
|
manifest.protocols.get("audio"),
|
||||||
|
Some(&AUDIO_PROTOCOL_VERSION)
|
||||||
|
);
|
||||||
|
assert!(!manifest.protocols.contains_key(JAM_ID));
|
||||||
|
manifest.validate().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
//! Receiving side of music federation.
|
||||||
|
//!
|
||||||
|
//! User-facing identity is content-addressed. An `(owner, item_id)` pair is
|
||||||
|
//! only a source locator and several locators may resolve the same track.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::{ItemKind, LibraryItem, normalize_content_id};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::Row as _;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
use super::{Federation, now_iso};
|
||||||
|
|
||||||
|
const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackKeyDto {
|
||||||
|
pub content_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ArtistKeyDto {
|
||||||
|
pub normalized_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ArtistRefDto {
|
||||||
|
pub key: ArtistKeyDto,
|
||||||
|
pub name: String,
|
||||||
|
pub local_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ReleaseKeyDto {
|
||||||
|
pub normalized_title: String,
|
||||||
|
pub primary_artists: Vec<String>,
|
||||||
|
pub release_type: Option<String>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ReleaseRefDto {
|
||||||
|
pub key: ReleaseKeyDto,
|
||||||
|
pub local_id: Option<i64>,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FederationSourceDto {
|
||||||
|
pub owner: String,
|
||||||
|
pub item_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LocalAvailabilityDto {
|
||||||
|
pub track_id: i64,
|
||||||
|
pub stream_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackMetadataDto {
|
||||||
|
pub title: String,
|
||||||
|
pub artists: Vec<ArtistRefDto>,
|
||||||
|
pub featured_artists: Vec<ArtistRefDto>,
|
||||||
|
pub release: Option<ReleaseRefDto>,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub duration_seconds: Option<f64>,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackAvailabilityDto {
|
||||||
|
pub state: &'static str,
|
||||||
|
pub local: Option<LocalAvailabilityDto>,
|
||||||
|
pub federation: Vec<FederationSourceDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TrackDto {
|
||||||
|
pub key: TrackKeyDto,
|
||||||
|
pub metadata: TrackMetadataDto,
|
||||||
|
pub availability: TrackAvailabilityDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SearchEvent {
|
||||||
|
pub search_id: String,
|
||||||
|
pub sequence: u64,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub peer: Option<String>,
|
||||||
|
pub entity_key: Value,
|
||||||
|
pub entity: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Federation {
|
||||||
|
pub fn stream_artist_catalogs(
|
||||||
|
self: &std::sync::Arc<Self>,
|
||||||
|
name: String,
|
||||||
|
) -> tokio::sync::mpsc::UnboundedReceiver<Result<(String, music_dht::catalog::CatalogArtist)>>
|
||||||
|
{
|
||||||
|
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let federation = std::sync::Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = async {
|
||||||
|
let service = federation.service().await?;
|
||||||
|
let normalized = music_dht::normalize_name(&name);
|
||||||
|
let outcome = service
|
||||||
|
.search_network(&name)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("federated artist search failed: {err}"))?;
|
||||||
|
let owners: std::collections::HashSet<_> = outcome
|
||||||
|
.network_results
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|
||||||
|
|| item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.chain(&item.featured_artist_names)
|
||||||
|
.any(|artist| music_dht::normalize_name(artist) == normalized)
|
||||||
|
})
|
||||||
|
.map(|item| item.owner)
|
||||||
|
.collect();
|
||||||
|
for owner in owners {
|
||||||
|
let service = std::sync::Arc::clone(&service);
|
||||||
|
let sender = sender.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(8),
|
||||||
|
fetch_artist_catalog(&service, owner, &name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("catalog request timed out"))
|
||||||
|
.and_then(|result| result)
|
||||||
|
.map(|artist| (owner.to_string(), artist));
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(err) = result {
|
||||||
|
let _ = sender.send(Err(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs one bounded DHT search and returns entity upserts. The HTTP
|
||||||
|
/// layer streams each upsert independently; catalog fan-out can append
|
||||||
|
/// events to the same contract without changing the browser model.
|
||||||
|
pub async fn search_events(&self, search_id: &str, query: &str) -> Result<Vec<SearchEvent>> {
|
||||||
|
let query = query.trim();
|
||||||
|
anyhow::ensure!(!query.is_empty(), "search query is empty");
|
||||||
|
anyhow::ensure!(query.chars().count() <= 200, "search query is too long");
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let service = self.service().await?;
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
connected_peers = service.connected_peers().len(),
|
||||||
|
known_contacts = service.known_peers().len(),
|
||||||
|
"federated search started"
|
||||||
|
);
|
||||||
|
let own = service.endpoint_id();
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(20),
|
||||||
|
service.search_network(query),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("federated search timed out after 20 seconds"))?
|
||||||
|
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
local_results = result.local_results.len(),
|
||||||
|
network_results = result.network_results.len(),
|
||||||
|
queried_nodes = result.queried_nodes,
|
||||||
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"federated DHT search finished"
|
||||||
|
);
|
||||||
|
let all_items: Vec<LibraryItem> = result
|
||||||
|
.local_results
|
||||||
|
.into_iter()
|
||||||
|
.chain(result.network_results)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut by_content: HashMap<String, TrackDto> = HashMap::new();
|
||||||
|
for item in all_items.iter().filter(|item| item.kind == ItemKind::Track) {
|
||||||
|
let Some(content_id) = item.content_id.as_deref().and_then(normalize_content_id) else {
|
||||||
|
// A globally usable track reference must be verifiable.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let local = local_availability(&pool, &content_id).await?;
|
||||||
|
let source = FederationSourceDto {
|
||||||
|
owner: item.owner.to_string(),
|
||||||
|
item_id: hex(item.id.as_bytes()),
|
||||||
|
};
|
||||||
|
let entry = by_content.entry(content_id.clone()).or_insert_with(|| {
|
||||||
|
track_from_item(content_id.clone(), item, local, item.owner == own)
|
||||||
|
});
|
||||||
|
if !entry.availability.federation.iter().any(|candidate| {
|
||||||
|
candidate.owner == source.owner && candidate.item_id == source.item_id
|
||||||
|
}) {
|
||||||
|
entry.availability.federation.push(source);
|
||||||
|
}
|
||||||
|
if entry.availability.local.is_some() {
|
||||||
|
entry.availability.state = "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tracks: Vec<_> = by_content.into_values().collect();
|
||||||
|
tracks.sort_by(|left, right| {
|
||||||
|
left.metadata
|
||||||
|
.title
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&right.metadata.title.to_lowercase())
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(all_items.len());
|
||||||
|
for (index, track) in tracks.into_iter().enumerate() {
|
||||||
|
persist_track_ref(&pool, &track).await?;
|
||||||
|
let peer = track
|
||||||
|
.availability
|
||||||
|
.federation
|
||||||
|
.first()
|
||||||
|
.map(|source| source.owner.clone());
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence: index as u64 + 1,
|
||||||
|
kind: "federation.track",
|
||||||
|
peer,
|
||||||
|
entity_key: serde_json::to_value(&track.key)?,
|
||||||
|
entity: serde_json::to_value(track)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut artist_peers: HashMap<String, (String, Vec<String>)> = HashMap::new();
|
||||||
|
let mut releases: HashMap<String, Value> = HashMap::new();
|
||||||
|
for item in &all_items {
|
||||||
|
match item.kind {
|
||||||
|
ItemKind::Artist => {
|
||||||
|
let key = music_dht::normalize_name(&item.name);
|
||||||
|
let entry = artist_peers
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| (item.name.clone(), Vec::new()));
|
||||||
|
let owner = item.owner.to_string();
|
||||||
|
if !entry.1.contains(&owner) {
|
||||||
|
entry.1.push(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ItemKind::Release => {
|
||||||
|
let artist_keys: Vec<String> = item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|name| music_dht::normalize_name(name))
|
||||||
|
.collect();
|
||||||
|
let normalized_title = music_dht::normalize_name(&item.name);
|
||||||
|
let cover_url = all_items
|
||||||
|
.iter()
|
||||||
|
.find(|track| {
|
||||||
|
track.kind == ItemKind::Track
|
||||||
|
&& track.release_title.as_deref().is_some_and(|title| {
|
||||||
|
music_dht::normalize_name(title) == normalized_title
|
||||||
|
})
|
||||||
|
&& track.year == item.year
|
||||||
|
})
|
||||||
|
.map(|track| {
|
||||||
|
format!(
|
||||||
|
"/api/player/federation/tracks/artwork?owner={}&item_id={}",
|
||||||
|
track.owner,
|
||||||
|
hex(track.id.as_bytes())
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let key = format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
normalized_title,
|
||||||
|
artist_keys.join(","),
|
||||||
|
item.year.map_or_else(String::new, |year| year.to_string())
|
||||||
|
);
|
||||||
|
releases.entry(key.clone()).or_insert_with(|| {
|
||||||
|
json!({
|
||||||
|
"key": {
|
||||||
|
"normalized_title": music_dht::normalize_name(&item.name),
|
||||||
|
"primary_artists": artist_keys,
|
||||||
|
"release_type": null,
|
||||||
|
"year": item.year,
|
||||||
|
},
|
||||||
|
"title": item.name,
|
||||||
|
"artists": item.artist_names,
|
||||||
|
"year": item.year,
|
||||||
|
"cover_url": cover_url,
|
||||||
|
"sources": [{
|
||||||
|
"owner": item.owner.to_string(),
|
||||||
|
"item_id": hex(item.id.as_bytes()),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ItemKind::Track => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, (name, peers)) in artist_peers {
|
||||||
|
let sequence = events.len() as u64 + 1;
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence,
|
||||||
|
kind: "federation.artist",
|
||||||
|
peer: peers.first().cloned(),
|
||||||
|
entity_key: json!({ "normalized_name": key }),
|
||||||
|
entity: json!({
|
||||||
|
"key": { "normalized_name": key },
|
||||||
|
"name": name,
|
||||||
|
"image_url": null,
|
||||||
|
"peers": peers,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (key, release) in releases {
|
||||||
|
let sequence = events.len() as u64 + 1;
|
||||||
|
events.push(SearchEvent {
|
||||||
|
search_id: search_id.to_owned(),
|
||||||
|
sequence,
|
||||||
|
kind: "federation.release",
|
||||||
|
peer: None,
|
||||||
|
entity_key: json!({ "composite": key }),
|
||||||
|
entity: release,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
search_id,
|
||||||
|
query,
|
||||||
|
events = events.len(),
|
||||||
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"federated search response ready"
|
||||||
|
);
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_artist_catalog(
|
||||||
|
service: &music_dht::MusicDhtService,
|
||||||
|
owner: music_dht::EndpointId,
|
||||||
|
artist: &str,
|
||||||
|
) -> Result<music_dht::catalog::CatalogArtist> {
|
||||||
|
let mut stream = service
|
||||||
|
.open_stream(owner, super::CATALOG_ALPN)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach catalog peer: {err}"))?;
|
||||||
|
let mut request = serde_json::to_vec(&music_dht::catalog::CatalogRequest {
|
||||||
|
artist: artist.to_owned(),
|
||||||
|
want: Some("catalog".to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
})?;
|
||||||
|
request.push(b'\n');
|
||||||
|
stream.send.write_all(&request).await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
stream
|
||||||
|
.recv
|
||||||
|
.take(MAX_CATALOG_BYTES + 1)
|
||||||
|
.read_to_end(&mut payload)
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
payload.len() as u64 <= MAX_CATALOG_BYTES,
|
||||||
|
"catalog response is too large"
|
||||||
|
);
|
||||||
|
let response: music_dht::catalog::CatalogResponse =
|
||||||
|
serde_json::from_slice(&payload).context("invalid catalog response")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
response.ok,
|
||||||
|
"peer refused catalog: {}",
|
||||||
|
response.error.unwrap_or_else(|| "unknown error".to_owned())
|
||||||
|
);
|
||||||
|
response.artist.context("peer returned no artist catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_from_item(
|
||||||
|
content_id: String,
|
||||||
|
item: &LibraryItem,
|
||||||
|
local: Option<LocalAvailabilityDto>,
|
||||||
|
own: bool,
|
||||||
|
) -> TrackDto {
|
||||||
|
let owner = item.owner.to_string();
|
||||||
|
let item_id = hex(item.id.as_bytes());
|
||||||
|
let artists = artist_refs(&item.artist_names);
|
||||||
|
let featured_artists = artist_refs(&item.featured_artist_names);
|
||||||
|
let release = item.release_title.as_ref().map(|title| ReleaseRefDto {
|
||||||
|
key: ReleaseKeyDto {
|
||||||
|
normalized_title: music_dht::normalize_name(title),
|
||||||
|
primary_artists: item
|
||||||
|
.artist_names
|
||||||
|
.iter()
|
||||||
|
.map(|artist| music_dht::normalize_name(artist))
|
||||||
|
.collect(),
|
||||||
|
release_type: None,
|
||||||
|
year: item.year,
|
||||||
|
},
|
||||||
|
local_id: None,
|
||||||
|
title: title.clone(),
|
||||||
|
});
|
||||||
|
let state = if local.is_some() || own {
|
||||||
|
"local"
|
||||||
|
} else {
|
||||||
|
"federated"
|
||||||
|
};
|
||||||
|
TrackDto {
|
||||||
|
key: TrackKeyDto { content_id },
|
||||||
|
metadata: TrackMetadataDto {
|
||||||
|
title: item.name.clone(),
|
||||||
|
artists,
|
||||||
|
featured_artists,
|
||||||
|
release,
|
||||||
|
year: item.year,
|
||||||
|
duration_seconds: item.duration_seconds,
|
||||||
|
track_number: item.track_number,
|
||||||
|
disc_number: item.disc_number,
|
||||||
|
cover_url: Some(format!(
|
||||||
|
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
availability: TrackAvailabilityDto {
|
||||||
|
state,
|
||||||
|
local,
|
||||||
|
federation: vec![FederationSourceDto { owner, item_id }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artist_refs(names: &[String]) -> Vec<ArtistRefDto> {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|name| ArtistRefDto {
|
||||||
|
key: ArtistKeyDto {
|
||||||
|
normalized_name: music_dht::normalize_name(name),
|
||||||
|
},
|
||||||
|
name: name.clone(),
|
||||||
|
local_id: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_availability(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<Option<LocalAvailabilityDto>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT t.id
|
||||||
|
FROM furumusic__federation_content_id_cache c
|
||||||
|
JOIN furumusic__track t ON t.audio_file_id = c.media_file_id
|
||||||
|
WHERE c.content_id = $1 AND t.is_hidden = false
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(content_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
LocalAvailabilityDto {
|
||||||
|
track_id,
|
||||||
|
stream_url: format!("/api/player/stream/{track_id}"),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_track_ref(pool: &sqlx::PgPool, track: &TrackDto) -> Result<()> {
|
||||||
|
let metadata = serde_json::to_value(&track.metadata)?;
|
||||||
|
let local_id = track
|
||||||
|
.availability
|
||||||
|
.local
|
||||||
|
.as_ref()
|
||||||
|
.map(|local| local.track_id);
|
||||||
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO furumusic__track_ref
|
||||||
|
(content_id, local_track_id, title, release_title, year,
|
||||||
|
duration_seconds, metadata_json, metadata_authority, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'federation', $8, $8)
|
||||||
|
ON CONFLICT (content_id) DO UPDATE SET
|
||||||
|
local_track_id = COALESCE(furumusic__track_ref.local_track_id, EXCLUDED.local_track_id),
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
release_title = EXCLUDED.release_title,
|
||||||
|
year = EXCLUDED.year,
|
||||||
|
duration_seconds = EXCLUDED.duration_seconds,
|
||||||
|
metadata_json = EXCLUDED.metadata_json,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&track.key.content_id)
|
||||||
|
.bind(local_id)
|
||||||
|
.bind(&track.metadata.title)
|
||||||
|
.bind(
|
||||||
|
track
|
||||||
|
.metadata
|
||||||
|
.release
|
||||||
|
.as_ref()
|
||||||
|
.map(|release| &release.title),
|
||||||
|
)
|
||||||
|
.bind(track.metadata.year)
|
||||||
|
.bind(track.metadata.duration_seconds)
|
||||||
|
.bind(metadata)
|
||||||
|
.bind(now_iso())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("persisting content-addressed track reference failed")?;
|
||||||
|
let track_ref_id: i64 = row.get(0);
|
||||||
|
for source in &track.availability.federation {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_track_source
|
||||||
|
(track_ref_id, owner_peer_id, item_id, last_seen_ms, metadata_json)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (owner_peer_id, item_id) DO UPDATE SET
|
||||||
|
track_ref_id = EXCLUDED.track_ref_id,
|
||||||
|
last_seen_ms = EXCLUDED.last_seen_ms,
|
||||||
|
metadata_json = EXCLUDED.metadata_json",
|
||||||
|
)
|
||||||
|
.bind(track_ref_id)
|
||||||
|
.bind(&source.owner)
|
||||||
|
.bind(&source.item_id)
|
||||||
|
.bind(chrono::Utc::now().timestamp_millis())
|
||||||
|
.bind(json!({ "track": track.metadata }))
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,999 @@
|
|||||||
|
//! P2P federation for the furumusic server.
|
||||||
|
//!
|
||||||
|
//! When enabled in the admin settings, the server becomes a regular peer of
|
||||||
|
//! the furumi federation: it publishes its whole visible library (artists,
|
||||||
|
//! releases, tracks — names and small metadata, never files) into the
|
||||||
|
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
||||||
|
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
||||||
|
//! clients speak among themselves. Serve-only: the server does not search
|
||||||
|
//! or download from other peers.
|
||||||
|
//!
|
||||||
|
//! Settings are the regular admin config entries (`federation_enabled`,
|
||||||
|
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
|
||||||
|
//! starts, stops or re-joins the node without a server restart.
|
||||||
|
|
||||||
|
mod capabilities;
|
||||||
|
pub mod client;
|
||||||
|
pub mod devices;
|
||||||
|
mod receive;
|
||||||
|
mod serve;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use music_dht::capabilities::CAPABILITIES_ALPN;
|
||||||
|
use music_dht::{
|
||||||
|
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||||
|
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use storage::PostgresFederationStorage;
|
||||||
|
|
||||||
|
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||||
|
|
||||||
|
/// How often the published library is re-synchronized with the database.
|
||||||
|
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
||||||
|
|
||||||
|
struct Running {
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
network_name: String,
|
||||||
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentHashJob {
|
||||||
|
media_file_id: i64,
|
||||||
|
sha256_hash: String,
|
||||||
|
file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CachedArtwork {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
mime: String,
|
||||||
|
fetched_at: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TransportSample {
|
||||||
|
at: String,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
peer_id: String,
|
||||||
|
selected_path: String,
|
||||||
|
open_paths: usize,
|
||||||
|
direct_paths: usize,
|
||||||
|
relay_paths: usize,
|
||||||
|
custom_paths: usize,
|
||||||
|
selected_rtt_ms: Option<u64>,
|
||||||
|
selected_tx_bytes: u64,
|
||||||
|
selected_rx_bytes: u64,
|
||||||
|
total_tx_bytes: u64,
|
||||||
|
total_rx_bytes: u64,
|
||||||
|
lost_packets: u64,
|
||||||
|
lost_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportSample {
|
||||||
|
fn from_stats(
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stats: ByteStreamConnectionStats,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
at: now_iso(),
|
||||||
|
protocol,
|
||||||
|
direction,
|
||||||
|
phase,
|
||||||
|
peer_id: stats.peer_id.to_string(),
|
||||||
|
selected_path: stats.selected_path.as_str().to_string(),
|
||||||
|
open_paths: stats.open_paths,
|
||||||
|
direct_paths: stats.direct_paths,
|
||||||
|
relay_paths: stats.relay_paths,
|
||||||
|
custom_paths: stats.custom_paths,
|
||||||
|
selected_rtt_ms: stats
|
||||||
|
.selected_rtt
|
||||||
|
.map(|duration| duration.as_millis() as u64),
|
||||||
|
selected_tx_bytes: stats.selected_tx_bytes,
|
||||||
|
selected_rx_bytes: stats.selected_rx_bytes,
|
||||||
|
total_tx_bytes: stats.total_tx_bytes,
|
||||||
|
total_rx_bytes: stats.total_rx_bytes,
|
||||||
|
lost_packets: stats.lost_packets,
|
||||||
|
lost_bytes: stats.lost_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TransportStatsState {
|
||||||
|
total_samples: u64,
|
||||||
|
direct_samples: u64,
|
||||||
|
relay_samples: u64,
|
||||||
|
custom_samples: u64,
|
||||||
|
unknown_samples: u64,
|
||||||
|
audio_samples: u64,
|
||||||
|
catalog_samples: u64,
|
||||||
|
sync_samples: u64,
|
||||||
|
last: VecDeque<TransportSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct TransportStats {
|
||||||
|
inner: std::sync::Mutex<TransportStatsState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportStats {
|
||||||
|
fn reset(&self) {
|
||||||
|
*lock(&self.inner) = TransportStatsState::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(
|
||||||
|
&self,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stats: ByteStreamConnectionStats,
|
||||||
|
) {
|
||||||
|
let sample = TransportSample::from_stats(protocol, direction, phase, stats);
|
||||||
|
let mut state = lock(&self.inner);
|
||||||
|
state.total_samples += 1;
|
||||||
|
match sample.selected_path.as_str() {
|
||||||
|
"direct" => state.direct_samples += 1,
|
||||||
|
"relay" => state.relay_samples += 1,
|
||||||
|
"custom" => state.custom_samples += 1,
|
||||||
|
_ => state.unknown_samples += 1,
|
||||||
|
}
|
||||||
|
match protocol {
|
||||||
|
"audio" => state.audio_samples += 1,
|
||||||
|
"catalog" => state.catalog_samples += 1,
|
||||||
|
"device-sync" => state.sync_samples += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
state.last.push_front(sample);
|
||||||
|
while state.last.len() > TRANSPORT_SAMPLE_LIMIT {
|
||||||
|
state.last.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self) -> Value {
|
||||||
|
let state = lock(&self.inner);
|
||||||
|
let latest = state.last.front();
|
||||||
|
json!({
|
||||||
|
"total_samples": state.total_samples,
|
||||||
|
"direct_samples": state.direct_samples,
|
||||||
|
"relay_samples": state.relay_samples,
|
||||||
|
"custom_samples": state.custom_samples,
|
||||||
|
"unknown_samples": state.unknown_samples,
|
||||||
|
"audio_samples": state.audio_samples,
|
||||||
|
"catalog_samples": state.catalog_samples,
|
||||||
|
"sync_samples": state.sync_samples,
|
||||||
|
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
||||||
|
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
||||||
|
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
||||||
|
"last": state.last.iter().map(|sample| json!({
|
||||||
|
"at": sample.at,
|
||||||
|
"protocol": sample.protocol,
|
||||||
|
"direction": sample.direction,
|
||||||
|
"phase": sample.phase,
|
||||||
|
"peer_id": sample.peer_id,
|
||||||
|
"selected_path": sample.selected_path,
|
||||||
|
"open_paths": sample.open_paths,
|
||||||
|
"direct_paths": sample.direct_paths,
|
||||||
|
"relay_paths": sample.relay_paths,
|
||||||
|
"custom_paths": sample.custom_paths,
|
||||||
|
"selected_rtt_ms": sample.selected_rtt_ms,
|
||||||
|
"selected_tx_bytes": sample.selected_tx_bytes,
|
||||||
|
"selected_rx_bytes": sample.selected_rx_bytes,
|
||||||
|
"total_tx_bytes": sample.total_tx_bytes,
|
||||||
|
"total_rx_bytes": sample.total_rx_bytes,
|
||||||
|
"lost_packets": sample.lost_packets,
|
||||||
|
"lost_bytes": sample.lost_bytes,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_stream_transport(
|
||||||
|
stats: &Arc<TransportStats>,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stream: &ByteStream,
|
||||||
|
) {
|
||||||
|
stats.record(protocol, direction, phase, stream.connection_stats());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Federation {
|
||||||
|
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||||
|
data_dir: PathBuf,
|
||||||
|
database_url: std::sync::Mutex<String>,
|
||||||
|
storage_dir: std::sync::Mutex<String>,
|
||||||
|
save_on_listen: std::sync::atomic::AtomicBool,
|
||||||
|
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
|
||||||
|
content_pending: std::sync::Mutex<HashSet<i64>>,
|
||||||
|
prepared_cache: std::sync::Mutex<HashMap<String, (PathBuf, String)>>,
|
||||||
|
artwork_cache: std::sync::Mutex<HashMap<String, CachedArtwork>>,
|
||||||
|
download_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||||
|
pool: tokio::sync::OnceCell<PgPool>,
|
||||||
|
running: tokio::sync::Mutex<Option<Running>>,
|
||||||
|
last_sync: std::sync::Mutex<Option<String>>,
|
||||||
|
last_error: std::sync::Mutex<Option<String>>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
|
mutex
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide federation handle.
|
||||||
|
pub fn handle() -> Arc<Federation> {
|
||||||
|
static HANDLE: OnceLock<Arc<Federation>> = OnceLock::new();
|
||||||
|
Arc::clone(HANDLE.get_or_init(|| {
|
||||||
|
Arc::new(Federation {
|
||||||
|
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
|
||||||
|
database_url: std::sync::Mutex::new(String::new()),
|
||||||
|
storage_dir: std::sync::Mutex::new(String::new()),
|
||||||
|
save_on_listen: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
content_cache: std::sync::Mutex::new(Default::default()),
|
||||||
|
content_pending: std::sync::Mutex::new(Default::default()),
|
||||||
|
prepared_cache: std::sync::Mutex::new(Default::default()),
|
||||||
|
artwork_cache: std::sync::Mutex::new(Default::default()),
|
||||||
|
download_locks: std::sync::Mutex::new(Default::default()),
|
||||||
|
pool: tokio::sync::OnceCell::new(),
|
||||||
|
running: tokio::sync::Mutex::new(None),
|
||||||
|
last_sync: std::sync::Mutex::new(None),
|
||||||
|
last_error: std::sync::Mutex::new(None),
|
||||||
|
transport_stats: Arc::new(TransportStats::default()),
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Federation {
|
||||||
|
fn set_error(&self, message: Option<String>) {
|
||||||
|
*lock(&self.last_error) = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pool(&self) -> Result<PgPool> {
|
||||||
|
let url = lock(&self.database_url).clone();
|
||||||
|
anyhow::ensure!(!url.is_empty(), "database is not configured");
|
||||||
|
let pool = self
|
||||||
|
.pool
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(4)
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(pool.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the node at boot when federation was left enabled. The
|
||||||
|
/// settings live in the config KV table, so this waits for the database
|
||||||
|
/// and resolves the same default → DB → env precedence the config uses.
|
||||||
|
pub async fn boot(self: &Arc<Self>, config: &AppConfig) {
|
||||||
|
*lock(&self.database_url) = config.database_url.clone();
|
||||||
|
if config.database_url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pool = match self.pool().await {
|
||||||
|
Ok(pool) => pool,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation boot: database unavailable: {err:#}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// `config` carries defaults + env; overlay the DB rows for fields
|
||||||
|
// that have no env override (env > DB > default).
|
||||||
|
let mut effective = config.clone();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT key, value FROM furumusic__config_entry
|
||||||
|
WHERE key IN ('federation_enabled', 'federation_network_id',
|
||||||
|
'federation_save_on_listen', 'agent_storage_dir')",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
for row in rows {
|
||||||
|
let key: String = row.get(0);
|
||||||
|
let value: String = row.get(1);
|
||||||
|
let env_key = format!("FURU_{}", key.to_ascii_uppercase());
|
||||||
|
if std::env::var(&env_key).is_ok() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match key.as_str() {
|
||||||
|
"federation_enabled" => {
|
||||||
|
if let Ok(parsed) = value.parse() {
|
||||||
|
effective.federation_enabled = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"federation_network_id" => effective.federation_network_id = value,
|
||||||
|
"federation_save_on_listen" => {
|
||||||
|
if let Ok(parsed) = value.parse() {
|
||||||
|
effective.federation_save_on_listen = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"agent_storage_dir" => {
|
||||||
|
effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.apply(&effective).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the effective configuration: starts, stops or re-joins the
|
||||||
|
/// node. Called at boot and every time the admin settings are saved.
|
||||||
|
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
|
||||||
|
*lock(&self.database_url) = config.database_url.clone();
|
||||||
|
*lock(&self.storage_dir) = config.agent_storage_dir.clone();
|
||||||
|
self.save_on_listen.store(
|
||||||
|
config.federation_save_on_listen,
|
||||||
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
let network = config.federation_network_id.trim().to_string();
|
||||||
|
if config.federation_enabled && !network.is_empty() {
|
||||||
|
if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await {
|
||||||
|
tracing::error!("federation start failed: {err:#}");
|
||||||
|
self.set_error(Some(format!("start failed: {err}")));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the DHT node. Idempotent per network name; a node on another
|
||||||
|
/// network is stopped and re-joined.
|
||||||
|
async fn start(self: &Arc<Self>, network_name: String, storage_dir: String) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut guard = self.running.lock().await;
|
||||||
|
if let Some(running) = guard.as_ref() {
|
||||||
|
if running.network_name == network_name {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
stop_running(guard.take()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||||
|
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||||
|
self.transport_stats.reset();
|
||||||
|
|
||||||
|
let config = MusicDhtConfig::builder()
|
||||||
|
.data_dir(&self.data_dir)
|
||||||
|
.network_id(NetworkId::from_name(&network_name))
|
||||||
|
// Peers of the network find each other knowing only its name.
|
||||||
|
.rendezvous(RendezvousConfig::default())
|
||||||
|
.stream_protocol(AUDIO_ALPN)
|
||||||
|
.stream_protocol(CATALOG_ALPN)
|
||||||
|
.stream_protocol(devices::SYNC_ALPN)
|
||||||
|
.schema_independent_stream_protocol(CAPABILITIES_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),
|
||||||
|
));
|
||||||
|
let capabilities_acceptor = service
|
||||||
|
.stream_acceptor(CAPABILITIES_ALPN)
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to take the capabilities acceptor: {err}"))?;
|
||||||
|
let capabilities_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
|
||||||
|
|
||||||
|
*guard = Some(Running {
|
||||||
|
service,
|
||||||
|
network_name,
|
||||||
|
tasks: vec![
|
||||||
|
event_task,
|
||||||
|
sync_task,
|
||||||
|
audio_task,
|
||||||
|
catalog_task,
|
||||||
|
device_task,
|
||||||
|
device_sync_task,
|
||||||
|
capabilities_task,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
self.set_error(None);
|
||||||
|
drop(guard);
|
||||||
|
// Publish right away instead of waiting for the first timer tick.
|
||||||
|
self.spawn_sync_soon().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self) {
|
||||||
|
let mut guard = self.running.lock().await;
|
||||||
|
stop_running(guard.take()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn service(&self) -> Result<Arc<MusicDhtService>> {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|running| Arc::clone(&running.service))
|
||||||
|
.context("federation is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||||
|
if let Ok(service) = self.service().await {
|
||||||
|
let fed = Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = fed.sync_once(&service).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let sync_stats = self.sync_once(&service).await?;
|
||||||
|
let publish_stats = match service.republish().await {
|
||||||
|
Ok(stats) => stats,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation republish failed: {err}");
|
||||||
|
self.set_error(Some(format!("republish failed: {err}")));
|
||||||
|
anyhow::bail!("republish failed: {err}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.record_publish_success(sync_stats, publish_stats);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_once(self: &Arc<Self>, service: &MusicDhtService) -> Result<SyncStats> {
|
||||||
|
let specs = match self.collect_specs().await {
|
||||||
|
Ok(specs) => specs,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation sync: library read failed: {err:#}");
|
||||||
|
self.set_error(Some(format!("library read failed: {err}")));
|
||||||
|
anyhow::bail!("library read failed: {err}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match service.sync_library(specs).await {
|
||||||
|
Ok(stats) => {
|
||||||
|
self.record_sync_success(stats);
|
||||||
|
if stats.failed > 0 {
|
||||||
|
self.set_error(Some(format!(
|
||||||
|
"{} item(s) failed to publish in the last sync",
|
||||||
|
stats.failed
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
self.set_error(None);
|
||||||
|
}
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation sync failed: {err}");
|
||||||
|
self.set_error(Some(format!("sync failed: {err}")));
|
||||||
|
Err(anyhow::anyhow!("sync failed: {err}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_sync_success(&self, stats: SyncStats) {
|
||||||
|
*lock(&self.last_sync) = Some(format!(
|
||||||
|
"{} (+{} ~{} −{}, unchanged {}, failed {})",
|
||||||
|
now_iso(),
|
||||||
|
stats.added,
|
||||||
|
stats.updated,
|
||||||
|
stats.removed,
|
||||||
|
stats.unchanged,
|
||||||
|
stats.failed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_publish_success(&self, sync_stats: SyncStats, publish_stats: PublishStats) {
|
||||||
|
*lock(&self.last_sync) = Some(format!(
|
||||||
|
"{} (+{} ~{} −{}, unchanged {}, failed {}; republished {} records, {} keys, remote nodes {})",
|
||||||
|
now_iso(),
|
||||||
|
sync_stats.added,
|
||||||
|
sync_stats.updated,
|
||||||
|
sync_stats.removed,
|
||||||
|
sync_stats.unchanged,
|
||||||
|
sync_stats.failed,
|
||||||
|
publish_stats.records,
|
||||||
|
publish_stats.keys,
|
||||||
|
publish_stats.remote_nodes,
|
||||||
|
));
|
||||||
|
self.set_error(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the regular player shows, as DHT item specs: non-hidden
|
||||||
|
/// artists, releases and tracks (a track also hides with its release).
|
||||||
|
async fn collect_specs(self: &Arc<Self>) -> Result<Vec<ItemSpec>> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
let mut specs = Vec::new();
|
||||||
|
|
||||||
|
let artists = sqlx::query("SELECT id, name FROM furumusic__artist WHERE is_hidden = false")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
for row in &artists {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
specs.push(ItemSpec {
|
||||||
|
local_key: format!("artist:{id}"),
|
||||||
|
kind: ItemKind::Artist,
|
||||||
|
name: row.get(1),
|
||||||
|
artist_names: Vec::new(),
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
year: None,
|
||||||
|
release_type: None,
|
||||||
|
release_title: None,
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let release_artists = sqlx::query(
|
||||||
|
"SELECT ra.release_id, a.name FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||||
|
ORDER BY ra.release_id, ra.position",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let mut artists_of_release: std::collections::HashMap<i64, Vec<String>> =
|
||||||
|
Default::default();
|
||||||
|
for row in &release_artists {
|
||||||
|
artists_of_release
|
||||||
|
.entry(row.get(0))
|
||||||
|
.or_default()
|
||||||
|
.push(row.get(1));
|
||||||
|
}
|
||||||
|
let releases = sqlx::query(
|
||||||
|
"SELECT id, title, year, release_type FROM furumusic__release
|
||||||
|
WHERE is_hidden = false",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
for row in &releases {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
specs.push(ItemSpec {
|
||||||
|
local_key: format!("release:{id}"),
|
||||||
|
kind: ItemKind::Release,
|
||||||
|
name: row.get(1),
|
||||||
|
artist_names: artists_of_release.remove(&id).unwrap_or_default(),
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
year: row.get(2),
|
||||||
|
release_type: row.get(3),
|
||||||
|
release_title: None,
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let track_artists = sqlx::query(
|
||||||
|
"SELECT ta.track_id, a.name, ta.role FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.role IN ('main', 'featuring')
|
||||||
|
ORDER BY ta.track_id,
|
||||||
|
CASE ta.role WHEN 'main' THEN 0 ELSE 1 END,
|
||||||
|
ta.position",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
|
let mut featured_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
|
for row in &track_artists {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
let name: String = row.get(1);
|
||||||
|
if row.get::<String, _>(2) == "featuring" {
|
||||||
|
featured_of_track.entry(id).or_default().push(name);
|
||||||
|
} else {
|
||||||
|
artists_of_track.entry(id).or_default().push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tracks = sqlx::query(
|
||||||
|
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds,
|
||||||
|
r.title, r.release_type, t.track_number, t.disc_number,
|
||||||
|
t.audio_file_id, m.file_path, m.sha256_hash, c.content_id
|
||||||
|
FROM furumusic__track t
|
||||||
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__federation_content_id_cache c
|
||||||
|
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||||
|
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let storage_dir = lock(&self.storage_dir).clone();
|
||||||
|
let mut content_hash_jobs = Vec::new();
|
||||||
|
for row in &tracks {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
let duration: f64 = row.get(3);
|
||||||
|
let media_file_id: i64 = row.get(8);
|
||||||
|
let file_path: String = row.get(9);
|
||||||
|
let sha256_hash: String = row.get(10);
|
||||||
|
let cached_content_id: Option<String> = row.get(11);
|
||||||
|
let content_id = cached_content_id
|
||||||
|
.or_else(|| self.cached_content_id_for_media(media_file_id, &sha256_hash));
|
||||||
|
if content_id.is_none()
|
||||||
|
&& !storage_dir.trim().is_empty()
|
||||||
|
&& self.mark_content_hash_pending(media_file_id)
|
||||||
|
{
|
||||||
|
content_hash_jobs.push(ContentHashJob {
|
||||||
|
media_file_id,
|
||||||
|
sha256_hash,
|
||||||
|
file_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
specs.push(ItemSpec {
|
||||||
|
local_key: format!("track:{id}"),
|
||||||
|
kind: ItemKind::Track,
|
||||||
|
name: row.get(1),
|
||||||
|
artist_names: artists_of_track.remove(&id).unwrap_or_default(),
|
||||||
|
featured_artist_names: featured_of_track.remove(&id).unwrap_or_default(),
|
||||||
|
year: row.get(2),
|
||||||
|
release_type: row.get(5),
|
||||||
|
release_title: Some(row.get(4)),
|
||||||
|
track_number: row.get(6),
|
||||||
|
disc_number: row.get(7),
|
||||||
|
duration_seconds: (duration > 0.0).then_some(duration),
|
||||||
|
content_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.spawn_content_warmer(pool.clone(), storage_dir, content_hash_jobs);
|
||||||
|
|
||||||
|
Ok(specs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_content_id_for_media(&self, media_file_id: i64, sha256_hash: &str) -> Option<String> {
|
||||||
|
if let Some((cached_hash, content_id)) = lock(&self.content_cache).get(&media_file_id)
|
||||||
|
&& cached_hash == sha256_hash
|
||||||
|
{
|
||||||
|
return Some(content_id.clone());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_content_hash_pending(&self, media_file_id: i64) -> bool {
|
||||||
|
lock(&self.content_pending).insert(media_file_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_content_warmer(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
jobs: Vec<ContentHashJob>,
|
||||||
|
) {
|
||||||
|
if jobs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fed = Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let total = jobs.len();
|
||||||
|
let mut stored = 0usize;
|
||||||
|
for job in jobs {
|
||||||
|
let job_storage_dir = storage_dir.clone();
|
||||||
|
let job_file_path = job.file_path.clone();
|
||||||
|
let content_id = tokio::task::spawn_blocking(move || {
|
||||||
|
audio_content_id(&job_storage_dir, &job_file_path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
lock(&fed.content_pending).remove(&job.media_file_id);
|
||||||
|
if let Some(content_id) = content_id {
|
||||||
|
lock(&fed.content_cache).insert(
|
||||||
|
job.media_file_id,
|
||||||
|
(job.sha256_hash.clone(), content_id.clone()),
|
||||||
|
);
|
||||||
|
if let Err(err) =
|
||||||
|
persist_content_id(&pool, job.media_file_id, &job.sha256_hash, &content_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
media_file_id = job.media_file_id,
|
||||||
|
"federation content-id cache write failed: {err:#}"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stored += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(total, stored, "federation content-id cache warm finished");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live status for the admin page.
|
||||||
|
pub async fn status(&self) -> Value {
|
||||||
|
let guard = self.running.lock().await;
|
||||||
|
let node = match guard.as_ref() {
|
||||||
|
Some(running) => {
|
||||||
|
let service = &running.service;
|
||||||
|
let published = service
|
||||||
|
.list_local_items()
|
||||||
|
.await
|
||||||
|
.map(|items| items.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let peers: Vec<String> = service
|
||||||
|
.connected_peers()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"running": true,
|
||||||
|
"network": running.network_name,
|
||||||
|
"endpoint_id": service.endpoint_id().to_string(),
|
||||||
|
"connected_peers": peers,
|
||||||
|
"known_contacts": service.known_peers().len(),
|
||||||
|
"published_items": published,
|
||||||
|
"transport": self.transport_stats.snapshot(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => json!({ "running": false }),
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"node": node,
|
||||||
|
"last_sync": lock(&self.last_sync).clone(),
|
||||||
|
"last_error": lock(&self.last_error).clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ticket(&self) -> Result<String> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let ticket = service
|
||||||
|
.ticket()
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot create a ticket: {err}"))?;
|
||||||
|
Ok(ticket.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&self, ticket: &str) -> Result<String> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let ticket: PeerTicket = ticket
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|err| anyhow::anyhow!("malformed ticket: {err}"))?;
|
||||||
|
let peer = service
|
||||||
|
.connect(ticket)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
||||||
|
Ok(peer.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_status(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
user_name: &str,
|
||||||
|
) -> Result<devices::FedDeviceStatus> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::status(&pool, user_id, user_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_invite(&self, user_id: i64, user_name: &str) -> Result<String> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::create_invite(&pool, service, user_id, user_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_connect(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
user_name: &str,
|
||||||
|
invite: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let network_id = devices::invite_network_id(invite)?;
|
||||||
|
{
|
||||||
|
let guard = self.running.lock().await;
|
||||||
|
let Some(running) = guard.as_ref() else {
|
||||||
|
anyhow::bail!("federation is not running");
|
||||||
|
};
|
||||||
|
let expected = NetworkId::from_name(&running.network_name);
|
||||||
|
anyhow::ensure!(
|
||||||
|
network_id == expected,
|
||||||
|
"device invite belongs to a different federation network"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::connect_invite(
|
||||||
|
&pool,
|
||||||
|
service,
|
||||||
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
user_id,
|
||||||
|
user_name,
|
||||||
|
invite,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_answer_pairing(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
request_id: &str,
|
||||||
|
accept: bool,
|
||||||
|
use_requester_group: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::answer_pairing(&pool, user_id, request_id, accept, use_requester_group).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_revoke(&self, user_id: i64, device_id: &str) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::revoke_device(&pool, user_id, device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_sync_now(&self, user_id: i64) -> Result<()> {
|
||||||
|
let service = self.service().await?;
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::sync_once(
|
||||||
|
&pool,
|
||||||
|
service,
|
||||||
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_command(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
target_device_id: &str,
|
||||||
|
command: &str,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
current_state: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_playback_command(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
target_device_id,
|
||||||
|
command,
|
||||||
|
payload,
|
||||||
|
current_state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_active_transfer(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
target_device_id: &str,
|
||||||
|
previous_device_id: Option<&str>,
|
||||||
|
state: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_active_transfer(
|
||||||
|
&pool,
|
||||||
|
user_id,
|
||||||
|
target_device_id,
|
||||||
|
previous_device_id,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fed_device_web_active_takeover(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
previous_device_id: &str,
|
||||||
|
state: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pool = self.pool().await?;
|
||||||
|
devices::record_web_active_takeover(&pool, user_id, previous_device_id, state).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_content_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
media_file_id: i64,
|
||||||
|
sha256_hash: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_content_id_cache
|
||||||
|
(media_file_id, sha256_hash, content_id, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (media_file_id) DO UPDATE SET
|
||||||
|
sha256_hash = EXCLUDED.sha256_hash,
|
||||||
|
content_id = EXCLUDED.content_id,
|
||||||
|
updated_at = EXCLUDED.updated_at",
|
||||||
|
)
|
||||||
|
.bind(media_file_id)
|
||||||
|
.bind(sha256_hash)
|
||||||
|
.bind(content_id)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_content_id(storage_dir: &str, file_path: &str) -> Option<String> {
|
||||||
|
if storage_dir.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||||
|
let mut file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
std::io::copy(&mut file, &mut hasher).ok()?;
|
||||||
|
Some(format!("b3:{}", hasher.finalize().to_hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_running(running: Option<Running>) {
|
||||||
|
let Some(running) = running else { return };
|
||||||
|
for task in &running.tasks {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
if let Err(err) = running.service.shutdown().await {
|
||||||
|
tracing::warn!("federation node shutdown reported an error: {err}");
|
||||||
|
}
|
||||||
|
tracing::info!("federation stopped");
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,775 @@
|
|||||||
|
//! 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";
|
||||||
|
/// Version of the peer-to-peer audio streaming protocol.
|
||||||
|
pub const AUDIO_PROTOCOL_VERSION: u16 = 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())
|
||||||
|
}
|
||||||
@@ -309,6 +309,7 @@ translations! {
|
|||||||
player_cancel: "Cancel" , "Отмена";
|
player_cancel: "Cancel" , "Отмена";
|
||||||
player_create: "Create" , "Создать";
|
player_create: "Create" , "Создать";
|
||||||
player_save: "Save" , "Сохранить";
|
player_save: "Save" , "Сохранить";
|
||||||
|
player_done: "Done" , "Готово";
|
||||||
player_delete: "Delete" , "Удалить";
|
player_delete: "Delete" , "Удалить";
|
||||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||||
player_rename: "Rename" , "Переименовать";
|
player_rename: "Rename" , "Переименовать";
|
||||||
@@ -373,6 +374,7 @@ translations! {
|
|||||||
player_repeat: "Repeat" , "Повтор";
|
player_repeat: "Repeat" , "Повтор";
|
||||||
player_volume: "Volume" , "Громкость";
|
player_volume: "Volume" , "Громкость";
|
||||||
player_appears_on: "Appears on" , "Участвует в";
|
player_appears_on: "Appears on" , "Участвует в";
|
||||||
|
player_top_tracks: "Popular tracks" , "Популярные треки";
|
||||||
player_albums: "Albums" , "Альбомы";
|
player_albums: "Albums" , "Альбомы";
|
||||||
player_eps: "EPs" , "EP";
|
player_eps: "EPs" , "EP";
|
||||||
player_singles: "Singles" , "Синглы";
|
player_singles: "Singles" , "Синглы";
|
||||||
|
|||||||
+121
-8
@@ -12,6 +12,21 @@ const AUDIO_EXTENSIONS: &[&str] = &[
|
|||||||
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// How long a `failed` review must stay untouched before discover
|
||||||
|
/// automatically requeues it (instead of creating a new row per attempt).
|
||||||
|
const FAILED_RETRY_COOLDOWN_SECS: i64 = 3600;
|
||||||
|
|
||||||
|
/// Leftover files that are safe to purge from inbox folders that no longer
|
||||||
|
/// contain any audio (covers, playlists, rip logs and similar sidecar files).
|
||||||
|
const JUNK_EXTENSIONS: &[&str] = &[
|
||||||
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "m3u", "m3u8", "cue", "log", "txt", "nfo", "sfv",
|
||||||
|
"md5", "accurip", "url", "ini", "pdf",
|
||||||
|
];
|
||||||
|
const JUNK_FILENAMES: &[&str] = &[".ds_store", "thumbs.db", "desktop.ini"];
|
||||||
|
|
||||||
|
/// Junk younger than this is kept — an upload might still be in progress.
|
||||||
|
const JUNK_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
|
||||||
|
|
||||||
pub struct InboxDiscoverJob;
|
pub struct InboxDiscoverJob;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -76,6 +91,10 @@ impl Job for InboxDiscoverJob {
|
|||||||
let mut audio_files = Vec::new();
|
let mut audio_files = Vec::new();
|
||||||
collect_audio_files(inbox, &mut audio_files).await?;
|
collect_audio_files(inbox, &mut audio_files).await?;
|
||||||
|
|
||||||
|
// Purge leftover junk (covers, playlists, logs) from subtrees that no
|
||||||
|
// longer contain audio, so processed uploads don't linger forever.
|
||||||
|
cleanup_inbox_junk(inbox, JUNK_MIN_AGE).await;
|
||||||
|
|
||||||
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
|
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
|
||||||
if audio_files.is_empty() {
|
if audio_files.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -87,6 +106,7 @@ impl Job for InboxDiscoverJob {
|
|||||||
let mut discovered = 0u64;
|
let mut discovered = 0u64;
|
||||||
let mut skipped_hash = 0u64;
|
let mut skipped_hash = 0u64;
|
||||||
let mut skipped_existing = 0u64;
|
let mut skipped_existing = 0u64;
|
||||||
|
let mut requeued = 0u64;
|
||||||
|
|
||||||
for (_folder, files) in &groups {
|
for (_folder, files) in &groups {
|
||||||
for file_path in files {
|
for file_path in files {
|
||||||
@@ -94,13 +114,34 @@ impl Job for InboxDiscoverJob {
|
|||||||
crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
|
crate::media_paths::path_for_root(&config.agent_inbox_dir, file_path)
|
||||||
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
// Skip if a PendingReview already exists for this path
|
// One review row per path: any existing row blocks creating a
|
||||||
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
|
// new one. A stale "failed" row is requeued in place instead,
|
||||||
Ok(true) => {
|
// so retries don't multiply rows. "rejected" stays rejected.
|
||||||
skipped_existing += 1;
|
match PendingReview::latest_for_path(&ctx.pool, &input_path_str).await {
|
||||||
|
Ok(None) => {}
|
||||||
|
Ok(Some((id, status, updated_at))) => {
|
||||||
|
if status == "failed" {
|
||||||
|
let stale = chrono::DateTime::parse_from_rfc3339(&updated_at)
|
||||||
|
.map(|t| {
|
||||||
|
chrono::Utc::now().signed_duration_since(t).num_seconds()
|
||||||
|
>= FAILED_RETRY_COOLDOWN_SECS
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
if stale {
|
||||||
|
match PendingReview::requeue_by_ids(&ctx.db, &[id]).await {
|
||||||
|
Ok(()) => requeued += 1,
|
||||||
|
Err(e) => log.warn(&format!(
|
||||||
|
"Failed to requeue review {id} for {input_path_str}: {e}"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped_existing += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped_existing += 1;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ok(false) => {}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log.warn(&format!(
|
log.warn(&format!(
|
||||||
"Error checking existing review for {}: {e}",
|
"Error checking existing review for {}: {e}",
|
||||||
@@ -215,8 +256,8 @@ impl Job for InboxDiscoverJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
|
"Discovered {} new files, requeued {} failed, skipped {} (hash known), skipped {} (already tracked)",
|
||||||
discovered, skipped_hash, skipped_existing
|
discovered, requeued, skipped_hash, skipped_existing
|
||||||
));
|
));
|
||||||
crate::metrics::record_agent_discover_files(
|
crate::metrics::record_agent_discover_files(
|
||||||
audio_files.len() as u64,
|
audio_files.len() as u64,
|
||||||
@@ -227,7 +268,7 @@ impl Job for InboxDiscoverJob {
|
|||||||
|
|
||||||
// Trigger inbox_process in background if new files were discovered
|
// Trigger inbox_process in background if new files were discovered
|
||||||
// and no orchestrator is already running
|
// and no orchestrator is already running
|
||||||
if discovered > 0 {
|
if discovered + requeued > 0 {
|
||||||
if crate::jobs::inbox_process::is_orchestrator_running() {
|
if crate::jobs::inbox_process::is_orchestrator_running() {
|
||||||
log.info(
|
log.info(
|
||||||
"New files discovered but inbox_process already running, it will pick them up",
|
"New files discovered but inbox_process already running, it will pick them up",
|
||||||
@@ -299,3 +340,75 @@ pub fn is_audio_file(name: &str) -> bool {
|
|||||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
AUDIO_EXTENSIONS.contains(&ext.as_str())
|
AUDIO_EXTENSIONS.contains(&ext.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_junk_file(name: &str) -> bool {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
// macOS AppleDouble sidecars ("._track.mp3") and well-known junk names
|
||||||
|
if lower.starts_with("._") || JUNK_FILENAMES.contains(&lower.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let ext = lower.rsplit('.').next().unwrap_or("");
|
||||||
|
JUNK_EXTENSIONS.contains(&ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove leftover sidecar files (covers, playlists, rip logs) from inbox
|
||||||
|
/// subtrees that no longer contain any audio, then prune emptied directories.
|
||||||
|
///
|
||||||
|
/// Junk younger than `min_age` is kept in case an upload is still in
|
||||||
|
/// progress, and unknown file types are never touched. Returns `true` when
|
||||||
|
/// `dir` still contains something worth keeping (so the caller must not
|
||||||
|
/// remove it).
|
||||||
|
async fn cleanup_inbox_junk(dir: &Path, min_age: std::time::Duration) -> bool {
|
||||||
|
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut has_audio = false;
|
||||||
|
let mut keep_other = false;
|
||||||
|
let mut junk: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let ft = match entry.file_type().await {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => {
|
||||||
|
keep_other = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ft.is_dir() {
|
||||||
|
if Box::pin(cleanup_inbox_junk(&entry.path(), min_age)).await {
|
||||||
|
keep_other = true;
|
||||||
|
} else {
|
||||||
|
let _ = tokio::fs::remove_dir(&entry.path()).await;
|
||||||
|
}
|
||||||
|
} else if !name.starts_with('.') && is_audio_file(&name) {
|
||||||
|
// dotfiles are invisible to discovery, so they don't count as audio
|
||||||
|
has_audio = true;
|
||||||
|
} else if is_junk_file(&name) {
|
||||||
|
junk.push(entry.path());
|
||||||
|
} else {
|
||||||
|
keep_other = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_audio {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut junk_left = false;
|
||||||
|
for path in junk {
|
||||||
|
let old_enough = tokio::fs::metadata(&path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.elapsed().ok())
|
||||||
|
.is_some_and(|age| age >= min_age);
|
||||||
|
if !old_enough || tokio::fs::remove_file(&path).await.is_err() {
|
||||||
|
junk_left = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keep_other || junk_left
|
||||||
|
}
|
||||||
|
|||||||
+66
-14
@@ -12,6 +12,11 @@ static ORCHESTRATOR_RUNNING: AtomicBool = AtomicBool::new(false);
|
|||||||
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
|
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
|
||||||
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
|
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
|
||||||
|
|
||||||
|
/// Maximum number of files sent to the LLM in a single batch call.
|
||||||
|
/// Folders with more files are processed in chunks of this size, otherwise
|
||||||
|
/// the model's completion window overflows and the JSON response is cut off.
|
||||||
|
const MAX_LLM_BATCH_FILES: usize = 20;
|
||||||
|
|
||||||
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
|
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
|
||||||
pub fn is_orchestrator_running() -> bool {
|
pub fn is_orchestrator_running() -> bool {
|
||||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||||
@@ -214,14 +219,25 @@ impl Job for InboxProcessJob {
|
|||||||
folder_rel, file_count,
|
folder_rel, file_count,
|
||||||
));
|
));
|
||||||
|
|
||||||
let (ok, fail) =
|
// Large folders are split into chunks: a single LLM call for
|
||||||
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
|
// 100+ files overflows the completion window and the whole
|
||||||
.await;
|
// batch fails with a truncated-JSON parse error.
|
||||||
|
for chunk in reviews.chunks(MAX_LLM_BATCH_FILES) {
|
||||||
|
let (ok, fail) = process_folder_batch(
|
||||||
|
&ctx.db,
|
||||||
|
&config,
|
||||||
|
&ctx.pool,
|
||||||
|
&folder_rel,
|
||||||
|
chunk.to_vec(),
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
total_ok += ok;
|
total_ok += ok;
|
||||||
total_fail += fail;
|
total_fail += fail;
|
||||||
|
}
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Folder done: {ok} ok, {fail} err. Total so far: {total_ok} ok, {total_fail} err"
|
"Folder done. Total so far: {total_ok} ok, {total_fail} err"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,6 +360,7 @@ async fn process_folder_batch(
|
|||||||
log.info("Phase 1: extracting metadata...");
|
log.info("Phase 1: extracting metadata...");
|
||||||
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
|
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
|
||||||
let mut failed_reviews: Vec<PendingReview> = Vec::new();
|
let mut failed_reviews: Vec<PendingReview> = Vec::new();
|
||||||
|
let mut merged_count = 0u64;
|
||||||
|
|
||||||
for mut review in reviews {
|
for mut review in reviews {
|
||||||
let stored_input_path = review.input_path_str().to_owned();
|
let stored_input_path = review.input_path_str().to_owned();
|
||||||
@@ -355,9 +372,6 @@ async fn process_folder_batch(
|
|||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
// Set status → processing
|
|
||||||
let _ = review.set_processing(db).await;
|
|
||||||
|
|
||||||
// Parse context_json
|
// Parse context_json
|
||||||
let mut context: serde_json::Value = review
|
let mut context: serde_json::Value = review
|
||||||
.context_json
|
.context_json
|
||||||
@@ -365,6 +379,42 @@ async fn process_folder_batch(
|
|||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Resolve duplicates and missing sources before any expensive work.
|
||||||
|
let sha = context
|
||||||
|
.get("sha256")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_owned();
|
||||||
|
let file_exists = file_path.exists();
|
||||||
|
if !sha.is_empty()
|
||||||
|
&& crate::agent::rag::file_hash_exists(pool, &sha)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
// Identical content is already in the library — drop the inbox
|
||||||
|
// copy (same as mover::Merged) and close the review.
|
||||||
|
if file_exists {
|
||||||
|
let _ = tokio::fs::remove_file(&file_path).await;
|
||||||
|
}
|
||||||
|
let _ = PendingReview::delete_by_ids(db, &[review.id_val()]).await;
|
||||||
|
log.info(&format!(
|
||||||
|
"{filename}: content already in library (sha256 match) — merged duplicate"
|
||||||
|
));
|
||||||
|
crate::metrics::record_agent_file_processed("ok", "merged_duplicate");
|
||||||
|
merged_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !file_exists {
|
||||||
|
let msg = format!("{filename}: source file missing: {stored_input_path}");
|
||||||
|
log.error(&msg);
|
||||||
|
let _ = review.set_failed(db, &msg).await;
|
||||||
|
failed_reviews.push(review);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set status → processing
|
||||||
|
let _ = review.set_processing(db).await;
|
||||||
|
|
||||||
// Extract metadata (with 60s timeout)
|
// Extract metadata (with 60s timeout)
|
||||||
let path_for_meta = file_path.to_path_buf();
|
let path_for_meta = file_path.to_path_buf();
|
||||||
let metadata_start = std::time::Instant::now();
|
let metadata_start = std::time::Instant::now();
|
||||||
@@ -444,15 +494,16 @@ async fn process_folder_batch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Phase 1 done: {} prepared, {} failed metadata",
|
"Phase 1 done: {} prepared, {} merged duplicates, {} failed",
|
||||||
prepared.len(),
|
prepared.len(),
|
||||||
|
merged_count,
|
||||||
failed_reviews.len(),
|
failed_reviews.len(),
|
||||||
));
|
));
|
||||||
|
|
||||||
if prepared.is_empty() {
|
if prepared.is_empty() {
|
||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||||
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
||||||
return (0, failed_reviews.len() as u64);
|
return (merged_count, failed_reviews.len() as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
|
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
|
||||||
@@ -648,16 +699,17 @@ async fn process_folder_batch(
|
|||||||
let err_msg = format!("Batch LLM call failed: {e}");
|
let err_msg = format!("Batch LLM call failed: {e}");
|
||||||
log.error(&err_msg);
|
log.error(&err_msg);
|
||||||
// Mark all files as failed
|
// Mark all files as failed
|
||||||
|
let prepared_count = prepared.len() as u64;
|
||||||
for mut p in prepared {
|
for mut p in prepared {
|
||||||
let _ = p.review.set_failed(db, &err_msg).await;
|
let _ = p.review.set_failed(db, &err_msg).await;
|
||||||
crate::metrics::record_agent_file_processed("failed", "failed");
|
crate::metrics::record_agent_file_processed("failed", "failed");
|
||||||
}
|
}
|
||||||
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
|
let total_fail_count = failed_reviews.len() as u64 + prepared_count;
|
||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||||
let _ = run
|
let _ = run
|
||||||
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
||||||
.await;
|
.await;
|
||||||
return (0, total_fail_count);
|
return (merged_count, total_fail_count);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -681,7 +733,7 @@ async fn process_folder_batch(
|
|||||||
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
|
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
|
||||||
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
|
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
|
||||||
|
|
||||||
let mut ok_count = 0u64;
|
let mut ok_count = merged_count;
|
||||||
let mut fail_count = failed_reviews.len() as u64;
|
let mut fail_count = failed_reviews.len() as u64;
|
||||||
|
|
||||||
for mut p in prepared {
|
for mut p in prepared {
|
||||||
|
|||||||
+5
-5
@@ -447,17 +447,17 @@ async fn fetch_pending_scrobbles(
|
|||||||
o.duration_seconds,
|
o.duration_seconds,
|
||||||
o.attempt_count,
|
o.attempt_count,
|
||||||
a.session_key::text AS session_key,
|
a.session_key::text AS session_key,
|
||||||
t.title::text AS title,
|
COALESCE(o.track_title, t.title::text) AS title,
|
||||||
r.title::text AS album_title,
|
COALESCE(o.album_title, r.title::text) AS album_title,
|
||||||
t.track_number,
|
t.track_number,
|
||||||
(
|
COALESCE(o.artist_name, (
|
||||||
SELECT ar.name::text
|
SELECT ar.name::text
|
||||||
FROM furumusic__track_artist ta
|
FROM furumusic__track_artist ta
|
||||||
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
||||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||||
ORDER BY ta.position
|
ORDER BY ta.position
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS artist_name,
|
)) AS artist_name,
|
||||||
(
|
(
|
||||||
SELECT ar.name::text
|
SELECT ar.name::text
|
||||||
FROM furumusic__release_artist ra
|
FROM furumusic__release_artist ra
|
||||||
@@ -468,7 +468,7 @@ async fn fetch_pending_scrobbles(
|
|||||||
) AS album_artist_name
|
) AS album_artist_name
|
||||||
FROM furumusic__lastfm_scrobble_outbox o
|
FROM furumusic__lastfm_scrobble_outbox o
|
||||||
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
||||||
JOIN furumusic__track t ON t.id = o.track_id
|
LEFT JOIN furumusic__track t ON t.id = o.track_id
|
||||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
WHERE o.user_id = $1
|
WHERE o.user_id = $1
|
||||||
AND o.status IN ('pending', 'retry')
|
AND o.status IN ('pending', 'retry')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod agent;
|
|||||||
mod api;
|
mod api;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod federation;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod jobs;
|
mod jobs;
|
||||||
mod lastfm;
|
mod lastfm;
|
||||||
@@ -559,6 +560,13 @@ impl Project for FuruProject {
|
|||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Join the federation at boot when it was left enabled (the settings
|
||||||
|
// live in the config KV table; changes apply live from the admin).
|
||||||
|
let fed_config = Arc::clone(&self.app_config);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
federation::handle().boot(&fed_config).await;
|
||||||
|
});
|
||||||
|
|
||||||
apps.register(cot::session::db::SessionApp::new());
|
apps.register(cot::session::db::SessionApp::new());
|
||||||
apps.register_with_views(
|
apps.register_with_views(
|
||||||
FuruApp {
|
FuruApp {
|
||||||
|
|||||||
@@ -883,6 +883,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[
|
|||||||
"/api/player/lastfm/now-playing",
|
"/api/player/lastfm/now-playing",
|
||||||
"/api/player/lastfm/scrobble",
|
"/api/player/lastfm/scrobble",
|
||||||
"/api/player/agent-queue",
|
"/api/player/agent-queue",
|
||||||
|
"/api/player/offline/manifest",
|
||||||
"/api/player/torrents",
|
"/api/player/torrents",
|
||||||
"/api/player/torrents/session/{id}",
|
"/api/player/torrents/session/{id}",
|
||||||
"/api/player/torrents/preview",
|
"/api/player/torrents/preview",
|
||||||
|
|||||||
@@ -1951,6 +1951,531 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_playlist_share_links).build()];
|
&[Operation::custom(create_playlist_share_links).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_fed_device_sync(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_identity (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
device_id TEXT NOT NULL UNIQUE,
|
||||||
|
group_id TEXT NOT NULL,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
local_seq BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_hlc_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
local_seeded_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_sync TEXT,
|
||||||
|
last_error TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
client_version TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
endpoint_id TEXT NOT NULL DEFAULT '',
|
||||||
|
endpoint_ticket TEXT NOT NULL DEFAULT '',
|
||||||
|
trusted_at_ms BIGINT,
|
||||||
|
last_seen_ms BIGINT,
|
||||||
|
revoked_at_ms BIGINT,
|
||||||
|
revoked_by TEXT,
|
||||||
|
revoke_cutoff_seq BIGINT,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_device_single_user
|
||||||
|
ON furumusic__fed_device (device_id)
|
||||||
|
WHERE trusted_at_ms IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_invite (
|
||||||
|
invite_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
secret_hash TEXT NOT NULL,
|
||||||
|
expires_at_ms BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
used_at_ms BIGINT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_pending_pairing (
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
client_version TEXT NOT NULL,
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
endpoint_ticket TEXT NOT NULL,
|
||||||
|
invite_id TEXT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
answered_at_ms BIGINT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
requester_group_id TEXT,
|
||||||
|
requester_group_active_devices BIGINT NOT NULL DEFAULT 1,
|
||||||
|
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
use_requester_group BOOLEAN NOT NULL DEFAULT false
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_pending_pairing_user_status
|
||||||
|
ON furumusic__fed_pending_pairing (user_id, status, created_at_ms DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_ops (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
seq BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
received_at_ms BIGINT NOT NULL,
|
||||||
|
tombstone BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_origin_seq
|
||||||
|
ON furumusic__fed_sync_ops (user_id, origin_device_id, seq)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_tombstone
|
||||||
|
ON furumusic__fed_sync_ops (user_id, tombstone)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_vector (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_peer_ack (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
peer_device_id TEXT NOT NULL,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
max_seq BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, peer_device_id, origin_device_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_like (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
liked BOOLEAN NOT NULL,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
local_playlist_id BIGINT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, playlist_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_state_playlist_local
|
||||||
|
ON furumusic__fed_state_playlist (user_id, local_playlist_id)
|
||||||
|
WHERE local_playlist_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist_item (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
present BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
position BIGINT NOT NULL DEFAULT 0,
|
||||||
|
hlc_ms BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
fed_json JSONB,
|
||||||
|
PRIMARY KEY (user_id, playlist_id, content_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fed_state_playlist_item_playlist
|
||||||
|
ON furumusic__fed_state_playlist_item
|
||||||
|
(user_id, playlist_id, present, position)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__fed_playback_applied (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
op_id TEXT NOT NULL,
|
||||||
|
applied_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, op_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0038CreateFedDeviceSync;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0038CreateFedDeviceSync {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0038_create_fed_device_sync";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0037_create_playlist_share_links",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_fed_device_sync).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn ensure_federation_content_id_cache(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache (content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0039EnsureFederationContentIdCache;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0039EnsureFederationContentIdCache {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0039_ensure_federation_content_id_cache";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0038_create_fed_device_sync",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(ensure_federation_content_id_cache).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_content_addressed_music_refs(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
// A track reference is durable user-facing identity. `local_track_id`
|
||||||
|
// is availability, not identity: it may become non-NULL after a
|
||||||
|
// federated track is materialized without changing likes/playlists.
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__track_ref (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
content_id TEXT NOT NULL UNIQUE,
|
||||||
|
local_track_id BIGINT UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
release_title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
duration_seconds DOUBLE PRECISION,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
metadata_authority TEXT NOT NULL DEFAULT 'local',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_track_ref_local_track
|
||||||
|
ON furumusic__track_ref (local_track_id)
|
||||||
|
WHERE local_track_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_track_source (
|
||||||
|
track_ref_id BIGINT NOT NULL REFERENCES furumusic__track_ref(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
owner_peer_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
last_seen_ms BIGINT NOT NULL,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (owner_peer_id, item_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_federation_track_source_ref
|
||||||
|
ON furumusic__federation_track_source (track_ref_id, last_seen_ms DESC)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__user_liked_track
|
||||||
|
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||||
|
REFERENCES furumusic__track_ref(id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_liked_track_ref_uniq
|
||||||
|
ON furumusic__user_liked_track (user_id, track_ref_id)
|
||||||
|
WHERE track_ref_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__playlist_track
|
||||||
|
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||||
|
REFERENCES furumusic__track_ref(id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_playlist_track_ref
|
||||||
|
ON furumusic__playlist_track (track_ref_id)
|
||||||
|
WHERE track_ref_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// History deliberately remains local-track based. Only the web
|
||||||
|
// player's existing playback report records history and triggers
|
||||||
|
// Last.fm scrobbling.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0040CreateContentAddressedMusicRefs;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0040CreateContentAddressedMusicRefs {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0040_create_content_addressed_music_refs";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0039_ensure_federation_content_id_cache",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_content_addressed_music_refs).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_synced_listen_history(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__listen_event (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
listen_id TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
local_track_id BIGINT,
|
||||||
|
origin_device_id TEXT NOT NULL,
|
||||||
|
started_at_ms BIGINT NOT NULL,
|
||||||
|
listened_ms BIGINT NOT NULL,
|
||||||
|
track_duration_ms BIGINT,
|
||||||
|
ended_reason TEXT NOT NULL,
|
||||||
|
qualified BOOLEAN NOT NULL,
|
||||||
|
metadata_json JSONB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, listen_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_listen_event_user_time
|
||||||
|
ON furumusic__listen_event (user_id, started_at_ms DESC, listen_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_listen_event_content
|
||||||
|
ON furumusic__listen_event (user_id, content_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"INSERT INTO furumusic__listen_event
|
||||||
|
(user_id, listen_id, content_id, local_track_id,
|
||||||
|
origin_device_id, started_at_ms, listened_ms,
|
||||||
|
track_duration_ms, ended_reason, qualified,
|
||||||
|
metadata_json, created_at)
|
||||||
|
SELECT ph.user_id,
|
||||||
|
'legacy-web:' || ph.id::text,
|
||||||
|
tr.content_id,
|
||||||
|
ph.track_id,
|
||||||
|
ident.device_id,
|
||||||
|
(EXTRACT(EPOCH FROM ph.played_at::timestamptz) * 1000)::bigint,
|
||||||
|
COALESCE(ph.duration_listened, 0)::bigint * 1000,
|
||||||
|
(t.duration_seconds * 1000)::bigint,
|
||||||
|
CASE WHEN ph.completed THEN '\"finished\"' ELSE '\"unknown\"' END,
|
||||||
|
ph.completed,
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', t.title::text,
|
||||||
|
'artist_names', COALESCE((
|
||||||
|
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = t.id
|
||||||
|
AND ta.role <> 'featuring'
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'featured_artist_names', COALESCE((
|
||||||
|
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||||
|
FROM furumusic__track_artist ta
|
||||||
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ta.track_id = t.id
|
||||||
|
AND ta.role = 'featuring'
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'release_title', r.title::text
|
||||||
|
),
|
||||||
|
ph.played_at::text
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
JOIN furumusic__track_ref tr ON tr.local_track_id = ph.track_id
|
||||||
|
JOIN furumusic__fed_device_identity ident
|
||||||
|
ON ident.user_id = ph.user_id
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
ON CONFLICT (user_id, listen_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||||
|
ALTER COLUMN track_id DROP NOT NULL",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||||
|
ADD COLUMN IF NOT EXISTS track_title TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS artist_name TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS album_title TEXT",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0041CreateSyncedListenHistory;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0041CreateSyncedListenHistory {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0041_create_synced_listen_history";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0040_create_content_addressed_music_refs",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_synced_listen_history).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn repair_legacy_listen_qualification(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"UPDATE furumusic__listen_event le
|
||||||
|
SET qualified = (
|
||||||
|
ph.completed
|
||||||
|
OR (
|
||||||
|
COALESCE(ph.duration_listened, 0) >= 5
|
||||||
|
AND COALESCE(t.duration_seconds, 0) > 0
|
||||||
|
AND COALESCE(ph.duration_listened, 0) >= LEAST(
|
||||||
|
COALESCE(t.duration_seconds, 0) / 2.0,
|
||||||
|
240.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
WHERE le.user_id = ph.user_id
|
||||||
|
AND le.listen_id = 'legacy-web:' || ph.id::text",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0042RepairLegacyListenQualification;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0042RepairLegacyListenQualification {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0042_repair_legacy_listen_qualification";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0041_create_synced_listen_history",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(repair_legacy_listen_qualification).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1979,5 +2504,10 @@ pub mod db_migrations {
|
|||||||
&M0035CreateEntityGenreTags,
|
&M0035CreateEntityGenreTags,
|
||||||
&M0036CreateExternalMetadataIds,
|
&M0036CreateExternalMetadataIds,
|
||||||
&M0037CreatePlaylistShareLinks,
|
&M0037CreatePlaylistShareLinks,
|
||||||
|
&M0038CreateFedDeviceSync,
|
||||||
|
&M0039EnsureFederationContentIdCache,
|
||||||
|
&M0040CreateContentAddressedMusicRefs,
|
||||||
|
&M0041CreateSyncedListenHistory,
|
||||||
|
&M0042RepairLegacyListenQualification,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-5
@@ -977,27 +977,54 @@ fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
|
|||||||
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
|
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
|
||||||
return Some(value.to_owned());
|
return Some(value.to_owned());
|
||||||
}
|
}
|
||||||
|
if is_loopback_http_redirect(&lower) {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
None
|
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 {
|
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
|
||||||
let deep_link = append_query_param(app_redirect_uri, "code", code);
|
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(
|
mobile_deep_link_page(
|
||||||
"success",
|
"success",
|
||||||
"Sign-in complete",
|
"Sign-in complete",
|
||||||
"Furumi should open automatically. You can close this window after the app opens.",
|
"Furumi should open automatically. If it doesn't, use the button or copy the code below.",
|
||||||
None,
|
None,
|
||||||
|
Some(code),
|
||||||
&deep_link,
|
&deep_link,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
|
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
|
||||||
let deep_link = append_query_param(app_redirect_uri, "error", error);
|
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(
|
mobile_deep_link_page(
|
||||||
"error",
|
"error",
|
||||||
"Sign-in failed",
|
"Sign-in failed",
|
||||||
"Furumi should open automatically and show the sign-in error. You can close this window after the app opens.",
|
"Furumi should open automatically and show the sign-in error.",
|
||||||
Some(error),
|
Some(error),
|
||||||
|
None,
|
||||||
&deep_link,
|
&deep_link,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1007,6 +1034,7 @@ fn mobile_deep_link_page(
|
|||||||
title: &str,
|
title: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
detail: Option<&str>,
|
detail: Option<&str>,
|
||||||
|
code: Option<&str>,
|
||||||
deep_link: &str,
|
deep_link: &str,
|
||||||
) -> cot::response::Response {
|
) -> cot::response::Response {
|
||||||
let state_class = html_escape(state);
|
let state_class = html_escape(state);
|
||||||
@@ -1015,6 +1043,15 @@ fn mobile_deep_link_page(
|
|||||||
let detail_html = detail
|
let detail_html = detail
|
||||||
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
|
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
|
||||||
.unwrap_or_default();
|
.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_html = html_escape(deep_link);
|
||||||
let deep_link_js =
|
let deep_link_js =
|
||||||
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
|
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
|
||||||
@@ -1095,6 +1132,19 @@ fn mobile_deep_link_page(
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #89847c;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1105,15 +1155,13 @@ fn mobile_deep_link_page(
|
|||||||
{detail_html}
|
{detail_html}
|
||||||
<a href="{deep_link_html}">Open Furumi</a>
|
<a href="{deep_link_html}">Open Furumi</a>
|
||||||
<p class="hint">If nothing happens, use the button above.</p>
|
<p class="hint">If nothing happens, use the button above.</p>
|
||||||
|
{code_html}
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const deepLink = {deep_link_js};
|
const deepLink = {deep_link_js};
|
||||||
window.setTimeout(() => {{
|
window.setTimeout(() => {{
|
||||||
window.location.href = deepLink;
|
window.location.href = deepLink;
|
||||||
}}, 100);
|
}}, 100);
|
||||||
window.setTimeout(() => {{
|
|
||||||
window.close();
|
|
||||||
}}, 1800);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
@@ -1230,4 +1278,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-4
@@ -51,6 +51,7 @@ pub(super) struct ArtistRef {
|
|||||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
pub(super) struct TrackItem {
|
pub(super) struct TrackItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
|
pub(super) content_id: Option<String>,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
pub(super) disc_number: Option<i32>,
|
pub(super) disc_number: Option<i32>,
|
||||||
@@ -74,6 +75,15 @@ pub(super) struct TrackItem {
|
|||||||
pub(super) lastfm_updated_at: Option<String>,
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistTrackItem {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(super) sort_key: Option<i64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) track: TrackItem,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct ArtistAppearanceTrack {
|
pub(super) struct ArtistAppearanceTrack {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -265,6 +275,24 @@ pub(super) struct PlayerDevicesResponse {
|
|||||||
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
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)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayerDevicePollResponse {
|
pub(super) struct PlayerDevicePollResponse {
|
||||||
pub(super) device_id: String,
|
pub(super) device_id: String,
|
||||||
@@ -286,7 +314,7 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) is_public: bool,
|
pub(super) is_public: bool,
|
||||||
pub(super) is_saved: bool,
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<PlaylistTrackItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -325,6 +353,45 @@ pub(super) struct UserProfile {
|
|||||||
pub(super) stats: UserStats,
|
pub(super) stats: UserStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflineManifestResponse {
|
||||||
|
pub(super) generated_at: String,
|
||||||
|
pub(super) tracks: Vec<OfflineTrackManifestItem>,
|
||||||
|
pub(super) playlists: Vec<OfflinePlaylistManifestItem>,
|
||||||
|
pub(super) liked_track_ids: Vec<i64>,
|
||||||
|
pub(super) followed_artist_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflineTrackManifestItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) stream_url: String,
|
||||||
|
pub(super) audio_file_id: i64,
|
||||||
|
pub(super) audio_hash: String,
|
||||||
|
pub(super) audio_size_bytes: i64,
|
||||||
|
pub(super) audio_mime_type: String,
|
||||||
|
pub(super) audio_updated_at: String,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) cover_url: Option<String>,
|
||||||
|
pub(super) cover_hash: Option<String>,
|
||||||
|
pub(super) cover_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct OfflinePlaylistManifestItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: Option<String>,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
|
pub(super) kind: String,
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct LastfmStatus {
|
pub(super) struct LastfmStatus {
|
||||||
pub(super) configured: bool,
|
pub(super) configured: bool,
|
||||||
@@ -474,14 +541,16 @@ pub(super) struct UserUploadReviewUpdateRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct PlayHistoryItem {
|
pub(super) struct PlayHistoryItem {
|
||||||
pub(super) id: i64,
|
pub(super) id: String,
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
pub(super) track_title: String,
|
pub(super) track_title: String,
|
||||||
pub(super) release_title: Option<String>,
|
pub(super) release_title: Option<String>,
|
||||||
pub(super) track: TrackItem,
|
pub(super) track: serde_json::Value,
|
||||||
pub(super) played_at: String,
|
pub(super) played_at: String,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
|
pub(super) device_id: String,
|
||||||
|
pub(super) device_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -497,6 +566,46 @@ pub(super) struct LikeStatus {
|
|||||||
pub(super) liked: bool,
|
pub(super) liked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct ContentTrackMutation {
|
||||||
|
pub(super) content_id: String,
|
||||||
|
pub(super) liked: Option<bool>,
|
||||||
|
pub(super) playlist_id: Option<i64>,
|
||||||
|
pub(super) position: Option<i64>,
|
||||||
|
pub(super) federation: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PrepareFederatedTrackRequest {
|
||||||
|
pub(super) content_id: String,
|
||||||
|
pub(super) owner: Option<String>,
|
||||||
|
pub(super) item_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtworkQuery {
|
||||||
|
pub(super) owner: String,
|
||||||
|
pub(super) item_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtistQuery {
|
||||||
|
pub(super) name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationCatalogArtworkQuery {
|
||||||
|
pub(super) owner: String,
|
||||||
|
pub(super) artist: String,
|
||||||
|
pub(super) release: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct FederationArtworkDiscoveryQuery {
|
||||||
|
pub(super) artist: String,
|
||||||
|
pub(super) release: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct LikedIds {
|
pub(super) struct LikedIds {
|
||||||
pub(super) track_ids: Vec<i64>,
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
|||||||
+2319
-272
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,11 @@ use serde::Deserialize;
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct HistoryEntry {
|
pub(super) struct HistoryEntry {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: i64,
|
||||||
|
pub(super) listen_id: Option<String>,
|
||||||
pub(super) started_at: Option<i64>,
|
pub(super) started_at: Option<i64>,
|
||||||
pub(super) duration_listened: Option<i32>,
|
pub(super) duration_listened: Option<i32>,
|
||||||
pub(super) completed: bool,
|
pub(super) completed: bool,
|
||||||
|
pub(super) ended_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -42,7 +44,13 @@ pub(super) struct AddTracksRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct RemoveTrackRequest {
|
pub(super) struct RemoveTrackRequest {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct ReorderPlaylistRequest {
|
||||||
|
pub(super) playlist_track_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
+30
-28
@@ -56,6 +56,8 @@ pub(super) struct MediaFileRow {
|
|||||||
pub(super) file_path: String,
|
pub(super) file_path: String,
|
||||||
pub(super) mime_type: String,
|
pub(super) mime_type: String,
|
||||||
pub(super) file_size_bytes: i64,
|
pub(super) file_size_bytes: i64,
|
||||||
|
pub(super) sha256_hash: String,
|
||||||
|
pub(super) created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -93,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
|
|||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct PlaylistTrackRow {
|
pub(super) struct PlaylistTrackRow {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
@@ -251,34 +254,6 @@ pub(super) struct ReleaseUploaderRow {
|
|||||||
pub(super) track_count: i64,
|
pub(super) track_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
pub(super) struct PlayHistoryTrackRow {
|
|
||||||
pub(super) history_id: i64,
|
|
||||||
pub(super) played_at: String,
|
|
||||||
pub(super) duration_listened: Option<i32>,
|
|
||||||
pub(super) completed: bool,
|
|
||||||
pub(super) id: i64,
|
|
||||||
pub(super) title: String,
|
|
||||||
pub(super) track_number: Option<i32>,
|
|
||||||
pub(super) disc_number: Option<i32>,
|
|
||||||
pub(super) duration_seconds: f64,
|
|
||||||
pub(super) cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_cover_file_id: Option<i64>,
|
|
||||||
pub(super) release_id: i64,
|
|
||||||
pub(super) release_title: String,
|
|
||||||
pub(super) release_year: Option<i32>,
|
|
||||||
pub(super) uploader_name: String,
|
|
||||||
pub(super) audio_format: Option<String>,
|
|
||||||
pub(super) audio_bitrate: Option<i32>,
|
|
||||||
pub(super) audio_sample_rate: Option<i32>,
|
|
||||||
pub(super) audio_bit_depth: Option<i32>,
|
|
||||||
pub(super) file_size_bytes: Option<i64>,
|
|
||||||
pub(super) lastfm_listeners: Option<i64>,
|
|
||||||
pub(super) lastfm_playcount: Option<i64>,
|
|
||||||
pub(super) lastfm_rating: Option<f64>,
|
|
||||||
pub(super) lastfm_updated_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct ReleaseInfoRow {
|
pub(super) struct ReleaseInfoRow {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -287,3 +262,30 @@ pub(super) struct ReleaseInfoRow {
|
|||||||
pub(super) year: Option<i32>,
|
pub(super) year: Option<i32>,
|
||||||
pub(super) cover_file_id: Option<i64>,
|
pub(super) cover_file_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct OfflineTrackManifestRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) audio_file_id: i64,
|
||||||
|
pub(super) audio_hash: String,
|
||||||
|
pub(super) audio_size_bytes: i64,
|
||||||
|
pub(super) audio_mime_type: String,
|
||||||
|
pub(super) audio_updated_at: String,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) cover_hash: Option<String>,
|
||||||
|
pub(super) cover_updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct OfflinePlaylistManifestRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) updated_at: String,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: String,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|||||||
+18
-8
@@ -496,14 +496,24 @@ impl PendingReview {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn exists_for_path(db: &Database, path: &str) -> cot::db::Result<bool> {
|
/// Latest review row for an inbox path: `(id, status, updated_at)`.
|
||||||
let all = Self::objects().all(db).await?;
|
///
|
||||||
let exists = all.iter().any(|r| {
|
/// Used by inbox_discover to decide whether a file needs a new review,
|
||||||
let s = r.status.as_str();
|
/// a requeue of its existing row, or nothing at all — without creating
|
||||||
// "rejected" and "failed" reviews should not block re-discovery
|
/// a fresh row per retry.
|
||||||
s != "rejected" && s != "failed" && r.input_path.as_deref() == Some(path)
|
pub async fn latest_for_path(
|
||||||
});
|
pool: &sqlx::PgPool,
|
||||||
Ok(exists)
|
path: &str,
|
||||||
|
) -> anyhow::Result<Option<(i64, String, String)>> {
|
||||||
|
let row: Option<(i64, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, status::text, updated_at::text \
|
||||||
|
FROM furumusic__pending_review WHERE input_path = $1 \
|
||||||
|
ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(path)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark all "processing" reviews as "failed" — called at scheduler
|
/// Mark all "processing" reviews as "failed" — called at scheduler
|
||||||
|
|||||||
+510
-15
@@ -1193,6 +1193,77 @@ tbody tr:hover {
|
|||||||
gap: 12px;
|
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 {
|
.image-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1542,7 +1613,7 @@ tbody tr:hover {
|
|||||||
</button>
|
</button>
|
||||||
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
|
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
|
||||||
<i data-lucide="list-checks"></i>
|
<i data-lucide="list-checks"></i>
|
||||||
Select filter
|
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
|
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
|
||||||
<i data-lucide="x"></i>
|
<i data-lucide="x"></i>
|
||||||
@@ -1836,6 +1907,10 @@ tbody tr:hover {
|
|||||||
<i data-lucide="square-pen"></i>
|
<i data-lucide="square-pen"></i>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</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')">
|
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
|
||||||
<i data-lucide="git-merge"></i>
|
<i data-lucide="git-merge"></i>
|
||||||
Merge
|
Merge
|
||||||
@@ -2190,6 +2265,107 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 class="setting-field">
|
||||||
|
<label>
|
||||||
|
<span>Save federated tracks on play</span>
|
||||||
|
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-toggle-row">
|
||||||
|
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
|
||||||
|
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
|
||||||
|
</div>
|
||||||
|
<div class="setting-help">Server-wide policy. Imported tracks become available to every user and are published by this peer. Federation metadata is trusted and bypasses the AI agent.</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">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
@@ -2419,8 +2595,8 @@ tbody tr:hover {
|
|||||||
<section class="modal">
|
<section class="modal">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<strong x-text="activeLibraryItem?.title || 'Editor'"></strong>
|
<strong x-text="editorTitle()"></strong>
|
||||||
<span x-text="activeLibraryItem?.kind || 'Library entity'"></span>
|
<span x-text="editorSubtitle()"></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn" @click="editorOpen = false">
|
<button class="icon-btn" @click="editorOpen = false">
|
||||||
<i data-lucide="x"></i>
|
<i data-lucide="x"></i>
|
||||||
@@ -2454,6 +2630,54 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</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="editor-grid" x-show="isTrackEditor()">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Track #</label>
|
<label>Track #</label>
|
||||||
@@ -2585,9 +2809,9 @@ tbody tr:hover {
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()">
|
<button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()">
|
||||||
<i :data-lucide="editorSaving ? 'loader-circle' : 'save'"></i>
|
<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>
|
||||||
<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>
|
<i data-lucide="trash-2"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -2682,8 +2906,12 @@ function adminV2() {
|
|||||||
editorImageFile: null,
|
editorImageFile: null,
|
||||||
editorArtistToAdd: '',
|
editorArtistToAdd: '',
|
||||||
editorReleaseToAdd: '',
|
editorReleaseToAdd: '',
|
||||||
|
releaseTrackSearch: '',
|
||||||
|
releaseTrackSearchResults: [],
|
||||||
|
releaseTrackSearchLoading: false,
|
||||||
|
releaseTrackSearchToken: 0,
|
||||||
editorDetail: null,
|
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 },
|
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
||||||
settingsDraft: {
|
settingsDraft: {
|
||||||
auth_password_enabled: false,
|
auth_password_enabled: false,
|
||||||
@@ -2705,10 +2933,17 @@ function adminV2() {
|
|||||||
agent_llm_auth: '',
|
agent_llm_auth: '',
|
||||||
agent_confidence_threshold: '',
|
agent_confidence_threshold: '',
|
||||||
agent_context_limit: '',
|
agent_context_limit: '',
|
||||||
agent_concurrency: ''
|
agent_concurrency: '',
|
||||||
|
federation_enabled: false,
|
||||||
|
federation_network_id: '',
|
||||||
|
federation_save_on_listen: false
|
||||||
},
|
},
|
||||||
settingsProbe: { status: 'idle', ok: false },
|
settingsProbe: { status: 'idle', ok: false },
|
||||||
settingsProbeLoading: false,
|
settingsProbeLoading: false,
|
||||||
|
federationStatus: {},
|
||||||
|
federationLoading: false,
|
||||||
|
federationTicket: '',
|
||||||
|
fedConnectTicket: '',
|
||||||
settingsSaving: false,
|
settingsSaving: false,
|
||||||
routeReady: false,
|
routeReady: false,
|
||||||
poller: null,
|
poller: null,
|
||||||
@@ -2988,6 +3223,7 @@ function adminV2() {
|
|||||||
body: JSON.stringify(this.settingsDraft)
|
body: JSON.stringify(this.settingsDraft)
|
||||||
});
|
});
|
||||||
await this.loadSettings(false);
|
await this.loadSettings(false);
|
||||||
|
await this.loadFederation(false);
|
||||||
this.showToast('Settings saved');
|
this.showToast('Settings saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(error.message);
|
this.showToast(error.message);
|
||||||
@@ -3001,11 +3237,87 @@ function adminV2() {
|
|||||||
this.activeView = 'settings';
|
this.activeView = 'settings';
|
||||||
this.setRoute('#settings');
|
this.setRoute('#settings');
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
await this.loadFederation(false);
|
||||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||||
await this.loadSettingsProbe(false);
|
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) {
|
async loadSettingsProbe(showErrors = true) {
|
||||||
this.settingsProbeLoading = true;
|
this.settingsProbeLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -3396,16 +3708,30 @@ function adminV2() {
|
|||||||
release_id: null,
|
release_id: null,
|
||||||
track_number: '',
|
track_number: '',
|
||||||
disc_number: '',
|
disc_number: '',
|
||||||
artist_ids: []
|
artist_ids: [],
|
||||||
|
release_tracks: []
|
||||||
};
|
};
|
||||||
this.editorDetail = null;
|
this.editorDetail = null;
|
||||||
this.editorImageFile = null;
|
this.editorImageFile = null;
|
||||||
this.editorArtistToAdd = '';
|
this.editorArtistToAdd = '';
|
||||||
this.editorReleaseToAdd = '';
|
this.editorReleaseToAdd = '';
|
||||||
|
this.clearReleaseTrackSearch();
|
||||||
this.editorOpen = true;
|
this.editorOpen = true;
|
||||||
this.loadEditorDetail(item);
|
this.loadEditorDetail(item);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openReleaseCreator() {
|
||||||
|
this.libraryKind = 'releases';
|
||||||
|
this.openEditor({
|
||||||
|
id: 0,
|
||||||
|
kind: 'releases',
|
||||||
|
title: '',
|
||||||
|
subtitle: 'New release',
|
||||||
|
is_hidden: false,
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async loadEditorDetail(item) {
|
async loadEditorDetail(item) {
|
||||||
const key = `${item.kind}:${item.id}`;
|
const key = `${item.kind}:${item.id}`;
|
||||||
this.editorLoading = true;
|
this.editorLoading = true;
|
||||||
@@ -3422,11 +3748,13 @@ function adminV2() {
|
|||||||
release_id: detail.release_id || null,
|
release_id: detail.release_id || null,
|
||||||
track_number: detail.track_number || '',
|
track_number: detail.track_number || '',
|
||||||
disc_number: detail.disc_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.editorImageFile = null;
|
||||||
this.editorArtistToAdd = '';
|
this.editorArtistToAdd = '';
|
||||||
this.editorReleaseToAdd = '';
|
this.editorReleaseToAdd = '';
|
||||||
|
this.clearReleaseTrackSearch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(error.message);
|
this.showToast(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3449,8 +3777,22 @@ function adminV2() {
|
|||||||
return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks';
|
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() {
|
canEditLibraryImage() {
|
||||||
return this.isArtistEditor() || this.isReleaseEditor();
|
return this.isArtistEditor() || (this.isReleaseEditor() && !this.editorIsNewRelease());
|
||||||
},
|
},
|
||||||
|
|
||||||
canShowMetadataTags() {
|
canShowMetadataTags() {
|
||||||
@@ -3497,6 +3839,7 @@ function adminV2() {
|
|||||||
|
|
||||||
editorCanSave() {
|
editorCanSave() {
|
||||||
if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false;
|
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;
|
if (this.isTrackEditor() && !this.editorDraft.release_id) return false;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -3605,6 +3948,146 @@ function adminV2() {
|
|||||||
return true;
|
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) {
|
setEditorImageFile(event) {
|
||||||
this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null;
|
this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null;
|
||||||
},
|
},
|
||||||
@@ -3729,6 +4212,7 @@ function adminV2() {
|
|||||||
if (!this.editorCanSave()) return;
|
if (!this.editorCanSave()) return;
|
||||||
this.editorSaving = true;
|
this.editorSaving = true;
|
||||||
try {
|
try {
|
||||||
|
const wasNewRelease = this.editorIsNewRelease();
|
||||||
const updated = await this.request(`${this.apiBase}/library/item`, {
|
const updated = await this.request(`${this.apiBase}/library/item`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -3741,13 +4225,15 @@ function adminV2() {
|
|||||||
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
|
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
|
||||||
track_number: this.editorDraft.track_number || '',
|
track_number: this.editorDraft.track_number || '',
|
||||||
disc_number: this.editorDraft.disc_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.replaceLibraryItem(updated);
|
||||||
this.activeLibraryItem = updated;
|
this.activeLibraryItem = updated;
|
||||||
if (this.editorDetail) this.editorDetail.item = 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();
|
await this.refreshCountsOnly();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(error.message);
|
this.showToast(error.message);
|
||||||
@@ -3768,9 +4254,18 @@ function adminV2() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
replaceLibraryItem(updated) {
|
replaceLibraryItem(updated) {
|
||||||
this.library.items = this.library.items.map(item =>
|
let replaced = false;
|
||||||
item.kind === updated.kind && item.id === updated.id ? updated : item
|
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() {
|
async refreshCountsOnly() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111827'/%3E%3Cpath d='M27 15v31.5a9 9 0 1 1-5-8.1V22l27-6v24.5a9 9 0 1 1-5-8.1V15.9L27 20.5' fill='%2367e8f9'/%3E%3C/svg%3E">
|
||||||
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
||||||
{% block head_extra %}{% endblock head_extra %}
|
{% block head_extra %}{% endblock head_extra %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -636,6 +636,144 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- User Settings Modal -->
|
||||||
|
<template x-if="$store.user.settingsOpen">
|
||||||
|
<div class="modal-overlay" @click.self="$store.user.closeSettings()">
|
||||||
|
<div class="modal-box user-settings-modal">
|
||||||
|
<div class="user-settings-head">
|
||||||
|
<div>
|
||||||
|
<h3>User settings</h3>
|
||||||
|
<p>Personal services, listening history and trusted devices.</p>
|
||||||
|
</div>
|
||||||
|
<button class="mobile-list-action" @click="$store.user.closeSettings()" title="{{ t.player_close }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="user-settings-section">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Listening history</h4>
|
||||||
|
<p>Review plays recorded by this web player.</p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn" @click="$store.user.openHistoryFromSettings()">Open history</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-settings-section">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Last.fm</h4>
|
||||||
|
<p x-text="$store.user.lastfmStatusLabel()"></p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:class="$store.user.lastfmClass()"
|
||||||
|
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||||
|
@click="$store.user.handleLastfm()"
|
||||||
|
x-text="$store.user.lastfm?.connected && !$store.user.lastfm?.reauth_required ? 'Disconnect' : 'Connect'"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-settings-section user-settings-devices">
|
||||||
|
<div class="user-settings-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>Connected devices</h4>
|
||||||
|
<p x-text="$store.devices.fedSummary()"></p>
|
||||||
|
</div>
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.syncFedDevices()">Sync now</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="$store.devices.fedError">
|
||||||
|
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="settings-device-group">
|
||||||
|
<div class="settings-device-label">Web player sessions</div>
|
||||||
|
<template x-for="device in $store.devices.webDevices()" :key="'settings-web-' + device.id">
|
||||||
|
<div class="settings-device-row">
|
||||||
|
<span class="device-row-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2"/>
|
||||||
|
<path d="M8 20h8M12 16v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="fed-device-main">
|
||||||
|
<span class="fed-device-name" x-text="device.name"></span>
|
||||||
|
<span class="fed-device-meta"
|
||||||
|
x-text="device.is_current ? 'This browser session' : (device.is_active ? 'Active web session' : 'Web session')"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-device-group">
|
||||||
|
<div class="settings-device-label">Trusted federation devices</div>
|
||||||
|
<template x-for="request in $store.devices.fedPending()" :key="request.request_id">
|
||||||
|
<div class="fed-pairing-card">
|
||||||
|
<div class="fed-pairing-title" x-text="request.name || request.device_id"></div>
|
||||||
|
<div class="fed-pairing-meta"
|
||||||
|
x-text="request.requester_group_id ? 'Already belongs to another sync group' : (request.client_version || 'Waiting for approval')"></div>
|
||||||
|
<div class="fed-device-actions">
|
||||||
|
<button class="fed-action-btn primary"
|
||||||
|
@click="$store.devices.answerFedPairing(request, true, !!request.requester_group_id)"
|
||||||
|
x-text="request.requester_group_id ? 'Use existing group' : 'Approve'"></button>
|
||||||
|
<button class="fed-action-btn"
|
||||||
|
@click="$store.devices.answerFedPairing(request, false, false)">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="device in $store.devices.fedDevices()" :key="'settings-fed-' + device.device_id">
|
||||||
|
<div class="settings-device-row federation">
|
||||||
|
<span class="device-row-icon federation-device-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||||
|
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="fed-device-main">
|
||||||
|
<span class="fed-device-name" x-text="device.name || device.device_id"></span>
|
||||||
|
<span class="fed-device-meta"
|
||||||
|
x-text="device.is_self ? 'This web player' : (device.client_version || 'Trusted device')"></span>
|
||||||
|
</span>
|
||||||
|
<button class="fed-revoke-btn"
|
||||||
|
x-show="!device.is_self"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-pairing-actions">
|
||||||
|
<button class="settings-primary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy"
|
||||||
|
@click="$store.devices.generateFedInvite()">Invite a device</button>
|
||||||
|
<template x-if="$store.devices.fedInvite">
|
||||||
|
<input class="fed-device-input"
|
||||||
|
readonly
|
||||||
|
:value="$store.devices.fedInvite"
|
||||||
|
@focus="$event.target.select()">
|
||||||
|
</template>
|
||||||
|
<div class="fed-connect-row">
|
||||||
|
<input class="fed-device-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Paste frid:// invite"
|
||||||
|
x-model="$store.devices.fedInviteInput"
|
||||||
|
@keydown.enter.prevent="$store.devices.connectFedInvite()">
|
||||||
|
<button class="settings-secondary-btn"
|
||||||
|
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
|
||||||
|
@click="$store.devices.connectFedInvite()">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Play History Modal -->
|
<!-- Play History Modal -->
|
||||||
<template x-if="$store.history.modal">
|
<template x-if="$store.history.modal">
|
||||||
<div class="modal-overlay" @click.self="$store.history.close()">
|
<div class="modal-overlay" @click.self="$store.history.close()">
|
||||||
@@ -683,7 +821,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
|
<div class="track-title">
|
||||||
|
<span x-text="item.track?.title || item.track_title"></span>
|
||||||
|
<span class="history-device-badge"
|
||||||
|
:title="item.device_id"
|
||||||
|
x-text="item.device_name"></span>
|
||||||
|
</div>
|
||||||
<div class="track-artists-inline">
|
<div class="track-artists-inline">
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.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>
|
<span>
|
||||||
|
|||||||
+1561
-21
File diff suppressed because it is too large
Load Diff
+667
-112
File diff suppressed because it is too large
Load Diff
+667
-11
@@ -67,11 +67,17 @@ body {
|
|||||||
|
|
||||||
.user-widget-main {
|
.user-widget-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px minmax(0, 1fr) 32px;
|
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-widget-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -489,6 +495,25 @@ button.user-stat:hover {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading .section-title {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -651,6 +676,42 @@ button.user-stat:hover {
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
.artist-release-group-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.artist-release-group-heading .artist-release-group-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.artist-top-tracks-toggle svg.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
/* Release detail header */
|
/* Release detail header */
|
||||||
.release-header {
|
.release-header {
|
||||||
@@ -735,6 +796,93 @@ button.user-stat:hover {
|
|||||||
.track-row:hover { background: var(--bg-hover); }
|
.track-row:hover { background: var(--bg-hover); }
|
||||||
.track-row.playing { color: var(--accent); }
|
.track-row.playing { color: var(--accent); }
|
||||||
.track-row.playing .track-num { color: var(--accent); }
|
.track-row.playing .track-num { color: var(--accent); }
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 32px 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.drag-over {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-copy {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle {
|
||||||
|
width: 24px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
padding: 0;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row.shared-target {
|
.track-row.shared-target {
|
||||||
background: rgba(29, 185, 84, 0.12);
|
background: rgba(29, 185, 84, 0.12);
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
@@ -1224,7 +1372,22 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-track:hover { background: var(--bg-hover); }
|
.queue-track:hover { background: var(--bg-hover); }
|
||||||
.queue-track.active { background: var(--bg-active); }
|
.queue-track.active,
|
||||||
|
.queue-track.current { background: var(--bg-active); }
|
||||||
|
.queue-track.played {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
.queue-track.played:hover {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
.queue-track.played .queue-track-cover {
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
.queue-track.synthetic .queue-drag-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.queue-track.foreign-jam-track {
|
.queue-track.foreign-jam-track {
|
||||||
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 78%);
|
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 78%);
|
||||||
}
|
}
|
||||||
@@ -1236,20 +1399,50 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-track-cover {
|
.queue-track-cover {
|
||||||
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; }
|
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||||
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
||||||
|
|
||||||
|
.queue-track-cover .queue-federation-status {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
z-index: 3;
|
||||||
|
width: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
max-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
max-height: 18px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
box-shadow: 0 0 0 1px var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.queue-track-cover .queue-federation-status > svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
.queue-track-cover .queue-federation-status .federation-download-progress,
|
||||||
|
.queue-track-cover .queue-federation-status .federation-download-progress svg {
|
||||||
|
width: 18px !important;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px !important;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-track-info { overflow: hidden; flex: 1; }
|
.queue-track-info { overflow: hidden; flex: 1; }
|
||||||
.queue-track-title {
|
.queue-track-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1259,7 +1452,9 @@ button.user-stat:hover {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-track.active .queue-track-title { color: var(--accent); }
|
.queue-track.active .queue-track-title,
|
||||||
|
.queue-track.current .queue-track-title { color: var(--accent); }
|
||||||
|
.queue-track.played .queue-track-title { color: var(--text-subdued); }
|
||||||
|
|
||||||
.queue-track-artist {
|
.queue-track-artist {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1733,9 +1928,9 @@ button.user-stat:hover {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 38px;
|
bottom: 38px;
|
||||||
width: 260px;
|
width: 320px;
|
||||||
max-width: calc(100vw - 24px);
|
max-width: calc(100vw - 24px);
|
||||||
max-height: min(320px, calc(100dvh - var(--player-bar-space) - 24px));
|
max-height: min(440px, calc(100dvh - var(--player-bar-space) - 24px));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -1832,6 +2027,169 @@ button.user-stat:hover {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-group-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 7px 8px 3px;
|
||||||
|
color: #b8d6ff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.device-group-divider::before,
|
||||||
|
.device-group-divider::after {
|
||||||
|
content: "";
|
||||||
|
height: 1px;
|
||||||
|
flex: 1;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
.device-group-divider span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.federation-device-row {
|
||||||
|
color: #c9dcff;
|
||||||
|
}
|
||||||
|
.federation-device-icon {
|
||||||
|
color: #78a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-section-label {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #b8d6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-panel {
|
||||||
|
margin: 2px 2px 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(82,145,255,0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(82,145,255,0.045);
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-status,
|
||||||
|
.fed-device-error,
|
||||||
|
.fed-pairing-note {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-error {
|
||||||
|
color: #ffb2b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-row {
|
||||||
|
min-height: 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 9px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #73d795;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-dot.self {
|
||||||
|
background: #ffd166;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-name,
|
||||||
|
.fed-pairing-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-meta,
|
||||||
|
.fed-pairing-meta {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-pairing-card {
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-actions,
|
||||||
|
.fed-connect-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-connect-row .fed-device-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn,
|
||||||
|
.fed-revoke-btn {
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn:hover,
|
||||||
|
.fed-revoke-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.13);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn.primary {
|
||||||
|
background: rgba(82,145,255,0.16);
|
||||||
|
color: #c9dcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-action-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-revoke-btn {
|
||||||
|
background: rgba(255,96,96,0.1);
|
||||||
|
color: #ffb2b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed-device-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(82,145,255,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.jam-section-label,
|
.jam-section-label,
|
||||||
.jam-row,
|
.jam-row,
|
||||||
.start-jam-row,
|
.start-jam-row,
|
||||||
@@ -2115,7 +2473,16 @@ button.user-stat:hover {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: none;
|
text-decoration: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-version-chip:hover,
|
||||||
|
.player-version-chip:focus-visible {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
opacity: 0.9;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-account-chip {
|
.mobile-account-chip {
|
||||||
@@ -2177,11 +2544,18 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-account-logout {
|
.mobile-account-logout {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
margin-top: 12px;
|
margin: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-account-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-import-btn {
|
.torrent-import-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2373,6 +2747,125 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
/* Search results */
|
/* Search results */
|
||||||
.search-section { margin-bottom: 24px; }
|
.search-section { margin-bottom: 24px; }
|
||||||
|
.federation-search-section {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
.federation-live-badge {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
}
|
||||||
|
.federation-search-status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.federation-search-status.error { color: var(--danger, #e66); }
|
||||||
|
.federation-track-status {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.federation-track-status > svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.federation-error-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px solid rgba(235, 105, 105, .7);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(190, 55, 55, .18);
|
||||||
|
color: #ffb0b0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.queue-federation-status .federation-error-badge {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.federation-download-progress {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.federation-download-progress svg {
|
||||||
|
width: 22px !important;
|
||||||
|
height: 22px !important;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.federation-download-progress circle {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.federation-download-progress .progress-track {
|
||||||
|
stroke: color-mix(in srgb, var(--text-muted) 28%, transparent);
|
||||||
|
}
|
||||||
|
.federation-download-progress .progress-value {
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 50.27;
|
||||||
|
transition: stroke-dashoffset 160ms linear;
|
||||||
|
}
|
||||||
|
.federation-download-progress.indeterminate .progress-value {
|
||||||
|
stroke-dasharray: 12.57 37.70;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: federation-progress-spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
.federation-download-progress .progress-percent {
|
||||||
|
position: absolute;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.federation-download-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
left: calc(100% + 10px);
|
||||||
|
top: 50%;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 290px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, .28);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(3px, -50%);
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.federation-download-progress:hover .federation-download-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
}
|
||||||
|
@keyframes federation-progress-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.federation-source-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.search-section-title {
|
.search-section-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -2416,7 +2909,7 @@ button.user-stat:hover {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-artist-img img { width: 100%; height: 100%; object-fit: cover; }
|
.search-artist-img img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.search-artist-img svg { width: 32px; height: 32px; color: var(--text-subdued); }
|
.search-artist-img svg { width: 32px; height: 32px; color: var(--text-subdued); }
|
||||||
|
|
||||||
.search-artist-name {
|
.search-artist-name {
|
||||||
@@ -3112,6 +3605,105 @@ button.user-stat:hover {
|
|||||||
max-width: 980px;
|
max-width: 980px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-settings-modal {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
max-width: 760px;
|
||||||
|
max-height: min(86dvh, 820px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.user-settings-head,
|
||||||
|
.user-settings-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.user-settings-head {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.user-settings-head h3,
|
||||||
|
.user-settings-section h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.user-settings-head p,
|
||||||
|
.user-settings-section p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.user-settings-section {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.user-settings-devices {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.settings-device-group {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.settings-device-label {
|
||||||
|
padding: 8px 11px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.settings-device-row {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||||
|
}
|
||||||
|
.settings-device-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.settings-device-row.federation {
|
||||||
|
background: rgba(82, 145, 255, 0.035);
|
||||||
|
}
|
||||||
|
.settings-primary-btn,
|
||||||
|
.settings-secondary-btn {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.settings-primary-btn {
|
||||||
|
border-color: rgba(82, 145, 255, 0.4);
|
||||||
|
background: rgba(82, 145, 255, 0.18);
|
||||||
|
color: #d5e4ff;
|
||||||
|
}
|
||||||
|
.settings-primary-btn:hover,
|
||||||
|
.settings-secondary-btn:hover {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
}
|
||||||
|
.settings-primary-btn:disabled,
|
||||||
|
.settings-secondary-btn:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
.settings-pairing-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.settings-pairing-actions > .settings-primary-btn {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.history-head {
|
.history-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -3154,6 +3746,18 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-row:last-child { border-bottom: 0; }
|
.history-row:last-child { border-bottom: 0; }
|
||||||
|
.history-device-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: .45rem;
|
||||||
|
padding: .08rem .38rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
vertical-align: .12rem;
|
||||||
|
}
|
||||||
|
|
||||||
.history-cover {
|
.history-cover {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -4216,6 +4820,16 @@ button.user-stat:hover {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px 32px minmax(0, 1fr) auto 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing span:nth-child(4),
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.history-table-head,
|
.history-table-head,
|
||||||
.history-row.track-row {
|
.history-row.track-row {
|
||||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||||
@@ -4925,6 +5539,28 @@ button.user-stat:hover {
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-expanded-queue-row.current {
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-expanded-queue-row.current .mobile-expanded-queue-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-expanded-queue-row.played {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-expanded-queue-row.played:active {
|
||||||
|
opacity: 0.74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-expanded-queue-row.played .mobile-expanded-queue-cover {
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-expanded-queue-row.foreign-jam-track {
|
.mobile-expanded-queue-row.foreign-jam-track {
|
||||||
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 82%);
|
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 82%);
|
||||||
}
|
}
|
||||||
@@ -5119,6 +5755,26 @@ button.user-stat:hover {
|
|||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing .track-num,
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row > span:nth-child(3),
|
.track-row > span:nth-child(3),
|
||||||
.track-duration {
|
.track-duration {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user