diff --git a/Cargo.lock b/Cargo.lock index 23474cc..ee8bc1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.9.1" +version = "0.9.3" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index f2f74f6..afc0a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.9.2" +version = "0.9.3" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/federation/receive.rs b/src/federation/receive.rs index abcfd58..a72444a 100644 --- a/src/federation/receive.rs +++ b/src/federation/receive.rs @@ -295,7 +295,60 @@ impl Federation { let dir = storage_root.join("federation"); tokio::fs::create_dir_all(&dir).await?; let downloaded = - download(&service, owner, item_id, &content_id, &dir, &mut progress).await?; + match download(&service, owner, item_id, &content_id, &dir, &mut progress).await { + Ok(downloaded) => downloaded, + Err(primary_error) => { + progress(DownloadProgress { + phase: "discovering", + received: 0, + total: 0, + }); + let outcome = service + .search_content_id(&content_id) + .await + .map_err(|err| { + anyhow::anyhow!("{primary_error:#}; content lookup also failed: {err}") + })?; + let mut last_error = primary_error; + let mut downloaded = None; + for item in outcome + .local_results + .into_iter() + .chain(outcome.network_results) + { + if item.kind != music_dht::ItemKind::Track + || item.content_id.as_deref() != Some(content_id.as_str()) + { + continue; + } + let candidate_item_id = hex_encode(item.id.as_bytes()); + if item.owner == owner && candidate_item_id == item_id { + continue; + } + match download( + &service, + item.owner, + &candidate_item_id, + &content_id, + &dir, + &mut progress, + ) + .await + { + Ok(candidate) => { + downloaded = Some(candidate); + break; + } + Err(err) => last_error = err, + } + } + downloaded.ok_or_else(|| { + anyhow::anyhow!( + "no reachable peer currently provides this track: {last_error:#}" + ) + })? + } + }; if !save { super::lock(&self.prepared_cache) .insert(token.clone(), (downloaded.path, downloaded.mime)); @@ -306,15 +359,20 @@ impl Federation { } progress(DownloadProgress { phase: "saving", - received: 1, - total: 1, + received: 0, + total: 0, }); let track_id = materialize(&pool, &storage_root, &content_id, downloaded).await?; - // The normal periodic sync will publish it; this immediate sync keeps - // save-on-listen useful to the federation without waiting a minute. - if let Err(err) = self.sync_now().await { - tracing::warn!(track_id, "post-import federation publish failed: {err:#}"); - } + // Materialization is the playback boundary: return the local track + // immediately so the browser can replace the pending queue entry and + // start it. Publishing must not keep the prepare stream stuck at 100% + // when a federation peer is slow or offline. + let federation = std::sync::Arc::clone(self); + tokio::spawn(async move { + if let Err(err) = federation.sync_now().await { + tracing::warn!(track_id, "post-import federation publish failed: {err:#}"); + } + }); Ok(PreparedTrack { local_track_id: Some(track_id), stream_url: format!("/api/player/stream/{track_id}"), @@ -362,6 +420,10 @@ fn cache_artwork(federation: &Federation, key: String, artwork: &(Vec, Strin ); } +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + async fn download( service: &music_dht::MusicDhtService, owner: EndpointId, diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 15b6abe..80a3db3 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -2495,6 +2495,7 @@ document.addEventListener('alpine:init', () => { federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] }, artistFederation: { loading: false, error: '', releases: [], tracks: [] }, federationPreparing: {}, + federationFailures: {}, trackAvailability: {}, _federatedReleaseCache: {}, federationLiked: [], @@ -3863,6 +3864,11 @@ document.addEventListener('alpine:init', () => { return; } if (!contentId || sources.length === 0) return; + if (this.federationFailures[contentId]) { + const failures = { ...this.federationFailures }; + delete failures[contentId]; + this.federationFailures = failures; + } if (this.federationPreparing[contentId]) { if (options.playAfterPrepare !== false) { this.federationPreparing = { @@ -4023,7 +4029,13 @@ document.addEventListener('alpine:init', () => { Alpine.store('queue').playRelease([playable], 0); } } catch (error) { - window.alert(error?.message || 'Federated track download failed'); + this.federationFailures = { + ...this.federationFailures, + [contentId]: { + message: error?.message || 'Federated track is temporarily unavailable', + failedAt: Date.now(), + }, + }; } finally { const next = { ...this.federationPreparing }; delete next[contentId]; @@ -4035,12 +4047,17 @@ document.addEventListener('alpine:init', () => { return this.federationPreparing[contentId] || null; }, + federationFailure(contentId) { + return this.federationFailures[contentId] || null; + }, + federationDownloadTooltip(contentId) { const progress = this.federationDownload(contentId); if (!progress) return ''; const phases = { checking: 'Checking local library', connecting: 'Connecting to peer', + discovering: 'Looking for another peer', downloading: 'Downloading', verifying: 'Verifying content ID', saving: 'Adding to library', diff --git a/templates/player/shell.html b/templates/player/shell.html index de2a512..869e8c4 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -562,13 +562,17 @@
-