Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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