From bda4db14de41593180acbeda6f62839d90900c9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 23:24:20 +0000 Subject: [PATCH 1/2] feat: add playlist item drill-down support Browse playlist tracks and episodes via Spotify's /playlists/{id}/items endpoint, matching album and podcast drill-down. Falls back to playing the playlist when Spotify forbids browsing (owned/collaborator-only). Co-authored-by: bautistaaa --- README.md | 15 ++++--- doc/jam.txt | 19 +++++---- lua/jam/providers/spotify.lua | 46 +++++++++++++++++++- lua/jam/ui/picker.lua | 34 ++++++++++++++- tests/provider.lua | 80 +++++++++++++++++++++++++++++++++++ tests/smoke.lua | 1 + 6 files changed, 178 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0ec4529..8eb0296 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,13 @@ 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. +For example, `a:Abbey Road`, `t:BTS`, `s:One More Night`, `l:Discover Weekly`, +`p:Radiolab`, or `e:Black Holes`. Queries without a prefix search all supported +item types. Picker mappings: @@ -205,8 +207,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 diff --git a/doc/jam.txt b/doc/jam.txt index 1afb347..932337f 100644 --- a/doc/jam.txt +++ b/doc/jam.txt @@ -62,14 +62,17 @@ 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. + +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/providers/spotify.lua b/lua/jam/providers/spotify.lua index 247f069..b7bbbda 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 @@ -246,6 +251,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..fbca043 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) 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) From 352a40cf9fcfbeee039f3091b044fa48db5c3e1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 23:40:52 +0000 Subject: [PATCH 2/2] fix: surface playlists in mixed search results Interleave search types so PLAYLIST rows appear near the top instead of after every track/album/artist. Cap search limit at Spotify's Dev Mode max of 10, and document the l: playlist filter. Co-authored-by: bautistaaa --- README.md | 9 ++++--- doc/jam.txt | 4 +++- lua/jam/config.lua | 2 +- lua/jam/providers/spotify.lua | 23 +++++++++++++++--- tests/provider.lua | 44 +++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8eb0296..cc734dd 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,12 @@ Search filters: | `p:` | Podcasts | | `e:` | Podcast episodes | +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`. Queries without a prefix search all supported -item types. +`p:Radiolab`, or `e:Black Holes`. Picker mappings: @@ -220,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 932337f..458ac89 100644 --- a/doc/jam.txt +++ b/doc/jam.txt @@ -63,7 +63,9 @@ the app may not make it an active Spotify Connect device. Prefix a search with `a:` for albums, `t:` for artists, `s:` for songs and tracks, `l:` for playlists, `p:` for podcasts, or `e:` for podcast episodes. A -query without a prefix searches all supported item types. +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 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 b7bbbda..e2692d7 100644 --- a/lua/jam/providers/spotify.lua +++ b/lua/jam/providers/spotify.lua @@ -123,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) @@ -136,7 +138,6 @@ function Spotify:search(query, options, callback) callback(err) return end - local results = {} local plural = { track = "tracks", album = "albums", @@ -145,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) diff --git a/tests/provider.lua b/tests/provider.lua index fbca043..2cbcea9 100644 --- a/tests/provider.lua +++ b/tests/provider.lua @@ -302,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)