Compare commits

...
13 Commits
Author SHA1 Message Date
Ultradesu bca0f5e2f0 added psql support
Build and Publish / Build and Publish Docker Image (push) Successful in 9m24s
2026-07-20 02:03:44 +03:00
Ultradesu 53b2ff29f8 Moved DHT state in psql 2026-07-20 01:59:45 +03:00
Ultradesu c349512fb0 fixed gitignore 2026-07-17 14:01:13 +03:00
Ultradesu 0615356785 Added federation
Build and Publish / Build and Publish Docker Image (push) Successful in 4m55s
2026-07-17 13:30:55 +03:00
Ultradesu 184371afca Added offline mode support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m46s
2026-07-15 18:13:40 +03:00
Ultradesu 716da908c9 Fixed inbox_process task
Build and Publish / Build and Publish Docker Image (push) Successful in 5m34s
2026-07-08 15:22:21 +03:00
Ultradesu 0c120c0868 Added support of TUI player
Build and Publish / Build and Publish Docker Image (push) Successful in 2m38s
2026-06-10 23:20:18 +01:00
Ultradesu d9d0fbb7d1 Added cli client SSO login support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s
2026-06-10 13:34:38 +01:00
Ultradesu 71d6556ba8 Improved artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m37s
2026-06-09 17:18:55 +01:00
Ultradesu 0ac59eb0ca Improved release editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m35s
2026-06-09 15:06:01 +01:00
Ultradesu 652c6a470d Fix queue display
Build and Publish / Build and Publish Docker Image (push) Successful in 2m33s
2026-06-08 17:59:56 +01:00
Ultradesu 1c54782dd7 Fix queue display 2026-06-08 17:59:46 +01:00
Ultradesu 8fa06038fe Added MacOS client support
Build and Publish / Build and Publish Docker Image (push) Successful in 2m32s
2026-06-08 16:36:30 +01:00
23 changed files with 5903 additions and 642 deletions
+1
View File
@@ -2,3 +2,4 @@
/nul /nul
/.claude /.claude
/media /media
/federation
Generated
+2425 -486
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,11 +1,14 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.4.1" version = "0.6.1-fd"
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"
@@ -28,5 +31,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 = { git = "https://gt.hexor.cy/ab/frid.git" }
+58
View File
@@ -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",
{ {
+408 -24
View File
@@ -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,10 @@ 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,
} }
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -456,6 +476,8 @@ 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,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -480,6 +502,10 @@ 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,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
@@ -538,6 +564,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 +582,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 +691,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 +988,11 @@ 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(),
),
]; ];
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 +1001,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 +1141,8 @@ 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,
}, },
sources: AdminSettingsSources { sources: AdminSettingsSources {
auth_password_enabled: sources.auth_password_enabled.code(), auth_password_enabled: sources.auth_password_enabled.code(),
@@ -1036,6 +1165,8 @@ 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(),
}, },
} }
} }
@@ -1289,6 +1420,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 +1447,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 +1483,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 +1599,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 +1625,14 @@ pub async fn update_library_item(
} }
} }
if kind == "releases" {
if let Some(release_tracks) = body.release_tracks.as_deref() {
update_release_tracks(pool, body.id, release_tracks, &now)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
}
let Some(item) = fetch_library_item(pool, &kind, body.id) 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 +2820,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 +2877,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 +2918,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 +3081,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 +3327,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 +3566,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 +3703,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 +3725,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);
+16
View File
@@ -135,6 +135,8 @@ 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,
} }
impl Default for ConfigSources { impl Default for ConfigSources {
@@ -162,6 +164,8 @@ 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,
} }
} }
} }
@@ -270,6 +274,12 @@ 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,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -297,6 +307,8 @@ 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(),
} }
} }
} }
@@ -325,6 +337,8 @@ impl_env_overrides!(
agent_concurrency, agent_concurrency,
lastfm_api_key, lastfm_api_key,
lastfm_shared_secret, lastfm_shared_secret,
federation_enabled,
federation_network_id,
); );
impl AppConfig { impl AppConfig {
@@ -452,6 +466,8 @@ 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);
} }
} }
+472
View File
@@ -0,0 +1,472 @@
//! P2P federation for the furumusic server.
//!
//! When enabled in the admin settings, the server becomes a regular peer of
//! the furumi federation: it publishes its whole visible library (artists,
//! releases, tracks — names and small metadata, never files) into the
//! shared DHT and serves audio, track metadata, cover art and per-artist
//! catalogs to other peers (TUI clients) over the same wire protocols the
//! clients speak among themselves. Serve-only: the server does not search
//! or download from other peers.
//!
//! Settings are the regular admin config entries (`federation_enabled`,
//! `federation_network_id`) and apply on the fly — saving the settings
//! starts, stops or re-joins the node without a server restart.
mod serve;
mod storage;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use anyhow::{Context, Result};
use music_dht::{
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, RendezvousConfig,
};
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);
struct Running {
service: Arc<MusicDhtService>,
network_name: String,
tasks: Vec<tokio::task::JoinHandle<()>>,
}
pub struct Federation {
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
data_dir: PathBuf,
database_url: std::sync::Mutex<String>,
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>>,
}
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()),
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),
})
}))
}
impl Federation {
fn set_error(&self, message: Option<String>) {
*lock(&self.last_error) = message;
}
async fn pool(&self) -> Result<PgPool> {
let url = lock(&self.database_url).clone();
anyhow::ensure!(!url.is_empty(), "database is not configured");
let pool = self
.pool
.get_or_try_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
})
.await?;
Ok(pool.clone())
}
/// Starts the node at boot when federation was left enabled. The
/// settings live in the config KV table, so this waits for the database
/// and resolves the same default → DB → env precedence the config uses.
pub async fn boot(self: &Arc<Self>, config: &AppConfig) {
*lock(&self.database_url) = config.database_url.clone();
if config.database_url.is_empty() {
return;
}
let pool = match self.pool().await {
Ok(pool) => pool,
Err(err) => {
tracing::warn!("federation boot: database unavailable: {err:#}");
return;
}
};
// `config` carries defaults + env; overlay the DB rows for fields
// that have no env override (env > DB > default).
let mut effective = config.clone();
let rows = sqlx::query(
"SELECT key, value FROM furumusic__config_entry
WHERE key IN ('federation_enabled', 'federation_network_id', 'agent_storage_dir')",
)
.fetch_all(&pool)
.await
.unwrap_or_default();
for row in rows {
let key: String = row.get(0);
let value: String = row.get(1);
let env_key = format!("FURU_{}", key.to_ascii_uppercase());
if std::env::var(&env_key).is_ok() {
continue;
}
match key.as_str() {
"federation_enabled" => {
if let Ok(parsed) = value.parse() {
effective.federation_enabled = parsed;
}
}
"federation_network_id" => effective.federation_network_id = value,
"agent_storage_dir" => {
effective.agent_storage_dir =
crate::media_paths::resolve_config_path(&value);
}
_ => {}
}
}
self.apply(&effective).await;
}
/// Applies the effective configuration: starts, stops or re-joins the
/// node. Called at boot and every time the admin settings are saved.
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
*lock(&self.database_url) = config.database_url.clone();
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?;
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)
.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;
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(),
));
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,
storage_dir,
service.endpoint_id(),
));
*guard = Some(Running {
service,
network_name,
tasks: vec![event_task, sync_task, audio_task, catalog_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 { fed.sync_once(&service).await });
}
}
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
let service = self.service().await?;
self.sync_once(&service).await;
Ok(())
}
async fn sync_once(&self, service: &MusicDhtService) {
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}")));
return;
}
};
match service.sync_library(specs).await {
Ok(stats) => {
*lock(&self.last_sync) = Some(format!(
"{} (+{} ~{} {}, unchanged {}, failed {})",
now_iso(),
stats.added,
stats.updated,
stats.removed,
stats.unchanged,
stats.failed
));
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);
}
}
Err(err) => {
tracing::warn!("federation sync failed: {err}");
self.set_error(Some(format!("sync failed: {err}")));
}
}
}
/// 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) -> 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(),
year: None,
release_type: None,
duration_seconds: 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(),
year: row.get(2),
release_type: row.get(3),
duration_seconds: None,
});
}
let track_artists = sqlx::query(
"SELECT ta.track_id, a.name 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, ta.position",
)
.fetch_all(&pool)
.await?;
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
for row in &track_artists {
artists_of_track
.entry(row.get(0))
.or_default()
.push(row.get(1));
}
let tracks = sqlx::query(
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds
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 &tracks {
let id: i64 = row.get(0);
let duration: f64 = row.get(3);
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(),
year: row.get(2),
release_type: None,
duration_seconds: (duration > 0.0).then_some(duration),
});
}
Ok(specs)
}
/// 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,
})
}
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())
}
}
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");
}
+662
View File
@@ -0,0 +1,662 @@
//! 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 anyhow::Result;
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use sqlx::Row as _;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
/// ALPN of the peer-to-peer audio streaming protocol.
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
/// ALPN of the per-artist catalog protocol.
pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/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>,
}
#[derive(Debug, Deserialize)]
struct CatalogRequest {
artist: String,
#[serde(default)]
want: Option<String>,
#[serde(default)]
release: Option<String>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogResponse {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
artist: Option<CatalogArtist>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogArtist {
name: String,
releases: Vec<CatalogRelease>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogRelease {
title: String,
release_type: String,
year: Option<i32>,
tracks: Vec<CatalogTrack>,
}
#[derive(Debug, Default, Serialize)]
struct CatalogTrack {
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: Option<f64>,
item_id: String,
}
#[derive(Debug, Default, Serialize)]
struct ImageHeader {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
mime_type: String,
size: u64,
}
// ---------------------------------------------------------------------------
// 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_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,
) {
while let Some(stream) = acceptor.accept().await {
let pool = pool.clone();
let storage_dir = storage_dir.clone();
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own).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,
) -> Result<()> {
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;
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,
) {
while let Some(stream) = acceptor.accept().await {
let pool = pool.clone();
let storage_dir = storage_dir.clone();
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(err) = serve_catalog_one(stream, pool, storage_dir, own).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,
) -> Result<()> {
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:#}")),
artist: None,
},
};
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}'")),
artist: None,
};
stream.send.write_all(&serde_json::to_vec(&response)?).await?;
}
}
stream.send.finish()?;
let _ = stream.send.stopped().await;
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()),
artist: None,
});
};
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 id, title, track_number, disc_number, duration_seconds
FROM furumusic__track
WHERE release_id = $1 AND is_hidden = false
ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title",
)
.bind(release_id)
.fetch_all(pool)
.await?;
releases.push(CatalogRelease {
title: release_row.get(1),
release_type: release_row.get(2),
year: release_row.get(3),
tracks: track_rows
.into_iter()
.map(|row| {
let track_id: i64 = row.get(0);
let duration: f64 = row.get(4);
CatalogTrack {
title: row.get(1),
track_number: row.get(2),
disc_number: row.get(3),
duration_seconds: (duration > 0.0).then_some(duration),
item_id: item_id_of(own, track_id),
}
})
.collect(),
});
}
Ok(CatalogResponse {
ok: true,
error: None,
artist: Some(CatalogArtist {
name: artist_row.get(1),
releases,
}),
})
}
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))))
}
+316
View File
@@ -0,0 +1,316 @@
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
)",
];
#[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 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(&self.pool)
.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(&self.pool)
.await
.map_err(db_error)?;
Ok(true)
}
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))
}
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
MusicDhtError::Database(err.to_string())
}
+1
View File
@@ -373,6 +373,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
View File
@@ -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
View File
@@ -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 {
+8
View File
@@ -12,6 +12,7 @@ mod music;
mod oidc; mod oidc;
mod player; mod player;
mod scheduler; mod scheduler;
mod federation;
mod torrents; mod torrents;
mod user; mod user;
@@ -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 {
+1
View File
@@ -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",
+264 -2
View File
@@ -977,15 +977,203 @@ 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 {
auth::redirect(&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(
"success",
"Sign-in complete",
"Furumi should open automatically. If it doesn't, use the button or copy the code below.",
None,
Some(code),
&deep_link,
)
} }
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response { fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
auth::redirect(&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(
"error",
"Sign-in failed",
"Furumi should open automatically and show the sign-in error.",
Some(error),
None,
&deep_link,
)
}
fn mobile_deep_link_page(
state: &str,
title: &str,
message: &str,
detail: Option<&str>,
code: Option<&str>,
deep_link: &str,
) -> cot::response::Response {
let state_class = html_escape(state);
let title_html = html_escape(title);
let message_html = html_escape(message);
let detail_html = detail
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
.unwrap_or_default();
let code_html = code
.map(|value| {
format!(
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
<input class="code" readonly value="{}" onclick="this.select()">"#,
html_escape(value)
)
})
.unwrap_or_default();
let deep_link_html = html_escape(deep_link);
let deep_link_js =
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
let html = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title_html}</title>
<style>
:root {{
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #101114;
color: #f5f2ea;
}}
body {{
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}}
main {{
width: min(420px, 100%);
text-align: center;
}}
.mark {{
width: 54px;
height: 54px;
margin: 0 auto 18px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 18px;
font-weight: 700;
background: #2f7d52;
color: white;
}}
.mark.error {{
background: #9d3d42;
}}
h1 {{
margin: 0 0 10px;
font-size: 26px;
line-height: 1.15;
letter-spacing: 0;
}}
p {{
margin: 0;
color: #c9c2b7;
font-size: 15px;
line-height: 1.55;
}}
.detail {{
margin-top: 12px;
color: #f1b3b7;
overflow-wrap: anywhere;
}}
a {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: 24px;
padding: 0 18px;
border-radius: 8px;
background: #e8d8a8;
color: #17150f;
font-weight: 700;
text-decoration: none;
}}
.hint {{
margin-top: 14px;
font-size: 13px;
color: #89847c;
}}
.code {{
width: 100%;
margin-top: 8px;
padding: 10px 12px;
box-sizing: border-box;
border: 1px solid #3a3c42;
border-radius: 8px;
background: #1a1c20;
color: #e8d8a8;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
text-align: center;
}}
</style>
</head>
<body>
<main>
<div class="mark {state_class}" aria-hidden="true">{mark}</div>
<h1>{title_html}</h1>
<p>{message_html}</p>
{detail_html}
<a href="{deep_link_html}">Open Furumi</a>
<p class="hint">If nothing happens, use the button above.</p>
{code_html}
</main>
<script>
const deepLink = {deep_link_js};
window.setTimeout(() => {{
window.location.href = deepLink;
}}, 100);
</script>
</body>
</html>"#,
mark = if state == "error" { "!" } else { "OK" }
);
cot::http::Response::builder()
.status(cot::http::StatusCode::OK)
.header(cot::http::header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(cot::http::header::CACHE_CONTROL, "no-store")
.body(cot::Body::fixed(html))
.expect("valid response")
} }
fn append_query_param(uri: &str, key: &str, value: &str) -> String { fn append_query_param(uri: &str, key: &str, value: &str) -> String {
@@ -999,6 +1187,21 @@ fn append_query_param(uri: &str, key: &str, value: &str) -> String {
out out
} }
fn html_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(ch),
}
}
out
}
fn extract_groups_from_jwt(token: &str) -> Vec<String> { fn extract_groups_from_jwt(token: &str) -> Vec<String> {
use base64::Engine; use base64::Engine;
@@ -1038,3 +1241,62 @@ fn urlencoded(s: &str) -> String {
} }
out out
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mobile_oidc_append_query_param_preserves_fragment() {
assert_eq!(
append_query_param("furumi://auth/callback#done", "code", "a b"),
"furumi://auth/callback?code=a%20b#done"
);
assert_eq!(
append_query_param("furumi://auth/callback?desktop=1", "error", "oidc_error"),
"furumi://auth/callback?desktop=1&error=oidc_error"
);
}
#[test]
fn mobile_oidc_html_escape_escapes_page_values() {
assert_eq!(
html_escape(r#"<tag attr="x&y">'text'</tag>"#),
"&lt;tag attr=&quot;x&amp;y&quot;&gt;&#39;text&#39;&lt;/tag&gt;"
);
}
#[test]
fn mobile_oidc_redirect_uri_allows_only_furumi_schemes() {
assert_eq!(
safe_mobile_redirect_uri(Some("furumi://auth/callback")).as_deref(),
Some("furumi://auth/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("furumusic://auth/callback")).as_deref(),
Some("furumusic://auth/callback")
);
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
}
#[test]
fn mobile_oidc_redirect_uri_allows_loopback_http() {
assert_eq!(
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
Some("http://127.0.0.1:8753/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
Some("http://localhost:1234/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
Some("http://[::1]:1234/callback")
);
// Non-loopback hosts, missing ports and https stay rejected.
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
}
}
+39
View File
@@ -325,6 +325,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,
+323 -48
View File
@@ -848,15 +848,25 @@ fn native_device_name_from_user_agent(user_agent: Option<&str>) -> Option<String
let Some((product, version)) = token.split_once('/') else { let Some((product, version)) = token.split_once('/') else {
continue; continue;
}; };
if !product.eq_ignore_ascii_case("FurumiAndroid") {
continue;
}
let version = sanitize_user_agent_version(version); let version = sanitize_user_agent_version(version);
return Some(match version.as_deref() { if product.eq_ignore_ascii_case("FurumiAndroid") {
Some(version) => format!("Furumi Android {version}"), return Some(match version.as_deref() {
None => "Furumi Android".to_string(), Some(v) => format!("Furumi Android {v}"),
}); None => "Furumi Android".to_string(),
});
}
if product.eq_ignore_ascii_case("FurumiMacOS") {
return Some(match version.as_deref() {
Some(v) => format!("Furumi MacOS {v}"),
None => "Furumi MacOS".to_string(),
});
}
if product.eq_ignore_ascii_case("FurumiTUI") || product.eq_ignore_ascii_case("furumi-tui") {
return Some(match version.as_deref() {
Some(v) => format!("Furumi TUI {v}"),
None => "Furumi TUI".to_string(),
});
}
} }
None None
} }
@@ -883,6 +893,12 @@ fn device_kind_from_user_agent(user_agent: Option<&str>) -> &'static str {
"phone" "phone"
}; };
} }
if ua.contains("furumimac") {
return "computer";
}
if ua.contains("furumitui/") || ua.contains("furumi-tui/") {
return "computer";
}
if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) { if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) {
"phone" "phone"
} else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") { } else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") {
@@ -907,6 +923,22 @@ mod device_tests {
assert_eq!(device_kind_from_user_agent(user_agent), "phone"); assert_eq!(device_kind_from_user_agent(user_agent), "phone");
} }
#[test]
fn detects_furumi_tui_native_client() {
let user_agent = Some("FurumiTUI/0.1.0 macos");
assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0");
assert_eq!(device_kind_from_user_agent(user_agent), "computer");
}
#[test]
fn detects_furumi_tui_http_user_agent_token() {
let user_agent = Some("furumi-tui/0.1.0 (macos)");
assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0");
assert_eq!(device_kind_from_user_agent(user_agent), "computer");
}
#[test] #[test]
fn keeps_browser_fallback_for_generic_android_user_agents() { fn keeps_browser_fallback_for_generic_android_user_agents() {
let user_agent = Some("Mozilla/5.0 Android Mobile"); let user_agent = Some("Mozilla/5.0 Android Mobile");
@@ -1016,6 +1048,192 @@ async fn me_handler(
.into_response() .into_response()
} }
// ---------------------------------------------------------------------------
// GET /api/player/offline/manifest
// ---------------------------------------------------------------------------
async fn offline_manifest_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
pool: &sqlx::PgPool,
) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let generated_at = now_iso_string();
let track_rows = sqlx::query_as::<_, OfflineTrackManifestRow>(
r#"SELECT t.id,
GREATEST(
t.updated_at::text,
r.updated_at::text,
mf.created_at::text,
COALESCE(cover_mf.created_at::text, ''),
COALESCE((
SELECT MAX(a.updated_at::text)
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = t.id
), ''),
COALESCE((
SELECT MAX(a.updated_at::text)
FROM furumusic__release_artist ra
JOIN furumusic__artist a ON a.id = ra.artist_id
WHERE ra.release_id = r.id
), '')
) AS updated_at,
mf.id AS audio_file_id,
mf.sha256_hash::text AS audio_hash,
mf.file_size_bytes AS audio_size_bytes,
mf.mime_type::text AS audio_mime_type,
mf.created_at::text AS audio_updated_at,
cover_mf.id AS cover_file_id,
cover_mf.sha256_hash::text AS cover_hash,
cover_mf.created_at::text AS cover_updated_at
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
LEFT JOIN furumusic__media_file cover_mf
ON cover_mf.id = COALESCE(t.cover_file_id, r.cover_file_id)
WHERE t.is_hidden = false AND r.is_hidden = false
ORDER BY t.id"#,
)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let liked_track_ids = sqlx::query_scalar::<_, i64>(
r#"SELECT ult.track_id
FROM furumusic__user_liked_track ult
JOIN furumusic__track t ON t.id = ult.track_id AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
WHERE ult.user_id = $1
ORDER BY ult.created_at DESC"#,
)
.bind(user.id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let likes_updated_at = sqlx::query_scalar::<_, Option<String>>(
"SELECT MAX(created_at::text) FROM furumusic__user_liked_track WHERE user_id = $1",
)
.bind(user.id)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
.unwrap_or_else(|| generated_at.clone());
let playlist_rows = sqlx::query_as::<_, OfflinePlaylistManifestRow>(
r#"SELECT p.id,
p.title::text AS title,
p.description,
p.updated_at::text AS updated_at,
(p.owner_id = $1) AS is_own,
COALESCE(NULLIF(u.display_name, ''), u.username)::text AS owner_name,
p.is_public,
EXISTS (
SELECT 1 FROM furumusic__saved_playlist sp
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
) AS is_saved,
COALESCE(
array_agg(pt.track_id ORDER BY pt.position)
FILTER (WHERE t.id IS NOT NULL AND r.id IS NOT NULL),
ARRAY[]::bigint[]
) AS track_ids
FROM furumusic__playlist p
JOIN furumusic__user u ON u.id = p.owner_id
LEFT JOIN furumusic__playlist_track pt ON pt.playlist_id = p.id
LEFT JOIN furumusic__track t ON t.id = pt.track_id AND t.is_hidden = false
LEFT JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
WHERE p.owner_id = $1
OR EXISTS (
SELECT 1 FROM furumusic__saved_playlist sp
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
)
OR p.is_public = true
GROUP BY p.id, u.display_name, u.username
ORDER BY
CASE WHEN p.owner_id = $1 THEN 0 WHEN p.is_public THEN 2 ELSE 1 END,
p.title"#,
)
.bind(user.id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let followed_artist_ids = sqlx::query_scalar::<_, i64>(
r#"SELECT ufa.artist_id
FROM furumusic__user_followed_artist ufa
JOIN furumusic__artist a ON a.id = ufa.artist_id AND a.is_hidden = false
WHERE ufa.user_id = $1
ORDER BY ufa.created_at DESC"#,
)
.bind(user.id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let tracks = track_rows
.into_iter()
.map(|row| OfflineTrackManifestItem {
id: row.id,
updated_at: row.updated_at,
stream_url: format!("/api/player/stream/{}", row.id),
audio_file_id: row.audio_file_id,
audio_hash: row.audio_hash,
audio_size_bytes: row.audio_size_bytes,
audio_mime_type: row.audio_mime_type,
audio_updated_at: row.audio_updated_at,
cover_file_id: row.cover_file_id,
cover_url: cover_variant_url(row.cover_file_id, "medium"),
cover_hash: row.cover_hash,
cover_updated_at: row.cover_updated_at,
})
.collect();
let mut playlists = vec![OfflinePlaylistManifestItem {
id: -1,
title: "Likes".to_string(),
description: None,
updated_at: likes_updated_at,
is_own: true,
owner_name: None,
is_public: false,
is_saved: false,
kind: "likes".to_string(),
track_ids: liked_track_ids.clone(),
}];
playlists.extend(
playlist_rows
.into_iter()
.map(|row| OfflinePlaylistManifestItem {
id: row.id,
title: row.title,
description: row.description,
updated_at: row.updated_at,
is_own: row.is_own,
owner_name: Some(row.owner_name),
is_public: row.is_public,
is_saved: row.is_saved,
kind: "user".to_string(),
track_ids: row.track_ids,
}),
);
Json(OfflineManifestResponse {
generated_at,
tracks,
playlists,
liked_track_ids,
followed_artist_ids,
})
.into_response()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Last.fm account + scrobbling // Last.fm account + scrobbling
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3194,40 +3412,44 @@ async fn artist_detail_handler(
.collect(); .collect();
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>( let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number, r#"SELECT * FROM (
t.duration_seconds, t.cover_file_id, SELECT DISTINCT ON (lower(t.title::text))
r.cover_file_id as release_cover_file_id, t.id, t.title::text as title, t.track_number, t.disc_number,
r.id as release_id, t.duration_seconds, t.cover_file_id,
r.title::text as release_title, r.cover_file_id as release_cover_file_id,
r.year as release_year, r.id as release_id,
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, r.title::text as release_title,
mf.audio_format, r.year as release_year,
mf.audio_bitrate, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
mf.audio_sample_rate, mf.audio_format,
mf.audio_bit_depth, mf.audio_bitrate,
mf.file_size_bytes, mf.audio_sample_rate,
t.lastfm_listeners, mf.audio_bit_depth,
t.lastfm_playcount, mf.file_size_bytes,
t.lastfm_rating, t.lastfm_listeners,
t.lastfm_updated_at t.lastfm_playcount,
FROM furumusic__track t t.lastfm_rating,
JOIN furumusic__release r ON r.id = t.release_id t.lastfm_updated_at
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id FROM furumusic__track t
WHERE t.is_hidden = false JOIN furumusic__release r ON r.id = t.release_id
AND r.is_hidden = false LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
AND EXISTS ( WHERE t.is_hidden = false
SELECT 1 AND r.is_hidden = false
FROM furumusic__track_artist ta AND EXISTS (
WHERE ta.track_id = t.id SELECT 1
AND ta.artist_id = $1 FROM furumusic__track_artist ta
AND ta.role <> 'featuring' WHERE ta.track_id = t.id
) AND ta.artist_id = $1
ORDER BY COALESCE(t.lastfm_rating, 0) DESC, AND ta.role <> 'featuring'
COALESCE(t.lastfm_playcount, 0) DESC, )
COALESCE(t.lastfm_listeners, 0) DESC, ORDER BY lower(t.title::text), COALESCE(t.lastfm_rating, 0) DESC
r.year DESC NULLS LAST, ) deduped
t.track_number NULLS LAST, ORDER BY COALESCE(lastfm_rating, 0) DESC,
t.id COALESCE(lastfm_playcount, 0) DESC,
COALESCE(lastfm_listeners, 0) DESC,
release_year DESC NULLS LAST,
track_number NULLS LAST,
id
LIMIT 50"#, LIMIT 50"#,
) )
.bind(artist_id) .bind(artist_id)
@@ -3819,7 +4041,11 @@ async fn stream_handler(
// Look up track → audio_file_id → MediaFile // Look up track → audio_file_id → MediaFile
let media = sqlx::query_as::<_, MediaFileRow>( let media = sqlx::query_as::<_, MediaFileRow>(
r#"SELECT mf.file_path, mf.mime_type::text as mime_type, mf.file_size_bytes r#"SELECT mf.file_path,
mf.mime_type::text as mime_type,
mf.file_size_bytes,
mf.sha256_hash::text AS sha256_hash,
mf.created_at::text AS created_at
FROM furumusic__track t FROM furumusic__track t
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.id = $1"#, WHERE t.id = $1"#,
@@ -3844,6 +4070,7 @@ async fn stream_handler(
} }
let file_size = media.file_size_bytes as u64; let file_size = media.file_size_bytes as u64;
let etag = media_etag("audio", track_id, &media.sha256_hash, None);
// Parse Range header // Parse Range header
let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok()); let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok());
@@ -3863,6 +4090,9 @@ async fn stream_handler(
.header(ACCEPT_RANGES, "bytes") .header(ACCEPT_RANGES, "bytes")
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}")) .header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}"))
.header(CONTENT_LENGTH, chunk_size.to_string()) .header(CONTENT_LENGTH, chunk_size.to_string())
.header("ETag", etag.as_str())
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
.body(Body::fixed(data)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -3881,6 +4111,9 @@ async fn stream_handler(
.header(CONTENT_TYPE, media.mime_type.as_str()) .header(CONTENT_TYPE, media.mime_type.as_str())
.header(ACCEPT_RANGES, "bytes") .header(ACCEPT_RANGES, "bytes")
.header(CONTENT_LENGTH, file_size.to_string()) .header(CONTENT_LENGTH, file_size.to_string())
.header("ETag", etag.as_str())
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
.body(Body::fixed(data)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -4131,7 +4364,12 @@ async fn cover_response(
}; };
let media = sqlx::query_as::<_, MediaFileRow>( let media = sqlx::query_as::<_, MediaFileRow>(
"SELECT file_path, mime_type::text as mime_type, file_size_bytes FROM furumusic__media_file WHERE id = $1", r#"SELECT file_path,
mime_type::text as mime_type,
file_size_bytes,
sha256_hash::text AS sha256_hash,
created_at::text AS created_at
FROM furumusic__media_file WHERE id = $1"#,
) )
.bind(media_file_id) .bind(media_file_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -4149,26 +4387,30 @@ async fn cover_response(
return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk")); return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk"));
} }
let (response_path, content_type) = variant_name let (response_path, content_type, etag_variant) = variant_name
.and_then(crate::agent::cover_variants::variant_by_name) .and_then(crate::agent::cover_variants::variant_by_name)
.map(|variant| { .map(|variant| {
let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant); let variant_path = crate::agent::cover_variants::variant_path(&full_path, variant);
if variant_path.exists() { if variant_path.exists() {
(variant_path, "image/jpeg") (variant_path, "image/jpeg", Some(variant.name))
} else { } else {
(full_path.clone(), media.mime_type.as_str()) (full_path.clone(), media.mime_type.as_str(), None)
} }
}) })
.unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str())); .unwrap_or_else(|| (full_path.clone(), media.mime_type.as_str(), None));
let data = tokio::fs::read(&response_path) let data = tokio::fs::read(&response_path)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|e| cot::Error::internal(e.to_string()))?;
let etag = media_etag("cover", media_file_id, &media.sha256_hash, etag_variant);
let response = cot::http::Response::builder() let response = cot::http::Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(CONTENT_TYPE, content_type) .header(CONTENT_TYPE, content_type)
.header(CONTENT_LENGTH, data.len().to_string()) .header(CONTENT_LENGTH, data.len().to_string())
.header("ETag", etag.as_str())
.header("X-Furumi-Content-Sha256", media.sha256_hash.as_str())
.header("X-Furumi-Content-Updated-At", media.created_at.as_str())
.header("Cache-Control", "public, max-age=86400") .header("Cache-Control", "public, max-age=86400")
.body(Body::fixed(data)) .body(Body::fixed(data))
.expect("valid response"); .expect("valid response");
@@ -4176,6 +4418,13 @@ async fn cover_response(
Ok(response) Ok(response)
} }
fn media_etag(kind: &str, id: i64, sha256_hash: &str, variant: Option<&str>) -> String {
match variant {
Some(variant) => format!("\"{kind}-{id}-{variant}-{sha256_hash}\""),
None => format!("\"{kind}-{id}-{sha256_hash}\""),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Player devices // Player devices
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -6235,6 +6484,32 @@ impl App for PlayerApp {
}, },
"player_me", "player_me",
), ),
Route::with_handler_and_name(
"/offline/manifest",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(
move |auth_ctx: auth::AuthContext, session: Session, db: Database| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
offline_manifest_handler(auth_ctx, session, db, pg_pool).await
}
},
)
},
"player_offline_manifest",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/lastfm/status", "/lastfm/status",
get({ get({
+29
View File
@@ -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)]
@@ -287,3 +289,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
View File
@@ -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
+460 -15
View File
@@ -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,73 @@ 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>
<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>
<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 +2561,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 +2596,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 +2775,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 +2872,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 +2899,16 @@ 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: ''
}, },
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 +3188,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 +3202,72 @@ 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)}…` : '-';
},
async loadSettingsProbe(showErrors = true) { async loadSettingsProbe(showErrors = true) {
this.settingsProbeLoading = true; this.settingsProbeLoading = true;
try { try {
@@ -3396,16 +3658,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 +3698,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 +3727,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 +3789,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 +3898,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 +4162,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 +4175,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 +4204,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() {
+54
View File
@@ -822,6 +822,7 @@ document.addEventListener('alpine:init', () => {
_playLocal(track, options = {}) { _playLocal(track, options = {}) {
this.currentTrack = track; this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this._localSourceTrackId = track.id; this._localSourceTrackId = track.id;
this._historyRecorded = false; this._historyRecorded = false;
this._resetPlaybackTracking(); this._resetPlaybackTracking();
@@ -1138,6 +1139,7 @@ document.addEventListener('alpine:init', () => {
_mirrorRemoteTrack(track, playing, positionSeconds = null) { _mirrorRemoteTrack(track, playing, positionSeconds = null) {
if (!track) return; if (!track) return;
this.currentTrack = track; this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this.isPlaying = !!playing; this.isPlaying = !!playing;
if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0)); if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0));
this.duration = Number(track.duration_seconds || this.duration || 0); this.duration = Number(track.duration_seconds || this.duration || 0);
@@ -1158,6 +1160,7 @@ document.addEventListener('alpine:init', () => {
const track = state.track || queue?.tracks?.[queue.currentIndex] || null; const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
if (track) { if (track) {
this.currentTrack = track; this.currentTrack = track;
queue?.syncCurrentIndexToTrack(track);
} }
this.shuffle = !!state.shuffle; this.shuffle = !!state.shuffle;
this.repeatMode = state.repeat_mode || 'off'; this.repeatMode = state.repeat_mode || 'off';
@@ -1359,6 +1362,7 @@ document.addEventListener('alpine:init', () => {
: tracks[idx]; : tracks[idx];
if (currentTrack) { if (currentTrack) {
this.currentTrack = currentTrack; this.currentTrack = currentTrack;
queue.syncCurrentIndexToTrack(currentTrack);
this._localSourceTrackId = currentTrack.id; this._localSourceTrackId = currentTrack.id;
this._historyRecorded = false; this._historyRecorded = false;
this._resetPlaybackTracking(); this._resetPlaybackTracking();
@@ -1976,6 +1980,56 @@ document.addEventListener('alpine:init', () => {
return this.tracks.slice(start, start + limit); return this.tracks.slice(start, start + limit);
}, },
effectiveCurrentIndex() {
const currentTrack = Alpine.store('player')?.currentTrack || null;
if (currentTrack?.id) {
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
}
if (!this.tracks.length) return -1;
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
},
queueItemState(index) {
const current = this.effectiveCurrentIndex();
if (current < 0) return 'upcoming';
if (index < current) return 'played';
if (index === current) return 'current';
return 'upcoming';
},
displayItems() {
const current = this.effectiveCurrentIndex();
const playerTrack = Alpine.store('player')?.currentTrack || null;
const items = this.tracks.map((track, index) => ({
track,
index,
key: `${index}-${track?.id || 'track'}`,
state: current >= 0
? (index < current ? 'played' : (index === current ? 'current' : 'upcoming'))
: 'upcoming',
synthetic: false,
}));
if (playerTrack?.id && current < 0) {
items.unshift({
track: playerTrack,
index: -1,
key: `current-${playerTrack.id}`,
state: 'current',
synthetic: true,
});
}
return items;
},
syncCurrentIndexToTrack(track) {
if (!track?.id || !this.tracks.length) return -1;
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
if (index >= 0) this.currentIndex = index;
return index;
},
addToEnd(tracks) { addToEnd(tracks) {
const items = this._tracksForQueueAdd(tracks); const items = this._tracksForQueueAdd(tracks);
if (!items.length) return; if (!items.length) return;
+111 -33
View File
@@ -620,6 +620,83 @@
</div> </div>
</div> </div>
</div> </div>
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
<div class="track-row artist-appearance-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<button class="artist-appearance-cover"
type="button"
@click.stop="$store.library.openRelease(track.release_id)"
:title="track.release_title"
aria-label="{{ t.player_release }}">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
</button>
<div class="artist-appearance-copy">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</section>
</template>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type"> <template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group"> <section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2> <h2 class="artist-release-group-title" x-text="group.label"></h2>
@@ -959,42 +1036,43 @@
</div> </div>
</div> </div>
<div class="queue-tracks"> <div class="queue-tracks">
<template x-if="$store.queue.tracks.length === 0"> <template x-if="$store.queue.displayItems().length === 0">
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<p>{{ t.player_queue_empty }}</p> <p>{{ t.player_queue_empty }}</p>
</div> </div>
</template> </template>
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id"> <template x-for="item in $store.queue.displayItems()" :key="item.key">
<div class="queue-track" <div class="queue-track"
:data-queue-index="idx" :data-queue-index="item.index"
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx, 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }" :class="{ active: item.state === 'current', current: item.state === 'current', played: item.state === 'played', dragging: $store.queue._dragIdx === item.index, synthetic: item.synthetic, 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''" :style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
@click="$store.queue.playFromIndex(idx)" @click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)"
draggable="true" :draggable="!item.synthetic"
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'" @dragstart="if (item.synthetic) { $event.preventDefault(); } else { $store.queue._dragIdx = item.index; $event.dataTransfer.effectAllowed = 'move'; }"
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))" @dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')" @dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
@dragleave="$event.currentTarget.classList.remove('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }"> @drop.prevent="$event.currentTarget.classList.remove('drag-over'); if (!item.synthetic && $store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, item.index); $store.queue._dragIdx = null; }">
<div class="queue-drag-handle" <div class="queue-drag-handle"
x-show="!item.synthetic"
@mousedown.stop @mousedown.stop
@click.stop @click.stop
@pointerdown.stop="$store.queue.startPointerReorder($event, idx)"> @pointerdown.stop="$store.queue.startPointerReorder($event, item.index)">
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</div> </div>
<div class="queue-track-cover"> <div class="queue-track-cover">
<template x-if="track.cover_url"> <template x-if="item.track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy"> <img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template> </template>
<template x-if="!track.cover_url"> <template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template> </template>
</div> </div>
<div class="queue-track-info"> <div class="queue-track-info">
<div class="queue-track-title" x-text="track.title"></div> <div class="queue-track-title" x-text="item.track.title"></div>
<div class="queue-track-artist"> <div class="queue-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx"> <template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span> <span>
<template x-if="artistIdx > 0"><span>, </span></template> <template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a> <a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
@@ -1004,15 +1082,15 @@
</div> </div>
<div class="queue-track-actions"> <div class="queue-track-actions">
<button class="queue-track-remove info-btn popularity-info-btn" <button class="queue-track-remove info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }" :class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
:style="$store.library.popularityStyle(track)" :style="$store.library.popularityStyle(item.track)"
@click.stop="$store.library.openTrackInfo(track)" @click.stop="$store.library.openTrackInfo(item.track)"
:title="$store.library.trackInfoTitle(track)" :title="$store.library.trackInfoTitle(item.track)"
aria-label="{{ t.player_track_info }}"> aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span> <span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span> <span x-show="!$store.library.hasPopularity(item.track)" class="info-letter">i</span>
</button> </button>
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}"> <button class="queue-track-remove" x-show="!item.synthetic" @click.stop="$store.queue.remove(item.index)" title="{{ t.player_remove }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
@@ -1292,27 +1370,27 @@
</div> </div>
<div class="mobile-expanded-queue"> <div class="mobile-expanded-queue">
<div class="mobile-expanded-queue-title">{{ t.player_queue }}</div> <div class="mobile-expanded-queue-title">{{ t.player_queue }}</div>
<template x-if="$store.queue.upcoming().length === 0"> <template x-if="$store.queue.displayItems().length === 0">
<div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div> <div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div>
</template> </template>
<template x-for="(track, idx) in $store.queue.upcoming()" :key="'mobile-expanded-queue-' + track.id + '-' + idx"> <template x-for="item in $store.queue.displayItems()" :key="'mobile-expanded-queue-' + item.key">
<button class="mobile-expanded-queue-row" <button class="mobile-expanded-queue-row"
:class="{ 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }" :class="{ current: item.state === 'current', played: item.state === 'played', 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''" :style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
type="button" type="button"
@click="$store.queue.playFromIndex($store.queue.currentIndex + idx + 1)"> @click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
<div class="mobile-expanded-queue-cover"> <div class="mobile-expanded-queue-cover">
<template x-if="track.cover_url"> <template x-if="item.track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy"> <img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template> </template>
<template x-if="!track.cover_url"> <template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template> </template>
</div> </div>
<div class="mobile-expanded-queue-info"> <div class="mobile-expanded-queue-info">
<div class="mobile-expanded-queue-name" x-text="track.title"></div> <div class="mobile-expanded-queue-name" x-text="item.track.title"></div>
<div class="mobile-expanded-queue-artist"> <div class="mobile-expanded-queue-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx"> <template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span> <span>
<template x-if="artistIdx > 0"><span>, </span></template> <template x-if="artistIdx > 0"><span>, </span></template>
<span x-text="artist.label"></span> <span x-text="artist.label"></span>
@@ -1320,7 +1398,7 @@
</template> </template>
</div> </div>
</div> </div>
<span class="mobile-expanded-queue-time" x-text="formatTime(track.duration_seconds)"></span> <span class="mobile-expanded-queue-time" x-text="formatTime(item.track.duration_seconds)"></span>
</button> </button>
</template> </template>
</div> </div>
+41 -2
View File
@@ -1224,7 +1224,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%);
} }
@@ -1259,7 +1274,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;
@@ -4925,6 +4942,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%);
} }