diff --git a/README.md b/README.md index 0ec4529..cc734dd 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Search Spotify and control playback from a Telescope picker without leaving Neov ## Features - Live Spotify search for music, playlists, podcasts, and episodes -- Drill-down for album tracks, artist top tracks, and podcast episodes +- Drill-down for album tracks, artist top tracks, podcast episodes, and playlist items - Play, pause, skip, go back, and add tracks or episodes to the queue - OAuth Authorization Code flow with PKCE—no client secret in your config - Album-art previews through `image.nvim` or `chafa`, with automatic detection @@ -189,11 +189,16 @@ Search filters: | `a:` | Albums | | `t:` | Artists | | `s:` | Songs/tracks | +| `l:` | Playlists | | `p:` | Podcasts | | `e:` | Podcast episodes | -For example, `a:Abbey Road`, `t:BTS`, `s:One More Night`, `p:Radiolab`, or -`e:Black Holes`. Queries without a prefix search all supported item types. +There is no separate playlist tab. Unfiltered search mixes types in the results +list (labeled `TRACK`, `ALBUM`, `ARTIST`, `PLAYLIST`, `PODCAST`, `EPISODE`). Use +`l:` when you only want playlists. + +For example, `a:Abbey Road`, `t:BTS`, `s:One More Night`, `l:Discover Weekly`, +`p:Radiolab`, or `e:Black Holes`. Picker mappings: @@ -205,8 +210,11 @@ Picker mappings: | `` | Return from a collection to the original search | Selecting an album opens its tracks in disc and track order. Selecting an artist -opens their top tracks, and selecting a podcast opens its episodes. Press `` -in any collection view to return to the same search query. +opens their top tracks, selecting a podcast opens its episodes, and selecting a +playlist opens its items when Spotify allows it. Spotify Development Mode only +returns playlist contents for playlists you own or collaborate on; for other +playlists, jam.nvim plays the playlist instead. Press `` in any collection +view to return to the same search query. ## Configuration @@ -215,7 +223,7 @@ require("jam").setup({ provider = "spotify", search = { debounce_ms = 250, - limit = 30, + limit = 10, types = { "track", "album", "artist", "playlist", "show", "episode" }, }, artwork = { diff --git a/doc/jam.txt b/doc/jam.txt index 1afb347..458ac89 100644 --- a/doc/jam.txt +++ b/doc/jam.txt @@ -62,14 +62,19 @@ the app may not make it an active Spotify Connect device. :Jam health Run health diagnostics. Prefix a search with `a:` for albums, `t:` for artists, `s:` for songs and -tracks, `p:` for podcasts, or `e:` for podcast episodes. A query without a -prefix searches all supported item types. - -The search picker maps to open an album, artist, or podcast, or play the -selected track or episode. Albums open in disc and track order, artists open to -their top tracks, and podcasts open to their episodes. In a collection picker, - plays the selected item and returns to the original search. CTRL-Q -queues the selected item and CTRL-P pauses playback. +tracks, `l:` for playlists, `p:` for podcasts, or `e:` for podcast episodes. A +query without a prefix searches all supported item types and mixes them in the +results list. There is no separate playlist tab; use `l:` to show only +playlists (labeled PLAYLIST in the results column). + +The search picker maps to open an album, artist, podcast, or playlist, or +play the selected track or episode. Albums open in disc and track order, artists +open to their top tracks, podcasts open to their episodes, and playlists open to +their items when Spotify allows browsing them. Spotify Development Mode only +exposes playlist contents for playlists you own or collaborate on; other +playlists play immediately instead. In a collection picker, plays the +selected item and returns to the original search. CTRL-Q queues the +selected item and CTRL-P pauses playback. The picker can also be opened after loading the Telescope extension: > diff --git a/lua/jam/config.lua b/lua/jam/config.lua index 261a794..64272ca 100644 --- a/lua/jam/config.lua +++ b/lua/jam/config.lua @@ -4,7 +4,7 @@ M.defaults = { provider = "spotify", search = { debounce_ms = 250, - limit = 30, + limit = 10, types = { "track", "album", "artist", "playlist", "show", "episode" }, }, artwork = { diff --git a/lua/jam/providers/spotify.lua b/lua/jam/providers/spotify.lua index 247f069..e2692d7 100644 --- a/lua/jam/providers/spotify.lua +++ b/lua/jam/providers/spotify.lua @@ -12,6 +12,7 @@ Spotify.capabilities = { album_tracks = true, artist_top_tracks = true, show_episodes = true, + playlist_items = true, library = true, artwork = true, } @@ -66,7 +67,10 @@ local function normalize(item, kind) total_episodes = item.total_episodes or (item.show and item.show.total_episodes), languages = item.languages, description = item.description, - total_tracks = item.total_tracks or (album and album.total_tracks), + total_tracks = item.total_tracks + or (album and album.total_tracks) + or (item.items and item.items.total) + or (item.tracks and item.tracks.total), album_type = item.album_type or (album and album.album_type), progress_ms = item.resume_point and item.resume_point.resume_position_ms, fully_played = item.resume_point and item.resume_point.fully_played, @@ -101,13 +105,14 @@ function Spotify:search(query, options, callback) end options = options or {} query = vim.trim(query) - local prefix, filtered_query = query:match("^([aAtTsSpPeE]):%s*(.*)$") + local prefix, filtered_query = query:match("^([aAtTsSpPeElL]):%s*(.*)$") local type_by_prefix = { a = "album", t = "artist", s = "track", p = "show", e = "episode", + l = "playlist", } local types = options.types or { "track", "album", "artist", "playlist", "show", "episode" } if prefix then @@ -118,12 +123,14 @@ function Spotify:search(query, options, callback) return end end + -- Spotify Development Mode caps search limit at 10 per type. + local limit = math.min(options.limit or 10, 10) local url = API_URL .. "/search?" .. util.query({ q = query, type = table.concat(types, ","), - limit = options.limit or 30, + limit = limit, }) return self:_request({ url = url }, function(err, response) @@ -131,7 +138,6 @@ function Spotify:search(query, options, callback) callback(err) return end - local results = {} local plural = { track = "tracks", album = "albums", @@ -140,15 +146,31 @@ function Spotify:search(query, options, callback) show = "shows", episode = "episodes", } + -- Interleave by type so playlists/podcasts aren't buried under tracks. + local buckets = {} + local max_count = 0 for _, kind in ipairs(types) do + local bucket = {} local group = response[plural[kind]] if type(group) == "table" then for _, item in ipairs(group.items or {}) do if item then - table.insert(results, normalize(item, kind)) + table.insert(bucket, normalize(item, kind)) end end end + table.insert(buckets, bucket) + if #bucket > max_count then + max_count = #bucket + end + end + local results = {} + for index = 1, max_count do + for _, bucket in ipairs(buckets) do + if bucket[index] then + table.insert(results, bucket[index]) + end + end end callback(nil, results) end) @@ -246,6 +268,43 @@ function Spotify:show_episodes(show, callback) fetch(API_URL .. "/shows/" .. util.urlencode(show.id) .. "/episodes?limit=50") end +function Spotify:playlist_items(playlist, callback) + if not playlist or not playlist.id then + callback("A Spotify playlist ID is required") + return + end + + local entries = {} + local function fetch(url) + self:_request({ url = url }, function(err, response) + if err then + callback(err) + return + end + for _, row in ipairs((response or {}).items or {}) do + local media = row and (row.item or row.track) + if media then + local kind = media.type == "episode" and "episode" or "track" + local entry = normalize(media, kind) + entry.list_position = #entries + 1 + entry.image_url = entry.image_url or playlist.image_url + table.insert(entries, entry) + end + end + if response and type(response.next) == "string" and response.next ~= "" then + fetch(response.next) + else + callback(nil, entries) + end + end) + end + + fetch(API_URL .. "/playlists/" .. util.urlencode(playlist.id) .. "/items?" .. util.query({ + limit = 50, + additional_types = "track,episode", + })) +end + function Spotify:play(item, callback) local body if item.kind == "track" or item.kind == "episode" then diff --git a/lua/jam/ui/picker.lua b/lua/jam/ui/picker.lua index d3677c7..3e4ca80 100644 --- a/lua/jam/ui/picker.lua +++ b/lua/jam/ui/picker.lua @@ -209,7 +209,8 @@ local function metadata_lines(item, width) table.insert(details, "Duration: " .. duration(item.duration_ms)) end if item.total_tracks then - table.insert(details, "Tracks: " .. item.total_tracks) + local count_label = item.kind == "playlist" and "Items: " or "Tracks: " + table.insert(details, count_label .. item.total_tracks) end if item.album_type then table.insert(details, "Type: " .. item.album_type) @@ -448,7 +449,7 @@ function M.open(provider, config, opts) attach_mappings = function(prompt_buffer, map) local loading_tracks = false - local function drill_down(item, method, title, results_title) + local function drill_down(item, method, title, results_title, on_error) if loading_tracks then return end @@ -457,6 +458,9 @@ function M.open(provider, config, opts) provider[method](provider, item, function(err, tracks) loading_tracks = false if err then + if on_error and on_error(err) then + return + end util.notify(err, vim.log.levels.ERROR) return end @@ -502,6 +506,32 @@ function M.open(provider, config, opts) drill_down(show, "show_episodes", "Podcast · " .. show.name, "Episodes") return end + if selected.value.kind == "playlist" and provider.playlist_items then + local playlist = selected.value + drill_down( + playlist, + "playlist_items", + "Playlist · " .. playlist.name, + "Items", + function(err) + local lower = tostring(err):lower() + local forbidden = lower:find("http 403", 1, true) + or lower:find("forbidden", 1, true) + or lower:find("not collaborate", 1, true) + if not forbidden then + return false + end + util.notify( + "Spotify only allows browsing playlists you own or collaborate on; playing instead", + vim.log.levels.INFO + ) + actions.close(prompt_buffer) + run_action(provider, "play", playlist, "Playing " .. playlist.name) + return true + end + ) + return + end actions.close(prompt_buffer) run_action(provider, "play", selected.value, "Playing " .. selected.value.name) end) diff --git a/tests/provider.lua b/tests/provider.lua index 252f4cd..2cbcea9 100644 --- a/tests/provider.lua +++ b/tests/provider.lua @@ -161,12 +161,92 @@ assert(episodes[1].list_position == 1) assert(episodes[2].image_url == "https://example.com/show.jpg") assert(episodes[2].list_position == 2) +local playlist_requests = {} +http.request = function(opts, callback) + table.insert(playlist_requests, opts) + if #playlist_requests == 1 then + callback(nil, { + items = { + { + item = { + id = "playlist-track-one", + uri = "spotify:track:playlist-one", + type = "track", + name = "Playlist Track", + artists = { { name = "Playlist Artist" } }, + album = { + name = "Playlist Album", + images = { { url = "https://example.com/playlist-track.jpg" } }, + }, + duration_ms = 200000, + }, + }, + { + track = { + id = "legacy-track", + uri = "spotify:track:legacy", + name = "Legacy Track Field", + artists = { { name = "Legacy Artist" } }, + duration_ms = 180000, + }, + }, + { + item = nil, + }, + }, + next = "https://api.spotify.com/v1/playlists/test-playlist/items?limit=50&offset=50", + }) + else + callback(nil, { + items = { + { + item = { + id = "playlist-episode-one", + uri = "spotify:episode:playlist-one", + type = "episode", + name = "Playlist Episode", + show = { name = "Playlist Podcast" }, + duration_ms = 2400000, + }, + }, + }, + next = vim.NIL, + }) + end +end + +local playlist_items +provider:playlist_items({ + id = "test-playlist", + name = "Test Playlist", + image_url = "https://example.com/playlist.jpg", +}, function(err, items) + callback_error = err + playlist_items = items +end) + +assert(not callback_error, callback_error) +assert(#playlist_requests == 2) +assert(playlist_requests[1].url:find("/playlists/test%-playlist/items%?")) +assert(playlist_requests[1].url:find("additional_types=track%2Cepisode", 1, true)) +assert(#playlist_items == 3) +assert(playlist_items[1].kind == "track") +assert(playlist_items[1].name == "Playlist Track") +assert(playlist_items[1].list_position == 1) +assert(playlist_items[2].kind == "track") +assert(playlist_items[2].name == "Legacy Track Field") +assert(playlist_items[2].image_url == "https://example.com/playlist.jpg") +assert(playlist_items[3].kind == "episode") +assert(playlist_items[3].podcast == "Playlist Podcast") +assert(playlist_items[3].list_position == 3) + for _, filter in ipairs({ { query = "a: Test Album", expected_type = "album" }, { query = "t: Test Artist", expected_type = "artist" }, { query = "s: Test Song", expected_type = "track" }, { query = "p: Test Podcast", expected_type = "show" }, { query = "e: Test Episode", expected_type = "episode" }, + { query = "l: Test Playlist", expected_type = "playlist" }, }) do local search_request http.request = function(opts, callback) @@ -222,6 +302,50 @@ assert(metadata_results[1].genres[1] == "pop") assert(metadata_results[2].publisher == "Metadata Publisher") assert(metadata_results[2].total_episodes == 42) +local interleaved_request +local interleaved_results +http.request = function(opts, callback) + interleaved_request = opts + callback(nil, { + tracks = { + items = { + { id = "t1", uri = "spotify:track:t1", name = "Track One", artists = {} }, + { id = "t2", uri = "spotify:track:t2", name = "Track Two", artists = {} }, + }, + }, + albums = { + items = { + { id = "a1", uri = "spotify:album:a1", name = "Album One", artists = {} }, + }, + }, + playlists = { + items = { + { + id = "p1", + uri = "spotify:playlist:p1", + name = "Playlist One", + owner = { display_name = "Owner" }, + }, + }, + }, + }) +end +provider:search("mixed", { + types = { "track", "album", "playlist" }, + limit = 30, +}, function(err, results) + callback_error = err + interleaved_results = results +end) +assert(not callback_error, callback_error) +assert(interleaved_request.url:find("limit=10", 1, true)) +assert(#interleaved_results == 4) +assert(interleaved_results[1].kind == "track") +assert(interleaved_results[2].kind == "album") +assert(interleaved_results[3].kind == "playlist") +assert(interleaved_results[4].kind == "track") +assert(interleaved_results[3].name == "Playlist One") + local playback_error local episode_play_request http.request = function(opts, callback) diff --git a/tests/smoke.lua b/tests/smoke.lua index c549f79..03ca4d8 100644 --- a/tests/smoke.lua +++ b/tests/smoke.lua @@ -20,6 +20,7 @@ assert(provider.capabilities.playback) assert(provider.capabilities.album_tracks) assert(provider.capabilities.artist_top_tracks) assert(provider.capabilities.show_episodes) +assert(provider.capabilities.playlist_items) assert(provider.capabilities.artwork) assert(vim.fn.exists(":Jam") == 2)