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:#}")
|
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(
|
pub async fn run(
|
||||||
mut terminal: DefaultTerminal,
|
mut terminal: DefaultTerminal,
|
||||||
mut keymap: Keymap,
|
mut keymap: Keymap,
|
||||||
@@ -128,6 +186,7 @@ pub async fn run(
|
|||||||
media_tx,
|
media_tx,
|
||||||
last_media_push: None,
|
last_media_push: None,
|
||||||
};
|
};
|
||||||
|
spawn_content_id_backfill(&runtime);
|
||||||
|
|
||||||
{
|
{
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
@@ -1242,6 +1301,17 @@ fn start_current_audio(
|
|||||||
spawn_fed_resolve(runtime, &track);
|
spawn_fed_resolve(runtime, &track);
|
||||||
return;
|
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 controller = runtime.player.clone();
|
||||||
let volume = player::amplitude(state.player.volume);
|
let volume = player::amplitude(state.player.volume);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
@@ -1271,7 +1341,7 @@ fn start_current_audio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn track_file_missing(track: &crate::library::models::TrackItem) -> bool {
|
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(
|
fn local_track_for_playback(
|
||||||
|
|||||||
+46
-3
@@ -106,6 +106,8 @@ pub struct PlaybackTrack {
|
|||||||
pub release_id: i64,
|
pub release_id: i64,
|
||||||
pub release_title: String,
|
pub release_title: String,
|
||||||
pub release_year: Option<i32>,
|
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)]
|
#[serde(default)]
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub content_id: Option<String>,
|
pub content_id: Option<String>,
|
||||||
@@ -141,8 +143,11 @@ impl PlaybackTrack {
|
|||||||
release_id: track.release_id,
|
release_id: track.release_id,
|
||||||
release_title: track.release_title.clone(),
|
release_title: track.release_title.clone(),
|
||||||
release_year: track.release_year,
|
release_year: track.release_year,
|
||||||
file_path: track.file_path.clone(),
|
file_path: String::new(),
|
||||||
content_id: track.content_id.clone(),
|
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_format: track.audio_format.clone(),
|
||||||
audio_bitrate: track.audio_bitrate,
|
audio_bitrate: track.audio_bitrate,
|
||||||
audio_sample_rate: track.audio_sample_rate,
|
audio_sample_rate: track.audio_sample_rate,
|
||||||
@@ -174,7 +179,7 @@ impl PlaybackTrack {
|
|||||||
release_id: self.release_id,
|
release_id: self.release_id,
|
||||||
release_title: self.release_title.clone(),
|
release_title: self.release_title.clone(),
|
||||||
release_year: self.release_year,
|
release_year: self.release_year,
|
||||||
file_path: self.file_path.clone(),
|
file_path: String::new(),
|
||||||
content_id: self.content_id.clone(),
|
content_id: self.content_id.clone(),
|
||||||
cover_path: None,
|
cover_path: None,
|
||||||
audio_format: self.audio_format.clone(),
|
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]
|
#[test]
|
||||||
fn compacted_device_revoke_removes_device_row() {
|
fn compacted_device_revoke_removes_device_row() {
|
||||||
let sync = test_sync();
|
let sync = test_sync();
|
||||||
|
|||||||
@@ -154,6 +154,20 @@ pub struct FederationExport {
|
|||||||
pub tracks: Vec<ExportTrack>,
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct ExportRelease {
|
pub struct ExportRelease {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -217,6 +231,78 @@ impl Library {
|
|||||||
&self.covers_dir
|
&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> {
|
fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
|
||||||
self.conn.lock().unwrap_or_else(|poisoned| {
|
self.conn.lock().unwrap_or_else(|poisoned| {
|
||||||
// A panic mid-query leaves the connection usable; keep going.
|
// A panic mid-query leaves the connection usable; keep going.
|
||||||
@@ -2230,6 +2316,60 @@ mod tests {
|
|||||||
assert_eq!(page.items[0].track_count, 1);
|
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]
|
#[test]
|
||||||
fn search_finds_all_kinds() {
|
fn search_finds_all_kinds() {
|
||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
|
|||||||
Reference in New Issue
Block a user