Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 73 additions & 43 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ mod sync;
mod sync_orchestrator;

use std::{
collections::{BTreeMap, BTreeSet, HashMap},
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
fs,
io::Read,
path::PathBuf,
Expand All @@ -29,8 +29,8 @@ use flate2::read::GzDecoder;

use playback::{AudioSettings, Playback, PlayerStateEvent, SnapshotTrack};
use provider::{
artist_albums_page, artist_descriptor, image_url, spotify_id, title_case, ArtistAlbumsPage,
SearchResults, SpotifySyncProvider, SyncBatch,
artist_albums_page, artist_descriptor, image_url, image_url_at_least, spotify_id, title_case,
ArtistAlbumsPage, SearchResults, SpotifySyncProvider, SyncBatch,
};
use retune_core::{
browse::{self, Selection},
Expand Down Expand Up @@ -76,7 +76,7 @@ struct AppState {
recovery_notice: Mutex<Option<String>>,
token_store: SharedTokenStore,
spotify: Mutex<Option<Arc<SpotifyProvider>>>,
artwork_cache: Mutex<HashMap<String, Option<String>>>,
artwork_cache: Mutex<HashMap<(String, u32), Option<String>>>,
playback: Arc<Playback>,
media_keys: media_keys::MediaKeys,
sync_orchestrator: SyncOrchestrator,
Expand Down Expand Up @@ -158,6 +158,8 @@ struct ExportSettings {
column_widths: BTreeMap<String, u32>,
hidden_columns: Vec<String>,
#[serde(default)]
playlist_hidden_columns: BTreeMap<String, Vec<String>>,
#[serde(default)]
sort_column: Option<String>,
#[serde(default)]
sort_desc: bool,
Expand All @@ -177,6 +179,7 @@ impl ExportSettings {
column_order: settings.column_order.clone(),
column_widths: settings.column_widths.clone(),
hidden_columns: settings.hidden_columns.clone(),
playlist_hidden_columns: settings.playlist_hidden_columns.clone(),
sort_column: settings.sort_column.clone(),
sort_desc: settings.sort_desc,
shuffle: settings.shuffle,
Expand All @@ -193,6 +196,7 @@ impl ExportSettings {
settings.column_order = self.column_order;
settings.column_widths = self.column_widths;
settings.hidden_columns = self.hidden_columns;
settings.playlist_hidden_columns = self.playlist_hidden_columns;
settings.sort_column = self.sort_column;
settings.sort_desc = self.sort_desc;
settings.shuffle = self.shuffle;
Expand Down Expand Up @@ -437,8 +441,19 @@ struct PlaylistTrackView {
name: String,
art: String,
alb: String,
cat: String,
disc_no: Option<u32>,
track_no: Option<u32>,
duration_secs: u64,
enabled: bool,
play_count: u32,
last_played_at: Option<u64>,
added_at: Option<u64>,
release_date: Option<String>,
kind: Option<String>,
bitrate_kbps: Option<u32>,
overridden: bool,
is_local: bool,
rating: Option<RatingView>,
}

Expand Down Expand Up @@ -805,18 +820,20 @@ fn track_id(uri: &str) -> Option<&str> {

async fn resolve_track_artwork<T: Transport, S: TokenStore>(
client: Option<&SpotifyClient<T, S>>,
cache: &Mutex<HashMap<String, Option<String>>>,
cache: &Mutex<HashMap<(String, u32), Option<String>>>,
uri: &str,
min_width: u32,
) -> Option<String> {
let local = uri.starts_with("file:");
let id = (!local).then(|| track_id(uri)).flatten();
if !local && id.is_none() {
return None;
}
let cache_key = (uri.into(), min_width);
if let Some(cached) = cache
.lock()
.expect("artwork cache mutex poisoned")
.get(uri)
.get(&cache_key)
.cloned()
{
return cached;
Expand All @@ -842,13 +859,13 @@ async fn resolve_track_artwork<T: Transport, S: TokenStore>(
.await
.ok()
.and_then(|track| track.album)
.and_then(|album| image_url(&album.images))
.and_then(|album| image_url_at_least(&album.images, min_width))
};
let mut cache = cache.lock().expect("artwork cache mutex poisoned");
if cache.len() >= 512 {
cache.clear();
}
cache.insert(uri.into(), artwork.clone());
cache.insert(cache_key, artwork.clone());
artwork
}

Expand All @@ -859,6 +876,7 @@ pub(crate) async fn publish_media_artwork(app: tauri::AppHandle, event: PlayerSt
provider.as_deref(),
&state.artwork_cache,
event.uri.as_deref().unwrap_or_default(),
300,
)
.await
else {
Expand Down Expand Up @@ -1143,12 +1161,7 @@ async fn sync_playlists(app: &tauri::AppHandle, client: &SpotifyProvider) -> Res
.lock()
.expect("playlist mutex poisoned")
.clone();
let library = state
.library
.lock()
.expect("library mutex poisoned")
.clone();
let synced = match playlists::sync(client, &current, &library).await {
let synced = match playlists::sync(client, &current).await {
Ok(synced) => synced,
Err(error) => {
let tokens = state.token_store.load().ok().flatten();
Expand Down Expand Up @@ -2057,6 +2070,7 @@ pub fn run() {
spotify_commands::add_spotify_album,
spotify_commands::remove_spotify_album,
spotify_commands::add_spotify_track,
spotify_commands::add_spotify_tracks,
spotify_commands::remove_spotify_track,
playlist_commands::playlists_list,
playlist_commands::open_spotify_playlist,
Expand Down Expand Up @@ -2472,7 +2486,8 @@ mod tests {
owner: None,
track_count: 2,
tracks: vec!["one".into(), "two".into()],
non_library_tracks: vec![],
track_metadata_version: playlists::TRACK_METADATA_VERSION,
spotify_tracks: vec![],
}],
}
}
Expand Down Expand Up @@ -2589,25 +2604,24 @@ mod tests {
}

#[tokio::test]
async fn track_artwork_resolves_smallest_usable_image_and_caches() {
async fn track_artwork_resolves_requested_sizes_and_caches_each() {
let track = serde_json::json!({
"uri": "spotify:track:track",
"name": "Track",
"album": {
"id": "album",
"uri": "spotify:album:album",
"name": "Album",
"images": [
{"url": "large", "width": 300},
{"url": "small", "width": 64},
{"url": "tiny", "width": 63}
]
}
});
let client = playlist_client([
Response::json(
200,
serde_json::json!({
"uri": "spotify:track:track",
"name": "Track",
"album": {
"id": "album",
"uri": "spotify:album:album",
"name": "Album",
"images": [
{"url": "large", "width": 300},
{"url": "small", "width": 64},
{"url": "tiny", "width": 63}
]
}
}),
),
Response::json(200, track.clone()),
Response::json(200, track),
Response::json(
200,
serde_json::json!({"uri": "spotify:track:missing", "name": "Missing"}),
Expand All @@ -2619,28 +2633,32 @@ mod tests {
assert_eq!(track_id("spotify:album:album"), None);
assert_eq!(track_id("spotify:track:"), None);
assert_eq!(
resolve_track_artwork(Some(&client), &cache, "spotify:track:track")
resolve_track_artwork(Some(&client), &cache, "spotify:track:track", 64)
.await
.as_deref(),
Some("small")
);
assert_eq!(
resolve_track_artwork(Some(&client), &cache, "spotify:track:track").await,
resolve_track_artwork(Some(&client), &cache, "spotify:track:track", 64).await,
Some("small".into())
);
assert_eq!(
resolve_track_artwork(Some(&client), &cache, "spotify:album:album").await,
resolve_track_artwork(Some(&client), &cache, "spotify:track:track", 300).await,
Some("large".into())
);
assert_eq!(
resolve_track_artwork(Some(&client), &cache, "spotify:album:album", 64).await,
None
);
assert_eq!(
resolve_track_artwork(Some(&client), &cache, "spotify:track:missing").await,
resolve_track_artwork(Some(&client), &cache, "spotify:track:missing", 64).await,
None
);
assert_eq!(
resolve_track_artwork(Some(&client), &cache, "spotify:track:missing").await,
resolve_track_artwork(Some(&client), &cache, "spotify:track:missing", 64).await,
None
);
assert_eq!(client.transport().requests().len(), 2);
assert_eq!(client.transport().requests().len(), 3);
}

#[tokio::test]
Expand All @@ -2657,6 +2675,7 @@ mod tests {
None::<&SpotifyClient<FakeTransport, InMemoryTokenStore>>,
&cache,
&uri,
64,
)
.await
.unwrap();
Expand All @@ -2675,7 +2694,8 @@ mod tests {
resolve_track_artwork(
None::<&SpotifyClient<FakeTransport, InMemoryTokenStore>>,
&cache,
&uri
&uri,
64
)
.await,
Some(artwork)
Expand All @@ -2687,7 +2707,8 @@ mod tests {
resolve_track_artwork(
None::<&SpotifyClient<FakeTransport, InMemoryTokenStore>>,
&cache,
&localfiles::file_uri(&wav)
&localfiles::file_uri(&wav),
64
)
.await,
None
Expand Down Expand Up @@ -2820,7 +2841,7 @@ mod tests {
);
assert_eq!(
cache.playlists[0]
.non_library_tracks
.spotify_tracks
.iter()
.map(|track| track.uri.as_str())
.collect::<Vec<_>>(),
Expand Down Expand Up @@ -3150,7 +3171,8 @@ mod tests {
owner: None,
track_count: 0,
tracks: vec![],
non_library_tracks: vec![],
track_metadata_version: playlists::TRACK_METADATA_VERSION,
spotify_tracks: vec![],
})
.to_vec(),
};
Expand Down Expand Up @@ -3192,6 +3214,10 @@ mod tests {
"added".into(),
"releaseDate".into(),
],
playlist_hidden_columns: BTreeMap::from([(
"first".into(),
vec!["genre".into(), "plays".into()],
)]),
sort_column: Some("plays".into()),
sort_desc: true,
auto_add_spotify_library: false,
Expand Down Expand Up @@ -3234,6 +3260,10 @@ mod tests {
assert_eq!(restored.column_order, exported.column_order);
assert_eq!(restored.column_widths, exported.column_widths);
assert_eq!(restored.hidden_columns, exported.hidden_columns);
assert_eq!(
restored.playlist_hidden_columns,
exported.playlist_hidden_columns
);
assert_eq!(restored.sort_column.as_deref(), Some("plays"));
assert!(restored.sort_desc);
assert!(restored.shuffle);
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src-tauri/src/library_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,21 @@ pub(super) fn click_track_star(
}

#[tauri::command]
pub(super) fn set_track_enabled(
state: tauri::State<'_, AppState>,
pub(super) async fn set_track_enabled(
app: tauri::AppHandle,
id: u64,
enabled: bool,
) -> Result<(), String> {
let state = app.state::<AppState>();
mutate_library(&state, |library| {
library
.set_track_enabled(TrackId(id), enabled)
.map_err(|error| error.to_string())
})
})?;
if !enabled {
state.playback.exclude_track(id).await;
}
Ok(())
}

#[tauri::command]
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src-tauri/src/playback/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ impl Snapshot {
fn has_next(&self) -> bool {
self.index + 1 < self.len()
}

fn exclude(&mut self, id: u64) -> bool {
let current = self.order[self.index];
let previous_len = self.order.len();
self.order
.retain(|&index| index == current || self.tracks[index].id != id);
self.index = self
.order
.iter()
.position(|&index| index == current)
.expect("current track is retained");
self.order.len() != previous_len
}
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
Expand Down Expand Up @@ -421,6 +434,20 @@ impl Playback {
.set_play_threshold_percent(percent);
}

pub async fn exclude_track(&self, id: u64) {
let mut state = self.state.lock().await;
if !state
.reducer
.snapshot_mut()
.is_some_and(|snapshot| snapshot.exclude(id))
{
return;
}
let snapshot = state.reducer.snapshot().cloned();
let repeat = state.reducer.repeat().to_owned();
state.backend.set_shuffle_snapshot(snapshot, &repeat).await;
}

pub fn listen(
self: &Arc<Self>,
app: tauri::AppHandle,
Expand Down Expand Up @@ -1110,6 +1137,27 @@ mod tests {
assert_eq!(ids, [1, 2, 3, 4, 5]);
}

#[test]
fn excluding_a_track_keeps_the_current_instance_and_removes_the_rest() {
let mut tracks = file_tracks(3);
tracks.push(tracks[1].clone());
let mut snapshot = Snapshot::new(tracks, 1);

assert!(snapshot.exclude(2));
assert_eq!(snapshot.current().id, 2);
assert_eq!(
snapshot
.active_tracks()
.into_iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
[1, 2, 3]
);
assert!(snapshot.exclude(1));
assert_eq!(snapshot.current().id, 2);
assert_eq!(snapshot.index, 0);
}

#[tokio::test]
async fn toggling_shuffle_mid_song_does_not_load() {
let playback = Playback::default();
Expand Down
Loading