Connected Devices: added remote control

This commit is contained in:
Ultradesu
2026-07-24 05:09:33 +03:00
parent 8605c72f10
commit d62c79b50c
3 changed files with 257 additions and 4 deletions
+71 -1
View File
@@ -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(
+46 -3
View File
@@ -106,6 +106,8 @@ pub struct PlaybackTrack {
pub release_id: i64,
pub release_title: String,
pub release_year: Option<i32>,
/// 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<String>,
@@ -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();
+140
View File
@@ -154,6 +154,20 @@ pub struct FederationExport {
pub tracks: Vec<ExportTrack>,
}
#[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<ContentIdBackfillStats> {
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<String>>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
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();