12 Commits
Author SHA1 Message Date
Ultradesu 29466d70e8 Added --help 2026-07-28 11:18:14 +01:00
Ultradesu abf9ad782c Added --status to get simple status 2026-07-28 11:16:38 +01:00
Ultradesu 28189bae95 Added playback history window 2026-07-27 23:37:46 +01:00
Ultradesu a7f41ff205 Bump libs 2026-07-27 23:20:21 +01:00
Ultradesu 40c318a1d0 Update frid dependency for listening history 2026-07-27 23:19:45 +01:00
Ultradesu 0d6c89761f Sync listening history across devices 2026-07-27 23:15:57 +01:00
Ultradesu f55571ab34 Fixed status icon background 2026-07-27 17:38:14 +01:00
Ultradesu eb97c6469e Fixed liked track sorting 2026-07-27 17:16:39 +01:00
Ultradesu 7ebf07161e Fixed liked track sorting 2026-07-27 17:09:33 +01:00
Ultradesu d36f55b3f6 Reworked MODE switch 2026-07-27 14:13:56 +01:00
Ultradesu 5722e5aae6 prepared release 2026-07-26 03:51:36 +03:00
Ultradesu 4e1c52c4e6 fix readmi 2026-07-26 03:41:43 +03:00
25 changed files with 1132 additions and 174 deletions
+5 -1
View File
@@ -10,6 +10,7 @@ permissions:
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
jobs:
build:
@@ -43,8 +44,11 @@ jobs:
- name: Show Rust version
run: rustc --version && cargo --version
- name: Fetch locked dependencies
run: cargo fetch --locked
- name: Build
run: cargo build --release --locked
run: cargo build --release --locked --offline
- name: Package
shell: bash
+1 -1
View File
@@ -29,7 +29,7 @@ Preserve these architectural invariants:
## Toolchain and checks
The crate uses Rust edition 2024 and Rust 1.88 or newer.
The crate uses Rust edition 2024 and Rust 1.97 or newer.
Run the checks relevant to every code change:
Generated
+12 -13
View File
@@ -240,7 +240,7 @@ version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener 5.4.1",
"event-listener 5.4.2",
"event-listener-strategy",
"pin-project-lite",
]
@@ -1376,11 +1376,10 @@ dependencies = [
[[package]]
name = "event-listener"
version = "5.4.1"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
@@ -1391,7 +1390,7 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener 5.4.1",
"event-listener 5.4.2",
"pin-project-lite",
]
@@ -1456,7 +1455,7 @@ dependencies = [
[[package]]
name = "federation-net"
version = "0.1.0"
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [
"blake3",
"data-encoding",
@@ -1567,7 +1566,7 @@ dependencies = [
[[package]]
name = "furumi_tui"
version = "0.1.6"
version = "0.2.1"
dependencies = [
"anyhow",
"blake3",
@@ -2989,8 +2988,8 @@ dependencies = [
[[package]]
name = "music-dht"
version = "0.1.0"
source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99"
version = "0.2.0"
source = "git+https://gt.hexor.cy/ab/frid.git?rev=8de7d1292708fa0b225e5a4a9d5ab4f0676202d3#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
dependencies = [
"async-trait",
"blake3",
@@ -5370,9 +5369,9 @@ dependencies = [
[[package]]
name = "thin-vec"
version = "0.2.18"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482"
checksum = "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d"
[[package]]
name = "thiserror"
@@ -5632,9 +5631,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
+3 -3
View File
@@ -1,8 +1,8 @@
[package]
name = "furumi_tui"
version = "0.1.6"
version = "0.2.1"
edition = "2024"
rust-version = "1.88"
rust-version = "1.97"
description = "A federated P2P player for personal music libraries"
license = "WTFPL"
@@ -21,7 +21,7 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
lofty = "0.22"
# P2P federation: library index in a shared DHT + audio streaming between
# peers (same protocol as furumi-fd).
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
music-dht = { git = "https://gt.hexor.cy/ab/frid.git", rev = "8de7d1292708fa0b225e5a4a9d5ab4f0676202d3" }
ratatui = "0.30.1"
rhai = { version = "1", features = ["sync"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
+42 -2
View File
@@ -71,8 +71,25 @@ without rebuilding the player.
## Install
Download a prebuilt archive from the project releases, or build Furumi from
source with Rust 1.97 or newer:
### macOS
On Apple Silicon Macs, install Furumi from the Homebrew tap:
```bash
brew install house-of-vanity/tap/furumi
```
Run it with:
```bash
furumi
```
### Linux, Windows, and other platforms
Download a prebuilt archive from the
[GitHub releases](https://github.com/house-of-vanity/furumi_tui/releases), or
build Furumi from source with Rust 1.97 or newer:
```bash
cargo build --release --locked
@@ -97,6 +114,29 @@ Import a music directory from Furumi's command line:
Federation, trusted-device pairing, and key bindings are configured directly
inside the player.
### Now playing in tmux
While Furumi is running, a second invocation can print a cheap, single-line
playback snapshot without opening the TUI or library:
```bash
furumi --status
# ▶ Artist — Track 1:23/4:05
```
For example, add this to `.tmux.conf`:
```tmux
set -g status-interval 1
set -g status-right '#(furumi --status) | %H:%M'
```
`furumi --status-json` returns the same snapshot as JSON, including playback
state, title, artist, album, position, duration, and volume. Both commands
print nothing when Furumi is stopped or no track is loaded. On Linux, Furumi
also exposes the existing MPRIS player `cy.hexor.furumi`, which can be queried
with tools such as `playerctl`.
## Architecture
Furumi is a Rust application built with:
+11 -4
View File
@@ -43,11 +43,13 @@ pub enum Action {
RemoveFromQueue,
ClearQueue,
OpenConnectedDevices,
OpenListenHistory,
GoToRelease,
AddToPlaylist,
NewPlaylist,
ToggleHelp,
ToggleViewMode,
CycleSourceMode,
OpenLibraryFilters,
OpenCommandLine,
OpenSearch,
@@ -101,7 +103,8 @@ impl Action {
| Action::ToggleShuffle
| Action::CycleRepeat
| Action::ToggleVisualizer
| Action::OpenConnectedDevices => Category::Playback,
| Action::OpenConnectedDevices
| Action::OpenListenHistory => Category::Playback,
Action::QueueAddNext
| Action::QueueAddLast
| Action::DownloadSelected
@@ -128,9 +131,10 @@ impl Action {
| Action::GoToTab(_)
| Action::GoToRelease
| Action::ToggleViewMode => Category::Navigation,
Action::EditSelected | Action::DeleteSelected | Action::OpenLibraryFilters => {
Category::Library
}
Action::EditSelected
| Action::DeleteSelected
| Action::CycleSourceMode
| Action::OpenLibraryFilters => Category::Library,
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
Action::ToggleHelp | Action::Quit => Category::System,
}
@@ -150,6 +154,7 @@ impl Action {
Action::CycleRepeat => Some(":repeat [off|one|all]"),
Action::ClearQueue => Some(":clear"),
Action::OpenConnectedDevices => None,
Action::OpenListenHistory => None,
Action::ToggleHelp => Some(":help"),
Action::OpenSearch => Some("/text"),
_ => None,
@@ -192,11 +197,13 @@ impl Action {
Action::RemoveFromQueue => "Queue: remove selected".into(),
Action::ClearQueue => "Queue: clear".into(),
Action::OpenConnectedDevices => "Connected devices…".into(),
Action::OpenListenHistory => "Listening history…".into(),
Action::GoToRelease => "Open the track's release".into(),
Action::AddToPlaylist => "Add track to a playlist…".into(),
Action::NewPlaylist => "Create a playlist".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::CycleSourceMode => "Cycle source mode: Local / My / Global".into(),
Action::OpenLibraryFilters => "Library filters…".into(),
Action::OpenCommandLine => "Command line (:help for commands)".into(),
Action::OpenSearch => "Search artists, releases, tracks".into(),
+1
View File
@@ -56,6 +56,7 @@ pub enum AppEvent {
LocalContentIdsLoaded(Result<Vec<String>, String>),
/// Counts and storage footprint of the local library/database.
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
ListenHistoryLoaded(Result<Vec<crate::library::ListenHistoryEntry>, String>),
/// One content id became available locally while the UI is open.
LocalContentAvailable {
content_id: String,
+98 -22
View File
@@ -66,6 +66,7 @@ pub struct Runtime {
pub player_start_pending: bool,
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
pub last_media_push: Option<std::time::Instant>,
pub status_publisher: crate::status::Publisher,
}
#[derive(Debug, Clone, Copy)]
@@ -286,6 +287,7 @@ pub async fn run(
player_start_pending: false,
media_tx,
last_media_push: None,
status_publisher: crate::status::Publisher::spawn(),
};
spawn_content_id_backfill(&runtime);
@@ -325,6 +327,9 @@ pub async fn run(
state.advance_spinner();
expire_quit_confirmation(&mut state);
sync_player_shared(&mut state, &runtime);
runtime
.status_publisher
.publish(crate::status::PlaybackStatus::from_player(&state.player));
maybe_prefetch_next(&mut state, &runtime);
push_media_update(&state, &mut runtime, false);
}
@@ -425,14 +430,21 @@ fn apply_playback_state_to_ui(
wire: &crate::devices::PlaybackStateWire,
library: Option<&Library>,
) {
state.player.queue = wire
let queue: Vec<_> = wire
.queue
.iter()
.map(|track| playback_track_to_ui(track, library))
.collect();
state.player.queue_pos = wire
.queue_pos
.min(state.player.queue.len().saturating_sub(1));
let queue_pos = queue
.iter()
.take(wire.queue_pos)
.filter(|track| update::track_allowed_by_source_mode(state, track))
.count();
state.player.queue = queue
.into_iter()
.filter(|track| update::track_allowed_by_source_mode(state, track))
.collect();
state.player.queue_pos = queue_pos.min(state.player.queue.len().saturating_sub(1));
state.player.playing = wire.playing && !state.player.queue.is_empty();
state.player.paused = wire.paused;
state.device_playback.local_idle_since_ms = if state.player.playing && !state.player.paused {
@@ -1291,6 +1303,24 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
Effect::SetOptions => {}
Effect::PlaybackQueueChanged => {}
Effect::SourceModeChanged => {
runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
cursors.clear();
}
if let Ok(mut done) = runtime.library_network_done.lock() {
done.clear();
}
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
attempted.clear();
}
save_app_settings(state);
reset_artist_pagination(state);
refresh_artists(state, runtime);
if let Some(effect) = update::apply_library_filter_change(state) {
perform_effect(state, runtime, effect);
}
}
Effect::EnqueueRelease { id, next } => {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
@@ -1307,6 +1337,18 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
});
}
Effect::LoadListenHistory => {
let library = Arc::clone(&runtime.library);
let devices = Arc::clone(&runtime.devices);
let tx = runtime.event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = library
.listen_history(500)
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::ListenHistoryLoaded(result));
let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status()));
});
}
Effect::ToggleLikes {
track_ids,
fed_tracks,
@@ -1660,7 +1702,10 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
state.player.volume = volume.min(100);
save_app_settings(state);
}
Effect::SetOptions | Effect::RemoveQueueIndices { .. } | Effect::PlaybackQueueChanged => {}
Effect::SetOptions
| Effect::RemoveQueueIndices { .. }
| Effect::PlaybackQueueChanged
| Effect::LoadListenHistory => {}
_ => {}
}
record_control_playback_state(state, runtime, seek);
@@ -1744,6 +1789,7 @@ fn start_current_audio(
state.player.position_secs = position_secs.max(0.0);
state.player.audio_analysis = player::AudioAnalysisSnapshot::default();
state.player.track_started_at = Some(now_epoch_seconds());
state.player.listen_id = Some(runtime.devices.new_listen_id());
state.player.prefetched_pos = None;
runtime.player_start_pending = true;
runtime.player.stop();
@@ -1771,16 +1817,19 @@ fn start_current_audio(
}
// The track that was playing until now was cut short by this switch.
let previous_started_at = state.player.track_started_at;
let previous_listen_id = state.player.listen_id.take();
let next_key = track_playback_key(&track);
let mut same_track = false;
let same_track_started_at = if let Some(previous) = state.player.current.take() {
let same_track = track_playback_key(&previous) == next_key;
same_track = track_playback_key(&previous) == next_key;
if state.player.playing && !same_track {
report_history(
runtime,
previous.id,
&previous,
previous_listen_id.as_deref(),
state.player.track_started_at,
state.player.position_secs.round() as i32,
false,
(state.player.position_secs * 1_000.0).round() as i64,
music_dht::device_sync::ListenEndReason::Replaced,
);
}
same_track.then_some(previous_started_at).flatten()
@@ -1793,6 +1842,11 @@ fn start_current_audio(
state.player.position_secs = position_secs.max(0.0);
state.player.audio_analysis = player::AudioAnalysisSnapshot::default();
state.player.track_started_at = same_track_started_at.or_else(|| Some(now_epoch_seconds()));
state.player.listen_id = if same_track {
previous_listen_id
} else {
Some(runtime.devices.new_listen_id())
};
state.player.prefetched_pos = None;
state.status_message = Some(format!("{}{}", track.title, track.artist_line()));
@@ -1947,18 +2001,28 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
/// than 5s are noise.
fn report_history(
runtime: &Runtime,
track_id: i64,
track: &crate::library::models::TrackItem,
listen_id: Option<&str>,
started_at: Option<i64>,
listened: i32,
completed: bool,
listened_ms: i64,
ended_reason: music_dht::device_sync::ListenEndReason,
) {
// Ephemeral federated tracks are not library rows; no history for them.
if listened < 5 || track_id < 0 {
let Some(listen_id) = listen_id else {
return;
}
let library = Arc::clone(&runtime.library);
};
let Some(event) = runtime.devices.listen_event_for_track(
listen_id.to_string(),
track,
started_at.unwrap_or_else(now_epoch_seconds) * 1_000,
listened_ms,
ended_reason,
) else {
tracing::warn!(title = %track.title, "history skipped: track has no content id");
return;
};
let devices = Arc::clone(&runtime.devices);
tokio::task::spawn_blocking(move || {
if let Err(err) = library.add_history(track_id, started_at, listened, completed) {
if let Err(err) = devices.record_listen(event) {
tracing::warn!(%err, "history write failed");
}
});
@@ -2787,6 +2851,12 @@ fn handle_playback_command(
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
match event {
AppEvent::StatusMessage(message) => state.status_message = Some(message),
AppEvent::ListenHistoryLoaded(result) => {
state.listen_history = Some(match result {
Ok(entries) => state::Loadable::Ready(entries),
Err(err) => state::Loadable::Failed(err),
});
}
AppEvent::FederationStatus(status) => {
state.federation.status = Some(status);
}
@@ -3267,10 +3337,11 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
if let Some(finished) = state.player.current.clone() {
report_history(
runtime,
finished.id,
&finished,
state.player.listen_id.as_deref(),
state.player.track_started_at,
finished.duration_seconds.round() as i32,
true,
(finished.duration_seconds * 1_000.0).round() as i64,
music_dht::device_sync::ListenEndReason::Finished,
);
}
if has_next {
@@ -3284,10 +3355,12 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0;
state.player.track_started_at = Some(now_epoch_seconds());
state.player.listen_id = Some(runtime.devices.new_listen_id());
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
} else {
state.player.current = None;
state.player.listen_id = None;
state.player.prefetched_pos = None;
if let Some(effect) = update::advance_after_finish(state) {
perform_effect(state, runtime, effect);
@@ -3410,10 +3483,13 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
});
}
AppEvent::EnqueueTracks { tracks, next } => {
let count = tracks.len();
let previous_len = state.player.queue.len();
update::enqueue_tracks(state, tracks, next);
let count = state.player.queue.len().saturating_sub(previous_len);
record_control_playback_state(state, runtime, false);
state.status_message = Some(if next {
state.status_message = Some(if count == 0 {
"no tracks available in the current source mode".to_string()
} else if next {
format!("{count} tracks queued next")
} else {
format!("{count} tracks queued")
+27 -27
View File
@@ -196,9 +196,28 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
Popup::ConnectedDevices { cursor } => {
handle_connected_devices(state, runtime, cursor, key);
}
Popup::ListenHistory { cursor } => handle_listen_history(state, cursor, key),
}
}
fn handle_listen_history(state: &mut AppState, cursor: usize, key: KeyEvent) {
let len = match state.listen_history.as_ref() {
Some(crate::app::state::Loadable::Ready(entries)) => entries.len(),
_ => 0,
};
let cursor = match key.code {
KeyCode::Esc | KeyCode::Char('q') => return,
KeyCode::Up | KeyCode::Char('k') => cursor.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => (cursor + 1).min(len.saturating_sub(1)),
KeyCode::PageUp => cursor.saturating_sub(10),
KeyCode::PageDown => (cursor + 10).min(len.saturating_sub(1)),
KeyCode::Home | KeyCode::Char('g') => 0,
KeyCode::End | KeyCode::Char('G') => len.saturating_sub(1),
_ => cursor,
};
state.popup = Some(Popup::ListenHistory { cursor });
}
fn handle_federation_status_details(
state: &mut AppState,
mut parent: FederationStatusPopupState,
@@ -404,45 +423,26 @@ fn handle_connected_devices(
fn handle_library_filters(
state: &mut AppState,
runtime: &mut Runtime,
cursor: usize,
_cursor: usize,
key: KeyEvent,
) {
let max_cursor = crate::config::settings::LibrarySourceMode::ALL.len();
let cursor = cursor.min(max_cursor);
let cursor = 0;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {}
KeyCode::Up | KeyCode::Char('k') => {
state.popup = Some(Popup::LibraryFilters {
cursor: cursor.saturating_sub(1),
});
state.popup = Some(Popup::LibraryFilters { cursor: 0 });
}
KeyCode::Down | KeyCode::Char('j') => {
state.popup = Some(Popup::LibraryFilters {
cursor: (cursor + 1).min(max_cursor),
});
state.popup = Some(Popup::LibraryFilters { cursor: 0 });
}
KeyCode::Enter | KeyCode::Char(' ') => {
if cursor == 0 {
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
} else if let Some(mode) =
crate::config::settings::LibrarySourceMode::ALL.get(cursor - 1)
{
state.global.filters.source_mode = *mode;
}
runtime.library_network_refresh_at = None;
if let Ok(mut cursors) = runtime.library_network_cursors.lock() {
cursors.clear();
}
if let Ok(mut done) = runtime.library_network_done.lock() {
done.clear();
}
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
attempted.clear();
}
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
super::save_app_settings(state);
super::reset_artist_pagination(state);
super::refresh_artists(state, runtime);
super::update::apply_library_filter_change(state);
if let Some(effect) = super::update::apply_library_filter_change(state) {
super::perform_effect(state, runtime, effect);
}
}
_ => state.popup = Some(Popup::LibraryFilters { cursor }),
}
+6
View File
@@ -732,6 +732,8 @@ pub enum Popup {
ConfirmDeviceLeave,
/// Connected playback devices and their current role/status.
ConnectedDevices { cursor: usize },
/// Qualified listening history from every trusted device.
ListenHistory { cursor: usize },
/// Full federation, transport and device status details.
FederationStatusDetails {
focus: StatusDetailFocus,
@@ -1170,6 +1172,8 @@ pub struct PlayerBar {
pub audio_analysis: crate::player::AudioAnalysisSnapshot,
/// Epoch seconds when the current track started (for history reports).
pub track_started_at: Option<i64>,
/// Stable id reused for every report of the current playback session.
pub listen_id: Option<String>,
/// Queue index already enqueued in the audio thread for gapless play.
pub prefetched_pos: Option<usize>,
pub volume: u8,
@@ -1191,6 +1195,7 @@ impl Default for PlayerBar {
position_secs: 0.0,
audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
track_started_at: None,
listen_id: None,
prefetched_pos: None,
original_order: None,
volume: 80,
@@ -1277,6 +1282,7 @@ pub struct AppState {
pub likes_loaded: bool,
pub local_content_ids_loaded: bool,
pub local_library_stats: Option<Loadable<crate::library::LocalLibraryStats>>,
pub listen_history: Option<Loadable<Vec<crate::library::ListenHistoryEntry>>>,
pub logs: LogsTab,
pub queue_tab: QueueTab,
pub federation: FederationTab,
+80 -16
View File
@@ -48,6 +48,8 @@ pub enum Effect {
},
/// Queue/options changed without a direct audio engine action.
PlaybackQueueChanged,
/// Persist and apply a Local / My / Global source-mode change.
SourceModeChanged,
/// Persist the federation settings and start/stop the node.
FedApplySettings,
/// Force an immediate library publish into the DHT.
@@ -80,6 +82,8 @@ pub enum Effect {
OpenVisualizerEditor {
path: std::path::PathBuf,
},
/// Load qualified listening history without blocking the UI thread.
LoadListenHistory,
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -112,6 +116,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
state.popup = Some(super::state::Popup::ConnectedDevices { cursor: 0 });
None
}
Action::OpenListenHistory => {
state.popup = Some(super::state::Popup::ListenHistory { cursor: 0 });
state.listen_history = Some(Loadable::Loading);
Some(Effect::LoadListenHistory)
}
Action::NextTab => {
switch_tab(state, state.active_tab.next());
None
@@ -239,6 +248,18 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
None
}
Action::CycleSourceMode => {
if matches!(state.active_tab, Tab::Global | Tab::Playlists | Tab::Queue) {
state.global.filters.source_mode = state.global.filters.source_mode.next();
state.status_message = Some(format!(
"source mode: {}",
state.global.filters.source_mode.label()
));
Some(Effect::SourceModeChanged)
} else {
None
}
}
Action::OpenLibraryFilters => {
if state.active_tab == Tab::Global {
state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 });
@@ -920,7 +941,7 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
}
}
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &[TrackItem])> {
fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, Vec<&TrackItem>)> {
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
@@ -931,23 +952,25 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Some((
TrackSelectionScope::ArtistTop(*id),
*cursor,
&detail.top_tracks,
detail.top_tracks.iter().collect(),
))
} else {
let featured = cursor.checked_sub(tracks + releases)?;
(featured < detail.featured_tracks.len()).then_some((
TrackSelectionScope::ArtistFeatured(*id),
featured,
&detail.featured_tracks,
detail.featured_tracks.iter().collect(),
))
}
}
_ => None,
},
GlobalView::Release { id, cursor } => match state.release_views.get(id)? {
Loadable::Ready(detail) => {
Some((TrackSelectionScope::Release(*id), *cursor, &detail.tracks))
}
Loadable::Ready(detail) => Some((
TrackSelectionScope::Release(*id),
*cursor,
detail.tracks.iter().collect(),
)),
_ => None,
},
_ => None,
@@ -963,7 +986,7 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
Tab::Queue => Some((
TrackSelectionScope::Queue,
state.queue_tab.cursor,
&state.player.queue,
state.player.queue.iter().collect(),
)),
Tab::Federation | Tab::Logs => None,
}
@@ -987,7 +1010,7 @@ pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
.unwrap_or_else(|| vec![cursor.min(tracks.len().saturating_sub(1))]);
indices
.into_iter()
.filter_map(|index| tracks.get(index).cloned())
.filter_map(|index| tracks.get(index).map(|track| (*track).clone()))
.collect()
}
@@ -1076,6 +1099,7 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0;
state.player.track_started_at = None;
state.player.listen_id = None;
state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1);
return QueueRemovalOutcome {
restart_paused: was_loaded.then_some(was_paused),
@@ -1175,21 +1199,32 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
let opened = state.playlists.opened.as_ref()?;
playlist_tracks(state, opened.id)?
.get(opened.cursor)
.cloned()
.map(|track| (*track).clone())
}
Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(),
Tab::Federation | Tab::Logs => None,
}
}
/// Tracks backing an opened playlist, if loaded.
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<&Vec<TrackItem>> {
/// Visible tracks backing an opened playlist. Local mode excludes pending
/// federation entries so they cannot be selected or copied into playback.
pub fn playlist_tracks(state: &AppState, id: i64) -> Option<Vec<&TrackItem>> {
match state.playlist_views.get(&id)? {
Loadable::Ready(detail) => Some(&detail.tracks),
Loadable::Ready(detail) => Some(
detail
.tracks
.iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect(),
),
_ => None,
}
}
pub(crate) fn track_allowed_by_source_mode(state: &AppState, track: &TrackItem) -> bool {
state.global.filters.source_mode.includes_network() || !track.is_fed_pending()
}
/// A *release* under the cursor (artist-view tile/row or a search release).
fn selected_release_id(state: &AppState) -> Option<i64> {
if state.active_tab != Tab::Global {
@@ -1334,6 +1369,10 @@ pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec<crate::library::models
/// Insert tracks after the playing one (`next`) or at the end. Keeps the
/// gapless prefetch index pointing at the same track if items shift.
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) {
let tracks: Vec<_> = tracks
.into_iter()
.filter(|track| track_allowed_by_source_mode(state, track))
.collect();
let player = &mut state.player;
if tracks.is_empty() {
return;
@@ -1790,7 +1829,7 @@ fn set_view_cursor(state: &mut AppState, value: usize) {
/// Items in the playlists tab's current view (list or opened playlist).
fn playlists_view_len(state: &AppState) -> usize {
match &state.playlists.opened {
Some(opened) => playlist_tracks(state, opened.id).map_or(0, Vec::len),
Some(opened) => playlist_tracks(state, opened.id).map_or(0, |tracks| tracks.len()),
None => match &state.playlists.list {
Some(Loadable::Ready(list)) => list.len(),
_ => 0,
@@ -1832,7 +1871,7 @@ fn current_view_len(state: &AppState) -> usize {
}
}
pub(crate) fn apply_library_filter_change(state: &mut AppState) {
pub(crate) fn apply_library_filter_change(state: &mut AppState) -> Option<Effect> {
state.track_selection.clear();
let len = current_view_len(state);
if state.active_tab == Tab::Global {
@@ -1848,11 +1887,32 @@ pub(crate) fn apply_library_filter_change(state: &mut AppState) {
};
*cursor = (*cursor).min(len.saturating_sub(1));
}
} else if state.active_tab == Tab::Playlists
&& let Some(opened) = &mut state.playlists.opened
{
opened.cursor = opened.cursor.min(len.saturating_sub(1));
}
if state.global.filters.source_mode.includes_network() {
return;
return None;
}
let remote_indices: Vec<_> = state
.player
.queue
.iter()
.enumerate()
.filter_map(|(index, track)| track.is_fed_pending().then_some(index))
.collect();
let queue_effect = if remote_indices.is_empty() {
None
} else {
let outcome = remove_queue_indices(state, &remote_indices);
Some(Effect::RemoveQueueIndices {
indices: remote_indices,
restart_paused: outcome.restart_paused,
stop: outcome.stop,
})
};
let message = match state.global.stack.last() {
Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) {
Some(Loadable::Ready(detail))
@@ -1885,6 +1945,7 @@ pub(crate) fn apply_library_filter_change(state: &mut AppState) {
if let Some(message) = message {
state.status_message = Some(message);
}
queue_effect
}
fn jump_selection(state: &mut AppState, first: bool) {
@@ -1954,7 +2015,10 @@ fn jump_selection(state: &mut AppState, first: bool) {
fn select_playlist(state: &mut AppState) -> Option<Effect> {
match state.playlists.opened {
Some(opened) => {
let tracks = playlist_tracks(state, opened.id)?.clone();
let tracks: Vec<_> = playlist_tracks(state, opened.id)?
.into_iter()
.cloned()
.collect();
if tracks.is_empty() {
return None;
}
+176
View File
@@ -16,6 +16,23 @@ fn with_artists(n: usize) -> AppState {
state
}
#[test]
fn listening_history_popup_requests_a_background_load() {
let mut state = AppState::default();
assert_eq!(
update(&mut state, Action::OpenListenHistory),
Some(Effect::LoadListenHistory)
);
assert!(matches!(
state.popup,
Some(crate::app::state::Popup::ListenHistory { cursor: 0 })
));
assert!(matches!(
state.listen_history,
Some(crate::app::state::Loadable::Loading)
));
}
fn test_track(id: i64) -> TrackItem {
TrackItem {
id,
@@ -41,6 +58,23 @@ fn test_track(id: i64) -> TrackItem {
}
}
fn pending_fed_track(id: i64) -> TrackItem {
crate::federation::pending_track(&crate::federation::FedTrack {
item_id: format!("fed-{id}"),
owner: "peer".into(),
own: false,
title: format!("remote-{id}"),
artist_names: vec!["remote artist".into()],
featured_artist_names: vec![],
year: None,
duration_seconds: Some(1),
content_id: Some(format!("b3:{id:064x}")),
release_title: Some("remote release".into()),
track_number: None,
disc_number: None,
})
}
#[test]
fn quit_needs_double_press() {
let mut state = AppState::default();
@@ -115,6 +149,36 @@ fn library_filters_popup_opens_on_library_screens() {
assert!(state.popup.is_none());
}
#[test]
fn source_mode_cycles_on_library_playlists_and_queue_tabs() {
use crate::config::settings::LibrarySourceMode;
let mut state = AppState::default();
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::My);
state.active_tab = Tab::Playlists;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Global);
state.active_tab = Tab::Queue;
assert_eq!(
update(&mut state, Action::CycleSourceMode),
Some(Effect::SourceModeChanged)
);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
state.active_tab = Tab::Federation;
assert_eq!(update(&mut state, Action::CycleSourceMode), None);
assert_eq!(state.global.filters.source_mode, LibrarySourceMode::Local);
}
#[test]
fn back_closes_help_first() {
let mut state = AppState::default();
@@ -387,6 +451,118 @@ fn queue_tab_select_and_clear() {
assert!(!state.player.playing);
}
#[test]
fn local_mode_hides_pending_federation_tracks_from_playlists_and_playback() {
let mut state = AppState {
active_tab: Tab::Playlists,
..AppState::default()
};
state.playlists.opened = Some(OpenedPlaylist { id: 7, cursor: 1 });
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2), test_track(3)],
}),
);
assert_eq!(
playlist_tracks(&state, 7)
.unwrap()
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(
update(&mut state, Action::Select),
Some(Effect::PlayCurrent)
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(state.player.queue_pos, 1);
}
#[test]
fn network_modes_show_pending_federation_playlist_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.playlist_views.insert(
7,
Loadable::Ready(crate::library::models::PlaylistDetail {
id: 7,
title: "mixed".into(),
description: None,
tracks: vec![test_track(1), pending_fed_track(2)],
}),
);
assert_eq!(playlist_tracks(&state, 7).unwrap().len(), 2);
}
#[test]
fn local_mode_rejects_async_federation_queue_additions() {
let mut state = AppState::default();
enqueue_tracks(
&mut state,
vec![test_track(1), pending_fed_track(2), test_track(3)],
false,
);
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
}
#[test]
fn switching_to_local_mode_removes_pending_federation_queue_tracks() {
let mut state = AppState::default();
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::My;
state.player.queue = vec![test_track(1), pending_fed_track(2), test_track(3)];
state.player.queue_pos = 1;
state.player.current = Some(state.player.queue[1].clone());
state.player.playing = true;
state.global.filters.source_mode = crate::config::settings::LibrarySourceMode::Local;
let effect = apply_library_filter_change(&mut state);
assert!(matches!(
effect,
Some(Effect::RemoveQueueIndices {
indices,
restart_paused: Some(false),
stop: false,
}) if indices == vec![1]
));
assert_eq!(
state
.player
.queue
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(state.player.queue_pos, 1);
assert_eq!(state.player.current.as_ref().map(|track| track.id), Some(3));
}
#[test]
fn current_track_info_uses_now_playing_track() {
let mut state = AppState {
+8
View File
@@ -197,6 +197,10 @@ command = "CycleRepeat"
key_sequence = "shift-l"
command = "ToggleVisualizer"
[[keymaps]]
key_sequence = "shift-h"
command = "OpenListenHistory"
[[keymaps]]
key_sequence = "x"
command = "ToggleLike"
@@ -225,6 +229,10 @@ command = "DeleteSelected"
key_sequence = "v"
command = "ToggleViewMode"
[[keymaps]]
key_sequence = "m"
command = "CycleSourceMode"
[[keymaps]]
key_sequence = "f"
command = "OpenLibraryFilters"
+24
View File
@@ -426,6 +426,21 @@ mod tests {
);
}
#[test]
fn default_source_mode_key_resolves_on_content_tabs() {
let mut km = keymap_from(DEFAULT_KEYMAP);
for context in [
KeyContext::Library,
KeyContext::Playlists,
KeyContext::Queue,
] {
assert_eq!(
km.resolve(key!(m), context),
KeyResolution::Action(Action::CycleSourceMode)
);
}
}
#[test]
fn default_shift_n_is_unbound() {
let mut km = keymap_from(DEFAULT_KEYMAP);
@@ -581,4 +596,13 @@ mod tests {
KeyResolution::Action(Action::SeekForward { seconds: 10 })
);
}
#[test]
fn default_listening_history_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert_eq!(
km.resolve(key!(shift - h), KeyContext::Library),
KeyResolution::Action(Action::OpenListenHistory)
);
}
}
+8 -21
View File
@@ -11,12 +11,6 @@ pub enum LibrarySourceMode {
}
impl LibrarySourceMode {
pub const ALL: [LibrarySourceMode; 3] = [
LibrarySourceMode::Local,
LibrarySourceMode::My,
LibrarySourceMode::Global,
];
pub fn label(self) -> &'static str {
match self {
LibrarySourceMode::Local => "Local",
@@ -25,14 +19,6 @@ impl LibrarySourceMode {
}
}
pub fn description(self) -> &'static str {
match self {
LibrarySourceMode::Local => "only this device",
LibrarySourceMode::My => "this device + connected devices",
LibrarySourceMode::Global => "my devices + known federation peers",
}
}
pub fn includes_network(self) -> bool {
!matches!(self, LibrarySourceMode::Local)
}
@@ -40,6 +26,14 @@ impl LibrarySourceMode {
pub fn includes_global_peers(self) -> bool {
matches!(self, LibrarySourceMode::Global)
}
pub fn next(self) -> Self {
match self {
LibrarySourceMode::Local => LibrarySourceMode::My,
LibrarySourceMode::My => LibrarySourceMode::Global,
LibrarySourceMode::Global => LibrarySourceMode::Local,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -50,12 +44,6 @@ pub struct LibraryFilters {
pub source_mode: LibrarySourceMode,
}
impl LibraryFilters {
pub fn is_active(&self) -> bool {
self.hide_featured_only || self.source_mode != LibrarySourceMode::Local
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default = "default_volume")]
@@ -140,7 +128,6 @@ hide_featured_only = true
assert_eq!(settings.volume, 100);
assert!(settings.library.hide_featured_only);
assert!(settings.library.is_active());
assert_eq!(settings.library.source_mode, LibrarySourceMode::Local);
}
}
+60 -2
View File
@@ -12,6 +12,7 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result};
use music_dht::device_sync::{ListenEvent, ListenTrackMetadata};
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
@@ -21,9 +22,9 @@ use crate::app::event::AppEvent;
use crate::library::Library;
use crate::library::models::{ArtistRef, TrackItem};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSION: u16 = 1;
const PROTOCOL_VERSION: u16 = 2;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -366,6 +367,9 @@ pub enum SyncOpPayload {
target_device_id: String,
command: PlaybackCommand,
},
ListenRecorded {
event: ListenEvent,
},
}
impl SyncOpPayload {
@@ -637,6 +641,10 @@ impl DeviceSync {
Ok((identity.device_id, identity.name))
}
pub fn new_listen_id(&self) -> String {
format!("{}-{}", now_ms(), random_hex(12))
}
pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) {
if snapshot.updated_at_ms <= 0 {
snapshot.updated_at_ms = now_ms();
@@ -1073,6 +1081,52 @@ impl DeviceSync {
Ok(())
}
pub fn record_listen(&self, event: ListenEvent) -> Result<()> {
if !event.should_record() {
return Ok(());
}
self.record_local_op(SyncOpPayload::ListenRecorded { event })
}
pub fn listen_event_for_track(
&self,
listen_id: String,
track: &TrackItem,
started_at_ms: i64,
listened_ms: i64,
ended_reason: music_dht::device_sync::ListenEndReason,
) -> Option<ListenEvent> {
let content_id = track
.content_id
.as_deref()
.or_else(|| track.fed.as_ref()?.content_id.as_deref())
.and_then(music_dht::normalize_content_id)?;
Some(ListenEvent {
listen_id,
content_id,
started_at_ms,
listened_ms,
track_duration_ms: (track.duration_seconds > 0.0)
.then_some((track.duration_seconds * 1_000.0).round() as i64),
ended_reason,
track: ListenTrackMetadata {
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(),
release_title: (!track.release_title.trim().is_empty())
.then(|| track.release_title.clone()),
},
})
}
pub fn record_playlist_created(&self, playlist_id: i64, title: &str) -> Result<()> {
let playlist_id = self.library.ensure_playlist_sync_id(playlist_id)?;
self.record_local_op(SyncOpPayload::PlaylistCreated {
@@ -1584,6 +1638,9 @@ impl DeviceSync {
self.apply_playback_command(target_device_id, command, &op.op_id)?;
false
}
SyncOpPayload::ListenRecorded { event } => self
.library
.apply_listen_event(event, &op.origin_device_id)?,
};
Ok(changed)
}
@@ -3229,6 +3286,7 @@ fn payload_kind(payload: &SyncOpPayload) -> &'static str {
SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
SyncOpPayload::PlaybackCommand { .. } => "playback_command",
SyncOpPayload::ListenRecorded { .. } => "listen_recorded",
}
}
+143 -21
View File
@@ -26,6 +26,16 @@ use models::{
pub const LIKES_PLAYLIST_ID: i64 = -1;
const NETWORK_ARTIST_CACHE_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenHistoryEntry {
pub listen_id: String,
pub content_id: String,
pub title: String,
pub artist: String,
pub origin_device_id: String,
pub started_at_ms: i64,
}
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY,
@@ -126,10 +136,43 @@ CREATE TABLE IF NOT EXISTS history (
completed INTEGER NOT NULL DEFAULT 0,
played_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS listen_events (
listen_id TEXT PRIMARY KEY,
content_id TEXT NOT NULL,
local_track_id INTEGER REFERENCES tracks(id) ON DELETE SET NULL,
origin_device_id TEXT NOT NULL,
started_at_ms INTEGER NOT NULL,
listened_ms INTEGER NOT NULL,
track_duration_ms INTEGER,
ended_reason TEXT NOT NULL,
qualified INTEGER NOT NULL,
metadata_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tracks_release ON tracks(release_id);
CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id);
CREATE INDEX IF NOT EXISTS idx_release_artists_artist ON release_artists(artist_id);
CREATE INDEX IF NOT EXISTS idx_history_track ON history(track_id);
CREATE INDEX IF NOT EXISTS idx_listen_events_content
ON listen_events(content_id, started_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_listen_events_local_track
ON listen_events(local_track_id, qualified);
CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_insert
AFTER INSERT ON tracks
WHEN NEW.content_id IS NOT NULL
BEGIN
UPDATE listen_events
SET local_track_id = NEW.id
WHERE content_id = NEW.content_id;
END;
CREATE TRIGGER IF NOT EXISTS reconcile_listen_events_after_track_content_id
AFTER UPDATE OF content_id ON tracks
WHEN NEW.content_id IS NOT NULL
BEGIN
UPDATE listen_events
SET local_track_id = NEW.id
WHERE content_id = NEW.content_id;
END;
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
CREATE INDEX IF NOT EXISTS idx_fed_playlist_tracks_playlist
ON fed_playlist_tracks(playlist_sync_id, position);
@@ -159,7 +202,9 @@ const TRACK_COLUMNS: &str = "
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
t.audio_bit_depth, t.file_size_bytes,
t.content_id,
(SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
((SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
+ (SELECT COUNT(*) FROM listen_events le
WHERE le.local_track_id = t.id AND le.qualified = 1))
";
/// Plain rows handed to the federation for publishing (see
@@ -798,9 +843,14 @@ impl Library {
|row| row.get(0),
)?;
let total_play_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM history h
WHERE h.completed = 1 AND h.track_id IN
(SELECT track_id FROM track_artists WHERE artist_id = ?1)",
"SELECT
(SELECT COUNT(*) FROM history h
WHERE h.completed = 1 AND h.track_id IN
(SELECT track_id FROM track_artists WHERE artist_id = ?1))
+
(SELECT COUNT(*) FROM listen_events le
WHERE le.qualified = 1 AND le.local_track_id IN
(SELECT track_id FROM track_artists WHERE artist_id = ?1))",
[id],
|row| row.get(0),
)?;
@@ -1201,7 +1251,11 @@ impl Library {
.get(&track.id)
.copied()
.unwrap_or_default();
(liked_at, track.title.clone(), track)
let stable_key = track
.content_id
.clone()
.unwrap_or_else(|| format!("local:{:020}", track.id));
(liked_at, stable_key, track)
})
.collect();
@@ -1218,11 +1272,11 @@ impl Library {
})?;
for row in fed_rows {
let (liked_at, fed) = row?;
entries.push((
liked_at,
fed.title.clone(),
crate::federation::pending_track(&fed),
));
let stable_key = fed
.content_id
.clone()
.unwrap_or_else(|| format!("fed:{}", fed.item_id));
entries.push((liked_at, stable_key, crate::federation::pending_track(&fed)));
}
entries.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
return Ok(PlaylistDetail {
@@ -2192,20 +2246,88 @@ impl Library {
Ok(true)
}
pub fn add_history(
/// Idempotently materialize a portable trusted-device listen event.
pub fn apply_listen_event(
&self,
track_id: i64,
started_at: Option<i64>,
listened_seconds: i32,
completed: bool,
) -> Result<()> {
event: &music_dht::device_sync::ListenEvent,
origin_device_id: &str,
) -> Result<bool> {
if !event.should_record() || origin_device_id.trim().is_empty() {
return Ok(false);
}
let content_id = music_dht::normalize_content_id(&event.content_id)
.context("invalid listen content id")?;
let conn = self.lock();
conn.execute(
"INSERT INTO history (track_id, started_at, listened_seconds, completed)
VALUES (?1, ?2, ?3, ?4)",
params![track_id, started_at, listened_seconds, completed],
let local_track_id: Option<i64> = conn
.query_row(
"SELECT id FROM tracks WHERE content_id = ?1 LIMIT 1",
[&content_id],
|row| row.get(0),
)
.optional()?;
let inserted = conn.execute(
"INSERT OR IGNORE INTO listen_events
(listen_id, content_id, local_track_id, origin_device_id,
started_at_ms, listened_ms, track_duration_ms, ended_reason,
qualified, metadata_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
event.listen_id,
content_id,
local_track_id,
origin_device_id,
event.started_at_ms,
event.listened_ms,
event.track_duration_ms,
serde_json::to_string(&event.ended_reason)?,
i64::from(event.qualifies_as_play()),
serde_json::to_string(&event.track)?,
],
)?;
Ok(())
Ok(inserted > 0)
}
/// Most recent qualified listens, including tracks that are not present
/// in this device's local library.
pub fn listen_history(&self, limit: usize) -> Result<Vec<ListenHistoryEntry>> {
let conn = self.lock();
let mut stmt = conn.prepare(
"SELECT listen_id, content_id, origin_device_id, started_at_ms, metadata_json
FROM listen_events
WHERE qualified = 1
ORDER BY started_at_ms DESC, listen_id DESC
LIMIT ?1",
)?;
let rows = stmt
.query_map([limit.min(i64::MAX as usize) as i64], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, String>(4)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
rows.into_iter()
.map(
|(listen_id, content_id, origin_device_id, started_at_ms, metadata_json)| {
let metadata: music_dht::device_sync::ListenTrackMetadata =
serde_json::from_str(&metadata_json)
.context("invalid listen history metadata")?;
let mut artists = metadata.artist_names;
artists.extend(metadata.featured_artist_names);
Ok(ListenHistoryEntry {
listen_id,
content_id,
title: metadata.title,
artist: artists.join(", "),
origin_device_id,
started_at_ms,
})
},
)
.collect()
}
// -----------------------------------------------------------------
+49 -2
View File
@@ -502,8 +502,55 @@ fn delete_track_drops_empty_release() {
fn history_counts_completed_plays() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.add_history(track_id, None, 60, true).unwrap();
lib.add_history(track_id, None, 10, false).unwrap();
let content_id = lib
.tracks_by_ids(&[track_id])
.unwrap()
.remove(0)
.content_id
.unwrap();
let event = music_dht::device_sync::ListenEvent {
listen_id: "listen-1".to_string(),
content_id,
started_at_ms: 1_700_000_000_000,
listened_ms: 60_000,
track_duration_ms: Some(60_000),
ended_reason: music_dht::device_sync::ListenEndReason::Finished,
track: music_dht::device_sync::ListenTrackMetadata {
title: "Song".to_string(),
artist_names: vec!["Artist".to_string()],
featured_artist_names: Vec::new(),
release_title: Some("Album".to_string()),
},
};
assert!(lib.apply_listen_event(&event, "device-a").unwrap());
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(track.play_count, 1);
let history = lib.listen_history(20).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].listen_id, "listen-1");
assert_eq!(history[0].title, "Song");
assert_eq!(history[0].artist, "Artist");
assert_eq!(history[0].origin_device_id, "device-a");
}
#[test]
fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
let lib = test_library();
let event = music_dht::device_sync::ListenEvent {
listen_id: "remote-listen".to_string(),
content_id: format!("b3:{}", "a".repeat(64)),
started_at_ms: 1_700_000_000_000,
listened_ms: 10_000,
track_duration_ms: Some(120_000),
ended_reason: music_dht::device_sync::ListenEndReason::Skipped,
track: music_dht::device_sync::ListenTrackMetadata {
title: "Remote song".to_string(),
artist_names: vec!["Remote artist".to_string()],
featured_artist_names: vec!["Guest".to_string()],
release_title: None,
},
};
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
assert!(lib.listen_history(20).unwrap().is_empty());
}
+30 -1
View File
@@ -7,6 +7,7 @@ mod library;
mod media;
mod player;
mod share;
mod status;
mod streaming;
mod ui;
mod visualizer;
@@ -19,11 +20,39 @@ use crossterm::event::{
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
const HELP: &str = "\
Furumi federated terminal music player
Usage:
furumi [OPTION]
Options:
-h, --help Show this help
-V, --version Show version
--status Print a one-line now-playing status
--status-json
Print now-playing status as JSON
tmux:
set -g status-right '#(furumi --status)'
";
fn main() -> Result<()> {
if std::env::args_os().any(|arg| arg == "--version" || arg == "-V") {
let args: Vec<_> = std::env::args_os().collect();
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
print!("{HELP}");
return Ok(());
}
if args.iter().any(|arg| arg == "--version" || arg == "-V") {
println!("furumi {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if args
.iter()
.any(|arg| arg == "--status" || arg == "--status-json")
{
return status::print(args.iter().any(|arg| arg == "--status-json"));
}
let mut startup_warning = None;
if let Err(err) = config::logging::init() {
+190
View File
@@ -0,0 +1,190 @@
//! Cheap now-playing export for status bars and other polling clients.
//!
//! The UI only sends snapshots through a bounded channel. A dedicated thread
//! performs the filesystem writes so a slow filesystem cannot stall drawing
//! or input handling.
use std::fs;
use std::io;
use std::path::PathBuf;
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::app::state::PlayerBar;
const STALE_AFTER_MS: u64 = 5_000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlaybackStatus {
pub playing: bool,
pub paused: bool,
pub title: String,
pub artist: String,
pub album: String,
pub position_secs: f64,
pub duration_secs: f64,
pub volume: u8,
pub updated_at_ms: u64,
}
impl PlaybackStatus {
pub fn from_player(player: &PlayerBar) -> Option<Self> {
let track = player.current.as_ref()?;
player.playing.then(|| Self {
playing: true,
paused: player.paused,
title: track.title.clone(),
artist: track.artist_line(),
album: track.release_title.clone(),
position_secs: player.position_secs.max(0.0),
duration_secs: track.duration_seconds.max(0.0),
volume: player.volume,
updated_at_ms: now_ms(),
})
}
pub fn one_line(&self) -> String {
let icon = if self.paused { "" } else { "" };
let track = if self.artist.is_empty() {
self.title.clone()
} else {
format!("{}{}", self.artist, self.title)
};
format!(
"{icon} {track} {}/{}",
duration(self.position_secs),
duration(self.duration_secs)
)
}
}
pub struct Publisher {
tx: Option<SyncSender<Option<PlaybackStatus>>>,
}
impl Publisher {
pub fn spawn() -> Self {
let (tx, rx) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("playback-status".to_string())
.spawn(move || {
while let Ok(snapshot) = rx.recv() {
if let Some(snapshot) = snapshot {
if let Err(err) = write(&snapshot) {
tracing::debug!(?err, "writing playback status failed");
}
} else {
remove();
}
}
remove();
})
.ok();
Self { tx: Some(tx) }
}
pub fn publish(&self, snapshot: Option<PlaybackStatus>) {
let Some(tx) = &self.tx else { return };
match tx.try_send(snapshot) {
Ok(()) | Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => {}
}
}
}
impl Drop for Publisher {
fn drop(&mut self) {
self.tx.take();
}
}
pub fn print(json: bool) -> anyhow::Result<()> {
let Some(status) = read()? else {
return Ok(());
};
if json {
println!("{}", serde_json::to_string(&status)?);
} else {
println!("{}", status.one_line());
}
Ok(())
}
fn read() -> anyhow::Result<Option<PlaybackStatus>> {
let Some(path) = path() else {
return Ok(None);
};
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let status: PlaybackStatus = serde_json::from_slice(&bytes)?;
if now_ms().saturating_sub(status.updated_at_ms) > STALE_AFTER_MS {
return Ok(None);
}
Ok(Some(status))
}
fn write(status: &PlaybackStatus) -> anyhow::Result<()> {
let Some(path) = path() else {
return Ok(());
};
let Some(dir) = path.parent() else {
return Ok(());
};
fs::create_dir_all(dir)?;
let temporary = dir.join("playback-status.tmp");
fs::write(&temporary, serde_json::to_vec(status)?)?;
#[cfg(windows)]
if path.exists() {
let _ = fs::remove_file(&path);
}
fs::rename(temporary, path)?;
Ok(())
}
fn remove() {
if let Some(path) = path() {
let _ = fs::remove_file(path);
}
}
fn path() -> Option<PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.cache_dir().join("playback-status.json"))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
fn duration(seconds: f64) -> String {
let total = seconds.max(0.0).round() as u64;
format!("{}:{:02}", total / 60, total % 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_line_contains_state_metadata_and_progress() {
let status = PlaybackStatus {
playing: true,
paused: true,
title: "Track".into(),
artist: "Artist".into(),
album: "Release".into(),
position_secs: 83.0,
duration_secs: 245.0,
volume: 80,
updated_at_ms: 0,
};
assert_eq!(status.one_line(), "⏸ Artist — Track 1:23/4:05");
}
}
+5 -4
View File
@@ -152,7 +152,8 @@ fn draw_tile_meta(
availability: Option<Availability>,
selected: bool,
) {
let marker = availability.map(|availability| availability_marker(availability, selected));
let selected_style = selected.then(|| theme::tab_active_for(state));
let marker = availability.map(|availability| availability_marker(availability, selected_style));
let marker_width = marker
.map(|(label, _)| UnicodeWidthStr::width(label) as u16)
.unwrap_or(0)
@@ -383,15 +384,15 @@ fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
let global = &state.global;
let title = if global.total > 0 {
format!(
" Library — {} artists · {} ",
" Library — {} artists · Mode: {} ",
global.total,
global.filters.source_mode.label()
)
} else {
format!(" Library · {} ", global.filters.source_mode.label())
format!(" Library · Mode: {} ", global.filters.source_mode.label())
};
let mut title_spans = vec![Span::styled(title, theme::tab_active_for(state))];
if global.filters.is_active() {
if global.filters.hide_featured_only {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(" FILTERED ", theme::tab_active_for(state)));
}
+21 -9
View File
@@ -19,25 +19,36 @@ use crate::library::models::Availability;
pub(crate) fn availability_marker(
availability: Availability,
selected: bool,
selected_style: Option<Style>,
) -> (&'static str, Style) {
let (label, style) = match availability {
Availability::Local => ("", Style::new().fg(Color::Green)),
Availability::Mixed => ("", Style::new().fg(Color::Yellow)),
Availability::Remote => ("", theme::accent()),
};
if selected {
(label, theme::tab_active())
} else {
(label, style)
}
(label, selected_style.unwrap_or(style))
}
pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> {
let (label, style) = availability_marker(availability, false);
let (label, style) = availability_marker(availability, None);
Span::styled(format!("{label} "), style)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_availability_marker_uses_the_supplied_mode_style() {
let client_style = Style::new().fg(Color::Black).bg(Color::Yellow);
let (label, style) = availability_marker(Availability::Local, Some(client_style));
assert_eq!(label, "");
assert_eq!(style, client_style);
}
}
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.visualizer.active {
crate::visualizer::draw(frame, state);
@@ -247,8 +258,9 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player;
let block = Block::bordered()
.title(format!(
" Queue — {} tracks · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len()
" Queue — {} tracks · Mode: {} · enter: play · d: remove · shift-v: select · :clear ",
player.queue.len(),
state.global.filters.source_mode.label()
))
.title_style(theme::header_for(state))
.border_style(theme::border_for(state));
+18 -3
View File
@@ -38,7 +38,15 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
}
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
let inner = bordered(frame, area, state, " Playlists ".to_string());
let inner = bordered(
frame,
area,
state,
format!(
" Playlists · Mode: {} ",
state.global.filters.source_mode.label()
),
);
let selected = state.playlists.selected;
let list = match &state.playlists.list {
@@ -97,8 +105,15 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.playlist_views.get(&id);
let title = match loadable {
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title),
_ => " Playlists ▸ ".to_string(),
Some(Loadable::Ready(detail)) => format!(
" Playlists ▸ {} · Mode: {} ",
detail.title,
state.global.filters.source_mode.label()
),
_ => format!(
" Playlists ▸ … · Mode: {} ",
state.global.filters.source_mode.label()
),
};
let inner = bordered(frame, area, state, title);
+114 -15
View File
@@ -1,7 +1,7 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState, Wrap};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::theme;
@@ -88,10 +88,121 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
}
Some(Popup::ConfirmDeviceLeave) => draw_device_leave(frame, state),
Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor),
Some(Popup::ListenHistory { cursor }) => draw_listen_history(frame, state, *cursor),
None => {}
}
}
fn draw_listen_history(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered(
frame.area(),
100,
frame.area().height.saturating_sub(4).clamp(10, 30),
);
let block = Block::bordered()
.title(" Listening history ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
match state.listen_history.as_ref() {
Some(Loadable::Loading) | None => {
frame.render_widget(
Paragraph::new(format!("{} Loading history…", state.spinner()))
.alignment(Alignment::Center),
body,
);
}
Some(Loadable::Failed(err)) => {
frame.render_widget(
Paragraph::new(format!("History unavailable: {err}"))
.style(theme::dim())
.wrap(Wrap { trim: true }),
body,
);
}
Some(Loadable::Ready(entries)) if entries.is_empty() => {
frame.render_widget(
Paragraph::new("No qualified listens yet.")
.style(theme::dim())
.alignment(Alignment::Center),
body,
);
}
Some(Loadable::Ready(entries)) => {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or_default();
let rows = entries.iter().map(|entry| {
Row::new(vec![
Cell::from(entry.title.clone()),
Cell::from(entry.artist.clone()),
Cell::from(relative_listen_time(entry.started_at_ms, now_ms)),
Cell::from(history_device_name(state, &entry.origin_device_id)),
])
});
let mut table_state =
TableState::default().with_selected(cursor.min(entries.len() - 1));
let table = Table::new(
rows,
[
Constraint::Percentage(34),
Constraint::Percentage(28),
Constraint::Length(12),
Constraint::Percentage(26),
],
)
.header(Row::new(["Track", "Artist", "When", "Device"]).style(theme::header_for(state)))
.row_highlight_style(theme::selection_for(state))
.highlight_symbol(" ");
frame.render_stateful_widget(table, body, &mut table_state);
}
}
frame.render_widget(
Paragraph::new("j/k scroll · pgup/pgdn page · esc close")
.style(theme::dim())
.alignment(Alignment::Center),
footer,
);
}
fn history_device_name(state: &AppState, device_id: &str) -> String {
state
.federation
.devices
.as_ref()
.and_then(|status| {
status
.devices
.iter()
.find(|device| device.device_id == device_id)
.map(|device| device.name.clone())
})
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| {
if device_id == state.device_playback.self_device_id {
state.device_playback.self_device_name.clone()
} else {
device_id.chars().take(10).collect()
}
})
}
fn relative_listen_time(started_at_ms: i64, now_ms: i64) -> String {
let elapsed = now_ms.saturating_sub(started_at_ms).max(0) / 1_000;
match elapsed {
0..=59 => "now".to_string(),
60..=3_599 => format!("{}m ago", elapsed / 60),
3_600..=86_399 => format!("{}h ago", elapsed / 3_600),
86_400..=604_799 => format!("{}d ago", elapsed / 86_400),
_ => format!("{}w ago", elapsed / 604_800),
}
}
fn draw_federation_status_details(
frame: &mut Frame,
state: &AppState,
@@ -598,7 +709,7 @@ fn clip_cells(text: &str, max_width: usize) -> String {
}
fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
let area = centered(frame.area(), 54, 9);
let area = centered(frame.area(), 44, 6);
let block = Block::bordered()
.title(" Library filters ")
.title_style(theme::header_for(state))
@@ -608,7 +719,7 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
frame.render_widget(block, area);
let [list_area, _, footer] = Layout::vertical([
Constraint::Length(4),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
@@ -624,18 +735,6 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
Span::styled(format!("{checked} "), theme::accent_for(state)),
Span::raw("Hide featured only"),
]));
for mode in crate::config::settings::LibrarySourceMode::ALL {
let marker = if state.global.filters.source_mode == mode {
"(*)"
} else {
"( )"
};
rows.push(Line::from(vec![
Span::styled(format!("{marker} "), theme::accent_for(state)),
Span::raw(mode.label()),
Span::styled(format!(" {}", mode.description()), theme::dim()),
]));
}
for (index, line) in rows.into_iter().enumerate() {
let row = Rect {
y: list_area.y + index as u16,
-7
View File
@@ -18,13 +18,6 @@ pub fn dim() -> Style {
Style::new().fg(DIM)
}
pub fn tab_active() -> Style {
Style::new()
.fg(Color::Black)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
}
pub fn tab_active_for(state: &AppState) -> Style {
Style::new()
.fg(Color::Black)