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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- **Detach a screen into a pane**: open the lyrics (`B`), the visualizer (`v`), cover art (`G`), the queue (`Q`) or the mini player (`T`) and press `Alt+d`. The view leaves full screen and stays on screen in a pane beside the library, so you can see the lyrics and the visualizer at the same time in one terminal. `Alt+d` in a focused pane puts it back to full screen. `Alt+h/j/k/l` (or `Alt` with an arrow key) moves the focus between panes, `Alt+H/J/K/L` (or `Alt+Shift` with an arrow key) swaps the focused pane with its neighbor, `Alt+r` turns its split, `Alt+=` and `Alt+-` resize it, `Alt+x` closes it, `Alt+0` closes every pane and `Alt+x` on the main layout hides it, with the search box, help, settings and the playbar still on screen. With mouse input on, a click focuses a pane, `[x]` closes it and a drag on the line between two panes resizes them. The layout is saved in `state.yml` and comes back at the next launch. Every key can be rebound in `config.yml`.
- **Search the add-to-playlist picker**: press `/` (the `search` key) in the picker that `w` and `W` open and type part of a playlist name. The list narrows to the matching playlists you can add to, including the ones inside folders. `Enter` keeps the search and a second `Enter` adds the track, `Esc` clears the search, and a second `Esc` closes the picker ([#924](https://github.com/LargeModGames/spotatui/issues/924)).
- **List a playlist's tracks from the CLI**: `spotatui list --playlist-tracks <URI>` prints every track of a playlist, given its `spotify:playlist:` URI or an `open.spotify.com/playlist/` link, in the `--format` you choose (`%t - %a (%u)` by default). `--limit N` stops after N tracks and asks Spotify only for those. Playlists the Web API refuses are read through the native session, and a playlist that cannot be read exits non-zero instead of printing nothing ([#908](https://github.com/LargeModGames/spotatui/issues/908)).

### Fixed

Expand Down
7 changes: 6 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,15 @@ spotatui play --playlist --name "Your Playlist" --random
| `-d, --devices` | List devices |
| `-p, --playlists` | List your playlists |
| `--liked` | List your liked songs |
| `--limit <1-50>` | Maximum results |
| `--playlist-tracks <URI>` | List the tracks of a playlist, given its `spotify:playlist:` URI or `open.spotify.com/playlist/` link |
| `--limit <N>` | Maximum results: 1-50, or any number of tracks with `--playlist-tracks` |

With `--playlist-tracks`, every track of the playlist is listed unless `--limit` stops it earlier, and the default format is `%t - %a (%u)`. A playlist episode fills `%t` with its name and `%h` with its show.

```bash
spotatui list --liked --limit 50
spotatui list --playlist-tracks spotify:playlist:37i9dQZF1DXcBWIGoYBM5M --format '%t - %a'
spotatui list --playlist-tracks "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc" --limit 20
```

## `search` (alias `s`)
Expand Down
117 changes: 109 additions & 8 deletions src/cli/clap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ spotify:playlist: or spotify:show: URI";
}
}

/// Reads a playlist URI or `open.spotify.com` link as a `spotify:playlist:` URI,
/// so `list --playlist-tracks` refuses anything else before boot.
fn parse_playlist_uri(input: &str) -> Result<String, String> {
let expected = "expected a spotify:playlist: URI or an open.spotify.com/playlist/ link";
let input = input.trim();
let id = if let Some(id) = input.strip_prefix("spotify:playlist:") {
id
} else {
let link = input
.strip_prefix("https://")
.or_else(|| input.strip_prefix("http://"))
.unwrap_or(input);
let path = link
.strip_prefix("open.spotify.com/")
.ok_or_else(|| expected.to_string())?;
// Localized links carry a language segment: open.spotify.com/intl-es/playlist/...
let path = match path.split_once('/') {
Some((lang, rest)) if lang.starts_with("intl-") => rest,
_ => path,
};
let id = path
.strip_prefix("playlist/")
.ok_or_else(|| expected.to_string())?;
// Shared links end in `?si=...`.
id.split(['?', '#', '/']).next().unwrap_or_default()
};
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(expected.to_string());
}
Ok(format!("spotify:playlist:{id}"))
}

/// Refuses a `--queue` or `--random` that the kind of `--uri` cannot honor.
///
/// Clap sees the kind of a `--name` search through its category flags, but the
Expand Down Expand Up @@ -325,19 +357,21 @@ pub fn list_subcommand() -> Command {
Command::new("list")
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
.about("Lists devices, liked songs and playlists")
.about("Lists devices, liked songs, playlists and the tracks of a playlist")
.long_about(
"This will list devices, liked songs or playlists. With the `--limit` flag you are \
able to specify the amount of results (between 1 and 50). Here, the `--format` is \
even more awesome, get your output exactly the way you want. The format option will \
be applied to every item found.",
"This will list devices, liked songs, playlists or the tracks of one playlist. With \
the `--limit` flag you are able to specify the amount of results (between 1 and 50). \
With `--playlist-tracks` every track is listed, and `--limit` stops after that many \
tracks instead. Here, the `--format` is even more awesome, get your output exactly the \
way you want. The format option will be applied to every item found.",
)
.visible_alias("l")
.arg(
format_arg()
.default_value_if("devices", ArgPredicate::IsPresent, "%v% %d")
.default_value_if("liked", ArgPredicate::IsPresent, "%t - %a (%u)")
.default_value_if("playlists", ArgPredicate::IsPresent, "%p (%u)"),
.default_value_if("playlists", ArgPredicate::IsPresent, "%p (%u)")
.default_value_if("playlist-tracks", ArgPredicate::IsPresent, "%t - %a (%u)"),
)
.arg(
Arg::new("devices")
Expand All @@ -359,14 +393,21 @@ be applied to every item found.",
.action(ArgAction::SetTrue)
.help("Lists liked songs"),
)
.arg(
Arg::new("playlist-tracks")
.long("playlist-tracks")
.value_name("URI")
.value_parser(parse_playlist_uri)
.help("Lists the tracks of a playlist, given its spotify:playlist: URI or link"),
)
.arg(
Arg::new("limit")
.long("limit")
.help("Specifies the maximum number of results (1 - 50)"),
.help("Specifies the maximum number of results (1 - 50, any number of playlist tracks)"),
)
.group(
ArgGroup::new("listable")
.args(["devices", "playlists", "liked"])
.args(["devices", "playlists", "liked", "playlist-tracks"])
.required(true)
.multiple(false),
)
Expand Down Expand Up @@ -531,6 +572,66 @@ mod tests {
);
}

const PLAYLIST: &str = "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M";

#[test]
fn playlist_tracks_takes_a_playlist_uri_as_given() {
let matches = list_subcommand()
.try_get_matches_from(["list", "--playlist-tracks", PLAYLIST])
.unwrap();
assert_eq!(
matches.get_one::<String>("playlist-tracks").unwrap(),
PLAYLIST
);
}

#[test]
fn playlist_tracks_reads_a_shared_link_as_its_uri() {
for link in [
"https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
"https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc123",
"https://open.spotify.com/intl-es/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc123",
"http://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
"open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
] {
assert_eq!(parse_playlist_uri(link).as_deref(), Ok(PLAYLIST), "{link}");
}
}

#[test]
fn playlist_tracks_refuses_anything_but_a_playlist() {
for input in [
"spotify:album:37i9dQZF1DXcBWIGoYBM5M",
"https://open.spotify.com/album/37i9dQZF1DXcBWIGoYBM5M",
"https://example.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
"spotify:playlist:",
"spotify:playlist:not a valid id",
"not-a-uri",
] {
let error = parse_playlist_uri(input).unwrap_err();
assert!(
error.starts_with("expected a spotify:playlist:"),
"{input}: {error}"
);
}
}

#[test]
fn playlist_tracks_formats_tracks_by_default() {
let matches = list_subcommand()
.try_get_matches_from(["list", "--playlist-tracks", PLAYLIST])
.unwrap();
assert_eq!(matches.get_one::<String>("format").unwrap(), "%t - %a (%u)");
}

#[test]
fn playlist_tracks_is_one_listable_kind_among_the_others() {
let error = list_subcommand()
.try_get_matches_from(["list", "--playlist-tracks", PLAYLIST, "--liked"])
.unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}

#[test]
fn play_uri_flags_allow_any_kind_without_flags() {
for uri in [
Expand Down
109 changes: 107 additions & 2 deletions src/cli/cli_app.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::core::app::SearchResult;
use crate::core::plugin_api::PlayableInfo;
use crate::core::user_config::UserConfig;
use crate::infra::network::{IoEvent, Network};

Expand Down Expand Up @@ -233,6 +234,34 @@ impl CliApp {
}
}

// spotatui list --playlist-tracks URI [--limit N]
pub async fn list_playlist_tracks(
&mut self,
uri: &str,
format: &str,
limit: Option<&str>,
) -> Result<String> {
let max = limit.map(parse_track_limit).transpose()?;
let id = crate::infra::network::ids::playlist_id(uri)
.ok_or_else(|| anyhow!("'{uri}' is not a playlist URI"))?;
let items = self.net.read_playlist_items(&id, max).await?;
if items.is_empty() {
return Ok("No tracks found".to_string());
}
Ok(
items
.into_iter()
.map(|item| {
self.format_output(
format.to_string(),
Format::from_type(playable_format_type(item)),
)
})
.collect::<Vec<String>>()
.join("\n"),
)
}

// spotatui playback --transfer DEVICE
pub async fn transfer_playback(&mut self, device: &str) -> Result<()> {
let id = {
Expand Down Expand Up @@ -788,12 +817,88 @@ fn parse_query_limit(max: &str, ceiling: u32) -> Result<u32> {
}
}

/// `--limit` for `--playlist-tracks` counts tracks, not one page, so it has no ceiling.
fn parse_track_limit(max: &str) -> Result<u32> {
match max.parse::<u32>() {
Ok(num) if num >= 1 => Ok(num),
_ => Err(anyhow!("limit must be a number of tracks, 1 or more")),
}
}

/// A playlist item as the format placeholders read it.
fn playable_format_type(item: PlayableInfo) -> FormatType {
match item {
PlayableInfo::Track(track) => FormatType::TrackInfo(Box::new(track)),
PlayableInfo::Episode(episode) => FormatType::EpisodeInfo(Box::new(episode)),
}
}

#[cfg(test)]
mod tests {
use super::{
device_id_by_name, first_result_uri, parse_query_limit, playlist_items_total, random_offset,
SearchResult, Type,
device_id_by_name, first_result_uri, parse_query_limit, parse_track_limit,
playable_format_type, playlist_items_total, random_offset, SearchResult, Type,
};
use crate::cli::util::{render_format, Format};
use crate::core::plugin_api::{EpisodeInfo, PlayableInfo};
use crate::core::user_config::UserConfig;

#[test]
fn a_track_limit_has_no_page_ceiling() {
assert_eq!(parse_track_limit("1").unwrap(), 1);
assert_eq!(parse_track_limit("500").unwrap(), 500);
}

#[test]
fn a_track_limit_refuses_zero_and_non_numbers() {
for max in ["0", "-1", "ten", ""] {
assert!(parse_track_limit(max).is_err(), "{max}");
}
}

fn render_item(format: &str, item: PlayableInfo) -> String {
render_format(
format,
&Format::from_type(playable_format_type(item)),
&UserConfig::new(),
)
}

#[test]
fn a_playlist_track_renders_like_a_liked_song() {
let track = TrackInfo::from(&full_track("0000000000000000000001", "Song"));
assert_eq!(
render_item("%t - %a (%u) [%b]", PlayableInfo::Track(track)),
"Song - Test Artist (spotify:track:0000000000000000000001) [Test Album]"
);
}

fn episode(show_name: &str) -> EpisodeInfo {
serde_json::from_value(serde_json::json!({
"id": "1",
"uri": "spotify:episode:1",
"name": "Episode",
"duration_ms": 1000,
"show_name": show_name,
}))
.unwrap()
}

#[test]
fn a_playlist_episode_renders_its_name_show_and_uri() {
assert_eq!(
render_item("%t - %h (%u)", PlayableInfo::Episode(episode("Show"))),
"Episode - Show (spotify:episode:1)"
);
}

#[test]
fn a_playlist_episode_without_its_show_name_renders_none() {
assert_eq!(
render_item("%t - %h", PlayableInfo::Episode(episode(""))),
"Episode - None"
);
}
use crate::core::pagination::Paged;
use crate::core::plugin_api::{AlbumInfo, ArtistInfo, ShowInfo, TrackInfo};
use crate::core::test_helpers::{full_track, playlist_info};
Expand Down
Loading
Loading