Connected Devices: added remote control
This commit is contained in:
+71
-1
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user