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
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -205,8 +210,11 @@ Picker mappings:
| `<Esc>` | 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 `<Esc>`
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 `<Esc>` in any collection
view to return to the same search query.

## Configuration

Expand All @@ -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 = {
Expand Down
21 changes: 13 additions & 8 deletions doc/jam.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CR> 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,
<CR> plays the selected item and <Esc> 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 <CR> 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, <CR> plays the
selected item and <Esc> 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:
>
Expand Down
2 changes: 1 addition & 1 deletion lua/jam/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ M.defaults = {
provider = "spotify",
search = {
debounce_ms = 250,
limit = 30,
limit = 10,
types = { "track", "album", "artist", "playlist", "show", "episode" },
},
artwork = {
Expand Down
69 changes: 64 additions & 5 deletions lua/jam/providers/spotify.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Spotify.capabilities = {
album_tracks = true,
artist_top_tracks = true,
show_episodes = true,
playlist_items = true,
library = true,
artwork = true,
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -118,20 +123,21 @@ 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)
if err then
callback(err)
return
end
local results = {}
local plural = {
track = "tracks",
album = "albums",
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions lua/jam/ui/picker.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading