Skip to content

Commit f6aea88

Browse files
committed
Add provider search for Navidrome, Jellyfin, and Local
- Implement provider.Searcher on each: Subsonic search3 for Navidrome, /Items?searchTerm for Jellyfin, in-memory scan for Local playlists - Wire Ctrl+F into the focusProvider key dispatcher and the Navidrome browser overlay so it works wherever the user is browsing - Add S / L / R provider-switch shortcuts to focusProvider (matched J / N) - Reorder overlay key dispatch so the search overlay claims keys above the nav browser - Rename the search overlay title from "Spotify Search" to plain "Search" - Tweak ColorKeyBG to use Accent instead of FG
1 parent 2d04832 commit f6aea88

11 files changed

Lines changed: 246 additions & 35 deletions

File tree

docs/keybindings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Press `Ctrl+K` in the player to see all keybindings.
4949
| Key | Action |
5050
|---|---|
5151
| `f` | Toggle bookmark ★ on selected track (or favorite radio station in radio browser) |
52-
| `Ctrl+F` | Search — active provider's native search (Spotify) or YouTube |
52+
| `Ctrl+F` | Search — active provider's native search (Spotify, Navidrome, Jellyfin, Plex, Local) or YouTube fallback. Available from playlist and provider-browser views. |
5353
| `u` | Load URL (stream/playlist) |
5454
| `y` | Show lyrics |
5555
| `Ctrl+S` | Save track to ~/Music |

external/jellyfin/client.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/url"
1010
"path"
1111
"sort"
12+
"strconv"
1213
"strings"
1314
"time"
1415

@@ -372,6 +373,39 @@ func (c *Client) Tracks(albumID string) ([]Track, error) {
372373
return out, nil
373374
}
374375

376+
// Search searches the user's audio library for tracks matching query and
377+
// returns up to limit results.
378+
func (c *Client) Search(query string, limit int) ([]Track, error) {
379+
userID, err := c.UserID()
380+
if err != nil {
381+
return nil, err
382+
}
383+
if limit <= 0 {
384+
limit = 50
385+
}
386+
387+
params := url.Values{
388+
"userId": {userID},
389+
"searchTerm": {query},
390+
"includeItemTypes": {"Audio"},
391+
"recursive": {"true"},
392+
"limit": {strconv.Itoa(limit)},
393+
"fields": {"RunTimeTicks"},
394+
"enableTotalRecordCount": {"false"},
395+
}
396+
397+
var resp itemsResponseDTO
398+
if err := c.get("/Items", params, &resp); err != nil {
399+
return nil, err
400+
}
401+
402+
out := make([]Track, 0, len(resp.Items))
403+
for _, it := range resp.Items {
404+
out = append(out, trackFromItem(it))
405+
}
406+
return out, nil
407+
}
408+
375409
// IsStreamURL reports whether the given URL looks like a Jellyfin item download
376410
// endpoint. Used by the player to route these URLs through the buffered ffmpeg
377411
// pipeline instead of native HTTP streaming.

external/jellyfin/provider.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package jellyfin
22

33
import (
4+
"context"
45
"fmt"
56
"sync"
67
"time"
@@ -15,6 +16,7 @@ var (
1516
_ provider.AlbumBrowser = (*Provider)(nil)
1617
_ provider.AlbumTrackLoader = (*Provider)(nil)
1718
_ provider.PlaybackReporter = (*Provider)(nil)
19+
_ provider.Searcher = (*Provider)(nil)
1820
)
1921

2022
// Provider implements playlist.Provider for a Jellyfin server.
@@ -117,6 +119,16 @@ func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
117119
return out, nil
118120
}
119121

122+
// SearchTracks searches the Jellyfin music library for tracks matching query.
123+
// Implements provider.Searcher.
124+
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
125+
jfTracks, err := p.client.Search(query, limit)
126+
if err != nil {
127+
return nil, err
128+
}
129+
return p.toPlaylistTracks(jfTracks), nil
130+
}
131+
120132
// Tracks returns the tracks for one album item.
121133
// Results are cached per album id.
122134
func (p *Provider) Tracks(albumID string) ([]playlist.Track, error) {
@@ -134,6 +146,21 @@ func (p *Provider) Tracks(albumID string) ([]playlist.Track, error) {
134146
return nil, err
135147
}
136148

149+
out := p.toPlaylistTracks(jfTracks)
150+
151+
p.mu.Lock()
152+
if p.trackCache == nil {
153+
p.trackCache = make(map[string][]playlist.Track)
154+
}
155+
p.trackCache[albumID] = out
156+
p.mu.Unlock()
157+
158+
return out, nil
159+
}
160+
161+
// toPlaylistTracks converts Jellyfin Tracks to playlist.Tracks, attaching the
162+
// authenticated stream URL and Jellyfin item ID metadata.
163+
func (p *Provider) toPlaylistTracks(jfTracks []Track) []playlist.Track {
137164
out := make([]playlist.Track, 0, len(jfTracks))
138165
for _, t := range jfTracks {
139166
out = append(out, playlist.Track{
@@ -148,13 +175,5 @@ func (p *Provider) Tracks(albumID string) ([]playlist.Track, error) {
148175
ProviderMeta: map[string]string{provider.MetaJellyfinID: t.ID},
149176
})
150177
}
151-
152-
p.mu.Lock()
153-
if p.trackCache == nil {
154-
p.trackCache = make(map[string][]playlist.Track)
155-
}
156-
p.trackCache[albumID] = out
157-
p.mu.Unlock()
158-
159-
return out, nil
178+
return out
160179
}

external/local/provider.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
var (
2525
_ provider.PlaylistWriter = (*Provider)(nil)
2626
_ provider.PlaylistDeleter = (*Provider)(nil)
27+
_ provider.Searcher = (*Provider)(nil)
2728
)
2829

2930
// Provider reads and writes TOML-based playlists stored on disk.
@@ -225,6 +226,63 @@ func (p *Provider) AddTrackToPlaylist(_ context.Context, playlistID string, trac
225226
return p.AddTrack(playlistID, track)
226227
}
227228

229+
// SearchTracks does a case-insensitive substring search across every saved
230+
// playlist for tracks whose title, artist, or album match query. Returns up to
231+
// limit results (limit <= 0 means no cap). Implements provider.Searcher.
232+
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
233+
q := strings.ToLower(strings.TrimSpace(query))
234+
if q == "" {
235+
return nil, nil
236+
}
237+
238+
entries, err := os.ReadDir(p.dir)
239+
if errors.Is(err, fs.ErrNotExist) {
240+
return nil, nil
241+
}
242+
if err != nil {
243+
return nil, err
244+
}
245+
246+
var out []playlist.Track
247+
seen := make(map[string]struct{})
248+
for _, e := range entries {
249+
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".toml") {
250+
continue
251+
}
252+
tracks, err := p.loadTOML(filepath.Join(p.dir, e.Name()))
253+
if err != nil {
254+
continue
255+
}
256+
for _, t := range tracks {
257+
if _, dup := seen[t.Path]; dup {
258+
continue
259+
}
260+
if !trackMatches(t, q) {
261+
continue
262+
}
263+
seen[t.Path] = struct{}{}
264+
out = append(out, t)
265+
if limit > 0 && len(out) >= limit {
266+
return out, nil
267+
}
268+
}
269+
}
270+
return out, nil
271+
}
272+
273+
func trackMatches(t playlist.Track, lowerQuery string) bool {
274+
if strings.Contains(strings.ToLower(t.Title), lowerQuery) {
275+
return true
276+
}
277+
if strings.Contains(strings.ToLower(t.Artist), lowerQuery) {
278+
return true
279+
}
280+
if strings.Contains(strings.ToLower(t.Album), lowerQuery) {
281+
return true
282+
}
283+
return false
284+
}
285+
228286
// DeletePlaylist removes the TOML file for the named playlist.
229287
func (p *Provider) DeletePlaylist(name string) error {
230288
path, err := p.safePath(name)

external/navidrome/client.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package navidrome
22

33
import (
4+
"context"
45
"crypto/md5"
56
"crypto/rand"
67
"encoding/hex"
@@ -10,6 +11,7 @@ import (
1011
"net/http"
1112
"net/url"
1213
"os"
14+
"strconv"
1315
"strings"
1416
"sync"
1517
"time"
@@ -26,6 +28,7 @@ var (
2628
_ provider.AlbumTrackLoader = (*NavidromeClient)(nil)
2729
_ provider.AlbumSortSaver = (*NavidromeClient)(nil)
2830
_ provider.PlaybackReporter = (*NavidromeClient)(nil)
31+
_ provider.Searcher = (*NavidromeClient)(nil)
2932
)
3033

3134
// httpClient is used for all Navidrome API calls with a finite timeout.
@@ -408,6 +411,35 @@ func (c *NavidromeClient) AlbumTracks(albumID string) ([]playlist.Track, error)
408411
return tracks, nil
409412
}
410413

414+
// SearchTracks searches the Subsonic library for songs matching query
415+
// using the search3.view endpoint. Implements provider.Searcher.
416+
func (c *NavidromeClient) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
417+
if limit <= 0 {
418+
limit = 50
419+
}
420+
params := url.Values{
421+
"query": {query},
422+
"songCount": {strconv.Itoa(limit)},
423+
"albumCount": {"0"},
424+
"artistCount": {"0"},
425+
}
426+
var result struct {
427+
SubsonicResponse struct {
428+
SearchResult3 struct {
429+
Song []subsonicSong `json:"song"`
430+
} `json:"searchResult3"`
431+
} `json:"subsonic-response"`
432+
}
433+
if err := c.subsonicGet("search3", params, &result); err != nil {
434+
return nil, err
435+
}
436+
tracks := make([]playlist.Track, 0, len(result.SubsonicResponse.SearchResult3.Song))
437+
for _, s := range result.SubsonicResponse.SearchResult3.Song {
438+
tracks = append(tracks, c.songToTrack(s))
439+
}
440+
return tracks, nil
441+
}
442+
411443
// subsonicSong holds the common JSON fields returned by the Subsonic API
412444
// for tracks in both getPlaylist and getAlbum responses.
413445
type subsonicSong struct {

external/navidrome/client_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,24 @@ func subsonicHandler(t *testing.T) http.HandlerFunc {
107107
}
108108
}
109109
}`))
110+
case strings.HasSuffix(path, "/rest/search3"):
111+
w.Write([]byte(`{
112+
"subsonic-response": {
113+
"status": "ok",
114+
"searchResult3": {
115+
"song": [
116+
{
117+
"id":"song-1","title":"So What","artist":"Miles Davis",
118+
"album":"Kind of Blue","year":1959,"track":1,"genre":"Jazz","duration":565
119+
},
120+
{
121+
"id":"song-7","title":"What'd I Say","artist":"Ray Charles",
122+
"album":"What'd I Say","year":1959,"track":1,"genre":"Soul","duration":380
123+
}
124+
]
125+
}
126+
}
127+
}`))
110128
case strings.HasSuffix(path, "/rest/scrobble"):
111129
w.Write([]byte(`{"subsonic-response":{"status":"ok"}}`))
112130
default:
@@ -320,6 +338,28 @@ func TestAlbumTracks(t *testing.T) {
320338
}
321339
}
322340

341+
func TestSearchTracks(t *testing.T) {
342+
c, srv := newTestClient(t)
343+
defer srv.Close()
344+
345+
tracks, err := c.SearchTracks(t.Context(), "what", 20)
346+
if err != nil {
347+
t.Fatalf("SearchTracks() error: %v", err)
348+
}
349+
if len(tracks) != 2 {
350+
t.Fatalf("expected 2 tracks, got %d", len(tracks))
351+
}
352+
if tracks[0].Title != "So What" {
353+
t.Errorf("tracks[0].Title = %q, want %q", tracks[0].Title, "So What")
354+
}
355+
if !tracks[0].Stream {
356+
t.Error("tracks[0].Stream should be true")
357+
}
358+
if tracks[0].Meta(provider.MetaNavidromeID) != "song-1" {
359+
t.Errorf("tracks[0] meta navidrome id = %q, want song-1", tracks[0].Meta(provider.MetaNavidromeID))
360+
}
361+
}
362+
323363
func TestSubsonicError(t *testing.T) {
324364
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
325365
w.Header().Set("Content-Type", "application/json")

site/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1899,7 +1899,7 @@ <h3>Run the setup wizard</h3>
18991899
<div class="keys-group-title">Search &amp; Browse</div>
19001900
<div class="keys-group-body">
19011901
<div class="key-row"><kbd>/</kbd><span>Search playlist</span></div>
1902-
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search (active provider or YouTube)</span></div>
1902+
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Navidrome, Jellyfin, Plex, Local) or YouTube fallback</span></div>
19031903
<div class="key-row"><kbd>f</kbd><span>Toggle bookmark &#9733; / radio favorite</span></div>
19041904
<div class="key-row"><kbd>u</kbd><span>Load URL (stream / playlist)</span></div>
19051905
<div class="key-row"><kbd>o</kbd><span>Open file browser</span></div>

0 commit comments

Comments
 (0)