From d62c79b50c466bcf1321ec3d5b619df36abf485a Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 24 Jul 2026 05:09:33 +0300 Subject: [PATCH] Connected Devices: added remote control --- src/app/mod.rs | 72 ++++++++++++++++++++++- src/devices.rs | 49 +++++++++++++++- src/library/mod.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 4 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index c552cf1..0bfe0fa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -69,6 +69,64 @@ fn err_string(err: anyhow::Error) -> String { format!("{err:#}") } +fn spawn_content_id_backfill(runtime: &Runtime) { + let library = Arc::clone(&runtime.library); + let federation = Arc::clone(&runtime.federation); + let devices = Arc::clone(&runtime.devices); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let stats = + match tokio::task::spawn_blocking(move || library.backfill_missing_content_ids()).await + { + Ok(Ok(stats)) => stats, + Ok(Err(err)) => { + tracing::warn!("content id backfill failed: {err:#}"); + let _ = tx.send(AppEvent::StatusMessage(format!( + "content id backfill failed: {err:#}" + ))); + return; + } + Err(err) => { + tracing::warn!("content id backfill task failed: {err}"); + let _ = tx.send(AppEvent::StatusMessage(format!( + "content id backfill task failed: {err}" + ))); + return; + } + }; + + if stats.updated() == 0 { + if stats.failed > 0 { + tracing::warn!( + checked = stats.checked, + failed = stats.failed, + "content id backfill finished with unreadable tracks" + ); + } + return; + } + + tracing::info!( + checked = stats.checked, + normalized = stats.normalized, + hashed = stats.hashed, + failed = stats.failed, + "content id backfill completed" + ); + let _ = tx.send(AppEvent::LibraryChanged { + message: Some(format!("content ids: indexed {} track(s)", stats.updated())), + }); + + if federation.status().await.running { + if let Err(err) = federation.sync_now().await { + tracing::warn!("federation sync after content id backfill failed: {err:#}"); + } + let _ = tx.send(AppEvent::FederationStatus(federation.status().await)); + let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status())); + } + }); +} + pub async fn run( mut terminal: DefaultTerminal, mut keymap: Keymap, @@ -128,6 +186,7 @@ pub async fn run( media_tx, last_media_push: None, }; + spawn_content_id_backfill(&runtime); { let fed = Arc::clone(&runtime.federation); @@ -1242,6 +1301,17 @@ fn start_current_audio( spawn_fed_resolve(runtime, &track); return; } + if track_file_missing(&track) { + runtime.player_start_pending = false; + runtime.player.stop(); + state.player.playing = false; + state.player.paused = false; + state.status_message = Some(format!( + "playback failed: \"{}\" is not available on this device", + track.title + )); + return; + } let controller = runtime.player.clone(); let volume = player::amplitude(state.player.volume); let tx = runtime.event_tx.clone(); @@ -1271,7 +1341,7 @@ fn start_current_audio( } fn track_file_missing(track: &crate::library::models::TrackItem) -> bool { - !track.file_path.is_empty() && !Path::new(&track.file_path).is_file() + track.file_path.is_empty() || !Path::new(&track.file_path).is_file() } fn local_track_for_playback( diff --git a/src/devices.rs b/src/devices.rs index 79e1a32..8ef8136 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -106,6 +106,8 @@ pub struct PlaybackTrack { pub release_id: i64, pub release_title: String, pub release_year: Option, + /// Legacy compatibility only. Paths are device-local, so playback sync + /// must resolve tracks from content/federation identifiers instead. #[serde(default)] pub file_path: String, pub content_id: Option, @@ -141,8 +143,11 @@ impl PlaybackTrack { release_id: track.release_id, release_title: track.release_title.clone(), release_year: track.release_year, - file_path: track.file_path.clone(), - content_id: track.content_id.clone(), + file_path: String::new(), + content_id: track + .content_id + .clone() + .or_else(|| track.fed.as_ref().and_then(|fed| fed.content_id.clone())), audio_format: track.audio_format.clone(), audio_bitrate: track.audio_bitrate, audio_sample_rate: track.audio_sample_rate, @@ -174,7 +179,7 @@ impl PlaybackTrack { release_id: self.release_id, release_title: self.release_title.clone(), release_year: self.release_year, - file_path: self.file_path.clone(), + file_path: String::new(), content_id: self.content_id.clone(), cover_path: None, audio_format: self.audio_format.clone(), @@ -3414,6 +3419,44 @@ mod tests { ); } + #[test] + fn playback_tracks_do_not_sync_device_local_paths() { + let source = TrackItem { + id: 7, + title: "Local Song".to_string(), + track_number: Some(1), + disc_number: Some(1), + duration_seconds: 180.0, + artists: vec![ArtistRef { + id: 1, + name: "Local Artist".to_string(), + }], + featured_artists: Vec::new(), + release_id: 2, + release_title: "Local Release".to_string(), + release_year: Some(2026), + file_path: r"C:\Users\me\Music\song.mp3".to_string(), + content_id: Some(format!("b3:{}", "a".repeat(64))), + cover_path: None, + audio_format: Some("mp3".to_string()), + audio_bitrate: Some(320), + audio_sample_rate: Some(44_100), + audio_bit_depth: None, + file_size_bytes: Some(123_456), + play_count: 3, + fed: None, + }; + + let wire = PlaybackTrack::from_track(&source); + assert!(wire.file_path.is_empty()); + + let mut legacy_wire = wire.clone(); + legacy_wire.file_path = "/Users/me/Music/song.mp3".to_string(); + let restored = legacy_wire.to_track_item(); + assert!(restored.file_path.is_empty()); + assert_eq!(restored.content_id, source.content_id); + } + #[test] fn compacted_device_revoke_removes_device_row() { let sync = test_sync(); diff --git a/src/library/mod.rs b/src/library/mod.rs index f0eca91..ee3e6fb 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -154,6 +154,20 @@ pub struct FederationExport { pub tracks: Vec, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ContentIdBackfillStats { + pub checked: usize, + pub normalized: usize, + pub hashed: usize, + pub failed: usize, +} + +impl ContentIdBackfillStats { + pub fn updated(&self) -> usize { + self.normalized + self.hashed + } +} + #[derive(Debug)] pub struct ExportRelease { pub id: i64, @@ -217,6 +231,78 @@ impl Library { &self.covers_dir } + /// Make `content_id` a local-library invariant. + /// + /// Old databases can have NULL/invalid ids because the column was added + /// after import already existed. This scans rows cheaply, hashes only the + /// tracks that actually need an id, and never holds the SQLite lock while + /// reading audio files from disk. + pub fn backfill_missing_content_ids(&self) -> Result { + let rows = { + let conn = self.lock(); + let mut statement = + conn.prepare("SELECT id, content_id, file_path FROM tracks ORDER BY id")?; + statement + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()? + }; + + let mut stats = ContentIdBackfillStats { + checked: rows.len(), + ..ContentIdBackfillStats::default() + }; + let mut updates = Vec::new(); + for (track_id, raw_content_id, file_path) in rows { + if let Some(normalized) = raw_content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + { + if raw_content_id.as_deref() != Some(normalized.as_str()) { + updates.push((track_id, normalized)); + stats.normalized += 1; + } + } else if let Some(content_id) = audio_content_id(&file_path) { + updates.push((track_id, content_id)); + stats.hashed += 1; + } else { + stats.failed += 1; + tracing::warn!( + track_id, + path = %file_path, + "content id backfill skipped an unreadable track" + ); + } + + if updates.len() >= 64 { + self.write_content_id_updates(&mut updates)?; + } + } + self.write_content_id_updates(&mut updates)?; + Ok(stats) + } + + fn write_content_id_updates(&self, updates: &mut Vec<(i64, String)>) -> Result<()> { + if updates.is_empty() { + return Ok(()); + } + let mut conn = self.lock(); + let tx = conn.transaction()?; + for (track_id, content_id) in updates.drain(..) { + tx.execute( + "UPDATE tracks SET content_id = ?2 WHERE id = ?1", + params![track_id, content_id], + )?; + } + tx.commit()?; + Ok(()) + } + fn lock(&self) -> std::sync::MutexGuard<'_, Connection> { self.conn.lock().unwrap_or_else(|poisoned| { // A panic mid-query leaves the connection usable; keep going. @@ -2230,6 +2316,60 @@ mod tests { assert_eq!(page.items[0].track_count, 1); } + #[test] + fn content_id_backfill_hashes_missing_track_ids() { + let lib = test_library(); + let path = std::env::temp_dir().join(format!( + "furumi-content-id-test-{}-{}.bin", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"portable content id").unwrap(); + let file_path = path.to_string_lossy().into_owned(); + let import = import::TrackImport { + release_type: None, + file_path: file_path.clone(), + title: "Portable".to_string(), + artists: vec!["Artist".to_string()], + featured_artists: Vec::new(), + album_artists: vec!["Artist".to_string()], + release_title: "Album".to_string(), + year: Some(2026), + track_number: None, + disc_number: None, + duration_seconds: 60.0, + audio_format: Some("bin".into()), + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: Some(19), + cover: None, + }; + let track_id = import::upsert_track(&lib, &import).unwrap().0; + let expected = audio_content_id(&file_path).unwrap(); + { + let conn = lib.lock(); + conn.execute( + "UPDATE tracks SET content_id = NULL WHERE id = ?1", + [track_id], + ) + .unwrap(); + } + + let stats = lib.backfill_missing_content_ids().unwrap(); + assert_eq!(stats.hashed, 1); + assert_eq!(stats.updated(), 1); + assert_eq!( + lib.track_content_id_by_id(track_id).unwrap().as_deref(), + Some(expected.as_str()) + ); + + let _ = std::fs::remove_file(path); + } + #[test] fn search_finds_all_kinds() { let lib = test_library();