Added streaming. Improved UI
This commit is contained in:
Generated
+11
-10
@@ -524,9 +524,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -862,9 +862,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crokey"
|
name = "crokey"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
|
checksum = "074bc31bff1084f74e5a145ad5f6ac74469fa312159faa5144f0b1c4506b8d80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crokey-proc_macros",
|
"crokey-proc_macros",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -875,9 +875,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crokey-proc_macros"
|
name = "crokey-proc_macros"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
|
checksum = "b88c4ff2d5717616c3bb4a31d06b0b241ed811c7c0c41d077d77631bee7caad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -1275,9 +1275,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embedded-io"
|
name = "embedded-io"
|
||||||
@@ -1456,7 +1456,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -2990,7 +2990,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -3001,6 +3001,7 @@ dependencies = [
|
|||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
+4
-3
@@ -50,10 +50,10 @@ pub enum AppEvent {
|
|||||||
id: i64,
|
id: i64,
|
||||||
result: Result<PlaylistDetail, String>,
|
result: Result<PlaylistDetail, String>,
|
||||||
},
|
},
|
||||||
/// Liked track ids for the ♥ markers.
|
/// Liked local content ids for the ♥ markers.
|
||||||
LikesLoaded(Result<Vec<i64>, String>),
|
LikesLoaded(Result<Vec<String>, String>),
|
||||||
LikeToggled {
|
LikeToggled {
|
||||||
track_id: i64,
|
content_id: String,
|
||||||
liked: bool,
|
liked: bool,
|
||||||
},
|
},
|
||||||
/// Liked federated item ids and content ids for the ♥ markers.
|
/// Liked federated item ids and content ids for the ♥ markers.
|
||||||
@@ -118,6 +118,7 @@ pub enum AppEvent {
|
|||||||
/// queue swaps the placeholder for the resolved track.
|
/// queue swaps the placeholder for the resolved track.
|
||||||
FedTrackResolved {
|
FedTrackResolved {
|
||||||
placeholder_id: i64,
|
placeholder_id: i64,
|
||||||
|
resolve_key: String,
|
||||||
result: Result<Box<crate::federation::FedPlayable>, String>,
|
result: Result<Box<crate::federation::FedPlayable>, String>,
|
||||||
},
|
},
|
||||||
/// Rich metadata for a federated track-info preview arrived without
|
/// Rich metadata for a federated track-info preview arrived without
|
||||||
|
|||||||
+400
-67
@@ -44,8 +44,10 @@ pub struct Runtime {
|
|||||||
/// Coalesces urgent personal-device syncs after remote playback commands.
|
/// Coalesces urgent personal-device syncs after remote playback commands.
|
||||||
pub device_sync_running: Arc<std::sync::atomic::AtomicBool>,
|
pub device_sync_running: Arc<std::sync::atomic::AtomicBool>,
|
||||||
pub device_sync_requested: Arc<std::sync::atomic::AtomicBool>,
|
pub device_sync_requested: Arc<std::sync::atomic::AtomicBool>,
|
||||||
/// Placeholder ids of federated tracks being downloaded right now.
|
/// Stable playback keys of federated tracks being resolved right now.
|
||||||
pub fed_resolving: std::sync::Mutex<std::collections::HashSet<i64>>,
|
pub fed_resolving: std::sync::Mutex<std::collections::HashSet<String>>,
|
||||||
|
/// Stable playback keys that already started through a streaming reader.
|
||||||
|
pub fed_streaming: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
|
||||||
/// Caps concurrent artwork loads so they never starve the disk.
|
/// Caps concurrent artwork loads so they never starve the disk.
|
||||||
pub art_semaphore: Arc<tokio::sync::Semaphore>,
|
pub art_semaphore: Arc<tokio::sync::Semaphore>,
|
||||||
/// The terminal screen was externally disturbed and needs a full repaint.
|
/// The terminal screen was externally disturbed and needs a full repaint.
|
||||||
@@ -58,6 +60,13 @@ pub struct Runtime {
|
|||||||
pub last_media_push: Option<std::time::Instant>,
|
pub last_media_push: Option<std::time::Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct StreamingPlaybackRequest {
|
||||||
|
volume: u8,
|
||||||
|
paused: bool,
|
||||||
|
position_secs: f64,
|
||||||
|
}
|
||||||
|
|
||||||
fn now_epoch_seconds() -> i64 {
|
fn now_epoch_seconds() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -176,6 +185,7 @@ pub async fn run(
|
|||||||
device_sync_running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
device_sync_running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
device_sync_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
device_sync_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
|
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||||
|
fed_streaming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||||
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
|
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
|
||||||
force_redraw: false,
|
force_redraw: false,
|
||||||
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||||
@@ -221,6 +231,7 @@ pub async fn run(
|
|||||||
},
|
},
|
||||||
Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event),
|
Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event),
|
||||||
_ = tick.tick() => {
|
_ = tick.tick() => {
|
||||||
|
state.advance_spinner();
|
||||||
expire_quit_confirmation(&mut state);
|
expire_quit_confirmation(&mut state);
|
||||||
sync_player_shared(&mut state, &runtime);
|
sync_player_shared(&mut state, &runtime);
|
||||||
maybe_prefetch_next(&mut state, &runtime);
|
maybe_prefetch_next(&mut state, &runtime);
|
||||||
@@ -313,24 +324,7 @@ fn playback_track_to_ui(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn track_playback_key(track: &crate::library::models::TrackItem) -> String {
|
fn track_playback_key(track: &crate::library::models::TrackItem) -> String {
|
||||||
if let Some(content_id) = track
|
state::track_key(track)
|
||||||
.content_id
|
|
||||||
.as_deref()
|
|
||||||
.and_then(music_dht::normalize_content_id)
|
|
||||||
.or_else(|| {
|
|
||||||
track
|
|
||||||
.fed
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|fed| fed.content_id.as_deref())
|
|
||||||
.and_then(music_dht::normalize_content_id)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return format!("content:{content_id}");
|
|
||||||
}
|
|
||||||
if let Some(fed) = &track.fed {
|
|
||||||
return format!("fed:{}:{}", fed.owner, fed.item_id);
|
|
||||||
}
|
|
||||||
format!("local:{}", track.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_playback_state_to_ui(
|
fn apply_playback_state_to_ui(
|
||||||
@@ -545,6 +539,53 @@ pub(crate) fn transfer_active_to_this_device(state: &mut AppState, runtime: &mut
|
|||||||
request_urgent_device_sync(runtime);
|
request_urgent_device_sync(runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn transfer_active_to_remote_device(
|
||||||
|
state: &mut AppState,
|
||||||
|
runtime: &mut Runtime,
|
||||||
|
target_device_id: String,
|
||||||
|
target_device_name: String,
|
||||||
|
) {
|
||||||
|
if target_device_id.trim().is_empty()
|
||||||
|
|| target_device_id == state.device_playback.self_device_id
|
||||||
|
{
|
||||||
|
transfer_active_to_this_device(state, runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extrapolate_control_position(state);
|
||||||
|
let previous_active_id = state.device_playback.active_device_id.clone();
|
||||||
|
if state.player.current.is_none() && !state.player.queue.is_empty() {
|
||||||
|
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
|
||||||
|
}
|
||||||
|
let wire = playback_state_from_ui(state);
|
||||||
|
let command = crate::devices::PlaybackCommand::ActiveChanged {
|
||||||
|
active_device_id: target_device_id.clone(),
|
||||||
|
active_device_name: target_device_name.clone(),
|
||||||
|
state: wire.clone(),
|
||||||
|
};
|
||||||
|
record_playback_command_async(
|
||||||
|
runtime,
|
||||||
|
target_device_id.clone(),
|
||||||
|
command.clone(),
|
||||||
|
"device handoff",
|
||||||
|
);
|
||||||
|
if let Some(previous) = previous_active_id
|
||||||
|
&& previous != target_device_id
|
||||||
|
&& previous != state.device_playback.self_device_id
|
||||||
|
{
|
||||||
|
record_playback_command_async(runtime, previous, command.clone(), "device handoff");
|
||||||
|
}
|
||||||
|
let snapshot = crate::devices::PlaybackSnapshot {
|
||||||
|
device_id: target_device_id.clone(),
|
||||||
|
device_name: target_device_name.clone(),
|
||||||
|
active: true,
|
||||||
|
updated_at_ms: unix_time_ms(),
|
||||||
|
state: wire,
|
||||||
|
};
|
||||||
|
become_control_device(state, runtime, snapshot);
|
||||||
|
request_urgent_device_sync(runtime);
|
||||||
|
state.status_message = Some(format!("active playback moved to {target_device_name}"));
|
||||||
|
}
|
||||||
|
|
||||||
fn record_active_handoff(
|
fn record_active_handoff(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
@@ -733,7 +774,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = library.likes().map_err(err_string);
|
let result = library.liked_content_ids().map_err(err_string);
|
||||||
let _ = tx.send(AppEvent::LikesLoaded(result));
|
let _ = tx.send(AppEvent::LikesLoaded(result));
|
||||||
let result = library.fed_like_ids().map_err(err_string);
|
let result = library.fed_like_ids().map_err(err_string);
|
||||||
let _ = tx.send(AppEvent::FedLikesLoaded(result));
|
let _ = tx.send(AppEvent::FedLikesLoaded(result));
|
||||||
@@ -952,12 +993,28 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
for track_id in track_ids {
|
for track_id in track_ids {
|
||||||
match library.toggle_like(track_id) {
|
let content_id = match library.track_content_id_by_id(track_id) {
|
||||||
|
Ok(Some(content_id)) => content_id,
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::warn!(track_id, "cannot toggle like without content id");
|
||||||
|
let _ = tx.send(AppEvent::StatusMessage(
|
||||||
|
"like failed: track has no content id".to_string(),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, track_id, "loading track content id failed");
|
||||||
|
let _ =
|
||||||
|
tx.send(AppEvent::StatusMessage(format!("like failed: {err:#}")));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match library.toggle_like_by_content_id(&content_id) {
|
||||||
Ok(liked) => {
|
Ok(liked) => {
|
||||||
if let Err(err) = devices.record_track_like(track_id, liked) {
|
if let Err(err) = devices.record_content_like(&content_id, liked) {
|
||||||
tracing::warn!(%err, track_id, "recording synced like failed");
|
tracing::warn!(%err, track_id, "recording synced like failed");
|
||||||
}
|
}
|
||||||
let _ = tx.send(AppEvent::LikeToggled { track_id, liked });
|
let _ = tx.send(AppEvent::LikeToggled { content_id, liked });
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(%err, track_id, "like toggle failed");
|
tracing::warn!(%err, track_id, "like toggle failed");
|
||||||
@@ -987,6 +1044,12 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let _ = tx.send(AppEvent::LikesLoaded(
|
||||||
|
library.liked_content_ids().map_err(err_string),
|
||||||
|
));
|
||||||
|
let _ = tx.send(AppEvent::FedLikesLoaded(
|
||||||
|
library.fed_like_ids().map_err(err_string),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Effect::RemoveFromPlaylist {
|
Effect::RemoveFromPlaylist {
|
||||||
@@ -1348,13 +1411,13 @@ fn start_current_audio(
|
|||||||
if let Some(local) = local_track_for_playback(runtime, &track) {
|
if let Some(local) = local_track_for_playback(runtime, &track) {
|
||||||
state.player.queue[state.player.queue_pos] = local.clone();
|
state.player.queue[state.player.queue_pos] = local.clone();
|
||||||
track = local;
|
track = local;
|
||||||
} else if let Some(fed) = track.fed.clone() {
|
} else if let Some(fed) = direct_fed_source(&track) {
|
||||||
let mut pending = crate::federation::pending_track(&fed);
|
let mut pending = crate::federation::pending_track(&fed);
|
||||||
pending.id = track.id;
|
pending.id = track.id;
|
||||||
pending.play_count = track.play_count;
|
pending.play_count = track.play_count;
|
||||||
state.player.queue[state.player.queue_pos] = pending.clone();
|
state.player.queue[state.player.queue_pos] = pending.clone();
|
||||||
track = pending;
|
track = pending;
|
||||||
} else if track.content_id.is_some() {
|
} else if track_content_id(&track).is_some() {
|
||||||
state.player.current = Some(track.clone());
|
state.player.current = Some(track.clone());
|
||||||
state.player.playing = true;
|
state.player.playing = true;
|
||||||
state.player.paused = paused;
|
state.player.paused = paused;
|
||||||
@@ -1368,15 +1431,30 @@ fn start_current_audio(
|
|||||||
"federation: locating \"{}\" for this device…",
|
"federation: locating \"{}\" for this device…",
|
||||||
track.title
|
track.title
|
||||||
));
|
));
|
||||||
spawn_content_id_resolve(runtime, &track);
|
spawn_content_id_resolve(
|
||||||
|
runtime,
|
||||||
|
&track,
|
||||||
|
Some(StreamingPlaybackRequest {
|
||||||
|
volume: state.player.volume,
|
||||||
|
paused,
|
||||||
|
position_secs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
|
} else if let Some(fed) = track.fed.clone() {
|
||||||
|
let mut pending = crate::federation::pending_track(&fed);
|
||||||
|
pending.id = track.id;
|
||||||
|
pending.play_count = track.play_count;
|
||||||
|
state.player.queue[state.player.queue_pos] = pending.clone();
|
||||||
|
track = pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The track that was playing until now was cut short by this switch.
|
// The track that was playing until now was cut short by this switch.
|
||||||
let previous_started_at = state.player.track_started_at;
|
let previous_started_at = state.player.track_started_at;
|
||||||
|
let next_key = track_playback_key(&track);
|
||||||
let same_track_started_at = if let Some(previous) = state.player.current.take() {
|
let same_track_started_at = if let Some(previous) = state.player.current.take() {
|
||||||
let same_track = previous.id == track.id;
|
let same_track = track_playback_key(&previous) == next_key;
|
||||||
if state.player.playing && previous.id != track.id {
|
if state.player.playing && !same_track {
|
||||||
report_history(
|
report_history(
|
||||||
runtime,
|
runtime,
|
||||||
previous.id,
|
previous.id,
|
||||||
@@ -1404,7 +1482,15 @@ fn start_current_audio(
|
|||||||
// audio, download it and resume through FedTrackResolved.
|
// audio, download it and resume through FedTrackResolved.
|
||||||
runtime.player.stop();
|
runtime.player.stop();
|
||||||
state.status_message = Some(format!("federation: fetching \"{}\"…", track.title));
|
state.status_message = Some(format!("federation: fetching \"{}\"…", track.title));
|
||||||
spawn_fed_resolve(runtime, &track);
|
spawn_fed_resolve(
|
||||||
|
runtime,
|
||||||
|
&track,
|
||||||
|
Some(StreamingPlaybackRequest {
|
||||||
|
volume: state.player.volume,
|
||||||
|
paused,
|
||||||
|
position_secs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if track_file_missing(&track) {
|
if track_file_missing(&track) {
|
||||||
@@ -1454,19 +1540,32 @@ fn local_track_for_playback(
|
|||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
track: &crate::library::models::TrackItem,
|
track: &crate::library::models::TrackItem,
|
||||||
) -> Option<crate::library::models::TrackItem> {
|
) -> Option<crate::library::models::TrackItem> {
|
||||||
let content_id = track.content_id.as_deref()?;
|
let content_id = track_content_id(track)?;
|
||||||
let local = runtime
|
let local = runtime
|
||||||
.library
|
.library
|
||||||
.track_by_content_id(content_id)
|
.track_by_content_id(&content_id)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()?;
|
.flatten()?;
|
||||||
Path::new(&local.file_path).is_file().then_some(local)
|
Path::new(&local.file_path).is_file().then_some(local)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn direct_fed_source(
|
||||||
|
track: &crate::library::models::TrackItem,
|
||||||
|
) -> Option<crate::federation::FedTrack> {
|
||||||
|
let fed = track.fed.clone()?;
|
||||||
|
let owner_ok = fed.owner.parse::<music_dht::EndpointId>().is_ok();
|
||||||
|
let item_ok = fed.item_id.len() == 64 && fed.item_id.chars().all(|c| c.is_ascii_hexdigit());
|
||||||
|
(owner_ok && item_ok).then_some(fed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_content_id(track: &crate::library::models::TrackItem) -> Option<String> {
|
||||||
|
state::track_content_id(track)
|
||||||
|
}
|
||||||
|
|
||||||
fn open_track_file(path: &str) -> std::io::Result<(player::TrackReader, Option<u64>)> {
|
fn open_track_file(path: &str) -> std::io::Result<(player::TrackReader, Option<u64>)> {
|
||||||
let file = std::fs::File::open(path)?;
|
let file = std::fs::File::open(path)?;
|
||||||
let byte_len = file.metadata().ok().map(|meta| meta.len());
|
let byte_len = file.metadata().ok().map(|meta| meta.len());
|
||||||
Ok((std::io::BufReader::new(file), byte_len))
|
Ok((Box::new(std::io::BufReader::new(file)), byte_len))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the next queue item ~30s before the current track ends and append
|
/// Open the next queue item ~30s before the current track ends and append
|
||||||
@@ -1488,14 +1587,28 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
|
|||||||
let Some(next_pos) = update::peek_next_pos(player) else {
|
let Some(next_pos) = update::peek_next_pos(player) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(next) = player.queue.get(next_pos).cloned() else {
|
let Some(mut next) = player.queue.get(next_pos).cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if next.is_fed_pending() {
|
if track_file_missing(&next) {
|
||||||
// Download the upcoming federated track ahead of time; the gapless
|
if let Some(local) = local_track_for_playback(runtime, &next) {
|
||||||
// enqueue happens on a later tick once it resolved to a file.
|
state.player.queue[next_pos] = local.clone();
|
||||||
spawn_fed_resolve(runtime, &next);
|
next = local;
|
||||||
return;
|
} else if let Some(fed) = direct_fed_source(&next) {
|
||||||
|
next = crate::federation::pending_track(&fed);
|
||||||
|
spawn_fed_resolve(runtime, &next, None);
|
||||||
|
return;
|
||||||
|
} else if track_content_id(&next).is_some() {
|
||||||
|
// Resolve by content id first: the actual owner/item id may be a
|
||||||
|
// synced placeholder from another client.
|
||||||
|
spawn_content_id_resolve(runtime, &next, None);
|
||||||
|
return;
|
||||||
|
} else if next.is_fed_pending() {
|
||||||
|
spawn_fed_resolve(runtime, &next, None);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state.player.prefetched_pos = Some(next_pos);
|
state.player.prefetched_pos = Some(next_pos);
|
||||||
tracing::debug!(title = %next.title, "prefetching next track");
|
tracing::debug!(title = %next.title, "prefetching next track");
|
||||||
@@ -1577,64 +1690,235 @@ pub(crate) fn device_connect(runtime: &Runtime, invite: String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn download_progress_sender(
|
||||||
|
tx: mpsc::UnboundedSender<AppEvent>,
|
||||||
|
title: String,
|
||||||
|
) -> impl FnMut(crate::federation::DownloadProgress) + Send + 'static {
|
||||||
|
let mut last_sent: Option<std::time::Instant> = None;
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
move |progress| {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let complete = progress.total > 0 && progress.received >= progress.total;
|
||||||
|
let first = progress.received == 0;
|
||||||
|
let due = last_sent.is_none_or(|last| now.duration_since(last) >= TICK_INTERVAL);
|
||||||
|
if first || complete || due {
|
||||||
|
last_sent = Some(now);
|
||||||
|
let elapsed_secs = now.duration_since(started).as_secs_f64();
|
||||||
|
let bytes_per_sec = if elapsed_secs >= 0.25 && progress.received > 0 {
|
||||||
|
Some(progress.received as f64 / elapsed_secs)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let _ = tx.send(AppEvent::StatusMessage(format_download_progress(
|
||||||
|
&title,
|
||||||
|
progress,
|
||||||
|
bytes_per_sec,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_download_progress(
|
||||||
|
title: &str,
|
||||||
|
progress: crate::federation::DownloadProgress,
|
||||||
|
bytes_per_sec: Option<f64>,
|
||||||
|
) -> String {
|
||||||
|
let speed = bytes_per_sec
|
||||||
|
.map(|bytes| format!(" · {}", format_transfer_rate(bytes)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if progress.total > 0 {
|
||||||
|
let percent = (progress.received as f64 / progress.total as f64 * 100.0).clamp(0.0, 100.0);
|
||||||
|
format!(
|
||||||
|
"federation: downloading \"{title}\" {:.0}% · {}/{}{speed}",
|
||||||
|
percent,
|
||||||
|
format_bytes(progress.received),
|
||||||
|
format_bytes(progress.total)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"federation: downloading \"{title}\" · {}{speed}",
|
||||||
|
format_bytes(progress.received),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_bytes(bytes: u64) -> String {
|
||||||
|
const KIB: f64 = 1024.0;
|
||||||
|
const MIB: f64 = KIB * 1024.0;
|
||||||
|
if bytes >= 1024 * 1024 {
|
||||||
|
format!("{:.1} MB", bytes as f64 / MIB)
|
||||||
|
} else if bytes >= 1024 {
|
||||||
|
format!("{:.0} KB", bytes as f64 / KIB)
|
||||||
|
} else {
|
||||||
|
format!("{bytes} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_transfer_rate(bytes_per_sec: f64) -> String {
|
||||||
|
const KIB: f64 = 1024.0;
|
||||||
|
const MIB: f64 = KIB * 1024.0;
|
||||||
|
if bytes_per_sec >= MIB {
|
||||||
|
format!("{:.1} MB/s", bytes_per_sec / MIB)
|
||||||
|
} else if bytes_per_sec >= KIB {
|
||||||
|
format!("{:.0} KB/s", bytes_per_sec / KIB)
|
||||||
|
} else {
|
||||||
|
format!("{:.0} B/s", bytes_per_sec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Downloads one pending federated track (into the cache, or the library
|
/// Downloads one pending federated track (into the cache, or the library
|
||||||
/// when save-on-listen is enabled) and reports back with the placeholder id
|
/// when save-on-listen is enabled) and reports back with the placeholder id
|
||||||
/// so the queue can swap the resolved track in.
|
/// so the queue can swap the resolved track in.
|
||||||
fn spawn_fed_resolve(runtime: &Runtime, track: &crate::library::models::TrackItem) {
|
fn spawn_fed_resolve(
|
||||||
|
runtime: &Runtime,
|
||||||
|
track: &crate::library::models::TrackItem,
|
||||||
|
stream_playback: Option<StreamingPlaybackRequest>,
|
||||||
|
) {
|
||||||
let Some(fed_track) = track.fed.clone() else {
|
let Some(fed_track) = track.fed.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let resolve_key = track_playback_key(track);
|
||||||
{
|
{
|
||||||
let mut resolving = runtime
|
let mut resolving = runtime
|
||||||
.fed_resolving
|
.fed_resolving
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
if !resolving.insert(track.id) {
|
if !resolving.insert(resolve_key.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let placeholder_id = track.id;
|
let placeholder_id = track.id;
|
||||||
|
let title = track.title.clone();
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
|
let controller = runtime.player.clone();
|
||||||
|
let streaming = Arc::clone(&runtime.fed_streaming);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = fed
|
let progress = download_progress_sender(tx.clone(), title.clone());
|
||||||
.prepare_playback(&fed_track)
|
let result = if let Some(playback) =
|
||||||
|
stream_playback.filter(|playback| playback.position_secs <= 0.5)
|
||||||
|
{
|
||||||
|
let stream_tx = tx.clone();
|
||||||
|
let stream_title = title.clone();
|
||||||
|
let stream_controller = controller.clone();
|
||||||
|
let stream_resolve_key = resolve_key.clone();
|
||||||
|
let stream_markers = Arc::clone(&streaming);
|
||||||
|
let mut started = false;
|
||||||
|
fed.prepare_playback_streaming_with_progress(&fed_track, progress, move |stream| {
|
||||||
|
if started {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
stream_markers
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.insert(stream_resolve_key.clone());
|
||||||
|
stream_controller.play_stream(
|
||||||
|
Box::new(stream.reader),
|
||||||
|
Some(stream.mime_type),
|
||||||
|
player::amplitude(playback.volume),
|
||||||
|
);
|
||||||
|
if playback.paused {
|
||||||
|
stream_controller.pause();
|
||||||
|
}
|
||||||
|
let _ = stream_tx.send(AppEvent::StatusMessage(format!(
|
||||||
|
"federation: streaming \"{stream_title}\" while downloading…"
|
||||||
|
)));
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map(Box::new)
|
} else {
|
||||||
.map_err(|err| format!("{err:#}"));
|
fed.prepare_playback_with_progress(&fed_track, progress)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map(Box::new)
|
||||||
|
.map_err(|err| format!("{err:#}"));
|
||||||
let _ = tx.send(AppEvent::FedTrackResolved {
|
let _ = tx.send(AppEvent::FedTrackResolved {
|
||||||
placeholder_id,
|
placeholder_id,
|
||||||
|
resolve_key,
|
||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_content_id_resolve(runtime: &Runtime, track: &crate::library::models::TrackItem) {
|
fn spawn_content_id_resolve(
|
||||||
let Some(content_id) = track.content_id.clone() else {
|
runtime: &Runtime,
|
||||||
|
track: &crate::library::models::TrackItem,
|
||||||
|
stream_playback: Option<StreamingPlaybackRequest>,
|
||||||
|
) {
|
||||||
|
let Some(content_id) = track_content_id(track) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let resolve_key = track_playback_key(track);
|
||||||
{
|
{
|
||||||
let mut resolving = runtime
|
let mut resolving = runtime
|
||||||
.fed_resolving
|
.fed_resolving
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
if !resolving.insert(track.id) {
|
if !resolving.insert(resolve_key.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let placeholder_id = track.id;
|
let placeholder_id = track.id;
|
||||||
let label = format!("{} {}", track.artist_line(), track.title);
|
let label = format!("{} {}", track.artist_line(), track.title);
|
||||||
|
let title = track.title.clone();
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
|
let controller = runtime.player.clone();
|
||||||
|
let streaming = Arc::clone(&runtime.fed_streaming);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = match fed.track_by_content_id(&content_id, Some(&label)).await {
|
let result = match fed.track_by_content_id(&content_id, Some(&label)).await {
|
||||||
Ok(fed_track) => fed.prepare_playback(&fed_track).await,
|
Ok(fed_track) => {
|
||||||
|
let _ = tx.send(AppEvent::StatusMessage(format!(
|
||||||
|
"federation: found source for \"{title}\", downloading…"
|
||||||
|
)));
|
||||||
|
let progress = download_progress_sender(tx.clone(), title.clone());
|
||||||
|
if let Some(playback) =
|
||||||
|
stream_playback.filter(|playback| playback.position_secs <= 0.5)
|
||||||
|
{
|
||||||
|
let stream_tx = tx.clone();
|
||||||
|
let stream_title = title.clone();
|
||||||
|
let stream_controller = controller.clone();
|
||||||
|
let stream_resolve_key = resolve_key.clone();
|
||||||
|
let stream_markers = Arc::clone(&streaming);
|
||||||
|
let mut started = false;
|
||||||
|
fed.prepare_playback_streaming_with_progress(
|
||||||
|
&fed_track,
|
||||||
|
progress,
|
||||||
|
move |stream| {
|
||||||
|
if started {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
stream_markers
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.insert(stream_resolve_key.clone());
|
||||||
|
stream_controller.play_stream(
|
||||||
|
Box::new(stream.reader),
|
||||||
|
Some(stream.mime_type),
|
||||||
|
player::amplitude(playback.volume),
|
||||||
|
);
|
||||||
|
if playback.paused {
|
||||||
|
stream_controller.pause();
|
||||||
|
}
|
||||||
|
let _ = stream_tx.send(AppEvent::StatusMessage(format!(
|
||||||
|
"federation: streaming \"{stream_title}\" while downloading…"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
fed.prepare_playback_with_progress(&fed_track, progress)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
.map(Box::new)
|
.map(Box::new)
|
||||||
.map_err(|err| format!("{err:#}"));
|
.map_err(|err| format!("{err:#}"));
|
||||||
let _ = tx.send(AppEvent::FedTrackResolved {
|
let _ = tx.send(AppEvent::FedTrackResolved {
|
||||||
placeholder_id,
|
placeholder_id,
|
||||||
|
resolve_key,
|
||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1660,12 +1944,14 @@ pub(crate) fn fed_download_spawn(
|
|||||||
let mut imported_fed_tracks = Vec::new();
|
let mut imported_fed_tracks = Vec::new();
|
||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
for (index, track) in tracks.iter().enumerate() {
|
for (index, track) in tracks.iter().enumerate() {
|
||||||
|
let title = track.title.clone();
|
||||||
let _ = tx.send(AppEvent::StatusMessage(format!(
|
let _ = tx.send(AppEvent::StatusMessage(format!(
|
||||||
"federation: downloading {}/{total}: {}",
|
"federation: downloading {}/{total}: {}",
|
||||||
index + 1,
|
index + 1,
|
||||||
track.title
|
track.title
|
||||||
)));
|
)));
|
||||||
match fed.download_to_library(track).await {
|
let progress = download_progress_sender(tx.clone(), title);
|
||||||
|
match fed.download_to_library_with_progress(track, progress).await {
|
||||||
Ok(imported) => {
|
Ok(imported) => {
|
||||||
imported_ids.push(imported.id);
|
imported_ids.push(imported.id);
|
||||||
imported_fed_tracks.push(track.clone());
|
imported_fed_tracks.push(track.clone());
|
||||||
@@ -1967,10 +2253,18 @@ fn apply_queue_refresh(
|
|||||||
) {
|
) {
|
||||||
let by_id: std::collections::HashMap<i64, _> =
|
let by_id: std::collections::HashMap<i64, _> =
|
||||||
tracks.into_iter().map(|track| (track.id, track)).collect();
|
tracks.into_iter().map(|track| (track.id, track)).collect();
|
||||||
|
let by_key: std::collections::HashMap<String, _> = by_id
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.map(|track| (track_playback_key(&track), track))
|
||||||
|
.collect();
|
||||||
let current_id = state.player.current.as_ref().map(|track| track.id);
|
let current_id = state.player.current.as_ref().map(|track| track.id);
|
||||||
|
let current_key = state.player.current.as_ref().map(track_playback_key);
|
||||||
for track in &mut state.player.queue {
|
for track in &mut state.player.queue {
|
||||||
if let Some(fresh) = by_id.get(&track.id) {
|
if let Some(fresh) = by_id.get(&track.id) {
|
||||||
*track = fresh.clone();
|
*track = fresh.clone();
|
||||||
|
} else if let Some(fresh) = by_key.get(&track_playback_key(track)) {
|
||||||
|
*track = fresh.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let had_missing = state
|
let had_missing = state
|
||||||
@@ -1995,15 +2289,29 @@ fn apply_queue_refresh(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||||
match current_id {
|
match (current_id, current_key) {
|
||||||
Some(id) if by_id.contains_key(&id) => {
|
(Some(id), _) if id < 0 => {
|
||||||
if let Some(position) = state.player.queue.iter().position(|track| track.id == id) {
|
// The current source can be an ephemeral federated stream while
|
||||||
|
// the queue is refreshed after save-on-listen. It is not a
|
||||||
|
// deleted library row, so keep playback untouched.
|
||||||
|
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
||||||
|
}
|
||||||
|
(Some(id), Some(key)) if by_id.contains_key(&id) || by_key.contains_key(&key) => {
|
||||||
|
if let Some(position) = state
|
||||||
|
.player
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.position(|track| track_playback_key(track) == key)
|
||||||
|
{
|
||||||
state.player.queue_pos = position;
|
state.player.queue_pos = position;
|
||||||
}
|
}
|
||||||
state.player.current = by_id.get(&id).cloned();
|
state.player.current = by_id
|
||||||
|
.get(&id)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| by_key.get(&key).cloned());
|
||||||
push_media_metadata(state, runtime);
|
push_media_metadata(state, runtime);
|
||||||
}
|
}
|
||||||
Some(_) => {
|
(Some(_), _) => {
|
||||||
// The playing track was deleted from the library.
|
// The playing track was deleted from the library.
|
||||||
runtime.player_start_pending = false;
|
runtime.player_start_pending = false;
|
||||||
runtime.player.stop();
|
runtime.player.stop();
|
||||||
@@ -2013,7 +2321,7 @@ fn apply_queue_refresh(
|
|||||||
state.player.paused = false;
|
state.player.paused = false;
|
||||||
push_media_update(state, runtime, true);
|
push_media_update(state, runtime, true);
|
||||||
}
|
}
|
||||||
None => {
|
(None, _) => {
|
||||||
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2251,18 +2559,29 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
AppEvent::FedTrackResolved {
|
AppEvent::FedTrackResolved {
|
||||||
placeholder_id,
|
placeholder_id,
|
||||||
|
resolve_key,
|
||||||
result,
|
result,
|
||||||
} => {
|
} => {
|
||||||
runtime
|
runtime
|
||||||
.fed_resolving
|
.fed_resolving
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
.remove(&placeholder_id);
|
.remove(&resolve_key);
|
||||||
|
let stream_started = runtime
|
||||||
|
.fed_streaming
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.remove(&resolve_key);
|
||||||
if state.device_playback.is_control() {
|
if state.device_playback.is_control() {
|
||||||
|
runtime.player_start_pending = false;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
placeholder_id,
|
placeholder_id,
|
||||||
|
resolve_key,
|
||||||
"ignored local federated track resolution while controlling remote playback"
|
"ignored local federated track resolution while controlling remote playback"
|
||||||
);
|
);
|
||||||
|
if let Err(message) = result {
|
||||||
|
state.status_message = Some(format!("federation: {message}"));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let placeholder_key = state
|
let placeholder_key = state
|
||||||
@@ -2277,7 +2596,8 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|track| track.id == placeholder_id)
|
.filter(|track| track.id == placeholder_id)
|
||||||
})
|
})
|
||||||
.map(track_playback_key);
|
.map(track_playback_key)
|
||||||
|
.or(Some(resolve_key.clone()));
|
||||||
let queue_pos_waiting =
|
let queue_pos_waiting =
|
||||||
state
|
state
|
||||||
.player
|
.player
|
||||||
@@ -2301,6 +2621,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
let already_streaming = waiting && stream_started && state.player.playing;
|
||||||
match result {
|
match result {
|
||||||
Ok(playable) => {
|
Ok(playable) => {
|
||||||
if playable.imported {
|
if playable.imported {
|
||||||
@@ -2327,7 +2648,8 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if waiting {
|
if waiting {
|
||||||
// Playback was parked on this track; start it now.
|
// Playback was either parked on this track or already
|
||||||
|
// running from the streaming reader.
|
||||||
let paused = state.player.paused;
|
let paused = state.player.paused;
|
||||||
if state
|
if state
|
||||||
.player
|
.player
|
||||||
@@ -2342,11 +2664,19 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
{
|
{
|
||||||
state.player.queue_pos = pos;
|
state.player.queue_pos = pos;
|
||||||
}
|
}
|
||||||
state.player.current =
|
if already_streaming {
|
||||||
state.player.queue.get(state.player.queue_pos).cloned();
|
// Do not swap the currently playing item under
|
||||||
start_current_audio(state, runtime, resume_position_secs, paused);
|
// the audio pipeline. The streaming reader will
|
||||||
push_media_metadata(state, runtime);
|
// play through the completed cache file naturally;
|
||||||
push_media_update(state, runtime, true);
|
// the resolved queue entry is for future starts.
|
||||||
|
push_media_update(state, runtime, true);
|
||||||
|
} else {
|
||||||
|
state.player.current =
|
||||||
|
state.player.queue.get(state.player.queue_pos).cloned();
|
||||||
|
start_current_audio(state, runtime, resume_position_secs, paused);
|
||||||
|
push_media_metadata(state, runtime);
|
||||||
|
push_media_update(state, runtime, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
@@ -2624,6 +2954,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
content_id.and_then(|id| music_dht::normalize_content_id(&id))
|
content_id.and_then(|id| music_dht::normalize_content_id(&id))
|
||||||
{
|
{
|
||||||
state.fed_likes.remove(&content_id);
|
state.fed_likes.remove(&content_id);
|
||||||
|
state.likes.remove(&content_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The virtual Likes playlist is stale now; refetch on next open.
|
// The virtual Likes playlist is stale now; refetch on next open.
|
||||||
@@ -2635,11 +2966,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
"like removed".to_string()
|
"like removed".to_string()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
AppEvent::LikeToggled { track_id, liked } => {
|
AppEvent::LikeToggled { content_id, liked } => {
|
||||||
if liked {
|
if liked {
|
||||||
state.likes.insert(track_id);
|
state.fed_likes.remove(&content_id);
|
||||||
|
state.likes.insert(content_id);
|
||||||
} else {
|
} else {
|
||||||
state.likes.remove(&track_id);
|
state.likes.remove(&content_id);
|
||||||
|
state.fed_likes.remove(&content_id);
|
||||||
}
|
}
|
||||||
// The virtual Likes playlist is stale now; refetch on next open.
|
// The virtual Likes playlist is stale now; refetch on next open.
|
||||||
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
|
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
|
||||||
|
|||||||
+10
-2
@@ -190,10 +190,18 @@ fn handle_connected_devices(
|
|||||||
super::transfer_active_to_this_device(state, runtime);
|
super::transfer_active_to_this_device(state, runtime);
|
||||||
state.status_message = Some("active playback moved to this device".into());
|
state.status_message = Some("active playback moved to this device".into());
|
||||||
} else if let Some(row) = other_rows.get(cursor.saturating_sub(1).min(last)) {
|
} else if let Some(row) = other_rows.get(cursor.saturating_sub(1).min(last)) {
|
||||||
if let Some(snapshot) = state.device_playback.remote.get(&row.device_id).cloned() {
|
if row.active
|
||||||
|
&& let Some(snapshot) =
|
||||||
|
state.device_playback.remote.get(&row.device_id).cloned()
|
||||||
|
{
|
||||||
super::become_control_device(state, runtime, snapshot);
|
super::become_control_device(state, runtime, snapshot);
|
||||||
} else {
|
} else {
|
||||||
state.status_message = Some("device has no playback snapshot yet".into());
|
super::transfer_active_to_remote_device(
|
||||||
|
state,
|
||||||
|
runtime,
|
||||||
|
row.device_id.clone(),
|
||||||
|
row.name.clone(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -900,9 +900,9 @@ pub struct PlayerBar {
|
|||||||
pub prefetched_pos: Option<usize>,
|
pub prefetched_pos: Option<usize>,
|
||||||
pub volume: u8,
|
pub volume: u8,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
/// Track ids in pre-shuffle order; restores the queue when shuffle is
|
/// Stable track keys in pre-shuffle order; restores the queue when
|
||||||
/// turned off.
|
/// shuffle is turned off.
|
||||||
pub original_order: Option<Vec<i64>>,
|
pub original_order: Option<Vec<String>>,
|
||||||
pub repeat: RepeatMode,
|
pub repeat: RepeatMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,6 +980,7 @@ pub struct AppState {
|
|||||||
pub help_visible: bool,
|
pub help_visible: bool,
|
||||||
pub pending_keys: Option<String>,
|
pub pending_keys: Option<String>,
|
||||||
pub status_message: Option<String>,
|
pub status_message: Option<String>,
|
||||||
|
pub spinner_frame: usize,
|
||||||
pub settings_cursor: usize,
|
pub settings_cursor: usize,
|
||||||
pub player: PlayerBar,
|
pub player: PlayerBar,
|
||||||
pub device_playback: DevicePlaybackState,
|
pub device_playback: DevicePlaybackState,
|
||||||
@@ -989,8 +990,8 @@ pub struct AppState {
|
|||||||
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
|
||||||
pub playlists: PlaylistsTab,
|
pub playlists: PlaylistsTab,
|
||||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||||
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
|
/// Liked local-library content ids, for the ♥ markers everywhere tracks are shown.
|
||||||
pub likes: std::collections::HashSet<i64>,
|
pub likes: std::collections::HashSet<String>,
|
||||||
/// Liked federated tracks (DHT item ids and content ids) — likes that
|
/// Liked federated tracks (DHT item ids and content ids) — likes that
|
||||||
/// reference peers' tracks without downloading them.
|
/// reference peers' tracks without downloading them.
|
||||||
pub fed_likes: std::collections::HashSet<String>,
|
pub fed_likes: std::collections::HashSet<String>,
|
||||||
@@ -1022,6 +1023,15 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
pub fn advance_spinner(&mut self) {
|
||||||
|
self.spinner_frame = self.spinner_frame.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spinner(&self) -> &'static str {
|
||||||
|
const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
FRAMES[self.spinner_frame % FRAMES.len()]
|
||||||
|
}
|
||||||
|
|
||||||
pub fn connected_devices_enabled(&self) -> bool {
|
pub fn connected_devices_enabled(&self) -> bool {
|
||||||
self.federation.settings.enabled && !self.federation.settings.network_id.trim().is_empty()
|
self.federation.settings.enabled && !self.federation.settings.network_id.trim().is_empty()
|
||||||
}
|
}
|
||||||
@@ -1032,7 +1042,9 @@ impl AppState {
|
|||||||
.content_id
|
.content_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(music_dht::normalize_content_id)
|
.and_then(music_dht::normalize_content_id)
|
||||||
.is_some_and(|content_id| self.fed_likes.contains(&content_id))
|
.is_some_and(|content_id| {
|
||||||
|
self.likes.contains(&content_id) || self.fed_likes.contains(&content_id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fed_card_track_liked(&self, track: &crate::federation::FedCardTrack) -> bool {
|
pub fn fed_card_track_liked(&self, track: &crate::federation::FedCardTrack) -> bool {
|
||||||
@@ -1040,10 +1052,46 @@ impl AppState {
|
|||||||
.content_id
|
.content_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(music_dht::normalize_content_id)
|
.and_then(music_dht::normalize_content_id)
|
||||||
.is_some_and(|content_id| self.fed_likes.contains(&content_id))
|
.is_some_and(|content_id| {
|
||||||
|
self.likes.contains(&content_id) || self.fed_likes.contains(&content_id)
|
||||||
|
})
|
||||||
|| track
|
|| track
|
||||||
.sources
|
.sources
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(_, item_id)| self.fed_likes.contains(item_id))
|
.any(|(_, item_id)| self.fed_likes.contains(item_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn track_liked(&self, track: &TrackItem) -> bool {
|
||||||
|
if let Some(content_id) = track_content_id(track) {
|
||||||
|
return self.likes.contains(&content_id) || self.fed_likes.contains(&content_id);
|
||||||
|
}
|
||||||
|
track
|
||||||
|
.fed
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|fed| self.fed_track_liked(fed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_content_id(track: &TrackItem) -> Option<String> {
|
||||||
|
track
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
.or_else(|| {
|
||||||
|
track
|
||||||
|
.fed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fed| fed.content_id.as_deref())
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_key(track: &TrackItem) -> String {
|
||||||
|
if let Some(content_id) = track_content_id(track) {
|
||||||
|
return format!("content:{content_id}");
|
||||||
|
}
|
||||||
|
if let Some(fed) = &track.fed {
|
||||||
|
return format!("fed:{}:{}", fed.owner, fed.item_id);
|
||||||
|
}
|
||||||
|
format!("local:{}", track.id)
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-41
@@ -6,7 +6,7 @@ use crate::library::models::TrackItem;
|
|||||||
use super::state::{
|
use super::state::{
|
||||||
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
||||||
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
|
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
|
||||||
release_display_order, release_rows, settings_rows,
|
release_display_order, release_rows, settings_rows, track_content_id, track_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
|
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
|
||||||
@@ -283,26 +283,31 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
.map(|track| vec![track.clone()])
|
.map(|track| vec![track.clone()])
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
}
|
}
|
||||||
// Local library rows are liked by id; federated tracks (pending
|
let mut local_tracks: Vec<TrackItem> = Vec::new();
|
||||||
// or cached) are liked as references into the federation.
|
|
||||||
let mut track_ids: Vec<i64> = Vec::new();
|
|
||||||
let mut fed_tracks: Vec<crate::federation::FedTrack> = Vec::new();
|
let mut fed_tracks: Vec<crate::federation::FedTrack> = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
for track in tracks {
|
for track in tracks {
|
||||||
|
if !seen.insert(track_key(&track)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match &track.fed {
|
match &track.fed {
|
||||||
Some(fed) => fed_tracks.push(fed.clone()),
|
Some(fed) => fed_tracks.push(fed.clone()),
|
||||||
None if track.id >= 0 => track_ids.push(track.id),
|
None if track.id >= 0 && track_content_id(&track).is_some() => {
|
||||||
|
local_tracks.push(track)
|
||||||
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if track_ids.is_empty() && fed_tracks.is_empty() {
|
if local_tracks.is_empty() && fed_tracks.is_empty() {
|
||||||
state.status_message = Some("no track selected".into());
|
state.status_message = Some("no track selected".into());
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let should_like = track_ids.iter().any(|id| !state.likes.contains(id))
|
let should_like = local_tracks.iter().any(|track| !state.track_liked(track))
|
||||||
|| fed_tracks.iter().any(|fed| !state.fed_track_liked(fed));
|
|| fed_tracks.iter().any(|fed| !state.fed_track_liked(fed));
|
||||||
let toggles: Vec<i64> = track_ids
|
let toggles: Vec<i64> = local_tracks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|id| state.likes.contains(id) != should_like)
|
.filter(|track| state.track_liked(track) != should_like)
|
||||||
|
.map(|track| track.id)
|
||||||
.collect();
|
.collect();
|
||||||
let fed_toggles: Vec<crate::federation::FedTrack> = fed_tracks
|
let fed_toggles: Vec<crate::federation::FedTrack> = fed_tracks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -355,10 +360,9 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::DownloadSelected => {
|
Action::DownloadSelected => {
|
||||||
let tracks = selected_fed_tracks(state);
|
let tracks = selected_downloadable_fed_tracks(state);
|
||||||
if tracks.is_empty() {
|
if tracks.is_empty() {
|
||||||
state.status_message =
|
state.status_message = Some("select federated tracks first".into());
|
||||||
Some("select federated tracks first (works in the federation results)".into());
|
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
@@ -614,30 +618,38 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let track_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
|
let track_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
|
||||||
let content_ids: Vec<String> = tracks
|
let content_ids: Vec<String> = tracks.iter().filter_map(track_content_id).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|track| {
|
|
||||||
track
|
|
||||||
.content_id
|
|
||||||
.as_deref()
|
|
||||||
.and_then(music_dht::normalize_content_id)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
if opened.id == super::state::LIKES_PLAYLIST_ID {
|
if opened.id == super::state::LIKES_PLAYLIST_ID {
|
||||||
let liked: Vec<i64> = track_ids
|
let mut seen = std::collections::HashSet::new();
|
||||||
.into_iter()
|
let mut liked = Vec::new();
|
||||||
.filter(|id| state.likes.contains(id))
|
let mut fed_tracks = Vec::new();
|
||||||
.collect();
|
for track in tracks {
|
||||||
state.status_message = Some(format!("removing {} like(s)", liked.len()));
|
if !state.track_liked(&track) || !seen.insert(track_key(&track)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(fed) = track.fed {
|
||||||
|
fed_tracks.push(fed);
|
||||||
|
} else if track.id >= 0 && track_content_id(&track).is_some() {
|
||||||
|
liked.push(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total = liked.len() + fed_tracks.len();
|
||||||
|
state.status_message = Some(format!("removing {} like(s)", total));
|
||||||
return Some(Effect::ToggleLikes {
|
return Some(Effect::ToggleLikes {
|
||||||
track_ids: liked,
|
track_ids: liked,
|
||||||
fed_tracks: vec![],
|
fed_tracks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let content_ids: Vec<String> = content_ids
|
||||||
|
.into_iter()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let track_ids: Vec<i64> = track_ids.into_iter().filter(|id| *id >= 0).collect();
|
||||||
state.status_message = Some(format!(
|
state.status_message = Some(format!(
|
||||||
"removing {} track(s) from playlist",
|
"removing {} track(s) from playlist",
|
||||||
track_ids.len()
|
content_ids.len().max(track_ids.len())
|
||||||
));
|
));
|
||||||
return Some(Effect::RemoveFromPlaylist {
|
return Some(Effect::RemoveFromPlaylist {
|
||||||
playlist_id: opened.id,
|
playlist_id: opened.id,
|
||||||
@@ -1015,14 +1027,14 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
|||||||
}
|
}
|
||||||
|
|
||||||
let old_queue_pos = state.player.queue_pos;
|
let old_queue_pos = state.player.queue_pos;
|
||||||
let current_id = state.player.current.as_ref().map(|track| track.id);
|
let current_key = state.player.current.as_ref().map(track_key);
|
||||||
let current_removed = current_id.is_some_and(|id| {
|
let current_removed = current_key.as_ref().is_some_and(|key| {
|
||||||
unique.iter().any(|index| {
|
unique.iter().any(|index| {
|
||||||
state
|
state
|
||||||
.player
|
.player
|
||||||
.queue
|
.queue
|
||||||
.get(*index)
|
.get(*index)
|
||||||
.is_some_and(|track| track.id == id)
|
.is_some_and(|track| track_key(track) == *key)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let removed_before_current = unique
|
let removed_before_current = unique
|
||||||
@@ -1060,19 +1072,24 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(id) = current_id {
|
if let Some(key) = current_key.as_ref() {
|
||||||
if let Some(position) = state.player.queue.iter().position(|track| track.id == id) {
|
if let Some(position) = state
|
||||||
|
.player
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.position(|track| track_key(track) == *key)
|
||||||
|
{
|
||||||
state.player.queue_pos = position;
|
state.player.queue_pos = position;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
state.player.queue_pos = state.player.queue_pos.min(state.player.queue.len() - 1);
|
||||||
}
|
}
|
||||||
state.player.current = current_id.and_then(|id| {
|
state.player.current = current_key.and_then(|key| {
|
||||||
state
|
state
|
||||||
.player
|
.player
|
||||||
.queue
|
.queue
|
||||||
.iter()
|
.iter()
|
||||||
.find(|track| track.id == id)
|
.find(|track| track_key(track) == key)
|
||||||
.cloned()
|
.cloned()
|
||||||
});
|
});
|
||||||
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
|
||||||
@@ -1416,7 +1433,7 @@ pub fn shuffle_upcoming(player: &mut super::state::PlayerBar) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if player.original_order.is_none() {
|
if player.original_order.is_none() {
|
||||||
player.original_order = Some(player.queue.iter().map(|t| t.id).collect());
|
player.original_order = Some(player.queue.iter().map(track_key).collect());
|
||||||
}
|
}
|
||||||
shuffle_range(player, upcoming_start(player));
|
shuffle_range(player, upcoming_start(player));
|
||||||
}
|
}
|
||||||
@@ -1440,7 +1457,7 @@ pub fn restore_queue_order(player: &mut super::state::PlayerBar) {
|
|||||||
let key = order
|
let key = order
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.position(|(slot, id)| !used[slot] && *id == track.id)
|
.position(|(slot, key)| !used[slot] && *key == track_key(&track))
|
||||||
.inspect(|&slot| used[slot] = true)
|
.inspect(|&slot| used[slot] = true)
|
||||||
.unwrap_or(usize::MAX);
|
.unwrap_or(usize::MAX);
|
||||||
(key, position, track)
|
(key, position, track)
|
||||||
@@ -2212,6 +2229,83 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn selected_downloadable_fed_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for track in selected_tracks(state) {
|
||||||
|
if let Some(fed) = fed_track_for_download(&track)
|
||||||
|
&& !tracks
|
||||||
|
.iter()
|
||||||
|
.any(|existing| same_fed_download(existing, &fed))
|
||||||
|
{
|
||||||
|
tracks.push(fed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for fed in selected_fed_tracks(state) {
|
||||||
|
if !tracks
|
||||||
|
.iter()
|
||||||
|
.any(|existing| same_fed_download(existing, &fed))
|
||||||
|
{
|
||||||
|
tracks.push(fed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fed_track_for_download(track: &TrackItem) -> Option<crate::federation::FedTrack> {
|
||||||
|
if let Some(fed) = &track.fed {
|
||||||
|
return Some(fed.clone());
|
||||||
|
}
|
||||||
|
let content_id = track
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)?;
|
||||||
|
Some(crate::federation::FedTrack {
|
||||||
|
item_id: String::new(),
|
||||||
|
owner: String::new(),
|
||||||
|
own: false,
|
||||||
|
title: track.title.clone(),
|
||||||
|
artist_names: track
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| artist.name.clone())
|
||||||
|
.collect(),
|
||||||
|
featured_artist_names: track
|
||||||
|
.featured_artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| artist.name.clone())
|
||||||
|
.collect(),
|
||||||
|
year: track.release_year,
|
||||||
|
duration_seconds: (track.duration_seconds > 0.0)
|
||||||
|
.then(|| track.duration_seconds.round().max(0.0) as i64),
|
||||||
|
content_id: Some(content_id),
|
||||||
|
release_title: (!track.release_title.trim().is_empty())
|
||||||
|
.then(|| track.release_title.clone()),
|
||||||
|
track_number: track.track_number,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_fed_download(
|
||||||
|
left: &crate::federation::FedTrack,
|
||||||
|
right: &crate::federation::FedTrack,
|
||||||
|
) -> bool {
|
||||||
|
let left_content = left
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id);
|
||||||
|
let right_content = right
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id);
|
||||||
|
if left_content.is_some() && left_content == right_content {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
!left.owner.is_empty()
|
||||||
|
&& !left.item_id.is_empty()
|
||||||
|
&& left.owner == right.owner
|
||||||
|
&& left.item_id == right.item_id
|
||||||
|
}
|
||||||
|
|
||||||
/// What Enter resolved to in the current view.
|
/// What Enter resolved to in the current view.
|
||||||
enum Outcome {
|
enum Outcome {
|
||||||
Push(GlobalView),
|
Push(GlobalView),
|
||||||
@@ -2482,7 +2576,7 @@ pub(super) fn on_new_queue(state: &mut AppState) {
|
|||||||
let player = &mut state.player;
|
let player = &mut state.player;
|
||||||
player.original_order = None;
|
player.original_order = None;
|
||||||
if player.shuffle && !player.queue.is_empty() {
|
if player.shuffle && !player.queue.is_empty() {
|
||||||
player.original_order = Some(player.queue.iter().map(|t| t.id).collect());
|
player.original_order = Some(player.queue.iter().map(track_key).collect());
|
||||||
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
|
shuffle_range(player, (player.queue_pos + 1).min(player.queue.len()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2610,7 +2704,7 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_path: None,
|
cover_path: None,
|
||||||
file_path: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
content_id: None,
|
content_id: Some(format!("b3:{id:064x}")),
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
@@ -3162,7 +3256,7 @@ mod tests {
|
|||||||
..AppState::default()
|
..AppState::default()
|
||||||
};
|
};
|
||||||
state.player.queue = (1..=3).map(test_track).collect();
|
state.player.queue = (1..=3).map(test_track).collect();
|
||||||
state.likes.insert(1);
|
state.likes.insert(format!("b3:{:064x}", 1));
|
||||||
|
|
||||||
update(&mut state, Action::ToggleTrackSelection);
|
update(&mut state, Action::ToggleTrackSelection);
|
||||||
update(&mut state, Action::SelectLast);
|
update(&mut state, Action::SelectLast);
|
||||||
@@ -3174,7 +3268,10 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
state.likes = [1, 2, 3].into_iter().collect();
|
state.likes = [1, 2, 3]
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| format!("b3:{id:064x}"))
|
||||||
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
update(&mut state, Action::ToggleLike),
|
update(&mut state, Action::ToggleLike),
|
||||||
Some(Effect::ToggleLikes {
|
Some(Effect::ToggleLikes {
|
||||||
|
|||||||
+81
-20
@@ -728,6 +728,7 @@ impl DeviceSync {
|
|||||||
&self,
|
&self,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
invite_link: &str,
|
invite_link: &str,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let invite = parse_invite(invite_link)?;
|
let invite = parse_invite(invite_link)?;
|
||||||
anyhow::ensure!(invite.expires_at_ms >= now_ms(), "invite expired");
|
anyhow::ensure!(invite.expires_at_ms >= now_ms(), "invite expired");
|
||||||
@@ -739,7 +740,12 @@ impl DeviceSync {
|
|||||||
let mut last_error: Option<String>;
|
let mut last_error: Option<String>;
|
||||||
loop {
|
loop {
|
||||||
match self
|
match self
|
||||||
.try_connect_invite(Arc::clone(&service), &invite, ticket.clone())
|
.try_connect_invite(
|
||||||
|
Arc::clone(&service),
|
||||||
|
&invite,
|
||||||
|
ticket.clone(),
|
||||||
|
Arc::clone(&transport_stats),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(PairAttempt::Accepted(message)) => return Ok(message),
|
Ok(PairAttempt::Accepted(message)) => return Ok(message),
|
||||||
@@ -765,6 +771,7 @@ impl DeviceSync {
|
|||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
invite: &InviteWire,
|
invite: &InviteWire,
|
||||||
ticket: PeerTicket,
|
ticket: PeerTicket,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<PairAttempt> {
|
) -> Result<PairAttempt> {
|
||||||
let peer = service.connect(ticket).await?;
|
let peer = service.connect(ticket).await?;
|
||||||
let own_ticket = service.ticket().await?.to_string();
|
let own_ticket = service.ticket().await?.to_string();
|
||||||
@@ -777,6 +784,13 @@ impl DeviceSync {
|
|||||||
let snapshot = self.snapshot()?;
|
let snapshot = self.snapshot()?;
|
||||||
let playback = self.local_playback_snapshot();
|
let playback = self.local_playback_snapshot();
|
||||||
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"device-sync",
|
||||||
|
"outbound",
|
||||||
|
"pair-open",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
write_msg(
|
write_msg(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
&WireMessage::PairRequest {
|
&WireMessage::PairRequest {
|
||||||
@@ -794,10 +808,17 @@ impl DeviceSync {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
finish_send(&mut stream).await?;
|
finish_send(&mut stream).await?;
|
||||||
match read_msg(&mut stream)
|
let response = read_msg(&mut stream)
|
||||||
.await
|
.await
|
||||||
.context("pairing response was not received")?
|
.context("pairing response was not received")?;
|
||||||
{
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"device-sync",
|
||||||
|
"outbound",
|
||||||
|
"pair-done",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
|
match response {
|
||||||
WireMessage::PairResponse {
|
WireMessage::PairResponse {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
group_id: Some(group_id),
|
group_id: Some(group_id),
|
||||||
@@ -936,14 +957,15 @@ impl DeviceSync {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_track_like(&self, track_id: i64, liked: bool) -> Result<()> {
|
pub fn record_content_like(&self, content_id: &str, liked: bool) -> Result<()> {
|
||||||
if let Some(content_id) = self.library.track_content_id_by_id(track_id)? {
|
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||||||
self.record_local_op(SyncOpPayload::TrackLikeSet {
|
return Ok(());
|
||||||
content_id,
|
};
|
||||||
liked,
|
self.record_local_op(SyncOpPayload::TrackLikeSet {
|
||||||
fed: None,
|
content_id,
|
||||||
})?;
|
liked,
|
||||||
}
|
fed: None,
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,13 +1079,20 @@ impl DeviceSync {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync_once(&self, service: Arc<MusicDhtService>) -> Result<()> {
|
pub async fn sync_once(
|
||||||
|
&self,
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
let devices = self.active_remote_devices()?;
|
let devices = self.active_remote_devices()?;
|
||||||
for device in devices {
|
for device in devices {
|
||||||
if device.endpoint_ticket.trim().is_empty() {
|
if device.endpoint_ticket.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(err) = self.sync_device(Arc::clone(&service), &device).await {
|
if let Err(err) = self
|
||||||
|
.sync_device(Arc::clone(&service), &device, Arc::clone(&transport_stats))
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::debug!(device = %device.device_id, "device sync failed: {err:#}");
|
tracing::debug!(device = %device.device_id, "device sync failed: {err:#}");
|
||||||
self.set_last_error(Some(format!("{}: {err:#}", short_id(&device.device_id))))?;
|
self.set_last_error(Some(format!("{}: {err:#}", short_id(&device.device_id))))?;
|
||||||
}
|
}
|
||||||
@@ -1076,6 +1105,7 @@ impl DeviceSync {
|
|||||||
&self,
|
&self,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
device: &StoredDevice,
|
device: &StoredDevice,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||||
let peer = service.connect(ticket).await?;
|
let peer = service.connect(ticket).await?;
|
||||||
@@ -1088,6 +1118,13 @@ impl DeviceSync {
|
|||||||
let snapshot = self.snapshot()?;
|
let snapshot = self.snapshot()?;
|
||||||
let playback = self.local_playback_snapshot();
|
let playback = self.local_playback_snapshot();
|
||||||
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"device-sync",
|
||||||
|
"outbound",
|
||||||
|
"sync-open",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
write_msg(
|
write_msg(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
&WireMessage::Hello {
|
&WireMessage::Hello {
|
||||||
@@ -1102,10 +1139,17 @@ impl DeviceSync {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
finish_send(&mut stream).await?;
|
finish_send(&mut stream).await?;
|
||||||
match read_msg(&mut stream)
|
let response = read_msg(&mut stream)
|
||||||
.await
|
.await
|
||||||
.context("device sync response was not received")?
|
.context("device sync response was not received")?;
|
||||||
{
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"device-sync",
|
||||||
|
"outbound",
|
||||||
|
"sync-done",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
|
match response {
|
||||||
WireMessage::SyncResponse {
|
WireMessage::SyncResponse {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
devices,
|
devices,
|
||||||
@@ -2432,24 +2476,33 @@ pub async fn serve_peers(
|
|||||||
mut acceptor: StreamAcceptor,
|
mut acceptor: StreamAcceptor,
|
||||||
sync: Arc<DeviceSync>,
|
sync: Arc<DeviceSync>,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) {
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let sync = Arc::clone(&sync);
|
let sync = Arc::clone(&sync);
|
||||||
let service = Arc::clone(&service);
|
let service = Arc::clone(&service);
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
let peer = stream.peer_id;
|
||||||
if let Err(err) = serve_one(stream, sync, service).await {
|
if let Err(err) = serve_one(stream, sync, service, transport_stats).await {
|
||||||
tracing::warn!(peer = %peer, "personal sync stream failed: {err:#}");
|
tracing::warn!(peer = %peer, "personal sync stream failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync_loop(sync: Arc<DeviceSync>, service: Arc<MusicDhtService>) {
|
pub async fn sync_loop(
|
||||||
|
sync: Arc<DeviceSync>,
|
||||||
|
service: Arc<MusicDhtService>,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
|
) {
|
||||||
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(err) = sync.sync_once(Arc::clone(&service)).await {
|
if let Err(err) = sync
|
||||||
|
.sync_once(Arc::clone(&service), Arc::clone(&transport_stats))
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::debug!("personal sync tick failed: {err:#}");
|
tracing::debug!("personal sync tick failed: {err:#}");
|
||||||
}
|
}
|
||||||
if let Some(tx) = lock(&sync.event_tx).as_ref() {
|
if let Some(tx) = lock(&sync.event_tx).as_ref() {
|
||||||
@@ -2462,7 +2515,15 @@ async fn serve_one(
|
|||||||
mut stream: ByteStream,
|
mut stream: ByteStream,
|
||||||
sync: Arc<DeviceSync>,
|
sync: Arc<DeviceSync>,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"device-sync",
|
||||||
|
"inbound",
|
||||||
|
"open",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
match read_msg(&mut stream).await? {
|
match read_msg(&mut stream).await? {
|
||||||
WireMessage::PairRequest {
|
WireMessage::PairRequest {
|
||||||
invite_id,
|
invite_id,
|
||||||
|
|||||||
+190
-10
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||||
@@ -20,6 +21,22 @@ pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
|||||||
|
|
||||||
/// Maximum size of a JSON protocol line (request or response header).
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
|
const STREAM_START_BUFFER_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct DownloadProgress {
|
||||||
|
pub stream_key: u64,
|
||||||
|
pub transport_phase: &'static str,
|
||||||
|
pub received: u64,
|
||||||
|
pub total: u64,
|
||||||
|
pub transport: Option<music_dht::ByteStreamConnectionStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StreamingStart {
|
||||||
|
pub reader: crate::streaming::GrowingFileReader,
|
||||||
|
pub mime_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct AudioRequest {
|
struct AudioRequest {
|
||||||
@@ -278,26 +295,31 @@ pub async fn fetch_metadata(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads a whole track (with metadata and cover art) from `owner` into
|
pub async fn download_track_with_streaming<F>(
|
||||||
/// `dir/<stem>.<ext>`. An already complete cached audio file is reused;
|
|
||||||
/// the metadata and cover still come fresh from the header.
|
|
||||||
pub async fn download_track(
|
|
||||||
service: &MusicDhtService,
|
service: &MusicDhtService,
|
||||||
owner: EndpointId,
|
owner: EndpointId,
|
||||||
item_id_hex: &str,
|
item_id_hex: &str,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
stem: &str,
|
stem: &str,
|
||||||
) -> Result<Downloaded> {
|
want_images: bool,
|
||||||
|
mut progress: F,
|
||||||
|
mut stream_start: Option<&mut (dyn FnMut(StreamingStart) + Send)>,
|
||||||
|
) -> Result<Downloaded>
|
||||||
|
where
|
||||||
|
F: FnMut(DownloadProgress) + Send,
|
||||||
|
{
|
||||||
|
let started = Instant::now();
|
||||||
let mut stream = service
|
let mut stream = service
|
||||||
.open_stream(owner, AUDIO_ALPN)
|
.open_stream(owner, AUDIO_ALPN)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
|
||||||
|
let stream_key = super::stream_transport_key(&stream);
|
||||||
write_line(
|
write_line(
|
||||||
&mut stream.send,
|
&mut stream.send,
|
||||||
&AudioRequest {
|
&AudioRequest {
|
||||||
item_id: item_id_hex.to_string(),
|
item_id: item_id_hex.to_string(),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
want_cover: true,
|
want_cover: want_images,
|
||||||
metadata_only: false,
|
metadata_only: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -311,6 +333,22 @@ pub async fn download_track(
|
|||||||
header.error.unwrap_or_else(|| "unknown error".to_string())
|
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
progress(DownloadProgress {
|
||||||
|
stream_key,
|
||||||
|
transport_phase: "download-open",
|
||||||
|
received: 0,
|
||||||
|
total: header.total_size,
|
||||||
|
transport: Some(stream.connection_stats()),
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
owner = %owner,
|
||||||
|
item_id = %item_id_hex,
|
||||||
|
audio_bytes = header.total_size,
|
||||||
|
cover_bytes = header.cover_size,
|
||||||
|
artist_image_bytes = header.artist_image_size,
|
||||||
|
want_images,
|
||||||
|
"federated audio stream opened"
|
||||||
|
);
|
||||||
|
|
||||||
// The image segments precede the audio bytes and are read regardless of
|
// The image segments precede the audio bytes and are read regardless of
|
||||||
// the cache state — they sit first in the stream.
|
// the cache state — they sit first in the stream.
|
||||||
@@ -346,6 +384,20 @@ pub async fn download_track(
|
|||||||
&& header.total_size > 0
|
&& header.total_size > 0
|
||||||
{
|
{
|
||||||
// Audio already fully downloaded earlier; no need to fetch again.
|
// Audio already fully downloaded earlier; no need to fetch again.
|
||||||
|
progress(DownloadProgress {
|
||||||
|
stream_key,
|
||||||
|
transport_phase: "download-done",
|
||||||
|
received: header.total_size,
|
||||||
|
total: header.total_size,
|
||||||
|
transport: Some(stream.connection_stats()),
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
owner = %owner,
|
||||||
|
item_id = %item_id_hex,
|
||||||
|
bytes = header.total_size,
|
||||||
|
path = %path.display(),
|
||||||
|
"federated audio reused from cache"
|
||||||
|
);
|
||||||
return Ok(Downloaded {
|
return Ok(Downloaded {
|
||||||
path,
|
path,
|
||||||
mime_type: header.mime_type,
|
mime_type: header.mime_type,
|
||||||
@@ -357,12 +409,56 @@ pub async fn download_track(
|
|||||||
|
|
||||||
let temp_path = dir.join(format!(".{stem}.{extension}.part"));
|
let temp_path = dir.join(format!(".{stem}.{extension}.part"));
|
||||||
let mut file = tokio::fs::File::create(&temp_path).await?;
|
let mut file = tokio::fs::File::create(&temp_path).await?;
|
||||||
|
let mut streaming = match stream_start.as_ref() {
|
||||||
|
Some(_) => match crate::streaming::growing_file(&temp_path) {
|
||||||
|
Ok((reader, writer)) => Some((Some(reader), writer, false)),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::debug!(%err, path = %temp_path.display(), "streaming reader disabled");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let stream_start_at = stream_start_buffer(header.total_size);
|
||||||
let mut received: u64 = 0;
|
let mut received: u64 = 0;
|
||||||
let mut chunk = vec![0u8; 64 * 1024];
|
let mut chunk = vec![0u8; 64 * 1024];
|
||||||
// quinn's inherent read returns None when the peer finished the stream.
|
// quinn's inherent read returns None when the peer finished the stream.
|
||||||
while let Some(n) = stream.recv.read(&mut chunk).await? {
|
while let Some(n) = stream.recv.read(&mut chunk).await? {
|
||||||
file.write_all(&chunk[..n]).await?;
|
file.write_all(&chunk[..n]).await?;
|
||||||
received += n as u64;
|
received += n as u64;
|
||||||
|
if let Some((reader, writer, started)) = &mut streaming {
|
||||||
|
writer.add_available(n as u64);
|
||||||
|
if !*started
|
||||||
|
&& received >= stream_start_at
|
||||||
|
&& let (Some(reader), Some(callback)) = (reader.take(), stream_start.as_mut())
|
||||||
|
{
|
||||||
|
callback(StreamingStart {
|
||||||
|
reader,
|
||||||
|
mime_type: header.mime_type.clone(),
|
||||||
|
});
|
||||||
|
*started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress(DownloadProgress {
|
||||||
|
stream_key,
|
||||||
|
transport_phase: "download",
|
||||||
|
received,
|
||||||
|
total: header.total_size,
|
||||||
|
transport: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some((reader, writer, started)) = &mut streaming {
|
||||||
|
if !*started
|
||||||
|
&& received > 0
|
||||||
|
&& let (Some(reader), Some(callback)) = (reader.take(), stream_start.as_mut())
|
||||||
|
{
|
||||||
|
callback(StreamingStart {
|
||||||
|
reader,
|
||||||
|
mime_type: header.mime_type.clone(),
|
||||||
|
});
|
||||||
|
*started = true;
|
||||||
|
}
|
||||||
|
writer.finish();
|
||||||
}
|
}
|
||||||
file.flush().await?;
|
file.flush().await?;
|
||||||
drop(file);
|
drop(file);
|
||||||
@@ -374,6 +470,27 @@ pub async fn download_track(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
tokio::fs::rename(&temp_path, &path).await?;
|
tokio::fs::rename(&temp_path, &path).await?;
|
||||||
|
progress(DownloadProgress {
|
||||||
|
stream_key,
|
||||||
|
transport_phase: "download-done",
|
||||||
|
received,
|
||||||
|
total: header.total_size,
|
||||||
|
transport: Some(stream.connection_stats()),
|
||||||
|
});
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
let kib_per_sec = if elapsed.as_secs_f64() > 0.0 {
|
||||||
|
received as f64 / 1024.0 / elapsed.as_secs_f64()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
owner = %owner,
|
||||||
|
item_id = %item_id_hex,
|
||||||
|
bytes = received,
|
||||||
|
elapsed_ms = elapsed.as_millis(),
|
||||||
|
kib_per_sec,
|
||||||
|
"federated audio downloaded"
|
||||||
|
);
|
||||||
Ok(Downloaded {
|
Ok(Downloaded {
|
||||||
path,
|
path,
|
||||||
mime_type: header.mime_type,
|
mime_type: header.mime_type,
|
||||||
@@ -383,6 +500,14 @@ pub async fn download_track(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_start_buffer(total_size: u64) -> u64 {
|
||||||
|
if total_size == 0 {
|
||||||
|
STREAM_START_BUFFER_BYTES
|
||||||
|
} else {
|
||||||
|
total_size.min(STREAM_START_BUFFER_BYTES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Serving side: answer audio requests from other peers
|
// Serving side: answer audio requests from other peers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -466,19 +591,37 @@ fn resolve_for_serving(
|
|||||||
/// Runs the accept loop of the audio protocol until the acceptor closes.
|
/// Runs the accept loop of the audio protocol until the acceptor closes.
|
||||||
/// Every track of the local library is downloadable by every peer of the
|
/// Every track of the local library is downloadable by every peer of the
|
||||||
/// network — the libraries of all participants are equal.
|
/// network — the libraries of all participants are equal.
|
||||||
pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc<Library>, own: EndpointId) {
|
pub async fn serve_peers(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
library: Arc<Library>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let library = Arc::clone(&library);
|
let library = Arc::clone(&library);
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
let peer = stream.peer_id;
|
||||||
if let Err(err) = serve_one(stream, library, own).await {
|
if let Err(err) = serve_one(stream, library, own, transport_stats).await {
|
||||||
tracing::warn!(peer = %peer, "audio stream failed: {err:#}");
|
tracing::warn!(peer = %peer, "audio stream failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointId) -> Result<()> {
|
async fn serve_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
library: Arc<Library>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"audio",
|
||||||
|
"inbound",
|
||||||
|
"open",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
peer = %stream.peer_id,
|
peer = %stream.peer_id,
|
||||||
@@ -555,17 +698,54 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
|
|||||||
let _ = stream.send.stopped().await;
|
let _ = stream.send.stopped().await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let cover_bytes = cover.as_ref().map_or(0, |(bytes, _)| bytes.len() as u64);
|
||||||
|
let artist_image_bytes = artist_image
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |(bytes, _)| bytes.len() as u64);
|
||||||
|
tracing::info!(
|
||||||
|
peer = %stream.peer_id,
|
||||||
|
item = %request.item_id,
|
||||||
|
audio_bytes = total_size.saturating_sub(offset),
|
||||||
|
cover_bytes,
|
||||||
|
artist_image_bytes,
|
||||||
|
want_images = request.want_cover,
|
||||||
|
"serving federated audio stream"
|
||||||
|
);
|
||||||
|
let started = Instant::now();
|
||||||
if let Some((bytes, _)) = &cover {
|
if let Some((bytes, _)) = &cover {
|
||||||
stream.send.write_all(bytes).await?;
|
stream.send.write_all(bytes).await?;
|
||||||
}
|
}
|
||||||
if let Some((bytes, _)) = &artist_image {
|
if let Some((bytes, _)) = &artist_image {
|
||||||
stream.send.write_all(bytes).await?;
|
stream.send.write_all(bytes).await?;
|
||||||
}
|
}
|
||||||
tokio::io::copy(&mut file, &mut stream.send).await?;
|
let audio_sent = tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||||
stream.send.finish()?;
|
stream.send.finish()?;
|
||||||
// Wait until the peer read everything (or gave up) before dropping the
|
// Wait until the peer read everything (or gave up) before dropping the
|
||||||
// stream, otherwise the tail of the file is lost.
|
// stream, otherwise the tail of the file is lost.
|
||||||
let _ = stream.send.stopped().await;
|
let _ = stream.send.stopped().await;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"audio",
|
||||||
|
"inbound",
|
||||||
|
"done",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
let total_sent = cover_bytes + artist_image_bytes + audio_sent;
|
||||||
|
let kib_per_sec = if elapsed.as_secs_f64() > 0.0 {
|
||||||
|
total_sent as f64 / 1024.0 / elapsed.as_secs_f64()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
peer = %stream.peer_id,
|
||||||
|
item = %request.item_id,
|
||||||
|
audio_bytes = audio_sent,
|
||||||
|
total_bytes = total_sent,
|
||||||
|
elapsed_ms = elapsed.as_millis(),
|
||||||
|
kib_per_sec,
|
||||||
|
"served federated audio stream"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,19 +124,37 @@ pub struct CatalogTrack {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Runs the catalog accept loop until the acceptor closes.
|
/// Runs the catalog accept loop until the acceptor closes.
|
||||||
pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc<Library>, own: EndpointId) {
|
pub async fn serve_peers(
|
||||||
|
mut acceptor: StreamAcceptor,
|
||||||
|
library: Arc<Library>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let library = Arc::clone(&library);
|
let library = Arc::clone(&library);
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
let peer = stream.peer_id;
|
||||||
if let Err(err) = serve_one(stream, library, own).await {
|
if let Err(err) = serve_one(stream, library, own, transport_stats).await {
|
||||||
tracing::warn!(peer = %peer, "catalog request failed: {err:#}");
|
tracing::warn!(peer = %peer, "catalog request failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointId) -> Result<()> {
|
async fn serve_one(
|
||||||
|
mut stream: ByteStream,
|
||||||
|
library: Arc<Library>,
|
||||||
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"catalog",
|
||||||
|
"inbound",
|
||||||
|
"open",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
let request: CatalogRequest =
|
let request: CatalogRequest =
|
||||||
serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?;
|
serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -190,6 +208,13 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
|
|||||||
}
|
}
|
||||||
stream.send.finish()?;
|
stream.send.finish()?;
|
||||||
let _ = stream.send.stopped().await;
|
let _ = stream.send.stopped().await;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
&transport_stats,
|
||||||
|
"catalog",
|
||||||
|
"inbound",
|
||||||
|
"done",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,11 +360,19 @@ pub async fn fetch_catalog(
|
|||||||
service: &MusicDhtService,
|
service: &MusicDhtService,
|
||||||
owner: EndpointId,
|
owner: EndpointId,
|
||||||
artist: &str,
|
artist: &str,
|
||||||
|
transport_stats: &Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<CatalogArtist> {
|
) -> Result<CatalogArtist> {
|
||||||
let mut stream = service
|
let mut stream = service
|
||||||
.open_stream(owner, CATALOG_ALPN)
|
.open_stream(owner, CATALOG_ALPN)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
transport_stats,
|
||||||
|
"catalog",
|
||||||
|
"outbound",
|
||||||
|
"open",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
let mut line = serde_json::to_vec(&CatalogRequest {
|
let mut line = serde_json::to_vec(&CatalogRequest {
|
||||||
artist: artist.to_string(),
|
artist: artist.to_string(),
|
||||||
want: None,
|
want: None,
|
||||||
@@ -360,6 +393,13 @@ pub async fn fetch_catalog(
|
|||||||
);
|
);
|
||||||
let response: CatalogResponse =
|
let response: CatalogResponse =
|
||||||
serde_json::from_slice(&payload).context("malformed catalog response")?;
|
serde_json::from_slice(&payload).context("malformed catalog response")?;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
transport_stats,
|
||||||
|
"catalog",
|
||||||
|
"outbound",
|
||||||
|
"done",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
if !response.ok {
|
if !response.ok {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"peer refused the catalog: {}",
|
"peer refused the catalog: {}",
|
||||||
@@ -379,11 +419,19 @@ pub async fn fetch_image(
|
|||||||
owner: EndpointId,
|
owner: EndpointId,
|
||||||
artist: &str,
|
artist: &str,
|
||||||
release: Option<&str>,
|
release: Option<&str>,
|
||||||
|
transport_stats: &Arc<crate::federation::TransportStats>,
|
||||||
) -> Result<Option<(Vec<u8>, &'static str)>> {
|
) -> Result<Option<(Vec<u8>, &'static str)>> {
|
||||||
let mut stream = service
|
let mut stream = service
|
||||||
.open_stream(owner, CATALOG_ALPN)
|
.open_stream(owner, CATALOG_ALPN)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
transport_stats,
|
||||||
|
"catalog",
|
||||||
|
"outbound",
|
||||||
|
"open",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
let mut line = serde_json::to_vec(&CatalogRequest {
|
let mut line = serde_json::to_vec(&CatalogRequest {
|
||||||
artist: artist.to_string(),
|
artist: artist.to_string(),
|
||||||
want: Some(if release.is_some() {
|
want: Some(if release.is_some() {
|
||||||
@@ -414,6 +462,13 @@ pub async fn fetch_image(
|
|||||||
.read_exact(&mut bytes)
|
.read_exact(&mut bytes)
|
||||||
.await
|
.await
|
||||||
.context("stream ended inside the image")?;
|
.context("stream ended inside the image")?;
|
||||||
|
crate::federation::record_stream_transport(
|
||||||
|
transport_stats,
|
||||||
|
"catalog",
|
||||||
|
"outbound",
|
||||||
|
"done",
|
||||||
|
&stream,
|
||||||
|
);
|
||||||
let extension = match header.mime_type.as_str() {
|
let extension = match header.mime_type.as_str() {
|
||||||
"image/png" => "png",
|
"image/png" => "png",
|
||||||
"image/webp" => "webp",
|
"image/webp" => "webp",
|
||||||
|
|||||||
+645
-175
File diff suppressed because it is too large
Load Diff
+90
-36
@@ -1263,6 +1263,56 @@ impl Library {
|
|||||||
.optional()?)
|
.optional()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn liked_content_ids(&self) -> Result<Vec<String>> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT DISTINCT t.content_id
|
||||||
|
FROM likes k
|
||||||
|
JOIN tracks t ON t.id = k.track_id
|
||||||
|
WHERE t.content_id IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|content_id| music_dht::normalize_content_id(&content_id))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the new liked state for this content id.
|
||||||
|
pub fn toggle_like_by_content_id(&self, content_id: &str) -> Result<bool> {
|
||||||
|
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||||||
|
anyhow::bail!("invalid content id");
|
||||||
|
};
|
||||||
|
let mut conn = self.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let removed_local = tx.execute(
|
||||||
|
"DELETE FROM likes
|
||||||
|
WHERE track_id IN (
|
||||||
|
SELECT id FROM tracks WHERE content_id = ?1
|
||||||
|
)",
|
||||||
|
[&content_id],
|
||||||
|
)?;
|
||||||
|
let removed_fed =
|
||||||
|
tx.execute("DELETE FROM fed_likes WHERE content_id = ?1", [&content_id])?;
|
||||||
|
if removed_local + removed_fed > 0 {
|
||||||
|
tx.commit()?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let track_id: i64 = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM tracks WHERE content_id = ?1 ORDER BY id LIMIT 1",
|
||||||
|
[&content_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.with_context(|| format!("no local track with content id {content_id}"))?;
|
||||||
|
tx.execute("INSERT INTO likes (track_id) VALUES (?1)", [track_id])?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_synced_like(&self, track_id: i64, liked: bool, liked_hlc_ms: i64) -> Result<bool> {
|
pub fn set_synced_like(&self, track_id: i64, liked: bool, liked_hlc_ms: i64) -> Result<bool> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
let changed = if liked {
|
let changed = if liked {
|
||||||
@@ -1460,28 +1510,40 @@ impl Library {
|
|||||||
|
|
||||||
/// Toggles a like on a federated track; returns the resulting state.
|
/// Toggles a like on a federated track; returns the resulting state.
|
||||||
pub fn toggle_fed_like(&self, fed: &crate::federation::FedTrack) -> Result<bool> {
|
pub fn toggle_fed_like(&self, fed: &crate::federation::FedTrack) -> Result<bool> {
|
||||||
let conn = self.lock();
|
|
||||||
let content_id = fed
|
let content_id = fed
|
||||||
.content_id
|
.content_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(music_dht::normalize_content_id);
|
.and_then(music_dht::normalize_content_id);
|
||||||
|
let mut conn = self.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
let removed = match content_id.as_deref() {
|
let removed = match content_id.as_deref() {
|
||||||
Some(content_id) => conn.execute(
|
Some(content_id) => {
|
||||||
"DELETE FROM fed_likes WHERE item_id = ?1 OR content_id = ?2",
|
let removed_fed = tx.execute(
|
||||||
params![fed.item_id, content_id],
|
"DELETE FROM fed_likes WHERE item_id = ?1 OR content_id = ?2",
|
||||||
)?,
|
params![fed.item_id, content_id],
|
||||||
None => conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [&fed.item_id])?,
|
)?;
|
||||||
|
let removed_local = tx.execute(
|
||||||
|
"DELETE FROM likes
|
||||||
|
WHERE track_id IN (
|
||||||
|
SELECT id FROM tracks WHERE content_id = ?1
|
||||||
|
)",
|
||||||
|
[content_id],
|
||||||
|
)?;
|
||||||
|
removed_fed + removed_local
|
||||||
|
}
|
||||||
|
None => tx.execute("DELETE FROM fed_likes WHERE item_id = ?1", [&fed.item_id])?,
|
||||||
};
|
};
|
||||||
if removed > 0 {
|
if removed > 0 {
|
||||||
|
tx.commit()?;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if let Some(content_id) = content_id.as_deref() {
|
if let Some(content_id) = content_id.as_deref() {
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"DELETE FROM fed_likes WHERE content_id = ?1 AND item_id != ?2",
|
"DELETE FROM fed_likes WHERE content_id = ?1 AND item_id != ?2",
|
||||||
params![content_id, fed.item_id],
|
params![content_id, fed.item_id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
|
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
|
||||||
featured_artist_names, year, duration_seconds, content_id,
|
featured_artist_names, year, duration_seconds, content_id,
|
||||||
release_title, track_number, disc_number)
|
release_title, track_number, disc_number)
|
||||||
@@ -1500,6 +1562,7 @@ impl Library {
|
|||||||
fed.disc_number,
|
fed.disc_number,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1697,26 +1760,6 @@ impl Library {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn likes(&self) -> Result<Vec<i64>> {
|
|
||||||
let conn = self.lock();
|
|
||||||
let mut statement = conn.prepare("SELECT track_id FROM likes")?;
|
|
||||||
let ids = statement
|
|
||||||
.query_map([], |row| row.get(0))?
|
|
||||||
.collect::<rusqlite::Result<Vec<i64>>>()?;
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the new liked state.
|
|
||||||
pub fn toggle_like(&self, track_id: i64) -> Result<bool> {
|
|
||||||
let conn = self.lock();
|
|
||||||
let removed = conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])?;
|
|
||||||
if removed > 0 {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
conn.execute("INSERT INTO likes (track_id) VALUES (?1)", [track_id])?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_history(
|
pub fn add_history(
|
||||||
&self,
|
&self,
|
||||||
track_id: i64,
|
track_id: i64,
|
||||||
@@ -2255,7 +2298,15 @@ mod tests {
|
|||||||
file_size_bytes: Some(1),
|
file_size_bytes: Some(1),
|
||||||
cover: None,
|
cover: None,
|
||||||
};
|
};
|
||||||
import::upsert_track(lib, &import).unwrap().0
|
let id = import::upsert_track(lib, &import).unwrap().0;
|
||||||
|
let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex());
|
||||||
|
lib.lock()
|
||||||
|
.execute(
|
||||||
|
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||||
|
params![id, content_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2414,10 +2465,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
|
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
|
||||||
|
|
||||||
assert!(lib.toggle_like(track_id).unwrap());
|
let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap();
|
||||||
assert_eq!(lib.likes().unwrap(), vec![track_id]);
|
assert!(lib.toggle_like_by_content_id(&content_id).unwrap());
|
||||||
|
assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]);
|
||||||
assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1);
|
assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1);
|
||||||
assert!(!lib.toggle_like(track_id).unwrap());
|
assert!(!lib.toggle_like_by_content_id(&content_id).unwrap());
|
||||||
|
|
||||||
lib.remove_tracks_from_playlist(playlist.id, &[track_id])
|
lib.remove_tracks_from_playlist(playlist.id, &[track_id])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2432,6 +2484,8 @@ mod tests {
|
|||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
let old_id = add_track(&lib, "Old Local", "Artist", "Album");
|
let old_id = add_track(&lib, "Old Local", "Artist", "Album");
|
||||||
let new_id = add_track(&lib, "New Local", "Artist", "Album");
|
let new_id = add_track(&lib, "New Local", "Artist", "Album");
|
||||||
|
let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap();
|
||||||
|
let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap();
|
||||||
let content_id = format!("b3:{}", "c".repeat(64));
|
let content_id = format!("b3:{}", "c".repeat(64));
|
||||||
let fed = crate::federation::FedTrack {
|
let fed = crate::federation::FedTrack {
|
||||||
item_id: "fed_item_order".to_string(),
|
item_id: "fed_item_order".to_string(),
|
||||||
@@ -2448,8 +2502,8 @@ mod tests {
|
|||||||
disc_number: Some(1),
|
disc_number: Some(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(lib.toggle_like(old_id).unwrap());
|
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||||
assert!(lib.toggle_like(new_id).unwrap());
|
assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap());
|
||||||
assert!(lib.toggle_fed_like(&fed).unwrap());
|
assert!(lib.toggle_fed_like(&fed).unwrap());
|
||||||
{
|
{
|
||||||
let conn = lib.lock();
|
let conn = lib.lock();
|
||||||
@@ -2479,8 +2533,8 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]);
|
assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]);
|
||||||
|
|
||||||
assert!(!lib.toggle_like(old_id).unwrap());
|
assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||||
assert!(lib.toggle_like(old_id).unwrap());
|
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
|
||||||
{
|
{
|
||||||
let conn = lib.lock();
|
let conn = lib.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod library;
|
|||||||
mod media;
|
mod media;
|
||||||
mod player;
|
mod player;
|
||||||
mod share;
|
mod share;
|
||||||
|
mod streaming;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod visualizer;
|
mod visualizer;
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -15,8 +15,12 @@ use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
|
|||||||
|
|
||||||
pub use analyzer::AudioAnalysisSnapshot;
|
pub use analyzer::AudioAnalysisSnapshot;
|
||||||
|
|
||||||
/// Local audio files are read straight from disk.
|
pub trait TrackReadSeek: std::io::Read + std::io::Seek + Send + Sync {}
|
||||||
pub type TrackReader = std::io::BufReader<std::fs::File>;
|
|
||||||
|
impl<T> TrackReadSeek for T where T: std::io::Read + std::io::Seek + Send + Sync {}
|
||||||
|
|
||||||
|
/// Playback sources are either ordinary files or growing streaming cache files.
|
||||||
|
pub type TrackReader = Box<dyn TrackReadSeek>;
|
||||||
|
|
||||||
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
|
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
|
||||||
/// equal percent steps sound like equal loudness steps and low percentages
|
/// equal percent steps sound like equal loudness steps and low percentages
|
||||||
@@ -40,14 +44,16 @@ pub enum PlayerEvent {
|
|||||||
|
|
||||||
enum Command {
|
enum Command {
|
||||||
Play {
|
Play {
|
||||||
reader: Box<TrackReader>,
|
reader: TrackReader,
|
||||||
byte_len: Option<u64>,
|
byte_len: Option<u64>,
|
||||||
|
mime_type: Option<String>,
|
||||||
|
seekable: bool,
|
||||||
volume: f32,
|
volume: f32,
|
||||||
},
|
},
|
||||||
/// Append the next track behind the current one without interrupting
|
/// Append the next track behind the current one without interrupting
|
||||||
/// playback — rodio switches sources back to back (gapless-ish).
|
/// playback — rodio switches sources back to back (gapless-ish).
|
||||||
Enqueue {
|
Enqueue {
|
||||||
reader: Box<TrackReader>,
|
reader: TrackReader,
|
||||||
byte_len: Option<u64>,
|
byte_len: Option<u64>,
|
||||||
},
|
},
|
||||||
Pause,
|
Pause,
|
||||||
@@ -88,17 +94,26 @@ pub struct Controller {
|
|||||||
impl Controller {
|
impl Controller {
|
||||||
pub fn play(&self, reader: TrackReader, byte_len: Option<u64>, volume: f32) {
|
pub fn play(&self, reader: TrackReader, byte_len: Option<u64>, volume: f32) {
|
||||||
let _ = self.tx.send(Command::Play {
|
let _ = self.tx.send(Command::Play {
|
||||||
reader: Box::new(reader),
|
reader,
|
||||||
byte_len,
|
byte_len,
|
||||||
|
mime_type: None,
|
||||||
|
seekable: true,
|
||||||
|
volume,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_stream(&self, reader: TrackReader, mime_type: Option<String>, volume: f32) {
|
||||||
|
let _ = self.tx.send(Command::Play {
|
||||||
|
reader,
|
||||||
|
byte_len: None,
|
||||||
|
mime_type,
|
||||||
|
seekable: false,
|
||||||
volume,
|
volume,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enqueue(&self, reader: TrackReader, byte_len: Option<u64>) {
|
pub fn enqueue(&self, reader: TrackReader, byte_len: Option<u64>) {
|
||||||
let _ = self.tx.send(Command::Enqueue {
|
let _ = self.tx.send(Command::Enqueue { reader, byte_len });
|
||||||
reader: Box::new(reader),
|
|
||||||
byte_len,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pause(&self) {
|
pub fn pause(&self) {
|
||||||
@@ -188,6 +203,8 @@ fn handle(
|
|||||||
Command::Play {
|
Command::Play {
|
||||||
reader,
|
reader,
|
||||||
byte_len,
|
byte_len,
|
||||||
|
mime_type,
|
||||||
|
seekable,
|
||||||
volume,
|
volume,
|
||||||
} => {
|
} => {
|
||||||
// The device is opened lazily on first playback so the app works
|
// The device is opened lazily on first playback so the app works
|
||||||
@@ -211,11 +228,14 @@ fn handle(
|
|||||||
|
|
||||||
let mut builder = Decoder::builder()
|
let mut builder = Decoder::builder()
|
||||||
.with_data(reader)
|
.with_data(reader)
|
||||||
.with_seekable(true)
|
.with_seekable(seekable)
|
||||||
.with_gapless(true);
|
.with_gapless(true);
|
||||||
if let Some(len) = byte_len {
|
if let Some(len) = byte_len {
|
||||||
builder = builder.with_byte_len(len);
|
builder = builder.with_byte_len(len);
|
||||||
}
|
}
|
||||||
|
if let Some(mime_type) = mime_type.as_deref() {
|
||||||
|
builder = builder.with_mime_type(mime_type);
|
||||||
|
}
|
||||||
match builder.build() {
|
match builder.build() {
|
||||||
Ok(decoder) => {
|
Ok(decoder) => {
|
||||||
shared.analysis.clear();
|
shared.analysis.clear();
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read, Seek, SeekFrom};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct State {
|
||||||
|
available: u64,
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Shared {
|
||||||
|
state: Mutex<State>,
|
||||||
|
changed: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct GrowingFileReader {
|
||||||
|
file: File,
|
||||||
|
pos: u64,
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct GrowingFileWriter {
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn growing_file(path: &Path) -> io::Result<(GrowingFileReader, GrowingFileWriter)> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let shared = Arc::new(Shared::default());
|
||||||
|
Ok((
|
||||||
|
GrowingFileReader {
|
||||||
|
file,
|
||||||
|
pos: 0,
|
||||||
|
shared: Arc::clone(&shared),
|
||||||
|
},
|
||||||
|
GrowingFileWriter { shared },
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrowingFileWriter {
|
||||||
|
pub fn add_available(&self, bytes: u64) {
|
||||||
|
let mut state = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
state.available = state.available.saturating_add(bytes);
|
||||||
|
self.shared.changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(&self) {
|
||||||
|
let mut state = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
state.complete = true;
|
||||||
|
self.shared.changed.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for GrowingFileWriter {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for GrowingFileReader {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let mut state = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
while self.pos >= state.available && !state.complete {
|
||||||
|
state = self
|
||||||
|
.shared
|
||||||
|
.changed
|
||||||
|
.wait(state)
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
}
|
||||||
|
if self.pos >= state.available && state.complete {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let readable = (state.available - self.pos).min(buf.len() as u64) as usize;
|
||||||
|
drop(state);
|
||||||
|
let read = self.file.read(&mut buf[..readable])?;
|
||||||
|
self.pos = self.pos.saturating_add(read as u64);
|
||||||
|
if read > 0 {
|
||||||
|
return Ok(read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for GrowingFileReader {
|
||||||
|
fn seek(&mut self, _: SeekFrom) -> io::Result<u64> {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Unsupported,
|
||||||
|
"streaming playback is not seekable yet",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn growing_reader_reads_available_bytes_then_eof() {
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("furumi-growing-reader-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
std::fs::File::create(&path).unwrap();
|
||||||
|
let (mut reader, writer) = growing_file(&path).unwrap();
|
||||||
|
let mut output = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
|
||||||
|
|
||||||
|
output.write_all(b"fur").unwrap();
|
||||||
|
writer.add_available(3);
|
||||||
|
let mut first = [0u8; 3];
|
||||||
|
reader.read_exact(&mut first).unwrap();
|
||||||
|
assert_eq!(&first, b"fur");
|
||||||
|
|
||||||
|
output.write_all(b"umi").unwrap();
|
||||||
|
writer.add_available(3);
|
||||||
|
writer.finish();
|
||||||
|
let mut rest = Vec::new();
|
||||||
|
reader.read_to_end(&mut rest).unwrap();
|
||||||
|
assert_eq!(&rest, b"umi");
|
||||||
|
drop(output);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
-53
@@ -16,6 +16,21 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
if inner.width >= 132 {
|
||||||
|
let desired_settings_width = ((inner.width as usize * 44) / 100).clamp(68, 92) as u16;
|
||||||
|
let settings_width = desired_settings_width.min(inner.width.saturating_sub(56));
|
||||||
|
let [rows_area, _, status_area] = Layout::horizontal([
|
||||||
|
Constraint::Length(settings_width),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
|
||||||
|
draw_settings_rows(frame, rows_area, state);
|
||||||
|
draw_status(frame, status_area, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let rows_height =
|
let rows_height =
|
||||||
(settings_rows(state).len() + 5 + device_presence_sections(state).len()) as u16;
|
(settings_rows(state).len() + 5 + device_presence_sections(state).len()) as u16;
|
||||||
let [rows_area, _, status_area] = Layout::vertical([
|
let [rows_area, _, status_area] = Layout::vertical([
|
||||||
@@ -340,7 +355,7 @@ fn draw_row_enabled(
|
|||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let marker = if selected { "▶ " } else { " " };
|
let marker = if selected { "▶ " } else { " " };
|
||||||
let label_width = 48usize;
|
let label_width = settings_label_width(area.width);
|
||||||
let line = Line::from(vec![
|
let line = Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
marker,
|
marker,
|
||||||
@@ -366,27 +381,142 @@ fn draw_row_enabled(
|
|||||||
*y = (*y).saturating_add(1);
|
*y = (*y).saturating_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn settings_label_width(width: u16) -> usize {
|
||||||
|
let width = width as usize;
|
||||||
|
if width >= 88 {
|
||||||
|
48
|
||||||
|
} else {
|
||||||
|
width.saturating_sub(28).clamp(24, 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn status_line(label: &str, value: String) -> Line<'static> {
|
fn status_line(label: &str, value: String) -> Line<'static> {
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(format!("{label:<22}"), theme::dim()),
|
Span::styled(format!("{label:<18}"), theme::dim()),
|
||||||
Span::raw(value),
|
Span::raw(value),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bytes_label(bytes: u64) -> String {
|
fn short_bytes_label(bytes: u64) -> String {
|
||||||
if bytes >= 1024 * 1024 {
|
if bytes >= 1024 * 1024 {
|
||||||
format!("{bytes} B ({:.1} MiB)", bytes as f64 / 1024.0 / 1024.0)
|
format!("{:.1} MiB", bytes as f64 / 1024.0 / 1024.0)
|
||||||
} else if bytes >= 1024 {
|
} else if bytes >= 1024 {
|
||||||
format!("{bytes} B ({:.1} KiB)", bytes as f64 / 1024.0)
|
format!("{:.1} KiB", bytes as f64 / 1024.0)
|
||||||
} else {
|
} else {
|
||||||
format!("{bytes} B")
|
format!("{bytes} B")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rtt_label(ms: Option<u64>) -> String {
|
||||||
|
ms.map(|ms| format!("{ms} ms"))
|
||||||
|
.unwrap_or_else(|| "rtt n/a".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn short_id(id: &str) -> String {
|
fn short_id(id: &str) -> String {
|
||||||
id.chars().take(12).collect::<String>() + "…"
|
id.chars().take(12).collect::<String>() + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_transport_status(lines: &mut Vec<Line<'static>>, status: &crate::federation::FedStatus) {
|
||||||
|
lines.push(Line::default());
|
||||||
|
lines.push(Line::styled("Iroh Transport", theme::header()));
|
||||||
|
let transport = &status.transport;
|
||||||
|
if transport.total_samples == 0 {
|
||||||
|
lines.push(status_line("Streams", "no samples yet".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let runtime_total = transport
|
||||||
|
.runtime_tx_bytes
|
||||||
|
.saturating_add(transport.runtime_rx_bytes);
|
||||||
|
lines.push(status_line(
|
||||||
|
"Runtime traffic",
|
||||||
|
format!(
|
||||||
|
"{} · tx {} · rx {} · active {}",
|
||||||
|
short_bytes_label(runtime_total),
|
||||||
|
short_bytes_label(transport.runtime_tx_bytes),
|
||||||
|
short_bytes_label(transport.runtime_rx_bytes),
|
||||||
|
transport.active_streams
|
||||||
|
),
|
||||||
|
));
|
||||||
|
if transport.runtime_lost_packets > 0 || transport.runtime_lost_bytes > 0 {
|
||||||
|
lines.push(status_line(
|
||||||
|
"Runtime loss",
|
||||||
|
format!(
|
||||||
|
"{} pkts · {}",
|
||||||
|
transport.runtime_lost_packets,
|
||||||
|
short_bytes_label(transport.runtime_lost_bytes)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.push(status_line(
|
||||||
|
"Samples",
|
||||||
|
format!(
|
||||||
|
"{} total · direct {} · relay {} · custom {} · unknown {}",
|
||||||
|
transport.total_samples,
|
||||||
|
transport.direct_samples,
|
||||||
|
transport.relay_samples,
|
||||||
|
transport.custom_samples,
|
||||||
|
transport.unknown_samples
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Protocols",
|
||||||
|
format!(
|
||||||
|
"audio {} · catalog {} · sync {}",
|
||||||
|
transport.audio_samples, transport.catalog_samples, transport.sync_samples
|
||||||
|
),
|
||||||
|
));
|
||||||
|
if let Some(sample) = transport.last.first() {
|
||||||
|
lines.push(status_line(
|
||||||
|
"Last stream",
|
||||||
|
format!(
|
||||||
|
"{} {} {} · {} · {}",
|
||||||
|
sample.protocol,
|
||||||
|
sample.direction,
|
||||||
|
sample.phase,
|
||||||
|
sample.selected_path,
|
||||||
|
rtt_label(sample.selected_rtt_ms)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Last peer",
|
||||||
|
format!(
|
||||||
|
"{} · paths d/r/c/open {}/{}/{}/{}",
|
||||||
|
short_id(&sample.peer_id),
|
||||||
|
sample.direct_paths,
|
||||||
|
sample.relay_paths,
|
||||||
|
sample.custom_paths,
|
||||||
|
sample.open_paths
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Last bytes",
|
||||||
|
format!(
|
||||||
|
"sel {}/{} · total {}/{} · lost {} / {}",
|
||||||
|
short_bytes_label(sample.selected_tx_bytes),
|
||||||
|
short_bytes_label(sample.selected_rx_bytes),
|
||||||
|
short_bytes_label(sample.total_tx_bytes),
|
||||||
|
short_bytes_label(sample.total_rx_bytes),
|
||||||
|
sample.lost_packets,
|
||||||
|
short_bytes_label(sample.lost_bytes)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for sample in transport.last.iter().take(3) {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!("{:<14}", sample.at), theme::dim()),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{} {} {} · {} · tx {} rx {}",
|
||||||
|
sample.protocol,
|
||||||
|
sample.direction,
|
||||||
|
sample.phase,
|
||||||
|
sample.selected_path,
|
||||||
|
short_bytes_label(sample.total_tx_bytes),
|
||||||
|
short_bytes_label(sample.total_rx_bytes)
|
||||||
|
)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
|
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
|
||||||
match &state.federation.status {
|
match &state.federation.status {
|
||||||
@@ -407,39 +537,53 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(status) => {
|
Some(status) => {
|
||||||
lines.push(status_line("Node", "running".to_string()));
|
lines.push(status_line("Node", format!("running · {}", status.network)));
|
||||||
lines.push(status_line("Network", status.network.clone()));
|
lines.push(status_line(
|
||||||
lines.push(status_line("Endpoint ID", status.endpoint_id.clone()));
|
"Endpoint",
|
||||||
lines.push(status_line("DHT node ID", status.dht_node_id.clone()));
|
format!(
|
||||||
|
"{} · dht {}",
|
||||||
|
short_id(&status.endpoint_id),
|
||||||
|
short_id(&status.dht_node_id)
|
||||||
|
),
|
||||||
|
));
|
||||||
let peers = if status.connected_peers.is_empty() {
|
let peers = if status.connected_peers.is_empty() {
|
||||||
"none yet".to_string()
|
format!("none · contacts {}", status.known_contacts)
|
||||||
} else {
|
} else {
|
||||||
let names: Vec<String> =
|
let names: Vec<String> = status
|
||||||
status.connected_peers.iter().map(|p| short_id(p)).collect();
|
.connected_peers
|
||||||
format!("{} — {}", status.connected_peers.len(), names.join(", "))
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|p| short_id(p))
|
||||||
|
.collect();
|
||||||
|
let more = status.connected_peers.len().saturating_sub(names.len());
|
||||||
|
let more = if more > 0 {
|
||||||
|
format!(" +{more}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{} connected{} · contacts {} · {}",
|
||||||
|
status.connected_peers.len(),
|
||||||
|
more,
|
||||||
|
status.known_contacts,
|
||||||
|
names.join(", ")
|
||||||
|
)
|
||||||
};
|
};
|
||||||
lines.push(status_line("Connected peers", peers));
|
lines.push(status_line("Peers", peers));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Known contacts",
|
"DHT",
|
||||||
status.known_contacts.to_string(),
|
format!(
|
||||||
));
|
"{} records · {} · {} published",
|
||||||
lines.push(status_line(
|
status
|
||||||
"Stored DHT records",
|
.stored_dht_records
|
||||||
status
|
.map(|count| count.to_string())
|
||||||
.stored_dht_records
|
.unwrap_or_else(|| "unavailable".to_string()),
|
||||||
.map(|count| count.to_string())
|
status
|
||||||
.unwrap_or_else(|| "unavailable".to_string()),
|
.stored_dht_bytes
|
||||||
));
|
.map(short_bytes_label)
|
||||||
lines.push(status_line(
|
.unwrap_or_else(|| "unavailable".to_string()),
|
||||||
"Stored DHT bytes",
|
status.published_items
|
||||||
status
|
),
|
||||||
.stored_dht_bytes
|
|
||||||
.map(bytes_label)
|
|
||||||
.unwrap_or_else(|| "unavailable".to_string()),
|
|
||||||
));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Published items",
|
|
||||||
status.published_items.to_string(),
|
|
||||||
));
|
));
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Last sync",
|
"Last sync",
|
||||||
@@ -451,6 +595,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
if let Some(error) = &status.last_error {
|
if let Some(error) = &status.last_error {
|
||||||
lines.push(status_line("Error", error.clone()));
|
lines.push(status_line("Error", error.clone()));
|
||||||
}
|
}
|
||||||
|
push_transport_status(&mut lines, status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
@@ -458,29 +603,32 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
match &state.federation.devices {
|
match &state.federation.devices {
|
||||||
None => lines.push(Line::styled("loading…", theme::dim())),
|
None => lines.push(Line::styled("loading…", theme::dim())),
|
||||||
Some(status) => {
|
Some(status) => {
|
||||||
lines.push(status_line("This device", status.this_device_id.clone()));
|
|
||||||
lines.push(status_line("Sync group", status.group_id.clone()));
|
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Active devices",
|
"This device",
|
||||||
status.active_devices.to_string(),
|
|
||||||
));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Revoked devices",
|
|
||||||
status.revoked_devices.to_string(),
|
|
||||||
));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Pending requests",
|
|
||||||
status.pending_requests.to_string(),
|
|
||||||
));
|
|
||||||
lines.push(status_line("Ops in log", status.ops_total.to_string()));
|
|
||||||
lines.push(status_line(
|
|
||||||
"Tombstones",
|
|
||||||
format!(
|
format!(
|
||||||
"{} ({} compactable)",
|
"{} · {}",
|
||||||
status.tombstone_ops, status.compactable_tombstones
|
status.this_device_name,
|
||||||
|
short_id(&status.this_device_id)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line("Sync group", short_id(&status.group_id)));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Devices",
|
||||||
|
format!(
|
||||||
|
"{} active · {} revoked · {} pending",
|
||||||
|
status.active_devices, status.revoked_devices, status.pending_requests
|
||||||
|
),
|
||||||
|
));
|
||||||
|
lines.push(status_line(
|
||||||
|
"Sync log",
|
||||||
|
format!(
|
||||||
|
"{} ops · {} outbox · {} tombstones ({} gc)",
|
||||||
|
status.ops_total,
|
||||||
|
status.outbox_ops,
|
||||||
|
status.tombstone_ops,
|
||||||
|
status.compactable_tombstones
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
lines.push(status_line("Outbox ops", status.outbox_ops.to_string()));
|
|
||||||
lines.push(status_line(
|
lines.push(status_line(
|
||||||
"Snapshot",
|
"Snapshot",
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
+11
-6
@@ -287,7 +287,7 @@ fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
let message = if let Some(error) = &global.error {
|
let message = if let Some(error) = &global.error {
|
||||||
Line::styled(error.clone(), error_style())
|
Line::styled(error.clone(), error_style())
|
||||||
} else if global.loading {
|
} else if global.loading {
|
||||||
Line::styled("loading artists…", theme::dim())
|
super::loading_line(state, "loading artists…")
|
||||||
} else {
|
} else {
|
||||||
Line::styled("no artists in the library", theme::dim())
|
Line::styled("no artists in the library", theme::dim())
|
||||||
};
|
};
|
||||||
@@ -386,7 +386,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
Some(Loadable::Failed(error)) => {
|
Some(Loadable::Failed(error)) => {
|
||||||
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
||||||
}
|
}
|
||||||
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
|
_ => return centered_line(frame, inner, super::loading_line(state, "loading…")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||||
@@ -653,7 +653,7 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
|||||||
Some(Loadable::Failed(error)) => {
|
Some(Loadable::Failed(error)) => {
|
||||||
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
|
||||||
}
|
}
|
||||||
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
|
_ => return centered_line(frame, inner, super::loading_line(state, "loading…")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
|
||||||
@@ -751,7 +751,12 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
|||||||
} else {
|
} else {
|
||||||
"searching…"
|
"searching…"
|
||||||
};
|
};
|
||||||
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
|
let line = if search.query.is_empty() {
|
||||||
|
Line::styled(hint, theme::dim())
|
||||||
|
} else {
|
||||||
|
super::loading_line(state, hint)
|
||||||
|
};
|
||||||
|
return centered_line(frame, inner, line);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if results.len() == 0
|
if results.len() == 0
|
||||||
@@ -795,7 +800,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
|||||||
if !results.tracks.is_empty() {
|
if !results.tracks.is_empty() {
|
||||||
rows.push((Line::styled("Tracks", theme::header()), None, None));
|
rows.push((Line::styled("Tracks", theme::header()), None, None));
|
||||||
for track in &results.tracks {
|
for track in &results.tracks {
|
||||||
let heart = if state.likes.contains(&track.id) {
|
let heart = if state.track_liked(track) {
|
||||||
Span::styled("♥ ", theme::accent())
|
Span::styled("♥ ", theme::accent())
|
||||||
} else {
|
} else {
|
||||||
Span::raw(" ")
|
Span::raw(" ")
|
||||||
@@ -936,7 +941,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
|||||||
return centered_line(
|
return centered_line(
|
||||||
frame,
|
frame,
|
||||||
inner,
|
inner,
|
||||||
Line::styled("assembling the card from peers…", theme::dim()),
|
super::loading_line(state, "assembling the card from peers…"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loadable::Failed(message) => {
|
Loadable::Failed(message) => {
|
||||||
|
|||||||
+50
-6
@@ -45,6 +45,26 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
|||||||
popup::draw(frame, state);
|
popup::draw(frame, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn loading_line(state: &AppState, text: impl Into<String>) -> Line<'static> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{} ", state.spinner()), theme::accent()),
|
||||||
|
Span::styled(text.into(), theme::dim()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_waiting_message(message: &str) -> bool {
|
||||||
|
let normalized = message.to_ascii_lowercase();
|
||||||
|
normalized.contains("loading")
|
||||||
|
|| normalized.contains("searching")
|
||||||
|
|| normalized.contains("fetching")
|
||||||
|
|| normalized.contains("downloading")
|
||||||
|
|| normalized.contains("locating")
|
||||||
|
|| normalized.contains("importing")
|
||||||
|
|| normalized.contains("waiting")
|
||||||
|
|| normalized.contains("assembling")
|
||||||
|
|| normalized.contains("resolving")
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let titles = Tab::ALL
|
let titles = Tab::ALL
|
||||||
.iter()
|
.iter()
|
||||||
@@ -68,11 +88,31 @@ pub(crate) fn track_row(
|
|||||||
selected: bool,
|
selected: bool,
|
||||||
visual_selected: bool,
|
visual_selected: bool,
|
||||||
) {
|
) {
|
||||||
let fed_liked = track
|
track_row_with_like_marker(
|
||||||
.fed
|
frame,
|
||||||
.as_ref()
|
area,
|
||||||
.is_some_and(|fed| state.fed_track_liked(fed));
|
state,
|
||||||
let heart = if state.likes.contains(&track.id) || fed_liked {
|
track,
|
||||||
|
index_label,
|
||||||
|
selected,
|
||||||
|
visual_selected,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn track_row_with_like_marker(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
state: &AppState,
|
||||||
|
track: &crate::library::models::TrackItem,
|
||||||
|
index_label: String,
|
||||||
|
selected: bool,
|
||||||
|
visual_selected: bool,
|
||||||
|
show_like_marker: bool,
|
||||||
|
) {
|
||||||
|
let heart = if !show_like_marker {
|
||||||
|
Span::raw("")
|
||||||
|
} else if state.track_liked(track) {
|
||||||
Span::styled("♥ ", theme::accent())
|
Span::styled("♥ ", theme::accent())
|
||||||
} else {
|
} else {
|
||||||
Span::raw(" ")
|
Span::raw(" ")
|
||||||
@@ -311,7 +351,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
} else {
|
} else {
|
||||||
spans.push(Span::styled("▶ ", theme::accent()));
|
spans.push(Span::styled("▶ ", theme::accent()));
|
||||||
}
|
}
|
||||||
if state.likes.contains(&track.id) {
|
if state.track_liked(track) {
|
||||||
spans.push(Span::styled("♥ ", theme::accent()));
|
spans.push(Span::styled("♥ ", theme::accent()));
|
||||||
}
|
}
|
||||||
spans.push(Span::raw(track.title.clone()));
|
spans.push(Span::raw(track.title.clone()));
|
||||||
@@ -340,6 +380,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let message = match &state.status_message {
|
let message = match &state.status_message {
|
||||||
|
Some(message) if is_waiting_message(message) => Line::from(vec![
|
||||||
|
Span::styled(format!("{} ", state.spinner()), theme::accent()),
|
||||||
|
Span::styled(message.clone(), theme::accent()),
|
||||||
|
]),
|
||||||
Some(message) => Line::styled(message.clone(), theme::accent()),
|
Some(message) => Line::styled(message.clone(), theme::accent()),
|
||||||
None => match &state.player.current {
|
None => match &state.player.current {
|
||||||
// Idle line doubles as the current track's tech data display.
|
// Idle line doubles as the current track's tech data display.
|
||||||
|
|||||||
+6
-9
@@ -4,8 +4,8 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph};
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
|
|
||||||
use super::{theme, track_row};
|
use super::{loading_line, theme, track_row_with_like_marker};
|
||||||
use crate::app::state::{AppState, Loadable, TrackSelectionScope};
|
use crate::app::state::{AppState, LIKES_PLAYLIST_ID, Loadable, TrackSelectionScope};
|
||||||
use crate::app::update::playlist_tracks;
|
use crate::app::update::playlist_tracks;
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
@@ -51,11 +51,7 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return centered_line(
|
return centered_line(frame, inner, loading_line(state, "loading playlists…"));
|
||||||
frame,
|
|
||||||
inner,
|
|
||||||
Line::styled("loading playlists…", theme::dim()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if list.is_empty() {
|
if list.is_empty() {
|
||||||
@@ -112,7 +108,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let Some(tracks) = playlist_tracks(state, id) else {
|
let Some(tracks) = playlist_tracks(state, id) else {
|
||||||
return centered_line(frame, inner, Line::styled("loading…", theme::dim()));
|
return centered_line(frame, inner, loading_line(state, "loading…"));
|
||||||
};
|
};
|
||||||
if tracks.is_empty() {
|
if tracks.is_empty() {
|
||||||
return centered_line(
|
return centered_line(
|
||||||
@@ -133,7 +129,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
width: inner.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
track_row(
|
track_row_with_like_marker(
|
||||||
frame,
|
frame,
|
||||||
row,
|
row,
|
||||||
state,
|
state,
|
||||||
@@ -143,6 +139,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
|||||||
state
|
state
|
||||||
.track_selection
|
.track_selection
|
||||||
.contains(&TrackSelectionScope::Playlist(id), index),
|
.contains(&TrackSelectionScope::Playlist(id), index),
|
||||||
|
id != LIKES_PLAYLIST_ID,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -197,7 +197,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) {
|
|||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::styled(
|
Paragraph::new(Line::styled(
|
||||||
"enter: activate this device / control selected · esc close",
|
"enter: activate selected device / control active selected · esc close",
|
||||||
theme::dim(),
|
theme::dim(),
|
||||||
))
|
))
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
|
|||||||
Reference in New Issue
Block a user