Many UI fixes
This commit is contained in:
Generated
+287
-418
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -20,7 +20,7 @@ lofty = "0.22"
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
ratatui = "0.30.1"
|
||||
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||
|
||||
+11
-3
@@ -56,6 +56,12 @@ pub enum AppEvent {
|
||||
track_id: i64,
|
||||
liked: bool,
|
||||
},
|
||||
/// Liked federated item ids for the ♥ markers.
|
||||
FedLikesLoaded(Result<Vec<String>, String>),
|
||||
FedLikeToggled {
|
||||
item_id: String,
|
||||
liked: bool,
|
||||
},
|
||||
/// A release fetched for queueing (a / shift-a on a release).
|
||||
EnqueueTracks {
|
||||
tracks: Vec<TrackItem>,
|
||||
@@ -107,9 +113,11 @@ pub enum AppEvent {
|
||||
release: Option<String>,
|
||||
path: String,
|
||||
},
|
||||
/// A federated track finished downloading and is ready to play.
|
||||
FedPlayReady {
|
||||
result: Result<crate::federation::FedPlayable, String>,
|
||||
/// A pending federated track finished downloading (or failed); the
|
||||
/// queue swaps the placeholder for the resolved track.
|
||||
FedTrackResolved {
|
||||
placeholder_id: i64,
|
||||
result: Result<Box<crate::federation::FedPlayable>, String>,
|
||||
},
|
||||
/// This peer's connection ticket, requested from the Federation tab.
|
||||
FedTicket(Result<String, String>),
|
||||
|
||||
+170
-32
@@ -36,6 +36,8 @@ pub struct Runtime {
|
||||
pub federation: Arc<crate::federation::Federation>,
|
||||
/// When the last Federation-tab status snapshot was requested.
|
||||
pub fed_status_at: Option<std::time::Instant>,
|
||||
/// Placeholder ids of federated tracks being downloaded right now.
|
||||
pub fed_resolving: std::sync::Mutex<std::collections::HashSet<i64>>,
|
||||
/// Caps concurrent artwork loads so they never starve the disk.
|
||||
pub art_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
/// Monotonic sequence for live search; stale responses are dropped.
|
||||
@@ -82,6 +84,7 @@ pub async fn run(
|
||||
library,
|
||||
federation,
|
||||
fed_status_at: None,
|
||||
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
|
||||
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
player: player::spawn(move |event| {
|
||||
@@ -185,6 +188,8 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library.likes().map_err(err_string);
|
||||
let _ = tx.send(AppEvent::LikesLoaded(result));
|
||||
let result = library.fed_like_ids().map_err(err_string);
|
||||
let _ = tx.send(AppEvent::FedLikesLoaded(result));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,10 +383,12 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::ToggleLikes { track_ids } => {
|
||||
// Ephemeral federated tracks (negative ids) are not in the DB.
|
||||
Effect::ToggleLikes {
|
||||
track_ids,
|
||||
fed_tracks,
|
||||
} => {
|
||||
let track_ids: Vec<i64> = track_ids.into_iter().filter(|id| *id >= 0).collect();
|
||||
if track_ids.is_empty() {
|
||||
if track_ids.is_empty() && fed_tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let library = Arc::clone(&runtime.library);
|
||||
@@ -400,6 +407,22 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for fed in fed_tracks {
|
||||
match library.toggle_fed_like(&fed) {
|
||||
Ok(liked) => {
|
||||
let _ = tx.send(AppEvent::FedLikeToggled {
|
||||
item_id: fed.item_id.clone(),
|
||||
liked,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, title = %fed.title, "federated like toggle failed");
|
||||
let _ =
|
||||
tx.send(AppEvent::StatusMessage(format!("like failed: {err:#}")));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::RemoveFromPlaylist {
|
||||
@@ -478,17 +501,6 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
});
|
||||
}
|
||||
Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None),
|
||||
Effect::FedPlay(fed_track) => {
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fed
|
||||
.prepare_playback(&fed_track)
|
||||
.await
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::FedPlayReady { result });
|
||||
});
|
||||
}
|
||||
Effect::RemoveQueueIndices {
|
||||
restart_paused,
|
||||
stop,
|
||||
@@ -550,6 +562,14 @@ fn start_current_audio(
|
||||
state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line()));
|
||||
|
||||
runtime.player_start_pending = true;
|
||||
if track.is_fed_pending() {
|
||||
// A federated track that is not on disk yet: silence the previous
|
||||
// audio, download it and resume through FedTrackResolved.
|
||||
runtime.player.stop();
|
||||
state.status_message = Some(format!("federation: fetching \"{}\"…", track.title));
|
||||
spawn_fed_resolve(runtime, &track);
|
||||
return;
|
||||
}
|
||||
let controller = runtime.player.clone();
|
||||
let volume = player::amplitude(state.player.volume);
|
||||
let tx = runtime.event_tx.clone();
|
||||
@@ -606,6 +626,12 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
|
||||
let Some(next) = player.queue.get(next_pos).cloned() else {
|
||||
return;
|
||||
};
|
||||
if next.is_fed_pending() {
|
||||
// Download the upcoming federated track ahead of time; the gapless
|
||||
// enqueue happens on a later tick once it resolved to a file.
|
||||
spawn_fed_resolve(runtime, &next);
|
||||
return;
|
||||
}
|
||||
state.player.prefetched_pos = Some(next_pos);
|
||||
tracing::debug!(title = %next.title, "prefetching next track");
|
||||
let controller = runtime.player.clone();
|
||||
@@ -667,6 +693,38 @@ pub(crate) fn fed_connect(runtime: &Runtime, ticket: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Downloads one pending federated track (into the cache, or the library
|
||||
/// when save-on-listen is enabled) and reports back with the placeholder id
|
||||
/// so the queue can swap the resolved track in.
|
||||
fn spawn_fed_resolve(runtime: &Runtime, track: &crate::library::models::TrackItem) {
|
||||
let Some(fed_track) = track.fed.clone() else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut resolving = runtime
|
||||
.fed_resolving
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !resolving.insert(track.id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let placeholder_id = track.id;
|
||||
let fed = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fed
|
||||
.prepare_playback(&fed_track)
|
||||
.await
|
||||
.map(Box::new)
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::FedTrackResolved {
|
||||
placeholder_id,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Downloads federated tracks into the library one by one (with progress in
|
||||
/// the status bar) and optionally links them to a playlist afterwards.
|
||||
pub(crate) fn fed_download_spawn(
|
||||
@@ -893,8 +951,16 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) {
|
||||
// until then.
|
||||
state.likes_loaded = false;
|
||||
|
||||
// Fresh copies of whatever sits in the queue.
|
||||
let ids: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||
// Fresh copies of whatever sits in the queue. Federated placeholders
|
||||
// and ephemeral tracks (negative ids) are not library rows and keep
|
||||
// their in-memory copies.
|
||||
let ids: Vec<i64> = state
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.filter(|id| *id >= 0)
|
||||
.collect();
|
||||
if !ids.is_empty() {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
@@ -956,9 +1022,12 @@ fn apply_queue_refresh(
|
||||
.player
|
||||
.queue
|
||||
.iter()
|
||||
.any(|track| !by_id.contains_key(&track.id));
|
||||
.any(|track| track.id >= 0 && !by_id.contains_key(&track.id));
|
||||
if had_missing {
|
||||
state.player.queue.retain(|track| by_id.contains_key(&track.id));
|
||||
state
|
||||
.player
|
||||
.queue
|
||||
.retain(|track| track.id < 0 || by_id.contains_key(&track.id));
|
||||
state.player.prefetched_pos = None;
|
||||
}
|
||||
if state.player.queue.is_empty() {
|
||||
@@ -1018,22 +1087,72 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
Err(message) => tracing::warn!(%message, "federated search failed"),
|
||||
}
|
||||
}
|
||||
AppEvent::FedPlayReady { result } => match result {
|
||||
Ok(playable) => {
|
||||
if playable.imported {
|
||||
// Save-on-listen imported the file; refresh the library
|
||||
// views through the standard change path.
|
||||
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
|
||||
message: Some(format!("saved \"{}\" to the library", playable.track.title)),
|
||||
});
|
||||
AppEvent::FedTrackResolved {
|
||||
placeholder_id,
|
||||
result,
|
||||
} => {
|
||||
runtime
|
||||
.fed_resolving
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.remove(&placeholder_id);
|
||||
match result {
|
||||
Ok(playable) => {
|
||||
if playable.imported {
|
||||
// Save-on-listen imported the file; refresh the
|
||||
// library views through the standard change path.
|
||||
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
|
||||
message: Some(format!(
|
||||
"saved \"{}\" to the library",
|
||||
playable.track.title
|
||||
)),
|
||||
});
|
||||
}
|
||||
let resolved = playable.track.clone();
|
||||
// Swap the placeholder for the real track everywhere it
|
||||
// sits in the queue.
|
||||
for slot in &mut state.player.queue {
|
||||
if slot.id == placeholder_id {
|
||||
*slot = resolved.clone();
|
||||
}
|
||||
}
|
||||
let waiting = state
|
||||
.player
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.id == placeholder_id);
|
||||
if waiting {
|
||||
// Playback was parked on this track; start it now.
|
||||
let paused = state.player.paused;
|
||||
start_current_audio(state, runtime, 0.0, paused);
|
||||
push_media_metadata(state, runtime);
|
||||
push_media_update(state, runtime, true);
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
state.status_message = Some(format!("federation: {message}"));
|
||||
let waiting = state
|
||||
.player
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.id == placeholder_id);
|
||||
if waiting {
|
||||
// Skip the failed track instead of stalling the queue.
|
||||
if state.player.queue_pos + 1 < state.player.queue.len() {
|
||||
state.player.queue_pos += 1;
|
||||
start_current_audio(state, runtime, 0.0, state.player.paused);
|
||||
push_media_metadata(state, runtime);
|
||||
push_media_update(state, runtime, true);
|
||||
} else {
|
||||
runtime.player_start_pending = false;
|
||||
state.player.playing = false;
|
||||
state.player.current = None;
|
||||
runtime.player.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
state.player.queue = vec![playable.track];
|
||||
state.player.queue_pos = 0;
|
||||
update::on_new_queue(state);
|
||||
perform_effect(state, runtime, Effect::PlayCurrent);
|
||||
}
|
||||
Err(message) => state.status_message = Some(format!("federation: {message}")),
|
||||
},
|
||||
}
|
||||
AppEvent::FedArtistLoaded { name, result } => {
|
||||
if let Some((current, data)) = &mut state.fed_artist_view
|
||||
&& *current == name
|
||||
@@ -1237,6 +1356,25 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
Err(message) => tracing::warn!(%message, "likes load failed"),
|
||||
},
|
||||
AppEvent::FedLikesLoaded(result) => match result {
|
||||
Ok(ids) => state.fed_likes = ids.into_iter().collect(),
|
||||
Err(message) => tracing::warn!(%message, "federated likes load failed"),
|
||||
},
|
||||
AppEvent::FedLikeToggled { item_id, liked } => {
|
||||
if liked {
|
||||
state.fed_likes.insert(item_id);
|
||||
} else {
|
||||
state.fed_likes.remove(&item_id);
|
||||
}
|
||||
// The virtual Likes playlist is stale now; refetch on next open.
|
||||
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
|
||||
state.playlists.list = None;
|
||||
state.status_message = Some(if liked {
|
||||
"♥ liked (federation)".to_string()
|
||||
} else {
|
||||
"like removed".to_string()
|
||||
});
|
||||
}
|
||||
AppEvent::LikeToggled { track_id, liked } => {
|
||||
if liked {
|
||||
state.likes.insert(track_id);
|
||||
|
||||
@@ -627,6 +627,9 @@ pub struct AppState {
|
||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
|
||||
pub likes: std::collections::HashSet<i64>,
|
||||
/// Liked federated tracks (DHT item ids) — likes that reference peers'
|
||||
/// tracks without downloading them.
|
||||
pub fed_likes: std::collections::HashSet<String>,
|
||||
pub likes_loaded: bool,
|
||||
pub logs: LogsTab,
|
||||
pub queue_tab: QueueTab,
|
||||
@@ -634,6 +637,10 @@ pub struct AppState {
|
||||
/// The one federated artist card being viewed (name + loading state);
|
||||
/// opening another card replaces it.
|
||||
pub fed_artist_view: Option<(String, Loadable<crate::federation::FedArtistCard>)>,
|
||||
/// The "search this artist in the federation" button of the open local
|
||||
/// artist view has the focus (reached by pressing Up from the first
|
||||
/// row, like the download button on a federated release).
|
||||
pub artist_fed_button: bool,
|
||||
pub track_selection: TrackSelection,
|
||||
/// Shift-J jump in flight: focus this (release, track) once the release
|
||||
/// view finishes loading.
|
||||
|
||||
+144
-40
@@ -30,6 +30,7 @@ pub enum Effect {
|
||||
},
|
||||
ToggleLikes {
|
||||
track_ids: Vec<i64>,
|
||||
fed_tracks: Vec<crate::federation::FedTrack>,
|
||||
},
|
||||
/// Remove tracks from a stored playlist in the library.
|
||||
RemoveFromPlaylist {
|
||||
@@ -47,8 +48,6 @@ pub enum Effect {
|
||||
FedSyncNow,
|
||||
/// Fetch this peer's ticket and show it in a popup.
|
||||
FedShowTicket,
|
||||
/// Download (or resolve) a federated track and play it.
|
||||
FedPlay(crate::federation::FedTrack),
|
||||
/// Assemble the federated artist card (fan-out to the owning peers).
|
||||
FedOpenArtist(String),
|
||||
/// Download federated tracks into the local library, one by one.
|
||||
@@ -225,32 +224,52 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
None
|
||||
}
|
||||
Action::ToggleLike => {
|
||||
let tracks = selected_tracks(state);
|
||||
let track_ids: Vec<i64> = if tracks.is_empty() {
|
||||
state
|
||||
let mut tracks = selected_tracks(state);
|
||||
if tracks.is_empty() {
|
||||
tracks = state
|
||||
.player
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|track| vec![track.id])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
tracks.into_iter().map(|track| track.id).collect()
|
||||
};
|
||||
if track_ids.is_empty() {
|
||||
.map(|track| vec![track.clone()])
|
||||
.unwrap_or_default();
|
||||
}
|
||||
// Local library rows are liked by id; federated tracks (pending
|
||||
// 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();
|
||||
for track in tracks {
|
||||
match &track.fed {
|
||||
Some(fed) => fed_tracks.push(fed.clone()),
|
||||
None if track.id >= 0 => track_ids.push(track.id),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if track_ids.is_empty() && fed_tracks.is_empty() {
|
||||
state.status_message = Some("no track selected".into());
|
||||
None
|
||||
} else {
|
||||
let should_like = track_ids.iter().any(|id| !state.likes.contains(id));
|
||||
let should_like = track_ids.iter().any(|id| !state.likes.contains(id))
|
||||
|| fed_tracks
|
||||
.iter()
|
||||
.any(|fed| !state.fed_likes.contains(&fed.item_id));
|
||||
let toggles: Vec<i64> = track_ids
|
||||
.into_iter()
|
||||
.filter(|id| state.likes.contains(id) != should_like)
|
||||
.collect();
|
||||
let fed_toggles: Vec<crate::federation::FedTrack> = fed_tracks
|
||||
.into_iter()
|
||||
.filter(|fed| state.fed_likes.contains(&fed.item_id) != should_like)
|
||||
.collect();
|
||||
let total = toggles.len() + fed_toggles.len();
|
||||
state.status_message = Some(if should_like {
|
||||
format!("liking {} track(s)", toggles.len())
|
||||
format!("liking {total} track(s)")
|
||||
} else {
|
||||
format!("removing like from {} track(s)", toggles.len())
|
||||
format!("removing like from {total} track(s)")
|
||||
});
|
||||
Some(Effect::ToggleLikes { track_ids: toggles })
|
||||
Some(Effect::ToggleLikes {
|
||||
track_ids: toggles,
|
||||
fed_tracks: fed_toggles,
|
||||
})
|
||||
}
|
||||
}
|
||||
Action::ToggleTrackSelection => {
|
||||
@@ -503,7 +522,10 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
||||
.filter(|id| state.likes.contains(id))
|
||||
.collect();
|
||||
state.status_message = Some(format!("removing {} like(s)", liked.len()));
|
||||
return Some(Effect::ToggleLikes { track_ids: liked });
|
||||
return Some(Effect::ToggleLikes {
|
||||
track_ids: liked,
|
||||
fed_tracks: vec![],
|
||||
});
|
||||
}
|
||||
state.status_message =
|
||||
Some(format!("removing {} track(s) from playlist", track_ids.len()));
|
||||
@@ -676,6 +698,9 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
||||
}
|
||||
|
||||
fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> {
|
||||
if state.artist_fed_button {
|
||||
return None;
|
||||
}
|
||||
match state.active_tab {
|
||||
Tab::Global => match state.global.stack.last()? {
|
||||
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
||||
@@ -786,6 +811,17 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
|
||||
}
|
||||
|
||||
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
|
||||
if state.artist_fed_button {
|
||||
return Vec::new();
|
||||
}
|
||||
// Federated contexts produce queueable placeholders that behave like
|
||||
// regular tracks (queue, info, playback-on-demand).
|
||||
{
|
||||
let fed = selected_fed_tracks(state);
|
||||
if !fed.is_empty() {
|
||||
return fed.iter().map(crate::federation::pending_track).collect();
|
||||
}
|
||||
}
|
||||
let Some((scope, cursor, tracks)) = current_track_list(state) else {
|
||||
return selected_track(state).into_iter().collect();
|
||||
};
|
||||
@@ -915,6 +951,9 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
|
||||
|
||||
/// The track under the cursor in whatever view is showing tracks.
|
||||
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
if state.artist_fed_button {
|
||||
return None;
|
||||
}
|
||||
match state.active_tab {
|
||||
Tab::Global => match state.global.stack.last()? {
|
||||
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
|
||||
@@ -937,9 +976,31 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
GlobalView::Search { cursor } => {
|
||||
let results = state.search.results.as_ref()?;
|
||||
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
|
||||
results.tracks.get(offset).cloned()
|
||||
match results.tracks.get(offset) {
|
||||
Some(track) => Some(track.clone()),
|
||||
// Below the local tracks: the federated section.
|
||||
None => {
|
||||
let fed_offset = offset
|
||||
.checked_sub(results.tracks.len() + state.search.fed_artists.len())?;
|
||||
state
|
||||
.search
|
||||
.fed_tracks
|
||||
.get(fed_offset)
|
||||
.map(crate::federation::pending_track)
|
||||
}
|
||||
}
|
||||
}
|
||||
GlobalView::FedArtist { .. } => None,
|
||||
GlobalView::FedRelease { index, cursor } => {
|
||||
let name = state.fed_artist_view.as_ref().map(|(name, _)| name.clone())?;
|
||||
let release = fed_card_release(state, *index)?;
|
||||
release
|
||||
.tracks
|
||||
.get(cursor.checked_sub(1)?)
|
||||
.and_then(|track| fed_track_from_card(&name, release, track))
|
||||
.as_ref()
|
||||
.map(crate::federation::pending_track)
|
||||
}
|
||||
GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
|
||||
},
|
||||
Tab::Playlists => {
|
||||
let opened = state.playlists.opened.as_ref()?;
|
||||
@@ -1013,6 +1074,10 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
||||
/// track. If the release view is still loading, the focus is applied when
|
||||
/// it arrives (`pending_release_focus`).
|
||||
fn open_release_for_track(state: &mut AppState, track: &TrackItem) {
|
||||
if track.release_id < 0 {
|
||||
state.status_message = Some("this federated track has no local release".into());
|
||||
return;
|
||||
}
|
||||
let release_id = track.release_id;
|
||||
let cursor = match state.release_views.get(&release_id) {
|
||||
Some(Loadable::Ready(detail)) => detail
|
||||
@@ -1338,6 +1403,17 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||
state.track_selection.clear();
|
||||
}
|
||||
Some(GlobalView::Artist { id, cursor }) => {
|
||||
if state.artist_fed_button {
|
||||
// Down leaves the federation-search button; other keys stay.
|
||||
if dy > 0 {
|
||||
state.artist_fed_button = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if cursor == 0 && dy < 0 && state.federation.settings.enabled {
|
||||
state.artist_fed_button = true;
|
||||
return;
|
||||
}
|
||||
let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else {
|
||||
return;
|
||||
};
|
||||
@@ -1503,6 +1579,7 @@ fn current_view_len(state: &AppState) -> usize {
|
||||
}
|
||||
|
||||
fn jump_selection(state: &mut AppState, first: bool) {
|
||||
state.artist_fed_button = false;
|
||||
if state.track_selection.is_active()
|
||||
&& let Some((scope, _, len)) = current_track_list_context(state)
|
||||
{
|
||||
@@ -1634,6 +1711,15 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
}),
|
||||
None => Outcome::Nothing,
|
||||
},
|
||||
Some(GlobalView::Artist { id, cursor: _ }) if state.artist_fed_button => {
|
||||
match state.artist_views.get(&id) {
|
||||
Some(Loadable::Ready(detail)) => {
|
||||
state.artist_fed_button = false;
|
||||
Outcome::OpenFedArtist(detail.name.clone())
|
||||
}
|
||||
_ => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) {
|
||||
Some(Loadable::Ready(detail)) => {
|
||||
let tracks = detail.top_tracks.len();
|
||||
@@ -1723,19 +1809,20 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
tracks => Outcome::DownloadFed(tracks),
|
||||
}
|
||||
}
|
||||
Some(release) => {
|
||||
let name = state
|
||||
.fed_artist_view
|
||||
.as_ref()
|
||||
.map(|(name, _)| name.clone())
|
||||
.unwrap_or_default();
|
||||
match release
|
||||
.tracks
|
||||
.get(cursor - 1)
|
||||
.and_then(|track| fed_track_from_card(&name, release, track))
|
||||
{
|
||||
Some(fed) => Outcome::PlayFed(fed),
|
||||
None => Outcome::Nothing,
|
||||
Some(_) => {
|
||||
// Play the whole release starting at the selected track,
|
||||
// exactly like a local release view.
|
||||
let tracks: Vec<_> = fed_release_tracks(state, index)
|
||||
.iter()
|
||||
.map(crate::federation::pending_track)
|
||||
.collect();
|
||||
if tracks.is_empty() || cursor > tracks.len() {
|
||||
Outcome::Nothing
|
||||
} else {
|
||||
Outcome::Play {
|
||||
tracks,
|
||||
start: cursor - 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Outcome::Nothing,
|
||||
@@ -1743,6 +1830,7 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
};
|
||||
match outcome {
|
||||
Outcome::Push(view) => {
|
||||
state.artist_fed_button = false;
|
||||
state.global.stack.push(view);
|
||||
None
|
||||
}
|
||||
@@ -1752,10 +1840,6 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
on_new_queue(state);
|
||||
Some(Effect::PlayCurrent)
|
||||
}
|
||||
Outcome::PlayFed(fed) => {
|
||||
state.status_message = Some(format!("federation: fetching \"{}\"…", fed.title));
|
||||
Some(Effect::FedPlay(fed))
|
||||
}
|
||||
Outcome::OpenFedArtist(name) => {
|
||||
state.fed_artist_view = Some((name.clone(), Loadable::Loading));
|
||||
state.global.stack.push(GlobalView::FedArtist { cursor: 0 });
|
||||
@@ -1853,22 +1937,30 @@ enum Outcome {
|
||||
tracks: Vec<crate::library::models::TrackItem>,
|
||||
start: usize,
|
||||
},
|
||||
PlayFed(crate::federation::FedTrack),
|
||||
OpenFedArtist(String),
|
||||
DownloadFed(Vec<crate::federation::FedTrack>),
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Enter inside the federated section of the search results: artists open
|
||||
/// their card, tracks play.
|
||||
/// their card, tracks queue up like a local track list.
|
||||
fn fed_outcome(state: &AppState, fed_index: usize) -> Outcome {
|
||||
let artists = &state.search.fed_artists;
|
||||
if fed_index < artists.len() {
|
||||
return Outcome::OpenFedArtist(artists[fed_index].name.clone());
|
||||
}
|
||||
match state.search.fed_tracks.get(fed_index - artists.len()) {
|
||||
Some(fed) => Outcome::PlayFed(fed.clone()),
|
||||
None => Outcome::Nothing,
|
||||
let start = fed_index - artists.len();
|
||||
if start >= state.search.fed_tracks.len() {
|
||||
return Outcome::Nothing;
|
||||
}
|
||||
Outcome::Play {
|
||||
tracks: state
|
||||
.search
|
||||
.fed_tracks
|
||||
.iter()
|
||||
.map(crate::federation::pending_track)
|
||||
.collect(),
|
||||
start,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1888,6 +1980,9 @@ fn fed_track_from_card(
|
||||
artist_names: vec![artist.to_string()],
|
||||
year: release.year,
|
||||
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
|
||||
release_title: Some(release.title.clone()),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1966,6 +2061,7 @@ fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) {
|
||||
/// Esc/Backspace: pop the navigation stack; leaving a search view resets the
|
||||
/// search so the next `:/` starts clean.
|
||||
fn go_back(state: &mut AppState) {
|
||||
state.artist_fed_button = false;
|
||||
state.track_selection.clear();
|
||||
match state.active_tab {
|
||||
Tab::Playlists => {
|
||||
@@ -1995,6 +2091,7 @@ fn go_back(state: &mut AppState) {
|
||||
}
|
||||
|
||||
fn switch_tab(state: &mut AppState, tab: Tab) {
|
||||
state.artist_fed_button = false;
|
||||
state.active_tab = tab;
|
||||
state.help_visible = false;
|
||||
state.track_selection.clear();
|
||||
@@ -2070,6 +2167,7 @@ mod tests {
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2294,6 +2392,7 @@ mod tests {
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.player.queue = vec![track(1), track(2)];
|
||||
@@ -2357,6 +2456,7 @@ mod tests {
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
@@ -2485,6 +2585,7 @@ mod tests {
|
||||
update(&mut state, Action::ToggleLike),
|
||||
Some(Effect::ToggleLikes {
|
||||
track_ids: vec![2, 3],
|
||||
fed_tracks: vec![],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2493,6 +2594,7 @@ mod tests {
|
||||
update(&mut state, Action::ToggleLike),
|
||||
Some(Effect::ToggleLikes {
|
||||
track_ids: vec![1, 2, 3],
|
||||
fed_tracks: vec![],
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -2519,6 +2621,7 @@ mod tests {
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState::default();
|
||||
state.player.queue = (1..=8).map(track).collect();
|
||||
@@ -2564,6 +2667,7 @@ mod tests {
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
let mut state = AppState {
|
||||
active_tab: Tab::Queue,
|
||||
|
||||
+67
-3
@@ -117,6 +117,10 @@ pub struct FedTrack {
|
||||
pub artist_names: Vec<String>,
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
/// Release context, known when the track came from an artist card.
|
||||
pub release_title: Option<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
impl FedTrack {
|
||||
@@ -388,14 +392,22 @@ impl Federation {
|
||||
match service.sync_library(specs).await {
|
||||
Ok(stats) => {
|
||||
*lock(&self.last_sync) = Some(format!(
|
||||
"{} (+{} ~{} −{}, unchanged {})",
|
||||
"{} (+{} ~{} −{}, unchanged {}, failed {})",
|
||||
now_label(),
|
||||
stats.added,
|
||||
stats.updated,
|
||||
stats.removed,
|
||||
stats.unchanged
|
||||
stats.unchanged,
|
||||
stats.failed
|
||||
));
|
||||
self.set_error(None);
|
||||
if stats.failed > 0 {
|
||||
self.set_error(Some(format!(
|
||||
"{} item(s) failed to publish in the last sync",
|
||||
stats.failed
|
||||
)));
|
||||
} else {
|
||||
self.set_error(None);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("federation sync failed: {err}");
|
||||
@@ -455,6 +467,9 @@ impl Federation {
|
||||
artist_names: item.artist_names.clone(),
|
||||
year: item.year,
|
||||
duration_seconds: item.duration_seconds.map(|d| d.round() as i64),
|
||||
release_title: None,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -696,6 +711,7 @@ impl Federation {
|
||||
let import_metadata = downloaded.metadata.clone();
|
||||
let import_cover = downloaded.cover.clone();
|
||||
let artist_image = downloaded.artist_image.clone();
|
||||
let fed_item_id = fed.item_id.clone();
|
||||
let imported = tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> {
|
||||
let mut import = crate::library::import::read_file(&import_path)?;
|
||||
// The owner's database is more authoritative than whatever
|
||||
@@ -709,6 +725,11 @@ impl Federation {
|
||||
import.cover = import_cover;
|
||||
}
|
||||
let (track_id, _) = crate::library::import::upsert_track(&library, &import)?;
|
||||
// A like that referenced the federated track moves onto the
|
||||
// freshly imported local row.
|
||||
if let Err(err) = library.transfer_fed_like(&fed_item_id, track_id) {
|
||||
tracing::warn!(%err, "federated like transfer failed");
|
||||
}
|
||||
// The owner's artist image fills the gap for a freshly
|
||||
// created (or still image-less) main artist.
|
||||
if let (Some((bytes, extension)), Some(artist_name)) =
|
||||
@@ -895,6 +916,46 @@ fn download_stem(fed: &FedTrack) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A queueable placeholder for a federated track that has not been
|
||||
/// downloaded yet: it behaves like a regular track everywhere (queue, info,
|
||||
/// selection) and resolves to a local file when playback reaches it.
|
||||
pub fn pending_track(fed: &FedTrack) -> TrackItem {
|
||||
let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed);
|
||||
let refs = |names: &[String]| -> Vec<ArtistRef> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| ArtistRef {
|
||||
id: -1,
|
||||
name: name.clone(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
TrackItem {
|
||||
id,
|
||||
title: fed.title.clone(),
|
||||
track_number: fed.track_number,
|
||||
disc_number: fed.disc_number,
|
||||
duration_seconds: fed.duration_seconds.unwrap_or(0) as f64,
|
||||
artists: refs(&fed.artist_names),
|
||||
featured_artists: Vec::new(),
|
||||
release_id: -1,
|
||||
release_title: fed
|
||||
.release_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("federation · {}", fed.owner_short())),
|
||||
release_year: fed.year,
|
||||
file_path: String::new(),
|
||||
cover_path: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: Some(fed.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A playable TrackItem for a downloaded-but-not-imported federated track.
|
||||
fn ephemeral_track(
|
||||
fed: &FedTrack,
|
||||
@@ -948,5 +1009,8 @@ fn ephemeral_track(
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: file_size,
|
||||
play_count: 0,
|
||||
// Keep the federation reference: the cached track can still be
|
||||
// liked, re-downloaded and marked as federated in the lists.
|
||||
fed: Some(fed.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
+152
-16
@@ -81,6 +81,18 @@ CREATE TABLE IF NOT EXISTS likes (
|
||||
track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
liked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS fed_likes (
|
||||
item_id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
artist_names TEXT NOT NULL DEFAULT '',
|
||||
year INTEGER,
|
||||
duration_seconds REAL,
|
||||
release_title TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
liked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY,
|
||||
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
@@ -155,6 +167,7 @@ impl Library {
|
||||
.with_context(|| format!("opening database {}", db_path.display()))?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
register_norm_function(&conn)?;
|
||||
conn.execute_batch(SCHEMA).context("applying schema")?;
|
||||
let covers_dir = db_path
|
||||
.parent()
|
||||
@@ -337,13 +350,19 @@ impl Library {
|
||||
if query.is_empty() {
|
||||
return Ok(SearchResults::default());
|
||||
}
|
||||
let pattern = format!("%{}%", like_escape(query));
|
||||
// instr over norm() folds case for every script, unlike LIKE/NOCASE
|
||||
// which only handle ASCII.
|
||||
let pattern = music_dht::normalize_name(query);
|
||||
if pattern.is_empty() {
|
||||
// Punctuation-only queries (e.g. "%") normalize to nothing.
|
||||
return Ok(SearchResults::default());
|
||||
}
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT a.id, a.name, a.image_path,
|
||||
(SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id),
|
||||
(SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id)
|
||||
FROM artists a WHERE a.name LIKE ?1 ESCAPE '\\'
|
||||
FROM artists a WHERE instr(norm(a.name), ?1) > 0
|
||||
ORDER BY a.name COLLATE NOCASE LIMIT ?2",
|
||||
)?;
|
||||
let artists = statement
|
||||
@@ -360,7 +379,7 @@ impl Library {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT r.id, r.title, r.release_type, r.year, r.cover_path,
|
||||
(SELECT COUNT(*) FROM tracks t WHERE t.release_id = r.id)
|
||||
FROM releases r WHERE r.title LIKE ?1 ESCAPE '\\'
|
||||
FROM releases r WHERE instr(norm(r.title), ?1) > 0
|
||||
ORDER BY r.title COLLATE NOCASE LIMIT ?2",
|
||||
)?;
|
||||
let releases = statement
|
||||
@@ -371,7 +390,7 @@ impl Library {
|
||||
&format!(
|
||||
"SELECT {TRACK_COLUMNS} FROM tracks t
|
||||
JOIN releases r ON r.id = t.release_id
|
||||
WHERE t.title LIKE ?1 ESCAPE '\\'
|
||||
WHERE instr(norm(t.title), ?1) > 0
|
||||
ORDER BY t.title COLLATE NOCASE LIMIT ?2"
|
||||
),
|
||||
params![pattern, limit],
|
||||
@@ -485,7 +504,11 @@ impl Library {
|
||||
|
||||
pub fn playlists(&self) -> Result<Vec<PlaylistCard>> {
|
||||
let conn = self.lock();
|
||||
let liked: i64 = conn.query_row("SELECT COUNT(*) FROM likes", [], |row| row.get(0))?;
|
||||
let liked: i64 = conn.query_row(
|
||||
"SELECT (SELECT COUNT(*) FROM likes) + (SELECT COUNT(*) FROM fed_likes)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let mut list = vec![PlaylistCard {
|
||||
id: LIKES_PLAYLIST_ID,
|
||||
title: "Liked tracks".to_string(),
|
||||
@@ -514,7 +537,7 @@ impl Library {
|
||||
pub fn playlist(&self, id: i64) -> Result<PlaylistDetail> {
|
||||
let conn = self.lock();
|
||||
if id == LIKES_PLAYLIST_ID {
|
||||
let tracks = query_tracks(
|
||||
let mut tracks = query_tracks(
|
||||
&conn,
|
||||
&format!(
|
||||
"SELECT {TRACK_COLUMNS} FROM tracks t
|
||||
@@ -524,6 +547,12 @@ impl Library {
|
||||
),
|
||||
params![],
|
||||
)?;
|
||||
drop(conn);
|
||||
// Liked federated tracks follow the local ones as queueable
|
||||
// placeholders (downloaded on playback like everywhere else).
|
||||
for fed in self.fed_likes()? {
|
||||
tracks.push(crate::federation::pending_track(&fed));
|
||||
}
|
||||
return Ok(PlaylistDetail {
|
||||
id,
|
||||
title: "Liked tracks".to_string(),
|
||||
@@ -616,6 +645,89 @@ impl Library {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liked federated tracks, newest first, as playable references.
|
||||
pub fn fed_likes(&self) -> Result<Vec<crate::federation::FedTrack>> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT item_id, owner, title, artist_names, year, duration_seconds,
|
||||
release_title, track_number, disc_number
|
||||
FROM fed_likes ORDER BY liked_at DESC",
|
||||
)?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
let artists: String = row.get(3)?;
|
||||
Ok(crate::federation::FedTrack {
|
||||
item_id: row.get(0)?,
|
||||
owner: row.get(1)?,
|
||||
own: false,
|
||||
title: row.get(2)?,
|
||||
artist_names: artists
|
||||
.split("; ")
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
year: row.get(4)?,
|
||||
duration_seconds: row.get::<_, Option<f64>>(5)?.map(|d| d.round() as i64),
|
||||
release_title: row.get(6)?,
|
||||
track_number: row.get(7)?,
|
||||
disc_number: row.get(8)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Item ids of every liked federated track (for the ♥ markers).
|
||||
pub fn fed_like_ids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare("SELECT item_id FROM fed_likes")?;
|
||||
let rows = statement
|
||||
.query_map([], |row| row.get(0))?
|
||||
.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Toggles a like on a federated track; returns the resulting state.
|
||||
pub fn toggle_fed_like(&self, fed: &crate::federation::FedTrack) -> Result<bool> {
|
||||
let conn = self.lock();
|
||||
let removed = conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [&fed.item_id])?;
|
||||
if removed > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names, year,
|
||||
duration_seconds, release_title, track_number, disc_number)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
fed.item_id,
|
||||
fed.owner,
|
||||
fed.title,
|
||||
fed.artist_names.join("; "),
|
||||
fed.year,
|
||||
fed.duration_seconds.map(|d| d as f64),
|
||||
fed.release_title,
|
||||
fed.track_number,
|
||||
fed.disc_number,
|
||||
],
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Moves a federated like onto a freshly imported local track. Returns
|
||||
/// whether a transfer happened.
|
||||
pub fn transfer_fed_like(&self, item_id: &str, track_id: i64) -> Result<bool> {
|
||||
let conn = self.lock();
|
||||
let removed = conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [item_id])?;
|
||||
if removed == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO likes (track_id) VALUES (?1)",
|
||||
[track_id],
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn likes(&self) -> Result<Vec<i64>> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare("SELECT track_id FROM likes")?;
|
||||
@@ -724,7 +836,7 @@ impl Library {
|
||||
let conn = self.lock();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
|
||||
"SELECT id FROM artists WHERE norm(name) = norm(?1) LIMIT 1",
|
||||
[name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -740,7 +852,7 @@ impl Library {
|
||||
"SELECT r.cover_path FROM releases r
|
||||
JOIN release_artists ra ON ra.release_id = r.id
|
||||
JOIN artists a ON a.id = ra.artist_id
|
||||
WHERE a.name = ?1 COLLATE NOCASE AND r.title = ?2 COLLATE NOCASE
|
||||
WHERE norm(a.name) = norm(?1) AND norm(r.title) = norm(?2)
|
||||
LIMIT 1",
|
||||
params![artist, release],
|
||||
|row| row.get(0),
|
||||
@@ -767,7 +879,7 @@ impl Library {
|
||||
let conn = self.lock();
|
||||
let missing: Option<bool> = conn
|
||||
.query_row(
|
||||
"SELECT image_path IS NULL FROM artists WHERE name = ?1 COLLATE NOCASE",
|
||||
"SELECT image_path IS NULL FROM artists WHERE norm(name) = norm(?1)",
|
||||
[name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -781,7 +893,7 @@ impl Library {
|
||||
let conn = self.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE artists SET image_path = ?2
|
||||
WHERE name = ?1 COLLATE NOCASE AND image_path IS NULL",
|
||||
WHERE norm(name) = norm(?1) AND image_path IS NULL",
|
||||
params![name, image_path],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
@@ -850,7 +962,7 @@ pub(crate) fn find_or_create_artist(conn: &Connection, name: &str) -> Result<i64
|
||||
anyhow::ensure!(!name.is_empty(), "artist name is empty");
|
||||
if let Some(id) = conn
|
||||
.query_row(
|
||||
"SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
|
||||
"SELECT id FROM artists WHERE norm(name) = norm(?1) LIMIT 1",
|
||||
[name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -901,6 +1013,7 @@ fn query_tracks(
|
||||
play_count: row.get(15)?,
|
||||
artists: Vec::new(),
|
||||
featured_artists: Vec::new(),
|
||||
fed: None,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -933,11 +1046,22 @@ fn query_tracks(
|
||||
}
|
||||
|
||||
/// Escape LIKE wildcards in user input; queries use `ESCAPE '\'`.
|
||||
fn like_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
/// Registers `norm(text)` — Unicode-aware case folding and normalization
|
||||
/// (NFKC, lowercase, punctuation stripped). SQLite's own LIKE/NOCASE only
|
||||
/// fold ASCII, so Cyrillic and other non-Latin names were unsearchable
|
||||
/// without it. Shares the exact algorithm with the federation DHT.
|
||||
fn register_norm_function(conn: &Connection) -> Result<()> {
|
||||
use rusqlite::functions::FunctionFlags;
|
||||
conn.create_scalar_function(
|
||||
"norm",
|
||||
1,
|
||||
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
|
||||
|ctx| {
|
||||
let value: String = ctx.get(0)?;
|
||||
Ok(music_dht::normalize_name(&value))
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -947,6 +1071,7 @@ mod tests {
|
||||
fn test_library() -> Library {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
register_norm_function(&conn).unwrap();
|
||||
conn.execute_batch(SCHEMA).unwrap();
|
||||
Library {
|
||||
conn: Mutex::new(conn),
|
||||
@@ -1018,6 +1143,17 @@ mod tests {
|
||||
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_folds_case_beyond_ascii() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом");
|
||||
// SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script.
|
||||
assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1);
|
||||
assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1);
|
||||
assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1);
|
||||
assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlists_and_likes_round_trip() {
|
||||
let lib = test_library();
|
||||
|
||||
@@ -40,9 +40,18 @@ pub struct TrackItem {
|
||||
pub file_size_bytes: Option<i64>,
|
||||
/// Completed local plays, from the history table.
|
||||
pub play_count: i64,
|
||||
/// Set for federated tracks that are not in the local library (yet):
|
||||
/// carries everything needed to download them from the owning peer.
|
||||
/// With an empty `file_path` the player resolves the track on demand.
|
||||
pub fed: Option<crate::federation::FedTrack>,
|
||||
}
|
||||
|
||||
impl TrackItem {
|
||||
/// A federated track that still needs downloading before playback.
|
||||
pub fn is_fed_pending(&self) -> bool {
|
||||
self.fed.is_some() && self.file_path.is_empty()
|
||||
}
|
||||
|
||||
pub fn artist_line(&self) -> String {
|
||||
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
|
||||
if !self.featured_artists.is_empty() {
|
||||
|
||||
+30
-1
@@ -405,7 +405,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
if !detail.featured_tracks.is_empty() {
|
||||
about.push_str(&format!(" · appears on {}", detail.featured_tracks.len()));
|
||||
}
|
||||
let info = vec![
|
||||
let mut info = vec![
|
||||
Line::default(),
|
||||
Line::styled(detail.name.clone(), theme::header()),
|
||||
Line::default(),
|
||||
@@ -418,6 +418,19 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
),
|
||||
Line::styled(about, theme::dim()),
|
||||
];
|
||||
if state.federation.settings.enabled {
|
||||
// Reached with Up from the first row, like the download button on
|
||||
// a federated release.
|
||||
info.push(Line::default());
|
||||
info.push(Line::styled(
|
||||
" ⌕ Search this artist in the federation ",
|
||||
if state.artist_fed_button {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::accent()
|
||||
},
|
||||
));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(info), info_area);
|
||||
|
||||
// Scrollable content: top tracks, releases grouped by type, then the
|
||||
@@ -827,6 +840,11 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
index += 1;
|
||||
}
|
||||
for fed in &state.search.fed_tracks {
|
||||
let heart = if state.fed_likes.contains(&fed.item_id) {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let origin = if fed.own {
|
||||
"your library".to_string()
|
||||
} else {
|
||||
@@ -841,6 +859,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
}
|
||||
rows.push((
|
||||
Line::from(vec![
|
||||
heart,
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(fed.title.clone()),
|
||||
Span::styled(
|
||||
@@ -1098,7 +1117,17 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz
|
||||
duration
|
||||
};
|
||||
let in_selection = state.track_selection.contains(&scope, position);
|
||||
let liked = track
|
||||
.sources
|
||||
.iter()
|
||||
.any(|(_, item_id)| state.fed_likes.contains(item_id));
|
||||
let heart = if liked {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
heart,
|
||||
Span::styled("⇅ ", theme::accent()),
|
||||
Span::raw(format!("{number}{}", track.title)),
|
||||
]);
|
||||
|
||||
+11
-1
@@ -63,14 +63,24 @@ pub(crate) fn track_row(
|
||||
selected: bool,
|
||||
visual_selected: bool,
|
||||
) {
|
||||
let heart = if state.likes.contains(&track.id) {
|
||||
let fed_liked = track
|
||||
.fed
|
||||
.as_ref()
|
||||
.is_some_and(|fed| state.fed_likes.contains(&fed.item_id));
|
||||
let heart = if state.likes.contains(&track.id) || fed_liked {
|
||||
Span::styled("♥ ", theme::accent())
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let fed_marker = if track.fed.is_some() {
|
||||
Span::styled("⇅ ", theme::accent())
|
||||
} else {
|
||||
Span::raw("")
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{index_label:>3} "), theme::dim()),
|
||||
heart,
|
||||
fed_marker,
|
||||
Span::raw(track.title.clone()),
|
||||
Span::styled(format!(" {}", track.artist_line()), theme::dim()),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user