Fix review findings: concurrency, plugin sandbox, provider bugs, and dedup (#254)
* fix(playlist): synchronize Playlist for concurrent plugin/UI access Lua plugin callbacks run on goroutines (hooks, timers, keybinds) and read playlist state through StateProvider closures, while the Bubbletea loop mutates the same *Playlist. The struct had no synchronization, so reads of p.tracks/p.order/p.pos raced with Next/Prev/Add/Remove/shuffle, and Current()'s unguarded p.tracks[idx] could panic on a stale index. Add a sync.Mutex and guard every exported method. Reads and writes are now serialized; no exported method calls another, so defer-unlock cannot deadlock. Fixes the high-severity track/player getter race and the playlist-mutation data race. * fix(luaplugin): block SSRF in cliamp.http doHTTP passed plugin-supplied URLs straight to the HTTP client with no validation, letting a plugin reach loopback, RFC1918, and link-local 169.254.169.254 (cloud metadata), and follow redirects into those targets. Add an ssrfGuard on the dialer Control hook so every connection attempt (including redirects and DNS-rebound hosts) is checked against the resolved IP and rejected if non-public, and reject non-http(s) schemes up front. A per-plugin network permission gate remains a possible follow-up; it is omitted here to avoid breaking existing plugins that legitimately use http. * fix(pluginmgr): sandbox plugin VM during metadata extraction extractMetadata ran DoFile on the entire plugin file with full stdlib, so any top-level os.execute/io call ran unsandboxed the moment a user listed installed plugins. Apply the same luaplugin.Sandbox used by the runtime before executing the file. Exports luaplugin.Sandbox for reuse. * fix(luaplugin): error on oversized fs.read instead of silent truncation fs.read used os.ReadFile (whole file into memory) then sliced to 1MB, returning a silently truncated value as success and not bounding memory for huge files. Stream through io.LimitReader(maxSize+1) and return an explicit error when the file exceeds the limit. * fix(luaplugin): make write allowlist symlink-safe isWriteAllowed validated paths lexically: the strings.Contains(abs, "..") check was dead (filepath.Abs already cleans "..", and it false-positived on filenames like "..bar"), and a symlink planted inside an allowed dir could redirect a write outside it. Resolve symlinks on the deepest existing ancestor of the target and on the allow dirs themselves before the prefix check. Also fixes the same gap in the exec cwd check, which reuses this function. * fix(luaplugin): bound timer callbacks with hookTimeout timer.after/every called CallByParam directly with no context timeout, so a runaway callback held the plugin mutex forever, blocking every other hook, keybind, and visualizer for that plugin. Add a shared callBounded helper that applies hookTimeout (mirroring invokeHook) and use it from both timer paths. * fix(luaplugin): bound visualizer render/init/destroy with hookTimeout RenderVis runs on the UI render loop and called CallByParam with no timeout while holding the plugin mutex; a hanging render() froze the UI with no escape. InitVis/DestroyVis had the same gap. Route all three through callBounded so a misbehaving visualizer times out and render falls back to the previous frame. * fix(luaplugin): wait for async hook goroutines before closing LStates Emit spawned untracked goroutines that call CallByParam on a plugin LState. Close stopped timers/execs then closed every LState without waiting, so a UI event dispatched just before shutdown could run against an already-closed LState (p.L.Close does not take the plugin mutex). Track Emit goroutines in a WaitGroup, set a closing flag under mu to reject late dispatch, and Wait before closing the LStates. * fix(ui): guard spectrum visualizers against zero-width panel renderFirework/renderBubbles/renderSakura compute dotCols = PanelWidth*2 and then mod by dotCols/dotRows/len(bands); on a narrow terminal PanelWidth can be 0, so seed % uint64(dotCols) panicked and crashed the TUI. Add the same dotRows<4 || dotCols<4 early-out the braille-grid renderers already use. * fix(history): do not clobber history on a transient load error Record discarded loadLocked's error and proceeded as if history were empty, so a one-off read failure (permission/I-O glitch) made saveLocked atomically rewrite the file with only the new entry, destroying all prior rows. Propagate the error instead, leaving the on-disk file untouched. * fix(history): detect write errors before committing history file saveLocked ignored errors from writeEntry's formatted writes, so a failure mid-write (e.g. ENOSPC) could still rename a truncated temp file over the real history. Thread writes through an errWriter and abort the rename when a write fails. * fix(lyrics): stop cleanQuery from erasing titles with label words The noise regex matched official/lyric/audio/video as bare substrings followed by .*, so 'Videotape', 'Audioslave', and 'Video Games' were cleaned to empty or truncated queries, breaking lyric lookups. Restrict bare-label stripping to genuine trailing labels (dash suffix, or 'official video/audio' and 'lyric(s) video' phrases). Add regression test cases. * fix(jellyfin,emby): synchronize client token/userID/album cache Client.token, userID, and albumCache were lazily read and written from concurrent tea.Cmd goroutines (Playlists, album browse, SearchTracks all run in their own goroutines) with no synchronization, a data race on the lazy auth writes and the cached slice. Add a mutex guarding those fields, taken only around field access and never across network I/O, with double-checked auth so at most a redundant (not racy) auth can occur. * fix(ipc): close in-flight connections on shutdown handleConn's read loop only observed the 60s per-request read deadline, never s.done, and Close closed only the listener. A still-connected client (e.g. a vis-bands polling client) kept its handler goroutine alive, so Close blocked on wg.Wait for up to 60s. Track live connections and close them in Close so their scanner.Scan unblocks immediately; addConn shares the conn mutex with the done check to close the accept-during-shutdown race. * refactor(ipc): route all reply-waits through waitReply The reply/timeout/shutdown select was open-coded in load/theme/vis/bands/ status and waitReply was used elsewhere with a generic 'timeout' message. Parameterize waitReply with a label and timeout and use it everywhere, which also gives the previously-generic commands specific timeout messages. * fix(ipc): handle accept errors instead of spinning silently acceptLoop swallowed every non-shutdown Accept error and retried at 10/s, treating a permanently closed listener as transient. Return on net.ErrClosed and log other errors before backing off. * fix(ui): notify Lua plugins on jump-to-time seek The jump enter handler called notifyPlayback (MPRIS only) plus notifier.Seeked, skipping plugin notification. Use finishSeek, which calls notifyAll, so a jump seek emits the playback-state event to plugins like every other completed seek. Seek stays synchronous (immediate seek), matching the existing test. * fix(resolve): bound startup feed sniff with a short timeout Args runs on the startup path and called sniffFeedURL, which did a HEAD on the 30s feed/M3U client, so one slow CDN could stall cliamp startup for up to 30s during pure URL classification. Give the sniff a dedicated 5s client. * fix(pluginmgr): reject empty download body An empty 200 response left download returning a non-nil empty slice, so Install wrote a 0-byte plugin file. Treat an empty body as a download error so the next candidate URL is tried (or the install fails cleanly). * fix(pluginmgr): reject raw URLs with no usable filename A raw URL ending in '/' made path.Base yield '.' or '/', producing a degenerate plugin filename. Return a clear error instead. * fix(config): let --low-power=false override config-enabled low power The override only set LowPower when the flag was true, so --low-power=false could not disable a config.toml low_power=true. Assign the flag value directly, matching the other boolean overrides. * fix(main): log MPRIS/NowPlaying init failure instead of dropping it wireMediaCtl's error was silently discarded in TUI mode, so a dbus/MPRIS setup failure left media-control silently disabled with no trace. Log it to the app log (not stderr, which would corrupt the TUI). * docs(plugins): remove unimplemented player getters cliamp.player.eq_preset(), visualizer(), and theme() were documented but never registered in the player API (calling them errors). Implementing them would require exposing model state to plugin goroutines, which would add a data race; remove them from the docs so the reference matches the API. * fix(luaplugin): reject partial bands table in set_eq_preset A bands table missing entries silently zeroed the unset bands. Require all 10 values and raise an arg error otherwise so partial input can't corrupt the EQ curve. * fix(luaplugin): track EmitKey goroutines and gate on shutdown EmitKey spawned fire-and-forget goroutines per keypress that were untracked and not gated on shutdown, so a keypress during Close could call into a closed LState. Track them in the manager WaitGroup and skip dispatch once closing, matching Emit. * fix(mediactl): apply MPRIS volume changes in order Each volume Change spawned its own goroutine to send SetVolumeMsg, so rapid changes could be delivered out of order and an older volume win. send (prog.Send) is goroutine-safe and non-blocking, so call it directly. * fix(mediactl): reject stale-track MPRIS SetPosition trackid was a fixed constant for every track, so SetPosition ignored its trackID argument and would seek whatever track became current after the client read its position. Assign a unique track object path per track change and ignore SetPosition when its trackID does not match the current track. * fix(radio): write favorites atomically save() truncated the real favorites file with os.Create and ignored every write error, so a partial write could lose all favorites. Build the content in memory, write a temp file, and rename so a failed write can't corrupt the existing file and the error surfaces to the caller. * fix(radio): propagate save errors from ToggleFavorite ToggleFavorite discarded the error from Add/Remove (which persist favorites), so a failed save was silently swallowed. Return it to the caller. * refactor(spotify): drop pointless lock around local slice append The mutex around appending the Your Music entry only touched the function-local 'all' slice, guarding nothing shared (the other locks in the loop legitimately guard trackCache). * fix(spotify): return cloned playlist list, not the cache slice Playlists returned the cached slice directly (both on cache hit and after building it), so a caller mutating the result corrupted the cache. Return a clone on both paths. * fix(spotify): return cloned cached tracks Tracks handed out the cached track slice directly, so a caller mutating it corrupted the per-playlist cache. Clone on both the cache-hit and freshly built return paths. (The snapshot-based invalidation within the playlist-list TTL is an intentional caching tradeoff and is left as-is.) * fix(spotify): don't sleep before giving up on rate limit On the final retry the loop slept the full backoff (up to 128s) and then returned the rate-limited error without making another request. Break out immediately on the last attempt. * fix(jellyfin): guard AlbumList against negative offset A negative offset passed the offset>=len check and then panicked at out[offset:end]. Clamp offset to 0 first, matching the Emby client. * docs(playlist): correct MoveQueue comment MoveQueue swaps any two positions, not only adjacent ones. * refactor(ui): simplify dead math.Min in flame tier mapping math.Min(0.65, 0.55) is a constant 0.55; replace with the literal and drop the now-unused math import. * refactor(netease): compare app code against its own constant resp.Code is NetEase's application-level status, not an HTTP status; it was compared against http.StatusOK which only coincidentally shares the value 200. Introduce neteaseCodeOK and use it at all four comparison sites. * fix(resolve): strip audio extension from derived title humanizeBasename left a trailing extension (e.g. .mp3) in the title derived from a URL basename. Drop a recognized audio extension first, leaving non-media dotted suffixes untouched. * fix(resolve): bound RSS feed read size resolveFeed decoded the response body with no size limit. Wrap it in an io.LimitReader (32 MB) so an oversized or malicious feed can't exhaust memory. * fix(navidrome): return cloned cache slices Playlists and Tracks handed out the internal cached slices, so a caller mutating the result corrupted the cache. Return slices.Clone on the cache-hit and freshly built return paths. * fix(player): include ffmpeg stderr in decode error decodeFFmpeg wrapped the exec error but dropped ffmpeg's stderr, where the actual failure reason is written. Surface ExitError.Stderr in the message. * refactor(player): forward speedStreamer.Err directly beep.Streamer already declares Err() error, so the local errorer type assertion was redundant. Call ss.s.Err() directly, matching the eq and volume streamers. * fix(ui): don't drop queued seek/lyric cmds on reconnect When the scheduled stream reconnect fired, the tick handler returned early with only playTrack + tick, discarding the seekCmd/lyricCmd computed earlier in the same tick. Fold them into the reconnect batch. * fix(ui): nav list footer counts reflect committed filter The footer only showed the filtered count while the search input bar was open (searching); once a filter was committed with Enter it fell back to the full count. Add navFilteredTotal and use the filtered count whenever a query is active, across the artist, album, and track footers. * refactor: remove dead internal/control package internal/control duplicated the message types in internal/playback and had no importers (only its own test); the live code uses internal/playback. Remove it. * fix(providers): implement Refresh for self-hosted providers Navidrome, Plex, Jellyfin, and Emby cache playlist/track (and album) data but none implemented playlist.Refresher, so the UI refresh key was a silent no-op for them. Add Refresh to clear the caches (including the jellyfin/emby client album cache) so the next fetch hits the server. * refactor: share minimal-TOML section parser The [[section]] parse loop was duplicated three times (radio loadStations, radio loadFavoriteStations, local loadTOML). Extract tomlutil.ParseSections and rewrite all three against it. Behavior is preserved, including the single-section-type assumption of the original parsers. * refactor: extract shared Emby/Jellyfin client into internal/embyapi The emby and jellyfin clients were ~95% identical (~700 duplicated lines) and had already drifted (jellyfin lacked the negative-offset guard and error wrapping). Extract one shared Client into internal/embyapi, isolating the real differences behind a dialect: auth header scheme (Emby Authorization vs Jellyfin X-Emby-Authorization), ping endpoint, user-id discovery, error prefix, and metadata key. emby and jellyfin become thin wrappers (NewClient, IsStreamURL, type aliases) plus their providers. Client tests are consolidated in embyapi: shared behavior once, dialect specifics per dialect. The client now uses a per- instance http.Client (SetHTTPClient) instead of a package global, which is what makes it testable from the provider packages.
This commit is contained in:
Binary file not shown.
+2
-2
@@ -75,8 +75,8 @@ func (o Overrides) Apply(cfg *Config) {
|
||||
if o.LogLevel != nil {
|
||||
cfg.LogLevel = *o.LogLevel
|
||||
}
|
||||
if o.LowPower != nil && *o.LowPower {
|
||||
cfg.LowPower = true
|
||||
if o.LowPower != nil {
|
||||
cfg.LowPower = *o.LowPower
|
||||
}
|
||||
cfg.clamp()
|
||||
}
|
||||
|
||||
@@ -224,9 +224,6 @@ cliamp.player.mono() --> boolean
|
||||
cliamp.player.repeat_mode() --> "Off" | "All" | "One"
|
||||
cliamp.player.shuffle() --> boolean
|
||||
cliamp.player.eq_bands() --> table of 10 dB values
|
||||
cliamp.player.eq_preset() --> string
|
||||
cliamp.player.visualizer() --> string
|
||||
cliamp.player.theme() --> string
|
||||
```
|
||||
|
||||
### cliamp.track (read-only)
|
||||
|
||||
Vendored
+13
-725
@@ -1,733 +1,21 @@
|
||||
// Package emby adapts the shared Emby/Jellyfin client (internal/embyapi) to an
|
||||
// Emby server and exposes it as a playlist provider.
|
||||
package emby
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
import "cliamp/internal/embyapi"
|
||||
|
||||
"cliamp/internal/appmeta"
|
||||
"cliamp/playlist"
|
||||
"cliamp/provider"
|
||||
// Client and Track alias the shared embyapi types so the provider layer reads
|
||||
// naturally and external callers keep using emby.Client.
|
||||
type (
|
||||
Client = embyapi.Client
|
||||
Track = embyapi.Track
|
||||
)
|
||||
|
||||
var apiClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// maxResponseBody limits API responses to 10 MB to prevent unbounded memory growth.
|
||||
const maxResponseBody = 10 << 20
|
||||
|
||||
// Client speaks to an Emby server over its HTTP API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
userID string
|
||||
user string
|
||||
password string
|
||||
deviceID string
|
||||
albumCache []Album // cached after first Albums() call
|
||||
}
|
||||
|
||||
// NewClient returns a Client for the given server URL and API token.
|
||||
// NewClient returns a Client for the given Emby server URL and credentials.
|
||||
func NewClient(baseURL, token, userID, user, password string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
userID: userID,
|
||||
user: user,
|
||||
password: password,
|
||||
deviceID: "cliamp",
|
||||
}
|
||||
return embyapi.NewEmbyClient(baseURL, token, userID, user, password)
|
||||
}
|
||||
|
||||
// Library represents an Emby music library view.
|
||||
type Library struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
const (
|
||||
SortAlbumsByName = "name"
|
||||
SortAlbumsByArtist = "artist"
|
||||
SortAlbumsByYear = "year"
|
||||
)
|
||||
|
||||
var albumSortTypes = []provider.SortType{
|
||||
{ID: SortAlbumsByName, Label: "Alphabetical by Name"},
|
||||
{ID: SortAlbumsByArtist, Label: "Alphabetical by Artist"},
|
||||
{ID: SortAlbumsByYear, Label: "By Year"},
|
||||
}
|
||||
|
||||
// Album represents an Emby album entry.
|
||||
type Album struct {
|
||||
ID string
|
||||
Name string
|
||||
Artist string
|
||||
ArtistID string
|
||||
Year int
|
||||
TrackCount int
|
||||
}
|
||||
|
||||
// Track represents an Emby track entry.
|
||||
type Track struct {
|
||||
ID string
|
||||
Name string
|
||||
Artist string
|
||||
Album string
|
||||
Year int
|
||||
TrackNumber int
|
||||
DurationSecs int
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type itemsResponseDTO struct {
|
||||
Items []itemDTO `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
type itemDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
CollectionType string `json:"CollectionType,omitempty"`
|
||||
Album string `json:"Album,omitempty"`
|
||||
AlbumArtist string `json:"AlbumArtist,omitempty"`
|
||||
AlbumArtists []nameIDDTO `json:"AlbumArtists,omitempty"`
|
||||
Artists []string `json:"Artists,omitempty"`
|
||||
ArtistItems []nameIDDTO `json:"ArtistItems,omitempty"`
|
||||
ProductionYear int `json:"ProductionYear,omitempty"`
|
||||
ChildCount int `json:"ChildCount,omitempty"`
|
||||
IndexNumber int `json:"IndexNumber,omitempty"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
}
|
||||
|
||||
type nameIDDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type systemInfoDTO struct {
|
||||
ServerName string `json:"ServerName"`
|
||||
Version string `json:"Version"`
|
||||
}
|
||||
|
||||
type authResponseDTO struct {
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
} `json:"User"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
|
||||
type playbackInfo struct {
|
||||
CanSeek bool `json:"CanSeek"`
|
||||
ItemID string `json:"ItemId"`
|
||||
IsPaused bool `json:"IsPaused"`
|
||||
IsMuted bool `json:"IsMuted"`
|
||||
PositionTicks int64 `json:"PositionTicks,omitempty"`
|
||||
PlayMethod string `json:"PlayMethod,omitempty"`
|
||||
}
|
||||
|
||||
type playbackStopInfo struct {
|
||||
ItemID string `json:"ItemId"`
|
||||
PositionTicks int64 `json:"PositionTicks,omitempty"`
|
||||
Failed bool `json:"Failed"`
|
||||
}
|
||||
|
||||
// Ping checks that the server is reachable and the token is accepted.
|
||||
// Uses /System/Info because Emby API keys are server-level credentials
|
||||
// with no user context, so /Users/Me returns 500 for API key auth.
|
||||
func (c *Client) Ping() error {
|
||||
var info systemInfoDTO
|
||||
return c.get("/System/Info", nil, &info)
|
||||
}
|
||||
|
||||
// UserID returns the active user id, discovering it lazily when needed.
|
||||
// For password-based logins the ID comes from the auth response. For API
|
||||
// key auth (no user context), it falls back to listing /Users and using
|
||||
// the first user whose name matches the configured user, or the first
|
||||
// admin user when no name was configured.
|
||||
func (c *Client) UserID() (string, error) {
|
||||
if c.userID != "" {
|
||||
return c.userID, nil
|
||||
}
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if c.userID != "" {
|
||||
return c.userID, nil
|
||||
}
|
||||
|
||||
// Try /Users/Me first (works for session tokens from password auth).
|
||||
var me userDTO
|
||||
if err := c.get("/Users/Me", nil, &me); err == nil && me.ID != "" {
|
||||
c.userID = me.ID
|
||||
return c.userID, nil
|
||||
}
|
||||
|
||||
// Fall back to /Users for API key auth (server-level key has no "me").
|
||||
var users []userDTO
|
||||
if err := c.get("/Users", nil, &users); err != nil {
|
||||
return "", fmt.Errorf("emby: could not discover user id (set user_id in config): %w", err)
|
||||
}
|
||||
// Prefer user matching the configured username; otherwise take first entry.
|
||||
for _, u := range users {
|
||||
if strings.EqualFold(u.Name, c.user) {
|
||||
c.userID = u.ID
|
||||
return c.userID, nil
|
||||
}
|
||||
}
|
||||
if c.user != "" {
|
||||
return "", fmt.Errorf("emby: user %q not found — check the user name in config", c.user)
|
||||
}
|
||||
if len(users) > 0 && users[0].ID != "" {
|
||||
c.userID = users[0].ID
|
||||
return c.userID, nil
|
||||
}
|
||||
return "", fmt.Errorf("emby: could not discover user id — set user_id in config")
|
||||
}
|
||||
|
||||
// MusicLibraries returns all user views whose collection type is music.
|
||||
func (c *Client) MusicLibraries() ([]Library, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Users/"+url.PathEscape(userID)+"/Views", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var libs []Library
|
||||
for _, it := range resp.Items {
|
||||
if strings.EqualFold(it.CollectionType, "music") {
|
||||
libs = append(libs, Library{ID: it.ID, Name: it.Name})
|
||||
}
|
||||
}
|
||||
return libs, nil
|
||||
}
|
||||
|
||||
// Albums returns all albums across every Emby music library.
|
||||
// Results are cached after the first successful call.
|
||||
func (c *Client) Albums() ([]Album, error) {
|
||||
if c.albumCache != nil {
|
||||
return c.albumCache, nil
|
||||
}
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Album
|
||||
for _, lib := range libs {
|
||||
albums, err := c.AlbumsByLibrary(lib.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, albums...)
|
||||
}
|
||||
c.albumCache = out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Artists returns a derived artist list built from the server's album catalog.
|
||||
func (c *Client) Artists() ([]provider.ArtistInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type artistKey struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
seen := make(map[artistKey]*provider.ArtistInfo)
|
||||
for _, album := range albums {
|
||||
key := artistKey{id: canonicalArtistID(album.ArtistID, album.Artist), name: album.Artist}
|
||||
if key.id == "" && key.name == "" {
|
||||
continue
|
||||
}
|
||||
info, ok := seen[key]
|
||||
if !ok {
|
||||
info = &provider.ArtistInfo{
|
||||
ID: key.id,
|
||||
Name: key.name,
|
||||
}
|
||||
seen[key] = info
|
||||
}
|
||||
info.AlbumCount++
|
||||
}
|
||||
|
||||
artists := make([]provider.ArtistInfo, 0, len(seen))
|
||||
for _, artist := range seen {
|
||||
artists = append(artists, *artist)
|
||||
}
|
||||
sort.Slice(artists, func(i, j int) bool {
|
||||
return strings.ToLower(artists[i].Name) < strings.ToLower(artists[j].Name)
|
||||
})
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
// ArtistAlbums returns all albums for one artist, derived from the full album list.
|
||||
func (c *Client) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []provider.AlbumInfo
|
||||
for _, album := range albums {
|
||||
if artistID != "" && album.ArtistID != artistID {
|
||||
if canonicalArtistID(album.ArtistID, album.Artist) != artistID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, provider.AlbumInfo{
|
||||
ID: album.ID,
|
||||
Name: album.Name,
|
||||
Artist: album.Artist,
|
||||
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
|
||||
Year: album.Year,
|
||||
TrackCount: album.TrackCount,
|
||||
})
|
||||
}
|
||||
sortAlbums(out, SortAlbumsByName)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AlbumList returns one page from the full album catalog, sorted client-side.
|
||||
func (c *Client) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]provider.AlbumInfo, 0, len(albums))
|
||||
for _, album := range albums {
|
||||
out = append(out, provider.AlbumInfo{
|
||||
ID: album.ID,
|
||||
Name: album.Name,
|
||||
Artist: album.Artist,
|
||||
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
|
||||
Year: album.Year,
|
||||
TrackCount: album.TrackCount,
|
||||
})
|
||||
}
|
||||
|
||||
sortAlbums(out, sortType)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(out) {
|
||||
return nil, nil
|
||||
}
|
||||
end := len(out)
|
||||
if size > 0 && offset+size < end {
|
||||
end = offset + size
|
||||
}
|
||||
return out[offset:end], nil
|
||||
}
|
||||
|
||||
func (c *Client) AlbumSortTypes() []provider.SortType {
|
||||
return albumSortTypes
|
||||
}
|
||||
|
||||
func (c *Client) DefaultAlbumSort() string {
|
||||
return SortAlbumsByName
|
||||
}
|
||||
|
||||
// AlbumsByLibrary returns all albums under one Emby music library view.
|
||||
func (c *Client) AlbumsByLibrary(libraryID string) ([]Album, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"parentId": {libraryID},
|
||||
"recursive": {"true"},
|
||||
"includeItemTypes": {"MusicAlbum"},
|
||||
"sortBy": {"SortName"},
|
||||
"sortOrder": {"Ascending"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Album, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, albumFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Tracks returns all audio tracks contained by an album item.
|
||||
func (c *Client) Tracks(albumID string) ([]Track, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"parentId": {albumID},
|
||||
"includeItemTypes": {"Audio"},
|
||||
"sortBy": {"ParentIndexNumber,IndexNumber,SortName"},
|
||||
"sortOrder": {"Ascending"},
|
||||
"fields": {"RunTimeTicks"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Track, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, trackFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Search searches the user's audio library for tracks matching query and
|
||||
// returns up to limit results.
|
||||
func (c *Client) Search(query string, limit int) ([]Track, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"searchTerm": {query},
|
||||
"includeItemTypes": {"Audio"},
|
||||
"recursive": {"true"},
|
||||
"limit": {strconv.Itoa(limit)},
|
||||
"fields": {"RunTimeTicks"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Track, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, trackFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsStreamURL reports whether the given URL looks like an Emby item download
|
||||
// endpoint. Used by the player to route these URLs through the buffered ffmpeg
|
||||
// pipeline instead of native HTTP streaming.
|
||||
func IsStreamURL(path string) bool {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
p := strings.ToLower(u.Path)
|
||||
return strings.Contains(p, "/items/") && strings.HasSuffix(p, "/download")
|
||||
}
|
||||
|
||||
// StreamURL returns an authenticated Emby audio URL for a track item.
|
||||
func (c *Client) StreamURL(itemID string) string {
|
||||
_ = c.ensureAuth()
|
||||
v := url.Values{
|
||||
"api_key": {c.token},
|
||||
}
|
||||
u := c.baseURL + path.Join("/", "Items", itemID, "Download")
|
||||
if enc := v.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (c *Client) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) error {
|
||||
return c.postJSON("/Sessions/Playing", playbackInfo{
|
||||
CanSeek: canSeek,
|
||||
ItemID: track.Meta(provider.MetaEmbyID),
|
||||
IsPaused: false,
|
||||
IsMuted: false,
|
||||
PositionTicks: toTicks(position),
|
||||
PlayMethod: "DirectPlay",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) ReportScrobble(track playlist.Track, elapsed time.Duration, canSeek bool) error {
|
||||
progress := playbackInfo{
|
||||
CanSeek: canSeek,
|
||||
ItemID: track.Meta(provider.MetaEmbyID),
|
||||
IsPaused: false,
|
||||
IsMuted: false,
|
||||
PositionTicks: toTicks(elapsed),
|
||||
PlayMethod: "DirectPlay",
|
||||
}
|
||||
if err := c.postJSON("/Sessions/Playing/Progress", progress); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.postJSON("/Sessions/Playing/Stopped", playbackStopInfo{
|
||||
ItemID: track.Meta(provider.MetaEmbyID),
|
||||
PositionTicks: toTicks(elapsed),
|
||||
Failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) get(p string, params url.Values, out any) error {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := c.newRequest(http.MethodGet, p, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := apiClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
default:
|
||||
return fmt.Errorf("emby: %s: http status %s", p, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(p string, payload any) error {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
|
||||
req, err := c.newRequestWithBody(http.MethodPost, p, nil, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := apiClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("emby: %s: http status %s", p, resp.Status)
|
||||
}
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureAuth() error {
|
||||
if c.token != "" {
|
||||
return nil
|
||||
}
|
||||
if c.user == "" || c.password == "" {
|
||||
return fmt.Errorf("emby: missing token or user/password")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"Username": c.user,
|
||||
"Pw": c.password,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: auth: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/Users/AuthenticateByName", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: auth: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", c.unauthHeader())
|
||||
|
||||
resp, err := apiClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: auth: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("emby: auth: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("emby: auth: %w", err)
|
||||
}
|
||||
|
||||
var out authResponseDTO
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return fmt.Errorf("emby: auth: %w", err)
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return fmt.Errorf("emby: auth: missing access token")
|
||||
}
|
||||
c.token = out.AccessToken
|
||||
if c.userID == "" {
|
||||
c.userID = out.User.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(method, p string, params url.Values) (*http.Request, error) {
|
||||
return c.newRequestWithBody(method, p, params, nil)
|
||||
}
|
||||
|
||||
func (c *Client) newRequestWithBody(method, p string, params url.Values, body io.Reader) (*http.Request, error) {
|
||||
u := c.baseURL + p
|
||||
if len(params) > 0 {
|
||||
u += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("emby: %s: %w", p, err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("X-Emby-Token", c.token)
|
||||
req.Header.Set("Authorization", c.authHeader())
|
||||
} else {
|
||||
req.Header.Set("Authorization", c.unauthHeader())
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// unauthHeader returns the Emby Authorization header value for unauthenticated
|
||||
// requests (no token or user id yet).
|
||||
func (c *Client) unauthHeader() string {
|
||||
return fmt.Sprintf(`Emby Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
|
||||
appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version())
|
||||
}
|
||||
|
||||
// authHeader returns the Emby Authorization header value for authenticated
|
||||
// requests, including the token and user id when available.
|
||||
func (c *Client) authHeader() string {
|
||||
if c.userID != "" {
|
||||
return fmt.Sprintf(`Emby UserId="%s", Client="%s", Device="%s", DeviceId="%s", Version="%s", Token="%s"`,
|
||||
c.userID, appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version(), c.token)
|
||||
}
|
||||
return fmt.Sprintf(`Emby Client="%s", Device="%s", DeviceId="%s", Version="%s", Token="%s"`,
|
||||
appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version(), c.token)
|
||||
}
|
||||
|
||||
func albumFromItem(it itemDTO) Album {
|
||||
a := Album{
|
||||
ID: it.ID,
|
||||
Name: it.Name,
|
||||
Artist: it.AlbumArtist,
|
||||
Year: it.ProductionYear,
|
||||
TrackCount: it.ChildCount,
|
||||
}
|
||||
if len(it.AlbumArtists) > 0 {
|
||||
if a.Artist == "" {
|
||||
a.Artist = it.AlbumArtists[0].Name
|
||||
}
|
||||
a.ArtistID = it.AlbumArtists[0].ID
|
||||
}
|
||||
if a.Artist == "" && len(it.ArtistItems) > 0 {
|
||||
a.Artist = it.ArtistItems[0].Name
|
||||
a.ArtistID = it.ArtistItems[0].ID
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func trackFromItem(it itemDTO) Track {
|
||||
t := Track{
|
||||
ID: it.ID,
|
||||
Name: it.Name,
|
||||
Album: it.Album,
|
||||
Year: it.ProductionYear,
|
||||
TrackNumber: it.IndexNumber,
|
||||
DurationSecs: int(it.RunTimeTicks / 10_000_000),
|
||||
}
|
||||
if len(it.Artists) > 0 {
|
||||
t.Artist = it.Artists[0]
|
||||
} else if len(it.ArtistItems) > 0 {
|
||||
t.Artist = it.ArtistItems[0].Name
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func sortAlbums(albums []provider.AlbumInfo, sortType string) {
|
||||
switch sortType {
|
||||
case "", SortAlbumsByName:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if strings.EqualFold(albums[i].Name, albums[j].Name) {
|
||||
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
|
||||
}
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
})
|
||||
case SortAlbumsByArtist:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if strings.EqualFold(albums[i].Artist, albums[j].Artist) {
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
}
|
||||
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
|
||||
})
|
||||
case SortAlbumsByYear:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if albums[i].Year == albums[j].Year {
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
}
|
||||
return albums[i].Year > albums[j].Year
|
||||
})
|
||||
default:
|
||||
sortAlbums(albums, SortAlbumsByName)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalArtistID(id, name string) string {
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return "name:" + strings.ToLower(name)
|
||||
}
|
||||
|
||||
func toTicks(d time.Duration) int64 {
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Nanoseconds() / 100
|
||||
}
|
||||
// IsStreamURL reports whether the URL is an Emby item download endpoint.
|
||||
// Used by the player to route these URLs through the buffered ffmpeg pipeline.
|
||||
func IsStreamURL(path string) bool { return embyapi.IsStreamURL(path) }
|
||||
|
||||
Vendored
-362
@@ -1,362 +0,0 @@
|
||||
package emby
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cliamp/internal/appmeta"
|
||||
"cliamp/playlist"
|
||||
"cliamp/provider"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func useTestClient(t *testing.T, fn roundTripFunc) {
|
||||
t.Helper()
|
||||
old := apiClient
|
||||
apiClient = &http.Client{Transport: fn}
|
||||
t.Cleanup(func() {
|
||||
apiClient = old
|
||||
})
|
||||
}
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func noContentResponse() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Status: "204 No Content",
|
||||
Body: io.NopCloser(bytes.NewBuffer(nil)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPing(t *testing.T) {
|
||||
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/System/Info" {
|
||||
t.Fatalf("Ping() called unexpected path %s, want /System/Info", req.URL.Path)
|
||||
}
|
||||
return jsonResponse(`{"ServerName":"My Emby","Version":"4.8.0.0"}`), nil
|
||||
})
|
||||
if err := c.Ping(); err != nil {
|
||||
t.Fatalf("Ping() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUserIDAPIKeyFallback(t *testing.T) {
|
||||
// API key auth: /Users/Me returns 500, should fall back to /Users list.
|
||||
c := NewClient("https://emby.example.com", "tok", "", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Status: "500 Internal Server Error",
|
||||
Body: io.NopCloser(bytes.NewBuffer(nil)),
|
||||
}, nil
|
||||
case "/Users":
|
||||
return jsonResponse(`[{"Id":"user-1","Name":"Alice"},{"Id":"user-2","Name":"Bob"}]`), nil
|
||||
case "/Users/user-1/Views":
|
||||
return jsonResponse(`{"Items":[{"Id":"lib-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if c.userID != "user-1" {
|
||||
t.Fatalf("userID = %q after API key fallback, want user-1", c.userID)
|
||||
}
|
||||
if len(libs) != 1 || libs[0].ID != "lib-1" {
|
||||
t.Fatalf("libraries = %+v", libs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMusicLibraries(t *testing.T) {
|
||||
c := NewClient("https://emby.example.com", "tok", "", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
|
||||
t.Fatalf("Authorization = %q, want Emby scheme", got)
|
||||
}
|
||||
return jsonResponse(`{
|
||||
"Items": [
|
||||
{"Id":"music-1","Name":"Music","CollectionType":"music"},
|
||||
{"Id":"movies-1","Name":"Movies","CollectionType":"movies"}
|
||||
]
|
||||
}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if len(libs) != 1 {
|
||||
t.Fatalf("expected 1 music library, got %d", len(libs))
|
||||
}
|
||||
if libs[0].ID != "music-1" || libs[0].Name != "Music" {
|
||||
t.Fatalf("library = %+v, want music-1/Music", libs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAlbumsByLibrary(t *testing.T) {
|
||||
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Items" {
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
}
|
||||
q := req.URL.Query()
|
||||
if got := q.Get("parentId"); got != "lib-1" {
|
||||
t.Fatalf("parentId = %q, want lib-1", got)
|
||||
}
|
||||
if got := q.Get("includeItemTypes"); got != "MusicAlbum" {
|
||||
t.Fatalf("includeItemTypes = %q, want MusicAlbum", got)
|
||||
}
|
||||
return jsonResponse(`{
|
||||
"Items": [
|
||||
{
|
||||
"Id":"album-1",
|
||||
"Name":"Kind of Blue",
|
||||
"AlbumArtist":"Miles Davis",
|
||||
"AlbumArtists":[{"Id":"artist-1","Name":"Miles Davis"}],
|
||||
"ProductionYear":1959,
|
||||
"ChildCount":5
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
})
|
||||
|
||||
albums, err := c.AlbumsByLibrary("lib-1")
|
||||
if err != nil {
|
||||
t.Fatalf("AlbumsByLibrary() error: %v", err)
|
||||
}
|
||||
if len(albums) != 1 {
|
||||
t.Fatalf("expected 1 album, got %d", len(albums))
|
||||
}
|
||||
a := albums[0]
|
||||
if a.ID != "album-1" || a.Name != "Kind of Blue" || a.Artist != "Miles Davis" || a.ArtistID != "artist-1" || a.Year != 1959 || a.TrackCount != 5 {
|
||||
t.Fatalf("album = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTracks(t *testing.T) {
|
||||
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Items" {
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
}
|
||||
q := req.URL.Query()
|
||||
if got := q.Get("parentId"); got != "album-1" {
|
||||
t.Fatalf("parentId = %q, want album-1", got)
|
||||
}
|
||||
if got := q.Get("includeItemTypes"); got != "Audio" {
|
||||
t.Fatalf("includeItemTypes = %q, want Audio", got)
|
||||
}
|
||||
return jsonResponse(`{
|
||||
"Items": [
|
||||
{
|
||||
"Id":"track-1",
|
||||
"Name":"So What",
|
||||
"Album":"Kind of Blue",
|
||||
"Artists":["Miles Davis"],
|
||||
"ProductionYear":1959,
|
||||
"IndexNumber":1,
|
||||
"RunTimeTicks":5650000000
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
})
|
||||
|
||||
tracks, err := c.Tracks("album-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Tracks() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected 1 track, got %d", len(tracks))
|
||||
}
|
||||
tr := tracks[0]
|
||||
if tr.ID != "track-1" || tr.Name != "So What" || tr.Artist != "Miles Davis" || tr.Album != "Kind of Blue" || tr.Year != 1959 || tr.TrackNumber != 1 || tr.DurationSecs != 565 {
|
||||
t.Fatalf("track = %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStreamURL(t *testing.T) {
|
||||
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
|
||||
tests := []struct {
|
||||
itemID string
|
||||
wantPrefix string
|
||||
}{
|
||||
{"track-1", "https://emby.example.com/Items/track-1/Download?"},
|
||||
{"album-99", "https://emby.example.com/Items/album-99/Download?"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.itemID, func(t *testing.T) {
|
||||
u := c.StreamURL(tc.itemID)
|
||||
if !strings.HasPrefix(u, tc.wantPrefix) {
|
||||
t.Fatalf("URL = %q, want prefix %q", u, tc.wantPrefix)
|
||||
}
|
||||
if !strings.Contains(u, "api_key=tok") {
|
||||
t.Fatalf("URL missing api_key: %q", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuthenticatesWithPassword(t *testing.T) {
|
||||
c := NewClient("https://emby.example.com", "", "", "alice", "s3cret")
|
||||
authCalls := 0
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/AuthenticateByName":
|
||||
authCalls++
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", req.Method)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
|
||||
t.Fatalf("auth request Authorization = %q, want Emby scheme (not MediaBrowser)", got)
|
||||
}
|
||||
return jsonResponse(`{"User":{"Id":"user-1"},"AccessToken":"tok-1"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok-1" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok-1", got)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") || !strings.Contains(got, `Token="tok-1"`) {
|
||||
t.Fatalf("Authorization = %q, want Emby scheme with token", got)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if authCalls != 1 {
|
||||
t.Fatalf("authCalls = %d, want 1", authCalls)
|
||||
}
|
||||
if c.token != "tok-1" || c.userID != "user-1" {
|
||||
t.Fatalf("client auth state = token:%q userID:%q", c.token, c.userID)
|
||||
}
|
||||
if len(libs) != 1 || libs[0].ID != "music-1" {
|
||||
t.Fatalf("libraries = %+v", libs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportNowPlaying(t *testing.T) {
|
||||
appmeta.SetVersion("v1.31.2")
|
||||
t.Cleanup(func() { appmeta.SetVersion("dev") })
|
||||
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
|
||||
track := playlist.Track{
|
||||
ProviderMeta: map[string]string{provider.MetaEmbyID: "track-1"},
|
||||
}
|
||||
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", req.Method)
|
||||
}
|
||||
if req.URL.Path != "/Sessions/Playing" {
|
||||
t.Fatalf("path = %s, want /Sessions/Playing", req.URL.Path)
|
||||
}
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
|
||||
t.Fatalf("Authorization scheme = %q, want Emby prefix", got)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.Contains(got, `Version="v1.31.2"`) {
|
||||
t.Fatalf("Authorization = %q, want release version", got)
|
||||
}
|
||||
|
||||
var payload playbackInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || !payload.CanSeek || payload.PositionTicks != 15*time.Second.Nanoseconds()/100 || payload.PlayMethod != "DirectPlay" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
return noContentResponse(), nil
|
||||
})
|
||||
|
||||
if err := c.ReportNowPlaying(track, 15*time.Second, true); err != nil {
|
||||
t.Fatalf("ReportNowPlaying() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportScrobble(t *testing.T) {
|
||||
c := NewClient("https://emby.example.com", "tok", "user-1", "", "")
|
||||
track := playlist.Track{
|
||||
ProviderMeta: map[string]string{provider.MetaEmbyID: "track-1"},
|
||||
}
|
||||
|
||||
call := 0
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
call++
|
||||
switch call {
|
||||
case 1:
|
||||
if req.URL.Path != "/Sessions/Playing/Progress" {
|
||||
t.Fatalf("progress path = %s", req.URL.Path)
|
||||
}
|
||||
var payload playbackInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode progress payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || !payload.CanSeek || payload.PositionTicks != 42*time.Second.Nanoseconds()/100 {
|
||||
t.Fatalf("progress payload = %+v", payload)
|
||||
}
|
||||
case 2:
|
||||
if req.URL.Path != "/Sessions/Playing/Stopped" {
|
||||
t.Fatalf("stopped path = %s", req.URL.Path)
|
||||
}
|
||||
var payload playbackStopInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode stop payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || payload.PositionTicks != 42*time.Second.Nanoseconds()/100 || payload.Failed {
|
||||
t.Fatalf("stop payload = %+v", payload)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected extra call %d", call)
|
||||
}
|
||||
return noContentResponse(), nil
|
||||
})
|
||||
|
||||
if err := c.ReportScrobble(track, 42*time.Second, true); err != nil {
|
||||
t.Fatalf("ReportScrobble() error: %v", err)
|
||||
}
|
||||
if call != 2 {
|
||||
t.Fatalf("call count = %d, want 2", call)
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -44,6 +44,16 @@ func NewFromConfig(cfg config.EmbyConfig) *Provider {
|
||||
// Name returns the display name used in the provider selector.
|
||||
func (p *Provider) Name() string { return "Emby" }
|
||||
|
||||
// Refresh clears cached playlist, track, and album data so the next call
|
||||
// re-fetches from the server. Implements playlist.Refresher.
|
||||
func (p *Provider) Refresh() {
|
||||
p.mu.Lock()
|
||||
p.playlistCache = nil
|
||||
p.trackCache = nil
|
||||
p.mu.Unlock()
|
||||
p.client.ClearCache()
|
||||
}
|
||||
|
||||
func (p *Provider) Artists() ([]provider.ArtistInfo, error) {
|
||||
artists, err := p.client.Artists()
|
||||
if err != nil {
|
||||
|
||||
Vendored
+23
-4
@@ -1,6 +1,8 @@
|
||||
package emby
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -8,6 +10,25 @@ import (
|
||||
"cliamp/provider"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func mockProvider(userID string, fn roundTripFunc) *Provider {
|
||||
c := NewClient("https://emby.example.com", "tok", userID, "", "")
|
||||
c.SetHTTPClient(&http.Client{Transport: fn})
|
||||
return newProvider(c)
|
||||
}
|
||||
|
||||
func TestProviderName(t *testing.T) {
|
||||
p := newProvider(NewClient("https://emby.example.com", "tok", "user-1", "", ""))
|
||||
if p.Name() != "Emby" {
|
||||
@@ -16,8 +37,7 @@ func TestProviderName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProviderPlaylists(t *testing.T) {
|
||||
p := newProvider(NewClient("https://emby.example.com", "tok", "", "", ""))
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
p := mockProvider("", func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
||||
@@ -47,8 +67,7 @@ func TestProviderPlaylists(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProviderTracks(t *testing.T) {
|
||||
p := newProvider(NewClient("https://emby.example.com", "tok", "user-1", "", ""))
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
p := mockProvider("user-1", func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Items" {
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
}
|
||||
|
||||
Vendored
+13
-682
@@ -1,690 +1,21 @@
|
||||
// Package jellyfin adapts the shared Emby/Jellyfin client (internal/embyapi)
|
||||
// to a Jellyfin server and exposes it as a playlist provider.
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
import "cliamp/internal/embyapi"
|
||||
|
||||
"cliamp/internal/appmeta"
|
||||
"cliamp/playlist"
|
||||
"cliamp/provider"
|
||||
// Client and Track alias the shared embyapi types so the provider layer reads
|
||||
// naturally and external callers keep using jellyfin.Client.
|
||||
type (
|
||||
Client = embyapi.Client
|
||||
Track = embyapi.Track
|
||||
)
|
||||
|
||||
var apiClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// maxResponseBody limits API responses to 10 MB to prevent unbounded memory growth.
|
||||
const maxResponseBody = 10 << 20
|
||||
|
||||
// Client speaks to a Jellyfin server over its HTTP API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
userID string
|
||||
user string
|
||||
password string
|
||||
deviceID string
|
||||
albumCache []Album // cached after first Albums() call
|
||||
}
|
||||
|
||||
// NewClient returns a Client for the given server URL and API token.
|
||||
// NewClient returns a Client for the given Jellyfin server URL and credentials.
|
||||
func NewClient(baseURL, token, userID, user, password string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
userID: userID,
|
||||
user: user,
|
||||
password: password,
|
||||
deviceID: "cliamp",
|
||||
}
|
||||
return embyapi.NewJellyfinClient(baseURL, token, userID, user, password)
|
||||
}
|
||||
|
||||
// Library represents a Jellyfin music library view.
|
||||
type Library struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
const (
|
||||
SortAlbumsByName = "name"
|
||||
SortAlbumsByArtist = "artist"
|
||||
SortAlbumsByYear = "year"
|
||||
)
|
||||
|
||||
var albumSortTypes = []provider.SortType{
|
||||
{ID: SortAlbumsByName, Label: "Alphabetical by Name"},
|
||||
{ID: SortAlbumsByArtist, Label: "Alphabetical by Artist"},
|
||||
{ID: SortAlbumsByYear, Label: "By Year"},
|
||||
}
|
||||
|
||||
// Album represents a Jellyfin album entry.
|
||||
type Album struct {
|
||||
ID string
|
||||
Name string
|
||||
Artist string
|
||||
ArtistID string
|
||||
Year int
|
||||
TrackCount int
|
||||
}
|
||||
|
||||
// Track represents a Jellyfin track entry.
|
||||
type Track struct {
|
||||
ID string
|
||||
Name string
|
||||
Artist string
|
||||
Album string
|
||||
Year int
|
||||
TrackNumber int
|
||||
DurationSecs int
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type itemsResponseDTO struct {
|
||||
Items []itemDTO `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
type itemDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
CollectionType string `json:"CollectionType,omitempty"`
|
||||
Album string `json:"Album,omitempty"`
|
||||
AlbumArtist string `json:"AlbumArtist,omitempty"`
|
||||
AlbumArtists []nameIDDTO `json:"AlbumArtists,omitempty"`
|
||||
Artists []string `json:"Artists,omitempty"`
|
||||
ArtistItems []nameIDDTO `json:"ArtistItems,omitempty"`
|
||||
ProductionYear int `json:"ProductionYear,omitempty"`
|
||||
ChildCount int `json:"ChildCount,omitempty"`
|
||||
IndexNumber int `json:"IndexNumber,omitempty"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
}
|
||||
|
||||
type nameIDDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type authResponseDTO struct {
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
} `json:"User"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
|
||||
type playbackInfo struct {
|
||||
CanSeek bool `json:"CanSeek"`
|
||||
ItemID string `json:"ItemId"`
|
||||
IsPaused bool `json:"IsPaused"`
|
||||
IsMuted bool `json:"IsMuted"`
|
||||
PositionTicks int64 `json:"PositionTicks,omitempty"`
|
||||
PlayMethod string `json:"PlayMethod,omitempty"`
|
||||
}
|
||||
|
||||
type playbackStopInfo struct {
|
||||
ItemID string `json:"ItemId"`
|
||||
PositionTicks int64 `json:"PositionTicks,omitempty"`
|
||||
Failed bool `json:"Failed"`
|
||||
}
|
||||
|
||||
// Ping checks that the server is reachable and the token is accepted.
|
||||
func (c *Client) Ping() error {
|
||||
var u userDTO
|
||||
return c.get("/Users/Me", nil, &u)
|
||||
}
|
||||
|
||||
// UserID returns the active user id, discovering it lazily when needed.
|
||||
func (c *Client) UserID() (string, error) {
|
||||
if c.userID != "" {
|
||||
return c.userID, nil
|
||||
}
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if c.userID != "" {
|
||||
return c.userID, nil
|
||||
}
|
||||
|
||||
var u userDTO
|
||||
if err := c.get("/Users/Me", nil, &u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.ID == "" {
|
||||
return "", fmt.Errorf("jellyfin: current user response missing id")
|
||||
}
|
||||
c.userID = u.ID
|
||||
return c.userID, nil
|
||||
}
|
||||
|
||||
// MusicLibraries returns all user views whose collection type is music.
|
||||
func (c *Client) MusicLibraries() ([]Library, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Users/"+url.PathEscape(userID)+"/Views", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var libs []Library
|
||||
for _, it := range resp.Items {
|
||||
if strings.EqualFold(it.CollectionType, "music") {
|
||||
libs = append(libs, Library{ID: it.ID, Name: it.Name})
|
||||
}
|
||||
}
|
||||
return libs, nil
|
||||
}
|
||||
|
||||
// Albums returns all albums across every Jellyfin music library.
|
||||
// Results are cached after the first successful call.
|
||||
func (c *Client) Albums() ([]Album, error) {
|
||||
if c.albumCache != nil {
|
||||
return c.albumCache, nil
|
||||
}
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Album
|
||||
for _, lib := range libs {
|
||||
albums, err := c.AlbumsByLibrary(lib.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, albums...)
|
||||
}
|
||||
c.albumCache = out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Artists returns a derived artist list built from the server's album catalog.
|
||||
func (c *Client) Artists() ([]provider.ArtistInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type artistKey struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
seen := make(map[artistKey]*provider.ArtistInfo)
|
||||
for _, album := range albums {
|
||||
key := artistKey{id: canonicalArtistID(album.ArtistID, album.Artist), name: album.Artist}
|
||||
if key.id == "" && key.name == "" {
|
||||
continue
|
||||
}
|
||||
info, ok := seen[key]
|
||||
if !ok {
|
||||
info = &provider.ArtistInfo{
|
||||
ID: key.id,
|
||||
Name: key.name,
|
||||
}
|
||||
seen[key] = info
|
||||
}
|
||||
info.AlbumCount++
|
||||
}
|
||||
|
||||
artists := make([]provider.ArtistInfo, 0, len(seen))
|
||||
for _, artist := range seen {
|
||||
artists = append(artists, *artist)
|
||||
}
|
||||
sort.Slice(artists, func(i, j int) bool {
|
||||
return strings.ToLower(artists[i].Name) < strings.ToLower(artists[j].Name)
|
||||
})
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
// ArtistAlbums returns all albums for one artist, derived from the full album list.
|
||||
func (c *Client) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []provider.AlbumInfo
|
||||
for _, album := range albums {
|
||||
if artistID != "" && album.ArtistID != artistID {
|
||||
if canonicalArtistID(album.ArtistID, album.Artist) != artistID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, provider.AlbumInfo{
|
||||
ID: album.ID,
|
||||
Name: album.Name,
|
||||
Artist: album.Artist,
|
||||
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
|
||||
Year: album.Year,
|
||||
TrackCount: album.TrackCount,
|
||||
})
|
||||
}
|
||||
sortAlbums(out, SortAlbumsByName)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AlbumList returns one page from the full album catalog, sorted client-side.
|
||||
func (c *Client) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]provider.AlbumInfo, 0, len(albums))
|
||||
for _, album := range albums {
|
||||
out = append(out, provider.AlbumInfo{
|
||||
ID: album.ID,
|
||||
Name: album.Name,
|
||||
Artist: album.Artist,
|
||||
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
|
||||
Year: album.Year,
|
||||
TrackCount: album.TrackCount,
|
||||
})
|
||||
}
|
||||
|
||||
sortAlbums(out, sortType)
|
||||
if offset >= len(out) {
|
||||
return nil, nil
|
||||
}
|
||||
end := len(out)
|
||||
if size > 0 && offset+size < end {
|
||||
end = offset + size
|
||||
}
|
||||
return out[offset:end], nil
|
||||
}
|
||||
|
||||
func (c *Client) AlbumSortTypes() []provider.SortType {
|
||||
return albumSortTypes
|
||||
}
|
||||
|
||||
func (c *Client) DefaultAlbumSort() string {
|
||||
return SortAlbumsByName
|
||||
}
|
||||
|
||||
// AlbumsByLibrary returns all albums under one Jellyfin music library view.
|
||||
func (c *Client) AlbumsByLibrary(libraryID string) ([]Album, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"parentId": {libraryID},
|
||||
"recursive": {"true"},
|
||||
"includeItemTypes": {"MusicAlbum"},
|
||||
"sortBy": {"SortName"},
|
||||
"sortOrder": {"Ascending"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Album, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, albumFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Tracks returns all audio tracks contained by an album item.
|
||||
func (c *Client) Tracks(albumID string) ([]Track, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"parentId": {albumID},
|
||||
"includeItemTypes": {"Audio"},
|
||||
"sortBy": {"ParentIndexNumber,IndexNumber,SortName"},
|
||||
"sortOrder": {"Ascending"},
|
||||
"fields": {"RunTimeTicks"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Track, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, trackFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Search searches the user's audio library for tracks matching query and
|
||||
// returns up to limit results.
|
||||
func (c *Client) Search(query string, limit int) ([]Track, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"searchTerm": {query},
|
||||
"includeItemTypes": {"Audio"},
|
||||
"recursive": {"true"},
|
||||
"limit": {strconv.Itoa(limit)},
|
||||
"fields": {"RunTimeTicks"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Track, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, trackFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsStreamURL reports whether the given URL looks like a Jellyfin item download
|
||||
// endpoint. Used by the player to route these URLs through the buffered ffmpeg
|
||||
// pipeline instead of native HTTP streaming.
|
||||
func IsStreamURL(path string) bool {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
p := strings.ToLower(u.Path)
|
||||
return strings.Contains(p, "/items/") && strings.HasSuffix(p, "/download")
|
||||
}
|
||||
|
||||
// StreamURL returns an authenticated Jellyfin audio URL for a track item.
|
||||
func (c *Client) StreamURL(itemID string) string {
|
||||
_ = c.ensureAuth()
|
||||
v := url.Values{
|
||||
"api_key": {c.token},
|
||||
}
|
||||
|
||||
// Use the direct item download route rather than the Audio controller.
|
||||
// On the live Jellyfin server used for validation, the Audio endpoints
|
||||
// returned 200 with an empty body, while Download returned the original
|
||||
// FLAC/MP3 bytes with byte-range support.
|
||||
u := c.baseURL + path.Join("/", "Items", itemID, "Download")
|
||||
if enc := v.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (c *Client) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) error {
|
||||
return c.postJSON("/Sessions/Playing", playbackInfo{
|
||||
CanSeek: canSeek,
|
||||
ItemID: track.Meta(provider.MetaJellyfinID),
|
||||
IsPaused: false,
|
||||
IsMuted: false,
|
||||
PositionTicks: toTicks(position),
|
||||
PlayMethod: "DirectPlay",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) ReportScrobble(track playlist.Track, elapsed time.Duration, canSeek bool) error {
|
||||
progress := playbackInfo{
|
||||
CanSeek: canSeek,
|
||||
ItemID: track.Meta(provider.MetaJellyfinID),
|
||||
IsPaused: false,
|
||||
IsMuted: false,
|
||||
PositionTicks: toTicks(elapsed),
|
||||
PlayMethod: "DirectPlay",
|
||||
}
|
||||
if err := c.postJSON("/Sessions/Playing/Progress", progress); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.postJSON("/Sessions/Playing/Stopped", playbackStopInfo{
|
||||
ItemID: track.Meta(provider.MetaJellyfinID),
|
||||
PositionTicks: toTicks(elapsed),
|
||||
Failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) get(p string, params url.Values, out any) error {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := c.newRequest(http.MethodGet, p, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := apiClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jellyfin: %s: %w", p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
default:
|
||||
return fmt.Errorf("jellyfin: %s: http status %s", p, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("jellyfin: %s: %w", p, err)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("jellyfin: %s: %w", p, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(p string, payload any) error {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := c.newRequestWithBody(http.MethodPost, p, nil, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := apiClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jellyfin: %s: %w", p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("jellyfin: %s: http status %s", p, resp.Status)
|
||||
}
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureAuth() error {
|
||||
if c.token != "" {
|
||||
return nil
|
||||
}
|
||||
if c.user == "" || c.password == "" {
|
||||
return fmt.Errorf("jellyfin: missing token or user/password")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"Username": c.user,
|
||||
"Pw": c.password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/Users/AuthenticateByName", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization",
|
||||
fmt.Sprintf(`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
|
||||
appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version()))
|
||||
|
||||
resp, err := apiClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jellyfin: auth: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("jellyfin: auth: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("jellyfin: auth: %w", err)
|
||||
}
|
||||
|
||||
var out authResponseDTO
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return fmt.Errorf("jellyfin: auth: %w", err)
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return fmt.Errorf("jellyfin: auth: missing access token")
|
||||
}
|
||||
c.token = out.AccessToken
|
||||
if c.userID == "" {
|
||||
c.userID = out.User.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(method, p string, params url.Values) (*http.Request, error) {
|
||||
return c.newRequestWithBody(method, p, params, nil)
|
||||
}
|
||||
|
||||
func (c *Client) newRequestWithBody(method, p string, params url.Values, body io.Reader) (*http.Request, error) {
|
||||
u := c.baseURL + p
|
||||
if len(params) > 0 {
|
||||
u += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("X-Emby-Token", c.token)
|
||||
}
|
||||
req.Header.Set("X-Emby-Authorization",
|
||||
fmt.Sprintf(`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
|
||||
appmeta.ClientName(), appmeta.DeviceName(), c.deviceID, appmeta.Version()))
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func albumFromItem(it itemDTO) Album {
|
||||
a := Album{
|
||||
ID: it.ID,
|
||||
Name: it.Name,
|
||||
Artist: it.AlbumArtist,
|
||||
Year: it.ProductionYear,
|
||||
TrackCount: it.ChildCount,
|
||||
}
|
||||
if len(it.AlbumArtists) > 0 {
|
||||
if a.Artist == "" {
|
||||
a.Artist = it.AlbumArtists[0].Name
|
||||
}
|
||||
a.ArtistID = it.AlbumArtists[0].ID
|
||||
}
|
||||
if a.Artist == "" && len(it.ArtistItems) > 0 {
|
||||
a.Artist = it.ArtistItems[0].Name
|
||||
a.ArtistID = it.ArtistItems[0].ID
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func trackFromItem(it itemDTO) Track {
|
||||
t := Track{
|
||||
ID: it.ID,
|
||||
Name: it.Name,
|
||||
Album: it.Album,
|
||||
Year: it.ProductionYear,
|
||||
TrackNumber: it.IndexNumber,
|
||||
DurationSecs: int(it.RunTimeTicks / 10_000_000),
|
||||
}
|
||||
if len(it.Artists) > 0 {
|
||||
t.Artist = it.Artists[0]
|
||||
} else if len(it.ArtistItems) > 0 {
|
||||
t.Artist = it.ArtistItems[0].Name
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func sortAlbums(albums []provider.AlbumInfo, sortType string) {
|
||||
switch sortType {
|
||||
case "", SortAlbumsByName:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if strings.EqualFold(albums[i].Name, albums[j].Name) {
|
||||
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
|
||||
}
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
})
|
||||
case SortAlbumsByArtist:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if strings.EqualFold(albums[i].Artist, albums[j].Artist) {
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
}
|
||||
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
|
||||
})
|
||||
case SortAlbumsByYear:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if albums[i].Year == albums[j].Year {
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
}
|
||||
return albums[i].Year > albums[j].Year
|
||||
})
|
||||
default:
|
||||
sortAlbums(albums, SortAlbumsByName)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalArtistID(id, name string) string {
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return "name:" + strings.ToLower(name)
|
||||
}
|
||||
|
||||
func toTicks(d time.Duration) int64 {
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Nanoseconds() / 100
|
||||
}
|
||||
// IsStreamURL reports whether the URL is a Jellyfin item download endpoint.
|
||||
// Used by the player to route these URLs through the buffered ffmpeg pipeline.
|
||||
func IsStreamURL(path string) bool { return embyapi.IsStreamURL(path) }
|
||||
|
||||
Vendored
-293
@@ -1,293 +0,0 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cliamp/internal/appmeta"
|
||||
"cliamp/playlist"
|
||||
"cliamp/provider"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func useTestClient(t *testing.T, fn roundTripFunc) {
|
||||
t.Helper()
|
||||
old := apiClient
|
||||
apiClient = &http.Client{Transport: fn}
|
||||
t.Cleanup(func() {
|
||||
apiClient = old
|
||||
})
|
||||
}
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func noContentResponse() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Status: "204 No Content",
|
||||
Body: io.NopCloser(bytes.NewBuffer(nil)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMusicLibraries(t *testing.T) {
|
||||
c := NewClient("https://jf.example.com", "tok", "", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
||||
}
|
||||
return jsonResponse(`{
|
||||
"Items": [
|
||||
{"Id":"music-1","Name":"Music","CollectionType":"music"},
|
||||
{"Id":"movies-1","Name":"Movies","CollectionType":"movies"}
|
||||
]
|
||||
}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if len(libs) != 1 {
|
||||
t.Fatalf("expected 1 music library, got %d", len(libs))
|
||||
}
|
||||
if libs[0].ID != "music-1" || libs[0].Name != "Music" {
|
||||
t.Fatalf("library = %+v, want music-1/Music", libs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAlbumsByLibrary(t *testing.T) {
|
||||
c := NewClient("https://jf.example.com", "tok", "user-1", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Items" {
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
}
|
||||
q := req.URL.Query()
|
||||
if got := q.Get("parentId"); got != "lib-1" {
|
||||
t.Fatalf("parentId = %q, want lib-1", got)
|
||||
}
|
||||
if got := q.Get("includeItemTypes"); got != "MusicAlbum" {
|
||||
t.Fatalf("includeItemTypes = %q, want MusicAlbum", got)
|
||||
}
|
||||
return jsonResponse(`{
|
||||
"Items": [
|
||||
{
|
||||
"Id":"album-1",
|
||||
"Name":"Kind of Blue",
|
||||
"AlbumArtist":"Miles Davis",
|
||||
"AlbumArtists":[{"Id":"artist-1","Name":"Miles Davis"}],
|
||||
"ProductionYear":1959,
|
||||
"ChildCount":5
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
})
|
||||
|
||||
albums, err := c.AlbumsByLibrary("lib-1")
|
||||
if err != nil {
|
||||
t.Fatalf("AlbumsByLibrary() error: %v", err)
|
||||
}
|
||||
if len(albums) != 1 {
|
||||
t.Fatalf("expected 1 album, got %d", len(albums))
|
||||
}
|
||||
a := albums[0]
|
||||
if a.ID != "album-1" || a.Name != "Kind of Blue" || a.Artist != "Miles Davis" || a.ArtistID != "artist-1" || a.Year != 1959 || a.TrackCount != 5 {
|
||||
t.Fatalf("album = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTracks(t *testing.T) {
|
||||
c := NewClient("https://jf.example.com", "tok", "user-1", "", "")
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Items" {
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
}
|
||||
q := req.URL.Query()
|
||||
if got := q.Get("parentId"); got != "album-1" {
|
||||
t.Fatalf("parentId = %q, want album-1", got)
|
||||
}
|
||||
if got := q.Get("includeItemTypes"); got != "Audio" {
|
||||
t.Fatalf("includeItemTypes = %q, want Audio", got)
|
||||
}
|
||||
return jsonResponse(`{
|
||||
"Items": [
|
||||
{
|
||||
"Id":"track-1",
|
||||
"Name":"So What",
|
||||
"Album":"Kind of Blue",
|
||||
"Artists":["Miles Davis"],
|
||||
"ProductionYear":1959,
|
||||
"IndexNumber":1,
|
||||
"RunTimeTicks":5650000000
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
})
|
||||
|
||||
tracks, err := c.Tracks("album-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Tracks() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected 1 track, got %d", len(tracks))
|
||||
}
|
||||
tr := tracks[0]
|
||||
if tr.ID != "track-1" || tr.Name != "So What" || tr.Artist != "Miles Davis" || tr.Album != "Kind of Blue" || tr.Year != 1959 || tr.TrackNumber != 1 || tr.DurationSecs != 565 {
|
||||
t.Fatalf("track = %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStreamURL(t *testing.T) {
|
||||
c := NewClient("https://jf.example.com", "tok", "user-1", "", "")
|
||||
u := c.StreamURL("track-1")
|
||||
if !strings.HasPrefix(u, "https://jf.example.com/Items/track-1/Download?") {
|
||||
t.Fatalf("unexpected stream URL prefix: %q", u)
|
||||
}
|
||||
if !strings.Contains(u, "api_key=tok") {
|
||||
t.Fatalf("stream URL missing api_key: %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuthenticatesWithPassword(t *testing.T) {
|
||||
c := NewClient("https://jf.example.com", "", "", "finamp", "1qazxsw2")
|
||||
authCalls := 0
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/AuthenticateByName":
|
||||
authCalls++
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", req.Method)
|
||||
}
|
||||
return jsonResponse(`{"User":{"Id":"user-1"},"AccessToken":"tok-1"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok-1" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok-1", got)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if authCalls != 1 {
|
||||
t.Fatalf("authCalls = %d, want 1", authCalls)
|
||||
}
|
||||
if c.token != "tok-1" || c.userID != "user-1" {
|
||||
t.Fatalf("client auth state = token:%q userID:%q", c.token, c.userID)
|
||||
}
|
||||
if len(libs) != 1 || libs[0].ID != "music-1" {
|
||||
t.Fatalf("libraries = %+v", libs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportNowPlaying(t *testing.T) {
|
||||
appmeta.SetVersion("v1.31.2")
|
||||
t.Cleanup(func() { appmeta.SetVersion("dev") })
|
||||
c := NewClient("https://jf.example.com", "tok", "user-1", "", "")
|
||||
track := playlist.Track{
|
||||
ProviderMeta: map[string]string{provider.MetaJellyfinID: "track-1"},
|
||||
}
|
||||
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", req.Method)
|
||||
}
|
||||
if req.URL.Path != "/Sessions/Playing" {
|
||||
t.Fatalf("path = %s, want /Sessions/Playing", req.URL.Path)
|
||||
}
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
||||
}
|
||||
if got := req.Header.Get("X-Emby-Authorization"); !strings.Contains(got, `Version="v1.31.2"`) {
|
||||
t.Fatalf("X-Emby-Authorization = %q, want release version", got)
|
||||
}
|
||||
|
||||
var payload playbackInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || !payload.CanSeek || payload.PositionTicks != 15*time.Second.Nanoseconds()/100 || payload.PlayMethod != "DirectPlay" {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
return noContentResponse(), nil
|
||||
})
|
||||
|
||||
if err := c.ReportNowPlaying(track, 15*time.Second, true); err != nil {
|
||||
t.Fatalf("ReportNowPlaying() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportScrobble(t *testing.T) {
|
||||
c := NewClient("https://jf.example.com", "tok", "user-1", "", "")
|
||||
track := playlist.Track{
|
||||
ProviderMeta: map[string]string{provider.MetaJellyfinID: "track-1"},
|
||||
}
|
||||
|
||||
call := 0
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
call++
|
||||
switch call {
|
||||
case 1:
|
||||
if req.URL.Path != "/Sessions/Playing/Progress" {
|
||||
t.Fatalf("progress path = %s", req.URL.Path)
|
||||
}
|
||||
var payload playbackInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode progress payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || !payload.CanSeek || payload.PositionTicks != 42*time.Second.Nanoseconds()/100 {
|
||||
t.Fatalf("progress payload = %+v", payload)
|
||||
}
|
||||
case 2:
|
||||
if req.URL.Path != "/Sessions/Playing/Stopped" {
|
||||
t.Fatalf("stopped path = %s", req.URL.Path)
|
||||
}
|
||||
var payload playbackStopInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode stop payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || payload.PositionTicks != 42*time.Second.Nanoseconds()/100 || payload.Failed {
|
||||
t.Fatalf("stop payload = %+v", payload)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected extra call %d", call)
|
||||
}
|
||||
return noContentResponse(), nil
|
||||
})
|
||||
|
||||
if err := c.ReportScrobble(track, 42*time.Second, true); err != nil {
|
||||
t.Fatalf("ReportScrobble() error: %v", err)
|
||||
}
|
||||
if call != 2 {
|
||||
t.Fatalf("call count = %d, want 2", call)
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -44,6 +44,16 @@ func NewFromConfig(cfg config.JellyfinConfig) *Provider {
|
||||
// Name returns the display name used in the provider selector.
|
||||
func (p *Provider) Name() string { return "Jellyfin" }
|
||||
|
||||
// Refresh clears cached playlist, track, and album data so the next call
|
||||
// re-fetches from the server. Implements playlist.Refresher.
|
||||
func (p *Provider) Refresh() {
|
||||
p.mu.Lock()
|
||||
p.playlistCache = nil
|
||||
p.trackCache = nil
|
||||
p.mu.Unlock()
|
||||
p.client.ClearCache()
|
||||
}
|
||||
|
||||
func (p *Provider) Artists() ([]provider.ArtistInfo, error) {
|
||||
return p.client.Artists()
|
||||
}
|
||||
|
||||
Vendored
+23
-4
@@ -1,6 +1,8 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -8,6 +10,25 @@ import (
|
||||
"cliamp/provider"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func mockProvider(userID string, fn roundTripFunc) *Provider {
|
||||
c := NewClient("https://jf.example.com", "tok", userID, "", "")
|
||||
c.SetHTTPClient(&http.Client{Transport: fn})
|
||||
return newProvider(c)
|
||||
}
|
||||
|
||||
func TestProviderName(t *testing.T) {
|
||||
p := newProvider(NewClient("https://jf.example.com", "tok", "user-1", "", ""))
|
||||
if p.Name() != "Jellyfin" {
|
||||
@@ -16,8 +37,7 @@ func TestProviderName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProviderPlaylists(t *testing.T) {
|
||||
p := newProvider(NewClient("https://jf.example.com", "tok", "", "", ""))
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
p := mockProvider("", func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
||||
@@ -47,8 +67,7 @@ func TestProviderPlaylists(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProviderTracks(t *testing.T) {
|
||||
p := newProvider(NewClient("https://jf.example.com", "tok", "user-1", "", ""))
|
||||
useTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
p := mockProvider("user-1", func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Items" {
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
}
|
||||
|
||||
Vendored
+23
-62
@@ -453,71 +453,32 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
|
||||
}
|
||||
|
||||
var tracks []playlist.Track
|
||||
var current *playlist.Track
|
||||
|
||||
for rawLine := range strings.SplitSeq(string(data), "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
|
||||
// Skip comments and blank lines.
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
tomlutil.ParseSections(data, "track", func(f map[string]string) {
|
||||
t := playlist.Track{
|
||||
Path: f["path"],
|
||||
Title: f["title"],
|
||||
Artist: f["artist"],
|
||||
Album: f["album"],
|
||||
Genre: f["genre"],
|
||||
Feed: f["feed"] == "true",
|
||||
}
|
||||
|
||||
// New track section.
|
||||
if line == "[[track]]" {
|
||||
if current != nil {
|
||||
tracks = append(tracks, *current)
|
||||
}
|
||||
current = &playlist.Track{}
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse key = "value" lines.
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
t.Stream = playlist.IsURL(t.Path)
|
||||
// "favorite" is the pre-rename alias for "bookmark"; prefer bookmark.
|
||||
bookmark, ok := f["bookmark"]
|
||||
if !ok {
|
||||
continue
|
||||
bookmark = f["favorite"]
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
val = tomlutil.Unquote(val)
|
||||
|
||||
switch key {
|
||||
case "path":
|
||||
current.Path = val
|
||||
current.Stream = playlist.IsURL(val)
|
||||
case "feed":
|
||||
current.Feed = val == "true"
|
||||
case "title":
|
||||
current.Title = val
|
||||
case "artist":
|
||||
current.Artist = val
|
||||
case "album":
|
||||
current.Album = val
|
||||
case "genre":
|
||||
current.Genre = val
|
||||
case "year":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
current.Year = n
|
||||
}
|
||||
case "track_number":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
current.TrackNumber = n
|
||||
}
|
||||
case "duration_secs":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
current.DurationSecs = n
|
||||
}
|
||||
case "bookmark", "favorite":
|
||||
// "favorite" accepted for backward compatibility with playlists saved before the rename.
|
||||
current.Bookmark = val == "true"
|
||||
t.Bookmark = bookmark == "true"
|
||||
if n, err := strconv.Atoi(f["year"]); err == nil {
|
||||
t.Year = n
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
tracks = append(tracks, *current)
|
||||
}
|
||||
if n, err := strconv.Atoi(f["track_number"]); err == nil {
|
||||
t.TrackNumber = n
|
||||
}
|
||||
if n, err := strconv.Atoi(f["duration_secs"]); err == nil {
|
||||
t.DurationSecs = n
|
||||
}
|
||||
tracks = append(tracks, t)
|
||||
})
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
Vendored
+14
-4
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -237,7 +238,7 @@ func (c *NavidromeClient) subsonicGet(endpoint string, params url.Values, result
|
||||
func (c *NavidromeClient) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
c.mu.Lock()
|
||||
if c.playlistCache != nil {
|
||||
cached := c.playlistCache
|
||||
cached := slices.Clone(c.playlistCache)
|
||||
c.mu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
@@ -271,7 +272,7 @@ func (c *NavidromeClient) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
c.playlistCache = lists
|
||||
c.mu.Unlock()
|
||||
|
||||
return lists, nil
|
||||
return slices.Clone(lists), nil
|
||||
}
|
||||
|
||||
func (c *NavidromeClient) Tracks(id string) ([]playlist.Track, error) {
|
||||
@@ -279,7 +280,7 @@ func (c *NavidromeClient) Tracks(id string) ([]playlist.Track, error) {
|
||||
if c.trackCache != nil {
|
||||
if cached, ok := c.trackCache[id]; ok {
|
||||
c.mu.Unlock()
|
||||
return cached, nil
|
||||
return slices.Clone(cached), nil
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
@@ -307,7 +308,16 @@ func (c *NavidromeClient) Tracks(id string) ([]playlist.Track, error) {
|
||||
c.trackCache[id] = tracks
|
||||
c.mu.Unlock()
|
||||
|
||||
return tracks, nil
|
||||
return slices.Clone(tracks), nil
|
||||
}
|
||||
|
||||
// Refresh clears cached playlist and track data so the next Playlists/Tracks
|
||||
// call re-fetches from the server. Implements playlist.Refresher.
|
||||
func (c *NavidromeClient) Refresh() {
|
||||
c.mu.Lock()
|
||||
c.playlistCache = nil
|
||||
c.trackCache = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Artists returns all artists from the server, flattening the index structure.
|
||||
|
||||
Vendored
+8
-4
@@ -31,6 +31,10 @@ const (
|
||||
defaultAPIBase = "https://music.163.com"
|
||||
probeURL = "https://music.163.com/#/playlist?id=3778678"
|
||||
apiTimeout = 15 * time.Second
|
||||
// neteaseCodeOK is the application-level success code in NetEase API
|
||||
// responses. It happens to share the value of HTTP 200 but is a distinct
|
||||
// field, so it gets its own constant rather than comparing to http.StatusOK.
|
||||
neteaseCodeOK = 200
|
||||
)
|
||||
|
||||
// ErrNotAuthenticated is returned when browser cookies do not contain a
|
||||
@@ -140,7 +144,7 @@ func (p *Provider) Account(ctx context.Context) (Account, error) {
|
||||
if err := p.apiGet(ctx, "/api/nuser/account/get", nil, &resp); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if resp.Code != http.StatusOK {
|
||||
if resp.Code != neteaseCodeOK {
|
||||
return Account{}, fmt.Errorf("netease: account request failed with code %d", resp.Code)
|
||||
}
|
||||
uid := resp.Account.ID
|
||||
@@ -216,7 +220,7 @@ func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
|
||||
if err := p.apiGet(ctx, "/api/playlist/detail", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Code != http.StatusOK {
|
||||
if resp.Code != neteaseCodeOK {
|
||||
return nil, fmt.Errorf("netease: playlist detail failed with code %d", resp.Code)
|
||||
}
|
||||
return songsToTracks(resp.Result.Tracks), nil
|
||||
@@ -241,7 +245,7 @@ func (p *Provider) SearchTracks(ctx context.Context, query string, limit int) ([
|
||||
if err := p.apiGet(ctx, "/api/search/get/web", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Code != http.StatusOK {
|
||||
if resp.Code != neteaseCodeOK {
|
||||
return nil, fmt.Errorf("netease: search failed with code %d", resp.Code)
|
||||
}
|
||||
return songsToTracks(resp.Result.Songs), nil
|
||||
@@ -264,7 +268,7 @@ func (p *Provider) userPlaylists(ctx context.Context, userID string) ([]playlist
|
||||
if err := p.apiGet(ctx, "/api/user/playlist", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Code != http.StatusOK {
|
||||
if resp.Code != neteaseCodeOK {
|
||||
return nil, fmt.Errorf("netease: playlist request failed with code %d", resp.Code)
|
||||
}
|
||||
for _, item := range resp.Playlist {
|
||||
|
||||
Vendored
+9
@@ -88,6 +88,15 @@ func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
// Refresh clears cached playlist and track data so the next call re-fetches
|
||||
// from the server. Implements playlist.Refresher.
|
||||
func (p *Provider) Refresh() {
|
||||
p.mu.Lock()
|
||||
p.playlistCache = nil
|
||||
p.trackCache = nil
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Tracks returns the tracks for the album identified by albumRatingKey.
|
||||
// Each track's Path is a complete authenticated HTTP URL ready for the player.
|
||||
// Tracks with no streamable part (missing Media/Part data) are silently skipped.
|
||||
|
||||
Vendored
+32
-59
@@ -88,36 +88,40 @@ func (f *Favorites) save() error {
|
||||
if err := os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(f.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Build the full content in memory (writes to a Builder can't fail), then
|
||||
// write a temp file and rename so a partial/failed write can never truncate
|
||||
// or corrupt the existing favorites file.
|
||||
var b strings.Builder
|
||||
for i, s := range f.stations {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(file)
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
fmt.Fprintln(file, "[[station]]")
|
||||
fmt.Fprintf(file, "name = %q\n", s.Name)
|
||||
fmt.Fprintf(file, "url = %q\n", s.URL)
|
||||
fmt.Fprintln(&b, "[[station]]")
|
||||
fmt.Fprintf(&b, "name = %q\n", s.Name)
|
||||
fmt.Fprintf(&b, "url = %q\n", s.URL)
|
||||
if s.Country != "" {
|
||||
fmt.Fprintf(file, "country = %q\n", s.Country)
|
||||
fmt.Fprintf(&b, "country = %q\n", s.Country)
|
||||
}
|
||||
if s.Bitrate > 0 {
|
||||
fmt.Fprintf(file, "bitrate = %d\n", s.Bitrate)
|
||||
fmt.Fprintf(&b, "bitrate = %d\n", s.Bitrate)
|
||||
}
|
||||
if s.Codec != "" {
|
||||
fmt.Fprintf(file, "codec = %q\n", s.Codec)
|
||||
fmt.Fprintf(&b, "codec = %q\n", s.Codec)
|
||||
}
|
||||
if s.Tags != "" {
|
||||
fmt.Fprintf(file, "tags = %q\n", s.Tags)
|
||||
fmt.Fprintf(&b, "tags = %q\n", s.Tags)
|
||||
}
|
||||
if s.Homepage != "" {
|
||||
fmt.Fprintf(file, "homepage = %q\n", s.Homepage)
|
||||
fmt.Fprintf(&b, "homepage = %q\n", s.Homepage)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
tmp := f.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, f.path)
|
||||
}
|
||||
|
||||
// loadFavoriteStations parses the favorites TOML file.
|
||||
@@ -131,52 +135,21 @@ func loadFavoriteStations(path string) ([]CatalogStation, error) {
|
||||
}
|
||||
|
||||
var stations []CatalogStation
|
||||
var current *CatalogStation
|
||||
|
||||
for rawLine := range strings.SplitSeq(string(data), "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
tomlutil.ParseSections(data, "station", func(f map[string]string) {
|
||||
s := CatalogStation{
|
||||
Name: f["name"],
|
||||
URL: f["url"],
|
||||
Country: f["country"],
|
||||
Codec: f["codec"],
|
||||
Tags: f["tags"],
|
||||
Homepage: f["homepage"],
|
||||
}
|
||||
if line == "[[station]]" {
|
||||
if current != nil && current.Name != "" && current.URL != "" {
|
||||
stations = append(stations, *current)
|
||||
}
|
||||
current = &CatalogStation{}
|
||||
continue
|
||||
if n, err := strconv.Atoi(f["bitrate"]); err == nil {
|
||||
s.Bitrate = n
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
if s.Name != "" && s.URL != "" {
|
||||
stations = append(stations, s)
|
||||
}
|
||||
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
|
||||
switch key {
|
||||
case "name":
|
||||
current.Name = tomlutil.Unquote(val)
|
||||
case "url":
|
||||
current.URL = tomlutil.Unquote(val)
|
||||
case "country":
|
||||
current.Country = tomlutil.Unquote(val)
|
||||
case "codec":
|
||||
current.Codec = tomlutil.Unquote(val)
|
||||
case "tags":
|
||||
current.Tags = tomlutil.Unquote(val)
|
||||
case "homepage":
|
||||
current.Homepage = tomlutil.Unquote(val)
|
||||
case "bitrate":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
current.Bitrate = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if current != nil && current.Name != "" && current.URL != "" {
|
||||
stations = append(stations, *current)
|
||||
}
|
||||
})
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
Vendored
+7
-39
@@ -205,11 +205,9 @@ func (p *Provider) ToggleFavorite(id string) (added bool, name string, err error
|
||||
}
|
||||
|
||||
if p.favorites.Contains(s.URL) {
|
||||
_ = p.favorites.Remove(s.URL)
|
||||
return false, s.Name, nil
|
||||
return false, s.Name, p.favorites.Remove(s.URL)
|
||||
}
|
||||
_ = p.favorites.Add(s)
|
||||
return true, s.Name, nil
|
||||
return true, s.Name, p.favorites.Add(s)
|
||||
}
|
||||
|
||||
// SetSearchResults activates search mode with the given results.
|
||||
@@ -322,41 +320,11 @@ func loadStations(path string) ([]station, error) {
|
||||
}
|
||||
|
||||
var stations []station
|
||||
var current *station
|
||||
|
||||
for rawLine := range strings.SplitSeq(string(data), "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
tomlutil.ParseSections(data, "station", func(f map[string]string) {
|
||||
s := station{name: f["name"], url: f["url"]}
|
||||
if s.name != "" && s.url != "" {
|
||||
stations = append(stations, s)
|
||||
}
|
||||
if line == "[[station]]" {
|
||||
if current != nil && current.name != "" && current.url != "" {
|
||||
stations = append(stations, *current)
|
||||
}
|
||||
current = &station{}
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
val = tomlutil.Unquote(val)
|
||||
|
||||
switch key {
|
||||
case "name":
|
||||
current.name = val
|
||||
case "url":
|
||||
current.url = val
|
||||
}
|
||||
}
|
||||
if current != nil && current.name != "" && current.url != "" {
|
||||
stations = append(stations, *current)
|
||||
}
|
||||
})
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
Vendored
+9
-6
@@ -240,7 +240,7 @@ func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
|
||||
p.mu.Lock()
|
||||
if p.listCache != nil && time.Since(p.listCacheAt) < playlistListCacheTTL {
|
||||
cached := p.listCache
|
||||
cached := slices.Clone(p.listCache)
|
||||
p.mu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
@@ -273,14 +273,12 @@ func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
// i.e. 'Liked Songs' or 'Lieblingssongs' etc.
|
||||
// For the moment, "Your Music" must sufficice without adding a localization
|
||||
// map.
|
||||
p.mu.Lock()
|
||||
all = append(all, playlist.PlaylistInfo{
|
||||
ID: "YOUR MUSIC",
|
||||
Name: "Your Music",
|
||||
TrackCount: result.Total,
|
||||
Section: "Library",
|
||||
})
|
||||
p.mu.Unlock()
|
||||
|
||||
for {
|
||||
query := url.Values{
|
||||
@@ -357,7 +355,7 @@ func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
p.listCacheAt = time.Now()
|
||||
p.mu.Unlock()
|
||||
|
||||
return all, nil
|
||||
return slices.Clone(all), nil
|
||||
}
|
||||
|
||||
// Tracks returns all tracks for the given Spotify playlist ID.
|
||||
@@ -370,7 +368,7 @@ func (p *SpotifyProvider) Tracks(playlistID string) ([]playlist.Track, error) {
|
||||
// Check cache — if we have tracks and the snapshot_id hasn't changed, return cached.
|
||||
p.mu.Lock()
|
||||
if cached, ok := p.trackCache[playlistID]; ok && cached.tracks != nil {
|
||||
tracks := cached.tracks
|
||||
tracks := slices.Clone(cached.tracks)
|
||||
p.mu.Unlock()
|
||||
return tracks, nil
|
||||
}
|
||||
@@ -490,7 +488,7 @@ func (p *SpotifyProvider) Tracks(playlistID string) ([]playlist.Track, error) {
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
return all, nil
|
||||
return slices.Clone(all), nil
|
||||
}
|
||||
|
||||
// isAuthError returns true if the error is an authentication/session-related
|
||||
@@ -614,6 +612,11 @@ func (p *SpotifyProvider) webAPIWithBody(ctx context.Context, method, path strin
|
||||
}
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
resp.Body.Close()
|
||||
// On the last attempt there's no retry after the wait, so don't
|
||||
// sleep (up to 128s) just to give up; fail now.
|
||||
if attempt == maxRetries-1 {
|
||||
break
|
||||
}
|
||||
wait := time.Duration(1<<uint(attempt)) * time.Second
|
||||
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
||||
|
||||
+39
-14
@@ -91,7 +91,12 @@ func (s *Store) Record(track playlist.Track, playedAt time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, _ := s.loadLocked()
|
||||
entries, err := s.loadLocked()
|
||||
if err != nil {
|
||||
// Don't clobber existing on-disk history on a transient read failure:
|
||||
// proceeding would rewrite the file with only the new entry.
|
||||
return fmt.Errorf("load history: %w", err)
|
||||
}
|
||||
if n := len(entries); n > 0 {
|
||||
top := entries[0]
|
||||
if top.Track.Path == track.Path && playedAt.Sub(top.PlayedAt) < dedupWindow {
|
||||
@@ -174,11 +179,17 @@ func (s *Store) saveLocked(entries []Entry) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ew := &errWriter{w: f}
|
||||
for i, e := range entries {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(f)
|
||||
ew.printf("\n")
|
||||
}
|
||||
writeEntry(f, e)
|
||||
writeEntry(ew, e)
|
||||
}
|
||||
if ew.err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return ew.err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
@@ -187,6 +198,20 @@ func (s *Store) saveLocked(entries []Entry) error {
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// errWriter records the first write error so a chain of formatted writes can be
|
||||
// checked once at the end instead of after every call.
|
||||
type errWriter struct {
|
||||
w io.Writer
|
||||
err error
|
||||
}
|
||||
|
||||
func (ew *errWriter) printf(format string, a ...any) {
|
||||
if ew.err != nil {
|
||||
return
|
||||
}
|
||||
_, ew.err = fmt.Fprintf(ew.w, format, a...)
|
||||
}
|
||||
|
||||
// mergeTrackMeta keeps any non-empty metadata from the previous entry when a
|
||||
// replay supplies a sparser track (e.g. an ICY title-only update arriving
|
||||
// after the original tags were captured).
|
||||
@@ -215,28 +240,28 @@ func mergeTrackMeta(prev, cur playlist.Track) playlist.Track {
|
||||
return cur
|
||||
}
|
||||
|
||||
func writeEntry(w io.Writer, e Entry) {
|
||||
fmt.Fprintln(w, "[[entry]]")
|
||||
fmt.Fprintf(w, "played_at = %q\n", e.PlayedAt.UTC().Format(time.RFC3339))
|
||||
fmt.Fprintf(w, "path = %q\n", e.Track.Path)
|
||||
fmt.Fprintf(w, "title = %q\n", e.Track.Title)
|
||||
func writeEntry(ew *errWriter, e Entry) {
|
||||
ew.printf("[[entry]]\n")
|
||||
ew.printf("played_at = %q\n", e.PlayedAt.UTC().Format(time.RFC3339))
|
||||
ew.printf("path = %q\n", e.Track.Path)
|
||||
ew.printf("title = %q\n", e.Track.Title)
|
||||
if e.Track.Artist != "" {
|
||||
fmt.Fprintf(w, "artist = %q\n", e.Track.Artist)
|
||||
ew.printf("artist = %q\n", e.Track.Artist)
|
||||
}
|
||||
if e.Track.Album != "" {
|
||||
fmt.Fprintf(w, "album = %q\n", e.Track.Album)
|
||||
ew.printf("album = %q\n", e.Track.Album)
|
||||
}
|
||||
if e.Track.Genre != "" {
|
||||
fmt.Fprintf(w, "genre = %q\n", e.Track.Genre)
|
||||
ew.printf("genre = %q\n", e.Track.Genre)
|
||||
}
|
||||
if e.Track.Year != 0 {
|
||||
fmt.Fprintf(w, "year = %d\n", e.Track.Year)
|
||||
ew.printf("year = %d\n", e.Track.Year)
|
||||
}
|
||||
if e.Track.TrackNumber != 0 {
|
||||
fmt.Fprintf(w, "track_number = %d\n", e.Track.TrackNumber)
|
||||
ew.printf("track_number = %d\n", e.Track.TrackNumber)
|
||||
}
|
||||
if e.Track.DurationSecs != 0 {
|
||||
fmt.Fprintf(w, "duration_secs = %d\n", e.Track.DurationSecs)
|
||||
ew.printf("duration_secs = %d\n", e.Track.DurationSecs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Package control defines shared message types for external playback control.
|
||||
// Used by both MPRIS (D-Bus) and IPC (Unix socket) to avoid type duplication.
|
||||
package control
|
||||
|
||||
// ToggleMsg requests a play/pause toggle.
|
||||
type ToggleMsg struct{}
|
||||
|
||||
// NextMsg requests advancing to the next track.
|
||||
type NextMsg struct{}
|
||||
|
||||
// PrevMsg requests going to the previous track.
|
||||
type PrevMsg struct{}
|
||||
|
||||
// StopMsg requests playback to stop.
|
||||
type StopMsg struct{}
|
||||
@@ -1,39 +0,0 @@
|
||||
package control
|
||||
|
||||
import "testing"
|
||||
|
||||
// The control package only defines zero-sized struct message types shared
|
||||
// between MPRIS and IPC dispatchers. These smoke tests just confirm the
|
||||
// types are each their own distinct Go type so a type switch can discriminate.
|
||||
func TestMessageTypesDiscriminate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg any
|
||||
tag string
|
||||
}{
|
||||
{"toggle", ToggleMsg{}, "toggle"},
|
||||
{"next", NextMsg{}, "next"},
|
||||
{"prev", PrevMsg{}, "prev"},
|
||||
{"stop", StopMsg{}, "stop"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var tag string
|
||||
switch c.msg.(type) {
|
||||
case ToggleMsg:
|
||||
tag = "toggle"
|
||||
case NextMsg:
|
||||
tag = "next"
|
||||
case PrevMsg:
|
||||
tag = "prev"
|
||||
case StopMsg:
|
||||
tag = "stop"
|
||||
default:
|
||||
tag = "unknown"
|
||||
}
|
||||
if tag != c.tag {
|
||||
t.Errorf("switch(%T) dispatched to %q, want %q", c.msg, tag, c.tag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
// Package embyapi implements the shared Emby/Jellyfin HTTP client. The two
|
||||
// servers speak nearly the same API; the few differences (auth header scheme,
|
||||
// ping endpoint, user-id discovery, error prefix, metadata key) are isolated
|
||||
// in a dialect so emby and jellyfin can be thin wrappers over one client.
|
||||
package embyapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cliamp/playlist"
|
||||
"cliamp/provider"
|
||||
)
|
||||
|
||||
var defaultHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// maxResponseBody limits API responses to 10 MB to prevent unbounded memory growth.
|
||||
const maxResponseBody = 10 << 20
|
||||
|
||||
// Client speaks to an Emby or Jellyfin server over its HTTP API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
user string
|
||||
password string
|
||||
deviceID string
|
||||
dialect dialect
|
||||
httpClient *http.Client
|
||||
|
||||
// mu guards the lazily-populated fields below, which are read and written
|
||||
// from concurrent tea.Cmd goroutines. It is never held across network I/O.
|
||||
mu sync.Mutex
|
||||
token string
|
||||
userID string
|
||||
albumCache []Album // cached after first Albums() call
|
||||
}
|
||||
|
||||
// NewEmbyClient returns a Client configured for an Emby server.
|
||||
func NewEmbyClient(baseURL, token, userID, user, password string) *Client {
|
||||
return newClient(baseURL, token, userID, user, password, embyDialect{})
|
||||
}
|
||||
|
||||
// NewJellyfinClient returns a Client configured for a Jellyfin server.
|
||||
func NewJellyfinClient(baseURL, token, userID, user, password string) *Client {
|
||||
return newClient(baseURL, token, userID, user, password, jellyfinDialect{})
|
||||
}
|
||||
|
||||
func newClient(baseURL, token, userID, user, password string, d dialect) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
userID: userID,
|
||||
user: user,
|
||||
password: password,
|
||||
deviceID: "cliamp",
|
||||
dialect: d,
|
||||
httpClient: defaultHTTPClient,
|
||||
}
|
||||
}
|
||||
|
||||
// SetHTTPClient overrides the HTTP client used for requests. Mainly for tests
|
||||
// that inject a custom transport.
|
||||
func (c *Client) SetHTTPClient(hc *http.Client) { c.httpClient = hc }
|
||||
|
||||
// authToken returns the current bearer token under the mutex.
|
||||
func (c *Client) authToken() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.token
|
||||
}
|
||||
|
||||
func (c *Client) setUserID(id string) {
|
||||
c.mu.Lock()
|
||||
c.userID = id
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// ClearCache discards the cached album list so the next Albums call re-fetches.
|
||||
func (c *Client) ClearCache() {
|
||||
c.mu.Lock()
|
||||
c.albumCache = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// MetaKey returns the playlist.Track ProviderMeta key for this server's item IDs.
|
||||
func (c *Client) MetaKey() string { return c.dialect.metaKey() }
|
||||
|
||||
// Library represents a music library view.
|
||||
type Library struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
const (
|
||||
SortAlbumsByName = "name"
|
||||
SortAlbumsByArtist = "artist"
|
||||
SortAlbumsByYear = "year"
|
||||
)
|
||||
|
||||
var albumSortTypes = []provider.SortType{
|
||||
{ID: SortAlbumsByName, Label: "Alphabetical by Name"},
|
||||
{ID: SortAlbumsByArtist, Label: "Alphabetical by Artist"},
|
||||
{ID: SortAlbumsByYear, Label: "By Year"},
|
||||
}
|
||||
|
||||
// Album represents an album entry.
|
||||
type Album struct {
|
||||
ID string
|
||||
Name string
|
||||
Artist string
|
||||
ArtistID string
|
||||
Year int
|
||||
TrackCount int
|
||||
}
|
||||
|
||||
// Track represents a track entry.
|
||||
type Track struct {
|
||||
ID string
|
||||
Name string
|
||||
Artist string
|
||||
Album string
|
||||
Year int
|
||||
TrackNumber int
|
||||
DurationSecs int
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type itemsResponseDTO struct {
|
||||
Items []itemDTO `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
type itemDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
CollectionType string `json:"CollectionType,omitempty"`
|
||||
Album string `json:"Album,omitempty"`
|
||||
AlbumArtist string `json:"AlbumArtist,omitempty"`
|
||||
AlbumArtists []nameIDDTO `json:"AlbumArtists,omitempty"`
|
||||
Artists []string `json:"Artists,omitempty"`
|
||||
ArtistItems []nameIDDTO `json:"ArtistItems,omitempty"`
|
||||
ProductionYear int `json:"ProductionYear,omitempty"`
|
||||
ChildCount int `json:"ChildCount,omitempty"`
|
||||
IndexNumber int `json:"IndexNumber,omitempty"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
}
|
||||
|
||||
type nameIDDTO struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type authResponseDTO struct {
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
} `json:"User"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
|
||||
type playbackInfo struct {
|
||||
CanSeek bool `json:"CanSeek"`
|
||||
ItemID string `json:"ItemId"`
|
||||
IsPaused bool `json:"IsPaused"`
|
||||
IsMuted bool `json:"IsMuted"`
|
||||
PositionTicks int64 `json:"PositionTicks,omitempty"`
|
||||
PlayMethod string `json:"PlayMethod,omitempty"`
|
||||
}
|
||||
|
||||
type playbackStopInfo struct {
|
||||
ItemID string `json:"ItemId"`
|
||||
PositionTicks int64 `json:"PositionTicks,omitempty"`
|
||||
Failed bool `json:"Failed"`
|
||||
}
|
||||
|
||||
// Ping checks that the server is reachable and the token is accepted.
|
||||
func (c *Client) Ping() error {
|
||||
var raw json.RawMessage
|
||||
return c.get(c.dialect.pingPath(), nil, &raw)
|
||||
}
|
||||
|
||||
// UserID returns the active user id, discovering it lazily when needed.
|
||||
func (c *Client) UserID() (string, error) {
|
||||
c.mu.Lock()
|
||||
id := c.userID
|
||||
c.mu.Unlock()
|
||||
if id != "" {
|
||||
return id, nil
|
||||
}
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.mu.Lock()
|
||||
id = c.userID
|
||||
c.mu.Unlock()
|
||||
if id != "" {
|
||||
return id, nil
|
||||
}
|
||||
return c.dialect.discoverUserID(c)
|
||||
}
|
||||
|
||||
// MusicLibraries returns all user views whose collection type is music.
|
||||
func (c *Client) MusicLibraries() ([]Library, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Users/"+url.PathEscape(userID)+"/Views", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var libs []Library
|
||||
for _, it := range resp.Items {
|
||||
if strings.EqualFold(it.CollectionType, "music") {
|
||||
libs = append(libs, Library{ID: it.ID, Name: it.Name})
|
||||
}
|
||||
}
|
||||
return libs, nil
|
||||
}
|
||||
|
||||
// Albums returns all albums across every music library.
|
||||
// Results are cached after the first successful call.
|
||||
func (c *Client) Albums() ([]Album, error) {
|
||||
c.mu.Lock()
|
||||
cached := c.albumCache
|
||||
c.mu.Unlock()
|
||||
if cached != nil {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Album
|
||||
for _, lib := range libs {
|
||||
albums, err := c.AlbumsByLibrary(lib.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, albums...)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.albumCache = out
|
||||
c.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Artists returns a derived artist list built from the server's album catalog.
|
||||
func (c *Client) Artists() ([]provider.ArtistInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type artistKey struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
seen := make(map[artistKey]*provider.ArtistInfo)
|
||||
for _, album := range albums {
|
||||
key := artistKey{id: canonicalArtistID(album.ArtistID, album.Artist), name: album.Artist}
|
||||
if key.id == "" && key.name == "" {
|
||||
continue
|
||||
}
|
||||
info, ok := seen[key]
|
||||
if !ok {
|
||||
info = &provider.ArtistInfo{
|
||||
ID: key.id,
|
||||
Name: key.name,
|
||||
}
|
||||
seen[key] = info
|
||||
}
|
||||
info.AlbumCount++
|
||||
}
|
||||
|
||||
artists := make([]provider.ArtistInfo, 0, len(seen))
|
||||
for _, artist := range seen {
|
||||
artists = append(artists, *artist)
|
||||
}
|
||||
sort.Slice(artists, func(i, j int) bool {
|
||||
return strings.ToLower(artists[i].Name) < strings.ToLower(artists[j].Name)
|
||||
})
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
// ArtistAlbums returns all albums for one artist, derived from the full album list.
|
||||
func (c *Client) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []provider.AlbumInfo
|
||||
for _, album := range albums {
|
||||
if artistID != "" && album.ArtistID != artistID {
|
||||
if canonicalArtistID(album.ArtistID, album.Artist) != artistID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, provider.AlbumInfo{
|
||||
ID: album.ID,
|
||||
Name: album.Name,
|
||||
Artist: album.Artist,
|
||||
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
|
||||
Year: album.Year,
|
||||
TrackCount: album.TrackCount,
|
||||
})
|
||||
}
|
||||
sortAlbums(out, SortAlbumsByName)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AlbumList returns one page from the full album catalog, sorted client-side.
|
||||
func (c *Client) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
|
||||
albums, err := c.Albums()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]provider.AlbumInfo, 0, len(albums))
|
||||
for _, album := range albums {
|
||||
out = append(out, provider.AlbumInfo{
|
||||
ID: album.ID,
|
||||
Name: album.Name,
|
||||
Artist: album.Artist,
|
||||
ArtistID: canonicalArtistID(album.ArtistID, album.Artist),
|
||||
Year: album.Year,
|
||||
TrackCount: album.TrackCount,
|
||||
})
|
||||
}
|
||||
|
||||
sortAlbums(out, sortType)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(out) {
|
||||
return nil, nil
|
||||
}
|
||||
end := len(out)
|
||||
if size > 0 && offset+size < end {
|
||||
end = offset + size
|
||||
}
|
||||
return out[offset:end], nil
|
||||
}
|
||||
|
||||
func (c *Client) AlbumSortTypes() []provider.SortType {
|
||||
return albumSortTypes
|
||||
}
|
||||
|
||||
func (c *Client) DefaultAlbumSort() string {
|
||||
return SortAlbumsByName
|
||||
}
|
||||
|
||||
// AlbumsByLibrary returns all albums under one music library view.
|
||||
func (c *Client) AlbumsByLibrary(libraryID string) ([]Album, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"parentId": {libraryID},
|
||||
"recursive": {"true"},
|
||||
"includeItemTypes": {"MusicAlbum"},
|
||||
"sortBy": {"SortName"},
|
||||
"sortOrder": {"Ascending"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Album, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, albumFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Tracks returns all audio tracks contained by an album item.
|
||||
func (c *Client) Tracks(albumID string) ([]Track, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"parentId": {albumID},
|
||||
"includeItemTypes": {"Audio"},
|
||||
"sortBy": {"ParentIndexNumber,IndexNumber,SortName"},
|
||||
"sortOrder": {"Ascending"},
|
||||
"fields": {"RunTimeTicks"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Track, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, trackFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Search searches the user's audio library for tracks matching query and
|
||||
// returns up to limit results.
|
||||
func (c *Client) Search(query string, limit int) ([]Track, error) {
|
||||
userID, err := c.UserID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"userId": {userID},
|
||||
"searchTerm": {query},
|
||||
"includeItemTypes": {"Audio"},
|
||||
"recursive": {"true"},
|
||||
"limit": {strconv.Itoa(limit)},
|
||||
"fields": {"RunTimeTicks"},
|
||||
"enableTotalRecordCount": {"false"},
|
||||
}
|
||||
|
||||
var resp itemsResponseDTO
|
||||
if err := c.get("/Items", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Track, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, trackFromItem(it))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsStreamURL reports whether the given URL looks like an item download
|
||||
// endpoint. Used by the player to route these URLs through the buffered ffmpeg
|
||||
// pipeline instead of native HTTP streaming.
|
||||
func IsStreamURL(path string) bool {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
p := strings.ToLower(u.Path)
|
||||
return strings.Contains(p, "/items/") && strings.HasSuffix(p, "/download")
|
||||
}
|
||||
|
||||
// StreamURL returns an authenticated audio URL for a track item.
|
||||
func (c *Client) StreamURL(itemID string) string {
|
||||
_ = c.ensureAuth()
|
||||
v := url.Values{
|
||||
"api_key": {c.authToken()},
|
||||
}
|
||||
|
||||
// Use the direct item download route rather than the Audio controller.
|
||||
// On the live servers used for validation, the Audio endpoints returned
|
||||
// 200 with an empty body, while Download returned the original FLAC/MP3
|
||||
// bytes with byte-range support.
|
||||
u := c.baseURL + path.Join("/", "Items", itemID, "Download")
|
||||
if enc := v.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (c *Client) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) error {
|
||||
return c.postJSON("/Sessions/Playing", playbackInfo{
|
||||
CanSeek: canSeek,
|
||||
ItemID: track.Meta(c.dialect.metaKey()),
|
||||
IsPaused: false,
|
||||
IsMuted: false,
|
||||
PositionTicks: toTicks(position),
|
||||
PlayMethod: "DirectPlay",
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) ReportScrobble(track playlist.Track, elapsed time.Duration, canSeek bool) error {
|
||||
progress := playbackInfo{
|
||||
CanSeek: canSeek,
|
||||
ItemID: track.Meta(c.dialect.metaKey()),
|
||||
IsPaused: false,
|
||||
IsMuted: false,
|
||||
PositionTicks: toTicks(elapsed),
|
||||
PlayMethod: "DirectPlay",
|
||||
}
|
||||
if err := c.postJSON("/Sessions/Playing/Progress", progress); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.postJSON("/Sessions/Playing/Stopped", playbackStopInfo{
|
||||
ItemID: track.Meta(c.dialect.metaKey()),
|
||||
PositionTicks: toTicks(elapsed),
|
||||
Failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) get(p string, params url.Values, out any) error {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := c.newRequest(http.MethodGet, p, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
default:
|
||||
return fmt.Errorf("%s: %s: http status %s", c.dialect.name(), p, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(p string, payload any) error {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
|
||||
req, err := c.newRequestWithBody(http.MethodPost, p, nil, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("%s: %s: http status %s", c.dialect.name(), p, resp.Status)
|
||||
}
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureAuth() error {
|
||||
c.mu.Lock()
|
||||
have := c.token != ""
|
||||
c.mu.Unlock()
|
||||
if have {
|
||||
return nil
|
||||
}
|
||||
if c.user == "" || c.password == "" {
|
||||
return fmt.Errorf("%s: missing token or user/password", c.dialect.name())
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"Username": c.user,
|
||||
"Pw": c.password,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/Users/AuthenticateByName", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
c.dialect.applyAuth(req, "", "", c.deviceID)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: auth: http status %s", c.dialect.name(), resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
|
||||
}
|
||||
|
||||
var out authResponseDTO
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return fmt.Errorf("%s: auth: missing access token", c.dialect.name())
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.token = out.AccessToken
|
||||
if c.userID == "" {
|
||||
c.userID = out.User.ID
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(method, p string, params url.Values) (*http.Request, error) {
|
||||
return c.newRequestWithBody(method, p, params, nil)
|
||||
}
|
||||
|
||||
func (c *Client) newRequestWithBody(method, p string, params url.Values, body io.Reader) (*http.Request, error) {
|
||||
u := c.baseURL + p
|
||||
if len(params) > 0 {
|
||||
u += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
c.mu.Lock()
|
||||
token, userID := c.token, c.userID
|
||||
c.mu.Unlock()
|
||||
c.dialect.applyAuth(req, token, userID, c.deviceID)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func albumFromItem(it itemDTO) Album {
|
||||
a := Album{
|
||||
ID: it.ID,
|
||||
Name: it.Name,
|
||||
Artist: it.AlbumArtist,
|
||||
Year: it.ProductionYear,
|
||||
TrackCount: it.ChildCount,
|
||||
}
|
||||
if len(it.AlbumArtists) > 0 {
|
||||
if a.Artist == "" {
|
||||
a.Artist = it.AlbumArtists[0].Name
|
||||
}
|
||||
a.ArtistID = it.AlbumArtists[0].ID
|
||||
}
|
||||
if a.Artist == "" && len(it.ArtistItems) > 0 {
|
||||
a.Artist = it.ArtistItems[0].Name
|
||||
a.ArtistID = it.ArtistItems[0].ID
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func trackFromItem(it itemDTO) Track {
|
||||
t := Track{
|
||||
ID: it.ID,
|
||||
Name: it.Name,
|
||||
Album: it.Album,
|
||||
Year: it.ProductionYear,
|
||||
TrackNumber: it.IndexNumber,
|
||||
DurationSecs: int(it.RunTimeTicks / 10_000_000),
|
||||
}
|
||||
if len(it.Artists) > 0 {
|
||||
t.Artist = it.Artists[0]
|
||||
} else if len(it.ArtistItems) > 0 {
|
||||
t.Artist = it.ArtistItems[0].Name
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func sortAlbums(albums []provider.AlbumInfo, sortType string) {
|
||||
switch sortType {
|
||||
case "", SortAlbumsByName:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if strings.EqualFold(albums[i].Name, albums[j].Name) {
|
||||
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
|
||||
}
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
})
|
||||
case SortAlbumsByArtist:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if strings.EqualFold(albums[i].Artist, albums[j].Artist) {
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
}
|
||||
return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
|
||||
})
|
||||
case SortAlbumsByYear:
|
||||
sort.Slice(albums, func(i, j int) bool {
|
||||
if albums[i].Year == albums[j].Year {
|
||||
return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
|
||||
}
|
||||
return albums[i].Year > albums[j].Year
|
||||
})
|
||||
default:
|
||||
sortAlbums(albums, SortAlbumsByName)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalArtistID(id, name string) string {
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return "name:" + strings.ToLower(name)
|
||||
}
|
||||
|
||||
func toTicks(d time.Duration) int64 {
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Nanoseconds() / 100
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package embyapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cliamp/internal/appmeta"
|
||||
"cliamp/playlist"
|
||||
"cliamp/provider"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func mock(c *Client, fn roundTripFunc) *Client {
|
||||
c.SetHTTPClient(&http.Client{Transport: fn})
|
||||
return c
|
||||
}
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func noContentResponse() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Status: "204 No Content",
|
||||
Body: io.NopCloser(bytes.NewBuffer(nil)),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialect-specific: Ping endpoint ---
|
||||
|
||||
func TestEmbyPingUsesSystemInfo(t *testing.T) {
|
||||
c := mock(NewEmbyClient("https://emby.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/System/Info" {
|
||||
t.Fatalf("Ping path = %s, want /System/Info", req.URL.Path)
|
||||
}
|
||||
return jsonResponse(`{"ServerName":"My Emby","Version":"4.8.0.0"}`), nil
|
||||
})
|
||||
if err := c.Ping(); err != nil {
|
||||
t.Fatalf("Ping() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinPingUsesUsersMe(t *testing.T) {
|
||||
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Users/Me" {
|
||||
t.Fatalf("Ping path = %s, want /Users/Me", req.URL.Path)
|
||||
}
|
||||
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
||||
})
|
||||
if err := c.Ping(); err != nil {
|
||||
t.Fatalf("Ping() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialect-specific: Emby API-key user-id fallback ---
|
||||
|
||||
func TestEmbyUserIDAPIKeyFallback(t *testing.T) {
|
||||
// /Users/Me returns 500 for server-level API keys; fall back to /Users.
|
||||
c := mock(NewEmbyClient("https://emby.example.com", "tok", "", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return &http.Response{StatusCode: 500, Status: "500 Internal Server Error", Body: io.NopCloser(bytes.NewBuffer(nil))}, nil
|
||||
case "/Users":
|
||||
return jsonResponse(`[{"Id":"user-1","Name":"Alice"},{"Id":"user-2","Name":"Bob"}]`), nil
|
||||
case "/Users/user-1/Views":
|
||||
return jsonResponse(`{"Items":[{"Id":"lib-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
libs, err := c.MusicLibraries()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if c.userID != "user-1" {
|
||||
t.Fatalf("userID = %q after API key fallback, want user-1", c.userID)
|
||||
}
|
||||
if len(libs) != 1 || libs[0].ID != "lib-1" {
|
||||
t.Fatalf("libraries = %+v", libs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialect-specific: auth header scheme ---
|
||||
|
||||
func TestEmbyAuthHeaderScheme(t *testing.T) {
|
||||
c := mock(NewEmbyClient("https://emby.example.com", "tok", "", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
|
||||
t.Fatalf("Authorization = %q, want Emby scheme", got)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
if _, err := c.MusicLibraries(); err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinAuthHeaderScheme(t *testing.T) {
|
||||
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/Me":
|
||||
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
||||
}
|
||||
if got := req.Header.Get("X-Emby-Authorization"); !strings.HasPrefix(got, "MediaBrowser ") {
|
||||
t.Fatalf("X-Emby-Authorization = %q, want MediaBrowser scheme", got)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
if _, err := c.MusicLibraries(); err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialect-specific: password auth ---
|
||||
|
||||
func TestEmbyAuthenticatesWithPassword(t *testing.T) {
|
||||
c := mock(NewEmbyClient("https://emby.example.com", "", "", "alice", "s3cret"), func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/AuthenticateByName":
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", req.Method)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") {
|
||||
t.Fatalf("auth request Authorization = %q, want Emby scheme", got)
|
||||
}
|
||||
return jsonResponse(`{"User":{"Id":"user-1"},"AccessToken":"tok-1"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") || !strings.Contains(got, `Token="tok-1"`) {
|
||||
t.Fatalf("Authorization = %q, want Emby scheme with token", got)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
if _, err := c.MusicLibraries(); err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if c.token != "tok-1" || c.userID != "user-1" {
|
||||
t.Fatalf("client auth state = token:%q userID:%q", c.token, c.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinAuthenticatesWithPassword(t *testing.T) {
|
||||
c := mock(NewJellyfinClient("https://jf.example.com", "", "", "finamp", "1qazxsw2"), func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/Users/AuthenticateByName":
|
||||
if got := req.Header.Get("X-Emby-Authorization"); !strings.HasPrefix(got, "MediaBrowser ") {
|
||||
t.Fatalf("auth request X-Emby-Authorization = %q, want MediaBrowser scheme", got)
|
||||
}
|
||||
return jsonResponse(`{"User":{"Id":"user-1"},"AccessToken":"tok-1"}`), nil
|
||||
case "/Users/user-1/Views":
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok-1" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok-1", got)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"music-1","Name":"Music","CollectionType":"music"}]}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
if _, err := c.MusicLibraries(); err != nil {
|
||||
t.Fatalf("MusicLibraries() error: %v", err)
|
||||
}
|
||||
if c.token != "tok-1" || c.userID != "user-1" {
|
||||
t.Fatalf("client auth state = token:%q userID:%q", c.token, c.userID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialect-specific: scrobble metadata key + auth header ---
|
||||
|
||||
func TestEmbyReportNowPlaying(t *testing.T) {
|
||||
appmeta.SetVersion("v1.31.2")
|
||||
t.Cleanup(func() { appmeta.SetVersion("dev") })
|
||||
c := mock(NewEmbyClient("https://emby.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Sessions/Playing" {
|
||||
t.Fatalf("path = %s, want /Sessions/Playing", req.URL.Path)
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Emby ") || !strings.Contains(got, `Version="v1.31.2"`) {
|
||||
t.Fatalf("Authorization = %q, want Emby scheme with release version", got)
|
||||
}
|
||||
var payload playbackInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || !payload.CanSeek || payload.PositionTicks != 15*time.Second.Nanoseconds()/100 {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
return noContentResponse(), nil
|
||||
})
|
||||
track := playlist.Track{ProviderMeta: map[string]string{provider.MetaEmbyID: "track-1"}}
|
||||
if err := c.ReportNowPlaying(track, 15*time.Second, true); err != nil {
|
||||
t.Fatalf("ReportNowPlaying() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinReportNowPlaying(t *testing.T) {
|
||||
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Sessions/Playing" {
|
||||
t.Fatalf("path = %s, want /Sessions/Playing", req.URL.Path)
|
||||
}
|
||||
if got := req.Header.Get("X-Emby-Token"); got != "tok" {
|
||||
t.Fatalf("X-Emby-Token = %q, want tok", got)
|
||||
}
|
||||
var payload playbackInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" {
|
||||
t.Fatalf("payload ItemID = %q, want track-1 (from jellyfin meta key)", payload.ItemID)
|
||||
}
|
||||
return noContentResponse(), nil
|
||||
})
|
||||
track := playlist.Track{ProviderMeta: map[string]string{provider.MetaJellyfinID: "track-1"}}
|
||||
if err := c.ReportNowPlaying(track, 15*time.Second, true); err != nil {
|
||||
t.Fatalf("ReportNowPlaying() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared behavior (parsing/caching/URLs): tested once, dialect-agnostic ---
|
||||
|
||||
func TestAlbumsByLibrary(t *testing.T) {
|
||||
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
q := req.URL.Query()
|
||||
if req.URL.Path != "/Items" || q.Get("parentId") != "lib-1" || q.Get("includeItemTypes") != "MusicAlbum" {
|
||||
t.Fatalf("unexpected request %s?%s", req.URL.Path, req.URL.RawQuery)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"album-1","Name":"Kind of Blue","AlbumArtist":"Miles Davis","AlbumArtists":[{"Id":"artist-1","Name":"Miles Davis"}],"ProductionYear":1959,"ChildCount":5}]}`), nil
|
||||
})
|
||||
albums, err := c.AlbumsByLibrary("lib-1")
|
||||
if err != nil {
|
||||
t.Fatalf("AlbumsByLibrary() error: %v", err)
|
||||
}
|
||||
if len(albums) != 1 {
|
||||
t.Fatalf("expected 1 album, got %d", len(albums))
|
||||
}
|
||||
a := albums[0]
|
||||
if a.ID != "album-1" || a.Name != "Kind of Blue" || a.Artist != "Miles Davis" || a.ArtistID != "artist-1" || a.Year != 1959 || a.TrackCount != 5 {
|
||||
t.Fatalf("album = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracksParsing(t *testing.T) {
|
||||
c := mock(NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", ""), func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/Items" || req.URL.Query().Get("includeItemTypes") != "Audio" {
|
||||
t.Fatalf("unexpected request %s?%s", req.URL.Path, req.URL.RawQuery)
|
||||
}
|
||||
return jsonResponse(`{"Items":[{"Id":"track-1","Name":"So What","Album":"Kind of Blue","Artists":["Miles Davis"],"ProductionYear":1959,"IndexNumber":1,"RunTimeTicks":5650000000}]}`), nil
|
||||
})
|
||||
tracks, err := c.Tracks("album-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Tracks() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected 1 track, got %d", len(tracks))
|
||||
}
|
||||
tr := tracks[0]
|
||||
if tr.ID != "track-1" || tr.Name != "So What" || tr.Artist != "Miles Davis" || tr.Album != "Kind of Blue" || tr.Year != 1959 || tr.TrackNumber != 1 || tr.DurationSecs != 565 {
|
||||
t.Fatalf("track = %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamURL(t *testing.T) {
|
||||
c := NewEmbyClient("https://emby.example.com", "tok", "user-1", "", "")
|
||||
u := c.StreamURL("track-1")
|
||||
if !strings.HasPrefix(u, "https://emby.example.com/Items/track-1/Download?") {
|
||||
t.Fatalf("URL = %q, want Download route prefix", u)
|
||||
}
|
||||
if !strings.Contains(u, "api_key=tok") {
|
||||
t.Fatalf("URL missing api_key: %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportScrobble(t *testing.T) {
|
||||
c := NewJellyfinClient("https://jf.example.com", "tok", "user-1", "", "")
|
||||
call := 0
|
||||
mock(c, func(req *http.Request) (*http.Response, error) {
|
||||
call++
|
||||
switch call {
|
||||
case 1:
|
||||
if req.URL.Path != "/Sessions/Playing/Progress" {
|
||||
t.Fatalf("progress path = %s", req.URL.Path)
|
||||
}
|
||||
case 2:
|
||||
if req.URL.Path != "/Sessions/Playing/Stopped" {
|
||||
t.Fatalf("stopped path = %s", req.URL.Path)
|
||||
}
|
||||
var payload playbackStopInfo
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode stop payload: %v", err)
|
||||
}
|
||||
if payload.ItemID != "track-1" || payload.PositionTicks != 42*time.Second.Nanoseconds()/100 || payload.Failed {
|
||||
t.Fatalf("stop payload = %+v", payload)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected extra call %d", call)
|
||||
}
|
||||
return noContentResponse(), nil
|
||||
})
|
||||
track := playlist.Track{ProviderMeta: map[string]string{provider.MetaJellyfinID: "track-1"}}
|
||||
if err := c.ReportScrobble(track, 42*time.Second, true); err != nil {
|
||||
t.Fatalf("ReportScrobble() error: %v", err)
|
||||
}
|
||||
if call != 2 {
|
||||
t.Fatalf("call count = %d, want 2", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStreamURL(t *testing.T) {
|
||||
if !IsStreamURL("https://x/Items/abc/Download?api_key=z") {
|
||||
t.Fatal("download URL should be a stream URL")
|
||||
}
|
||||
if IsStreamURL("https://x/Items/abc") {
|
||||
t.Fatal("non-download URL should not be a stream URL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package embyapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cliamp/internal/appmeta"
|
||||
"cliamp/provider"
|
||||
)
|
||||
|
||||
// dialect captures the handful of behaviors that differ between Emby and
|
||||
// Jellyfin. Everything else in Client is shared.
|
||||
type dialect interface {
|
||||
name() string // error-wrapping prefix
|
||||
pingPath() string // endpoint Ping hits
|
||||
metaKey() string // playlist.Track ProviderMeta key
|
||||
applyAuth(req *http.Request, token, userID, deviceID string) // set auth headers
|
||||
discoverUserID(c *Client) (string, error) // user-id discovery strategy
|
||||
}
|
||||
|
||||
// embyDialect speaks Emby's `Authorization: Emby ...` scheme and discovers the
|
||||
// user id with an API-key fallback.
|
||||
type embyDialect struct{}
|
||||
|
||||
func (embyDialect) name() string { return "emby" }
|
||||
func (embyDialect) pingPath() string { return "/System/Info" }
|
||||
func (embyDialect) metaKey() string { return provider.MetaEmbyID }
|
||||
|
||||
func (embyDialect) applyAuth(req *http.Request, token, userID, deviceID string) {
|
||||
if token != "" {
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
req.Header.Set("Authorization", embyAuthHeader(userID, token, deviceID))
|
||||
} else {
|
||||
req.Header.Set("Authorization", embyUnauthHeader(deviceID))
|
||||
}
|
||||
}
|
||||
|
||||
func (embyDialect) discoverUserID(c *Client) (string, error) {
|
||||
// Try /Users/Me first (works for session tokens from password auth).
|
||||
var me userDTO
|
||||
if err := c.get("/Users/Me", nil, &me); err == nil && me.ID != "" {
|
||||
c.setUserID(me.ID)
|
||||
return me.ID, nil
|
||||
}
|
||||
|
||||
// Fall back to /Users for API key auth (server-level key has no "me").
|
||||
var users []userDTO
|
||||
if err := c.get("/Users", nil, &users); err != nil {
|
||||
return "", fmt.Errorf("emby: could not discover user id (set user_id in config): %w", err)
|
||||
}
|
||||
// Prefer user matching the configured username; otherwise take first entry.
|
||||
for _, u := range users {
|
||||
if strings.EqualFold(u.Name, c.user) {
|
||||
c.setUserID(u.ID)
|
||||
return u.ID, nil
|
||||
}
|
||||
}
|
||||
if c.user != "" {
|
||||
return "", fmt.Errorf("emby: user %q not found — check the user name in config", c.user)
|
||||
}
|
||||
if len(users) > 0 && users[0].ID != "" {
|
||||
c.setUserID(users[0].ID)
|
||||
return users[0].ID, nil
|
||||
}
|
||||
return "", fmt.Errorf("emby: could not discover user id — set user_id in config")
|
||||
}
|
||||
|
||||
// unauthHeader / authHeader build Emby's Authorization header values.
|
||||
func embyUnauthHeader(deviceID string) string {
|
||||
return fmt.Sprintf(`Emby Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
|
||||
appmeta.ClientName(), appmeta.DeviceName(), deviceID, appmeta.Version())
|
||||
}
|
||||
|
||||
func embyAuthHeader(userID, token, deviceID string) string {
|
||||
if userID != "" {
|
||||
return fmt.Sprintf(`Emby UserId="%s", Client="%s", Device="%s", DeviceId="%s", Version="%s", Token="%s"`,
|
||||
userID, appmeta.ClientName(), appmeta.DeviceName(), deviceID, appmeta.Version(), token)
|
||||
}
|
||||
return fmt.Sprintf(`Emby Client="%s", Device="%s", DeviceId="%s", Version="%s", Token="%s"`,
|
||||
appmeta.ClientName(), appmeta.DeviceName(), deviceID, appmeta.Version(), token)
|
||||
}
|
||||
|
||||
// jellyfinDialect speaks Jellyfin's `X-Emby-Authorization: MediaBrowser ...`
|
||||
// scheme and discovers the user id from /Users/Me only.
|
||||
type jellyfinDialect struct{}
|
||||
|
||||
func (jellyfinDialect) name() string { return "jellyfin" }
|
||||
func (jellyfinDialect) pingPath() string { return "/Users/Me" }
|
||||
func (jellyfinDialect) metaKey() string { return provider.MetaJellyfinID }
|
||||
|
||||
func (jellyfinDialect) applyAuth(req *http.Request, token, _, deviceID string) {
|
||||
if token != "" {
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
}
|
||||
req.Header.Set("X-Emby-Authorization",
|
||||
fmt.Sprintf(`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
|
||||
appmeta.ClientName(), appmeta.DeviceName(), deviceID, appmeta.Version()))
|
||||
}
|
||||
|
||||
func (jellyfinDialect) discoverUserID(c *Client) (string, error) {
|
||||
var u userDTO
|
||||
if err := c.get("/Users/Me", nil, &u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.ID == "" {
|
||||
return "", fmt.Errorf("jellyfin: current user response missing id")
|
||||
}
|
||||
c.setUserID(u.ID)
|
||||
return u.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package tomlutil
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParseSections parses a minimal TOML document made up of repeated
|
||||
// [[<section>]] blocks of `key = "value"` lines. For each section header it
|
||||
// calls emit once with the accumulated fields, with values unquoted via
|
||||
// Unquote. Blank lines, comments (#), and lines outside any section are
|
||||
// ignored. An empty section still triggers emit, so callers apply their own
|
||||
// validation. When a key repeats within a section, the last value wins.
|
||||
func ParseSections(data []byte, section string, emit func(fields map[string]string)) {
|
||||
header := "[[" + section + "]]"
|
||||
var fields map[string]string
|
||||
flush := func() {
|
||||
if fields != nil {
|
||||
emit(fields)
|
||||
}
|
||||
}
|
||||
for rawLine := range strings.SplitSeq(string(data), "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if line == header {
|
||||
flush()
|
||||
fields = make(map[string]string)
|
||||
continue
|
||||
}
|
||||
if fields == nil {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields[strings.TrimSpace(key)] = Unquote(strings.TrimSpace(val))
|
||||
}
|
||||
flush()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tomlutil
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSections(t *testing.T) {
|
||||
data := []byte(`
|
||||
# a comment
|
||||
stray = "ignored before any section"
|
||||
|
||||
[[station]]
|
||||
name = "Radio A"
|
||||
url = "http://a"
|
||||
bitrate = "128"
|
||||
|
||||
[[station]]
|
||||
name = "Radio B"
|
||||
url = "http://b"
|
||||
`)
|
||||
|
||||
var got []map[string]string
|
||||
ParseSections(data, "station", func(f map[string]string) {
|
||||
got = append(got, f)
|
||||
})
|
||||
|
||||
want := []map[string]string{
|
||||
{"name": "Radio A", "url": "http://a", "bitrate": "128"},
|
||||
{"name": "Radio B", "url": "http://b"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSections = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSectionsLastKeyWins(t *testing.T) {
|
||||
data := []byte("[[t]]\nk = \"first\"\nk = \"second\"\n")
|
||||
var got string
|
||||
ParseSections(data, "t", func(f map[string]string) { got = f["k"] })
|
||||
if got != "second" {
|
||||
t.Fatalf("k = %q, want second", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSectionsEmptySectionEmits(t *testing.T) {
|
||||
data := []byte("[[t]]\n[[t]]\nk = \"v\"\n")
|
||||
count := 0
|
||||
ParseSections(data, "t", func(map[string]string) { count++ })
|
||||
if count != 2 {
|
||||
t.Fatalf("emit count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
+69
-53
@@ -3,6 +3,7 @@ package ipc
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
@@ -31,6 +32,39 @@ type Server struct {
|
||||
plugins PluginDispatcher
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
connMu sync.Mutex
|
||||
conns map[net.Conn]struct{} // live connections, closed on shutdown
|
||||
}
|
||||
|
||||
// addConn registers a live connection. It returns false if the server is
|
||||
// already shutting down, in which case the caller must close the connection
|
||||
// and return. The done check shares connMu with closeConns so a connection
|
||||
// accepted during shutdown is always closed by exactly one of them.
|
||||
func (s *Server) addConn(c net.Conn) bool {
|
||||
s.connMu.Lock()
|
||||
defer s.connMu.Unlock()
|
||||
select {
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
s.conns[c] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) removeConn(c net.Conn) {
|
||||
s.connMu.Lock()
|
||||
delete(s.conns, c)
|
||||
s.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) closeConns() {
|
||||
s.connMu.Lock()
|
||||
for c := range s.conns {
|
||||
c.Close()
|
||||
}
|
||||
s.connMu.Unlock()
|
||||
}
|
||||
|
||||
// SetPluginDispatcher wires in the Lua plugin manager after the server starts.
|
||||
@@ -76,6 +110,7 @@ func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
|
||||
sockPath: sockPath,
|
||||
disp: disp,
|
||||
done: make(chan struct{}),
|
||||
conns: make(map[net.Conn]struct{}),
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
@@ -87,6 +122,9 @@ func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
|
||||
func (s *Server) Close() error {
|
||||
close(s.done)
|
||||
err := s.listener.Close()
|
||||
// Close in-flight connections so their handleConn read loops unblock
|
||||
// immediately rather than waiting out the per-request read deadline.
|
||||
s.closeConns()
|
||||
s.wg.Wait()
|
||||
os.Remove(s.sockPath)
|
||||
os.Remove(s.sockPath + ".pid")
|
||||
@@ -103,9 +141,16 @@ func (s *Server) acceptLoop() {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
// A closed listener is permanent — stop instead of spinning.
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
// Other errors may be transient (e.g. EMFILE); log and back off
|
||||
// rather than silently retrying.
|
||||
applog.Warn("ipc: accept: %v", err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go s.handleConn(conn)
|
||||
@@ -118,6 +163,11 @@ func (s *Server) handleConn(conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
if !s.addConn(conn) {
|
||||
return // server shutting down
|
||||
}
|
||||
defer s.removeConn(conn)
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
|
||||
for {
|
||||
@@ -186,14 +236,7 @@ func (s *Server) dispatch(req Request) Response {
|
||||
}
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(LoadMsg{Playlist: req.Playlist, Reply: reply})
|
||||
select {
|
||||
case resp := <-reply:
|
||||
return resp
|
||||
case <-time.After(3 * time.Second):
|
||||
return Response{OK: false, Error: "load timeout"}
|
||||
case <-s.done:
|
||||
return Response{OK: false, Error: "server shutting down"}
|
||||
}
|
||||
return waitReply(reply, s.done, "load", 3*time.Second)
|
||||
|
||||
case "queue":
|
||||
if req.Path == "" {
|
||||
@@ -208,14 +251,7 @@ func (s *Server) dispatch(req Request) Response {
|
||||
}
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(ThemeMsg{Name: req.Name, Reply: reply})
|
||||
select {
|
||||
case resp := <-reply:
|
||||
return resp
|
||||
case <-time.After(3 * time.Second):
|
||||
return Response{OK: false, Error: "theme timeout"}
|
||||
case <-s.done:
|
||||
return Response{OK: false, Error: "server shutting down"}
|
||||
}
|
||||
return waitReply(reply, s.done, "theme", 3*time.Second)
|
||||
|
||||
case "vis":
|
||||
if req.Name == "" {
|
||||
@@ -223,29 +259,22 @@ func (s *Server) dispatch(req Request) Response {
|
||||
}
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(VisMsg{Name: req.Name, Reply: reply})
|
||||
select {
|
||||
case resp := <-reply:
|
||||
return resp
|
||||
case <-time.After(3 * time.Second):
|
||||
return Response{OK: false, Error: "vis timeout"}
|
||||
case <-s.done:
|
||||
return Response{OK: false, Error: "server shutting down"}
|
||||
}
|
||||
return waitReply(reply, s.done, "vis", 3*time.Second)
|
||||
|
||||
case "shuffle":
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(ShuffleMsg{Name: req.Name, Reply: reply})
|
||||
return waitReply(reply, s.done)
|
||||
return waitReply(reply, s.done, "shuffle", 3*time.Second)
|
||||
|
||||
case "repeat":
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(RepeatMsg{Name: req.Name, Reply: reply})
|
||||
return waitReply(reply, s.done)
|
||||
return waitReply(reply, s.done, "repeat", 3*time.Second)
|
||||
|
||||
case "mono":
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(MonoMsg{Name: req.Name, Reply: reply})
|
||||
return waitReply(reply, s.done)
|
||||
return waitReply(reply, s.done, "mono", 3*time.Second)
|
||||
|
||||
case "speed":
|
||||
if req.Value <= 0 {
|
||||
@@ -253,12 +282,12 @@ func (s *Server) dispatch(req Request) Response {
|
||||
}
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(SpeedMsg{Speed: req.Value, Reply: reply})
|
||||
return waitReply(reply, s.done)
|
||||
return waitReply(reply, s.done, "speed", 3*time.Second)
|
||||
|
||||
case "eq":
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(EQMsg{Name: req.Name, Band: req.Band, Value: req.Value, Reply: reply})
|
||||
return waitReply(reply, s.done)
|
||||
return waitReply(reply, s.done, "eq", 3*time.Second)
|
||||
|
||||
case "device":
|
||||
if req.Name == "" {
|
||||
@@ -266,7 +295,7 @@ func (s *Server) dispatch(req Request) Response {
|
||||
}
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(DeviceMsg{Name: req.Name, Reply: reply})
|
||||
return waitReply(reply, s.done)
|
||||
return waitReply(reply, s.done, "device", 3*time.Second)
|
||||
|
||||
case "status":
|
||||
return s.handleStatus()
|
||||
@@ -274,14 +303,7 @@ func (s *Server) dispatch(req Request) Response {
|
||||
case "bands":
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(BandsRequestMsg{Reply: reply})
|
||||
select {
|
||||
case resp := <-reply:
|
||||
return resp
|
||||
case <-time.After(1 * time.Second):
|
||||
return Response{OK: false, Error: "bands timeout"}
|
||||
case <-s.done:
|
||||
return Response{OK: false, Error: "server shutting down"}
|
||||
}
|
||||
return waitReply(reply, s.done, "bands", 1*time.Second)
|
||||
|
||||
case "plugin.call":
|
||||
if s.plugins == nil {
|
||||
@@ -307,13 +329,15 @@ func (s *Server) dispatch(req Request) Response {
|
||||
}
|
||||
}
|
||||
|
||||
// waitReply waits up to 3 seconds for a response on the reply channel.
|
||||
func waitReply(reply chan Response, done chan struct{}) Response {
|
||||
// waitReply waits up to timeout for a response on the reply channel, returning
|
||||
// a "<label> timeout" error if it elapses or a shutdown error if the server
|
||||
// closes first.
|
||||
func waitReply(reply chan Response, done chan struct{}, label string, timeout time.Duration) Response {
|
||||
select {
|
||||
case resp := <-reply:
|
||||
return resp
|
||||
case <-time.After(3 * time.Second):
|
||||
return Response{OK: false, Error: "timeout"}
|
||||
case <-time.After(timeout):
|
||||
return Response{OK: false, Error: label + " timeout"}
|
||||
case <-done:
|
||||
return Response{OK: false, Error: "server shutting down"}
|
||||
}
|
||||
@@ -324,15 +348,7 @@ func waitReply(reply chan Response, done chan struct{}) Response {
|
||||
func (s *Server) handleStatus() Response {
|
||||
reply := make(chan Response, 1)
|
||||
s.disp.Send(StatusRequestMsg{Reply: reply})
|
||||
|
||||
select {
|
||||
case resp := <-reply:
|
||||
return resp
|
||||
case <-time.After(3 * time.Second):
|
||||
return Response{OK: false, Error: "status timeout"}
|
||||
case <-s.done:
|
||||
return Response{OK: false, Error: "server shutting down"}
|
||||
}
|
||||
return waitReply(reply, s.done, "status", 3*time.Second)
|
||||
}
|
||||
|
||||
// writeResponse marshals a Response as JSON and writes it followed by a newline.
|
||||
|
||||
@@ -95,9 +95,14 @@ func registerControlAPI(L *lua.LState, cliamp *lua.LTable, ctrl *ControlProvider
|
||||
if tbl := L.OptTable(2, nil); tbl != nil {
|
||||
b := [10]float64{}
|
||||
for i := range 10 {
|
||||
if v := tbl.RawGetInt(i + 1); v != lua.LNil {
|
||||
b[i] = max(min(float64(lua.LVAsNumber(v)), 12), -12)
|
||||
v := tbl.RawGetInt(i + 1)
|
||||
if v == lua.LNil {
|
||||
// A partial table would silently zero the unset bands;
|
||||
// require all 10 so the caller's intent is explicit.
|
||||
L.ArgError(2, "eq bands table must contain all 10 values")
|
||||
return 0
|
||||
}
|
||||
b[i] = max(min(float64(lua.LVAsNumber(v)), 12), -12)
|
||||
}
|
||||
bands = &b
|
||||
}
|
||||
|
||||
+65
-13
@@ -1,6 +1,7 @@
|
||||
package luaplugin
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -16,30 +17,67 @@ var (
|
||||
allowDirs []string
|
||||
)
|
||||
|
||||
// writeAllowDirs returns the directories where plugins can write files.
|
||||
// The result is cached since these paths never change at runtime.
|
||||
// writeAllowDirs returns the directories where plugins can write files, with
|
||||
// symlinks resolved so the prefix check in isWriteAllowed cannot be bypassed
|
||||
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). The result is
|
||||
// cached since these paths never change at runtime.
|
||||
func writeAllowDirs() []string {
|
||||
allowDirsOnce.Do(func() {
|
||||
allowDirs = []string{"/tmp/", os.TempDir() + "/"}
|
||||
raw := []string{"/tmp", os.TempDir()}
|
||||
if configDir, err := appdir.Dir(); err == nil {
|
||||
allowDirs = append(allowDirs, configDir+"/")
|
||||
raw = append(raw, configDir)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
allowDirs = append(allowDirs, filepath.Join(home, ".local", "share", "cliamp")+"/")
|
||||
allowDirs = append(allowDirs, filepath.Join(home, "Music", "cliamp")+"/")
|
||||
raw = append(raw, filepath.Join(home, ".local", "share", "cliamp"))
|
||||
raw = append(raw, filepath.Join(home, "Music", "cliamp"))
|
||||
}
|
||||
sep := string(os.PathSeparator)
|
||||
for _, d := range raw {
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = resolved
|
||||
}
|
||||
allowDirs = append(allowDirs, abs+sep)
|
||||
}
|
||||
})
|
||||
return allowDirs
|
||||
}
|
||||
|
||||
// isWriteAllowed checks if a path is within one of the allowed write directories.
|
||||
func isWriteAllowed(path string) bool {
|
||||
// canonicalExistingPath resolves symlinks on the deepest existing ancestor of
|
||||
// path, re-appending any non-existent tail (e.g. a file about to be created).
|
||||
// This prevents a symlink planted inside an allowed dir from redirecting a
|
||||
// write to a target outside it; a purely lexical check cannot catch that.
|
||||
func canonicalExistingPath(path string) (string, bool) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
// Block directory traversal.
|
||||
if strings.Contains(abs, "..") {
|
||||
suffix := ""
|
||||
cur := abs
|
||||
for {
|
||||
if resolved, err := filepath.EvalSymlinks(cur); err == nil {
|
||||
if suffix != "" {
|
||||
resolved = filepath.Join(resolved, suffix)
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return abs, true // nothing along the path exists yet
|
||||
}
|
||||
suffix = filepath.Join(filepath.Base(cur), suffix)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// isWriteAllowed checks if a path is within one of the allowed write
|
||||
// directories, resolving symlinks on both sides first.
|
||||
func isWriteAllowed(path string) bool {
|
||||
abs, ok := canonicalExistingPath(path)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, dir := range writeAllowDirs() {
|
||||
@@ -109,14 +147,28 @@ func registerFSAPI(L *lua.LState, cliamp *lua.LTable) {
|
||||
// cliamp.fs.read(path) -> string (max 1MB)
|
||||
L.SetField(tbl, "read", L.NewFunction(func(L *lua.LState) int {
|
||||
path := L.CheckString(1)
|
||||
data, err := os.ReadFile(path)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
defer f.Close()
|
||||
const maxSize = 1 << 20 // 1MB
|
||||
data = data[:min(len(data), maxSize)]
|
||||
// Read one byte past the cap so an oversized file is detected without
|
||||
// pulling the whole thing into memory, then reject it explicitly
|
||||
// rather than returning a silently truncated value.
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxSize+1))
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
if len(data) > maxSize {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString("file exceeds 1MB read limit"))
|
||||
return 2
|
||||
}
|
||||
L.Push(lua.LString(string(data)))
|
||||
return 1
|
||||
}))
|
||||
|
||||
+36
-2
@@ -2,16 +2,45 @@ package luaplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// ssrfGuard rejects connections to non-public addresses. It runs as the
|
||||
// dialer's Control hook, so it sees the resolved IP for every connection
|
||||
// attempt, including ones reached via HTTP redirects or DNS rebinding.
|
||||
func ssrfGuard(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("cannot resolve dial address %q", address)
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return fmt.Errorf("blocked request to non-public address %s", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
Control: ssrfGuard,
|
||||
}).DialContext,
|
||||
},
|
||||
}
|
||||
|
||||
// registerHTTPAPI adds cliamp.http.{get,post} to the cliamp table.
|
||||
@@ -34,7 +63,12 @@ func registerHTTPAPI(L *lua.LState, cliamp *lua.LTable) {
|
||||
const maxResponseBody = 1 << 20 // 1MB
|
||||
|
||||
func doHTTP(L *lua.LState, method string) int {
|
||||
url := L.CheckString(1)
|
||||
rawURL := L.CheckString(1)
|
||||
if u, err := url.Parse(rawURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString("only http and https URLs are allowed"))
|
||||
return 2
|
||||
}
|
||||
opts := L.OptTable(2, nil)
|
||||
|
||||
var bodyReader io.Reader
|
||||
@@ -57,7 +91,7 @@ func doHTTP(L *lua.LState, method string) int {
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
req, err := http.NewRequest(method, rawURL, bodyReader)
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
|
||||
+11
-2
@@ -47,15 +47,24 @@ func (m *Manager) KeyBindings() []KeyBinding {
|
||||
// dispatcher for keys the core doesn't handle.
|
||||
func (m *Manager) EmitKey(key string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.closing {
|
||||
return false
|
||||
}
|
||||
hooks := m.keyBinds[key]
|
||||
m.mu.RUnlock()
|
||||
if len(hooks) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
label := "keybind " + key
|
||||
for _, h := range hooks {
|
||||
go m.invokeHook(h, label, lua.LString(key))
|
||||
// Tracked in wg and gated on closing (same as Emit) so a keypress
|
||||
// during shutdown can't call into a closed LState.
|
||||
m.wg.Add(1)
|
||||
go func(h *luaHook) {
|
||||
defer m.wg.Done()
|
||||
m.invokeHook(h, label, lua.LString(key))
|
||||
}(h)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
+2
-10
@@ -123,11 +123,7 @@ func registerTimerAPI(L *lua.LState, cliamp *lua.LTable, tm *timerManager, p *Pl
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
_ = L.CallByParam(lua.P{
|
||||
Fn: fn,
|
||||
NRet: 0,
|
||||
Protect: true,
|
||||
})
|
||||
_ = p.callBounded(0, fn)
|
||||
p.mu.Unlock()
|
||||
case <-e.done:
|
||||
}
|
||||
@@ -156,11 +152,7 @@ func registerTimerAPI(L *lua.LState, cliamp *lua.LTable, tm *timerManager, p *Pl
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
_ = L.CallByParam(lua.P{
|
||||
Fn: fn,
|
||||
NRet: 0,
|
||||
Protect: true,
|
||||
})
|
||||
_ = p.callBounded(0, fn)
|
||||
p.mu.Unlock()
|
||||
case <-e.done:
|
||||
return
|
||||
|
||||
+23
-3
@@ -34,6 +34,17 @@ type luaHook struct {
|
||||
fn *lua.LFunction
|
||||
}
|
||||
|
||||
// callBounded runs fn on the plugin's LState under hookTimeout so a runaway
|
||||
// callback cannot hold the plugin mutex forever. The caller must already hold
|
||||
// p.mu. Results (if nret > 0) are left on the stack for the caller to read.
|
||||
func (p *Plugin) callBounded(nret int, fn *lua.LFunction, args ...lua.LValue) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hookTimeout)
|
||||
defer cancel()
|
||||
p.L.SetContext(ctx)
|
||||
defer p.L.RemoveContext()
|
||||
return p.L.CallByParam(lua.P{Fn: fn, NRet: nret, Protect: true}, args...)
|
||||
}
|
||||
|
||||
// invokeHook calls a plugin's Lua callback under the plugin's mutex with a
|
||||
// bounded context. Logs any error to the plugin log. Used by every dispatch
|
||||
// site that fires Lua from Go (events, key binds, command handlers).
|
||||
@@ -79,12 +90,21 @@ func filterOutPlugin(hooks []*luaHook, p *Plugin) []*luaHook {
|
||||
// mutex serializes all LState access so concurrent events are safe.
|
||||
func (m *Manager) Emit(event string, data map[string]any) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.closing {
|
||||
return
|
||||
}
|
||||
hooks := m.hooks[event]
|
||||
m.mu.RUnlock()
|
||||
|
||||
label := event + " handler"
|
||||
for _, h := range hooks {
|
||||
go m.invokeHookWithData(h, label, data)
|
||||
// Add under RLock so Close (which sets closing under Lock, then Wait)
|
||||
// can never miss an in-flight goroutine: either we register it before
|
||||
// closing is set, or we observe closing and skip.
|
||||
m.wg.Add(1)
|
||||
go func(h *luaHook) {
|
||||
defer m.wg.Done()
|
||||
m.invokeHookWithData(h, label, data)
|
||||
}(h)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ type Manager struct {
|
||||
execs *execManager
|
||||
logger *pluginLogger
|
||||
mu sync.RWMutex
|
||||
closing bool // set under mu.Lock during Close; blocks new async dispatch
|
||||
wg sync.WaitGroup // tracks in-flight async Emit goroutines
|
||||
}
|
||||
|
||||
// New scans the plugin directory and loads all .lua files.
|
||||
@@ -422,9 +424,17 @@ func (m *Manager) SetUIProvider(up UIProvider) {
|
||||
|
||||
// Close fires the "app.quit" event synchronously and shuts down all Lua VMs.
|
||||
func (m *Manager) Close() {
|
||||
// Block new async dispatch before tearing anything down.
|
||||
m.mu.Lock()
|
||||
m.closing = true
|
||||
m.mu.Unlock()
|
||||
|
||||
m.EmitSync(EventAppQuit, nil)
|
||||
m.timers.stopAll()
|
||||
m.execs.stopAll()
|
||||
// Wait for any in-flight async hook goroutines to finish before closing
|
||||
// the LStates they call into.
|
||||
m.wg.Wait()
|
||||
if m.logger != nil {
|
||||
m.logger.close()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ package luaplugin
|
||||
|
||||
import lua "github.com/yuin/gopher-lua"
|
||||
|
||||
// Sandbox applies the plugin sandbox to L. It is exported for tools that load
|
||||
// plugin files outside the runtime (e.g. pluginmgr metadata extraction) and
|
||||
// must apply the same stdlib restrictions before executing untrusted code.
|
||||
func Sandbox(L *lua.LState) { sandbox(L) }
|
||||
|
||||
// sandbox removes dangerous Lua standard library functions from the VM,
|
||||
// leaving only safe operations available to plugins. It also adds
|
||||
// compatibility helpers missing from Lua 5.1 (e.g. utf8.char).
|
||||
|
||||
+3
-15
@@ -69,11 +69,7 @@ func (m *Manager) InitVis(name string, rows, cols int) {
|
||||
vis.plugin.mu.Lock()
|
||||
defer vis.plugin.mu.Unlock()
|
||||
|
||||
_ = vis.plugin.L.CallByParam(lua.P{
|
||||
Fn: vis.init,
|
||||
NRet: 0,
|
||||
Protect: true,
|
||||
}, vis.obj, lua.LNumber(rows), lua.LNumber(cols))
|
||||
_ = vis.plugin.callBounded(0, vis.init, vis.obj, lua.LNumber(rows), lua.LNumber(cols))
|
||||
}
|
||||
|
||||
// DestroyVis calls a Lua visualizer's destroy() if it exists.
|
||||
@@ -88,11 +84,7 @@ func (m *Manager) DestroyVis(name string) {
|
||||
vis.plugin.mu.Lock()
|
||||
defer vis.plugin.mu.Unlock()
|
||||
|
||||
_ = vis.plugin.L.CallByParam(lua.P{
|
||||
Fn: vis.destroy,
|
||||
NRet: 0,
|
||||
Protect: true,
|
||||
}, vis.obj)
|
||||
_ = vis.plugin.callBounded(0, vis.destroy, vis.obj)
|
||||
}
|
||||
|
||||
// RenderVis calls a Lua visualizer's render(bands, frame) and returns
|
||||
@@ -116,11 +108,7 @@ func (m *Manager) RenderVis(name string, bands [10]float64, rows, cols int, fram
|
||||
tbl.RawSetInt(i+1, lua.LNumber(b))
|
||||
}
|
||||
|
||||
err := L.CallByParam(lua.P{
|
||||
Fn: vis.render,
|
||||
NRet: 1,
|
||||
Protect: true,
|
||||
}, vis.obj, tbl, lua.LNumber(frame), lua.LNumber(rows), lua.LNumber(cols))
|
||||
err := vis.plugin.callBounded(1, vis.render, vis.obj, tbl, lua.LNumber(frame), lua.LNumber(rows), lua.LNumber(cols))
|
||||
if err != nil {
|
||||
return vis.last
|
||||
}
|
||||
|
||||
+12
-1
@@ -51,7 +51,18 @@ var lrcRegex = regexp.MustCompile(`\[(\d{2,}):(\d{2})\.(\d{2,3})\](.*)`)
|
||||
|
||||
// cleanQuery strips noise from a search query: bracketed text like "[Official Video]",
|
||||
// parenthesized text like "(Lyric Video)", and common video/audio label suffixes.
|
||||
var noiseRegex = regexp.MustCompile(`(?i)(?:\[.*?\]|\(.*?\)|-?\s*(?:official|lyric|audio|video).*)`)
|
||||
//
|
||||
// The label words are only stripped when they form a genuine trailing label
|
||||
// (after a dash, or as an "official video/audio" / "lyric(s) video" phrase),
|
||||
// never as a bare substring. Otherwise legitimate titles like "Videotape",
|
||||
// "Audioslave", or "Video Games" would be erased.
|
||||
var noiseRegex = regexp.MustCompile(`(?i)(?:` +
|
||||
`\[.*?\]` + // [Official Video]
|
||||
`|\(.*?\)` + // (Lyric Video)
|
||||
`|\s*-\s*(?:official|lyric|audio|video).*` + // - Official Video
|
||||
`|\s+official(?:\s+music)?\s+(?:video|audio).*` + // Official Music Video
|
||||
`|\s+lyrics?\s+video.*` + // Lyric Video / Lyrics Video
|
||||
`)`)
|
||||
|
||||
func cleanQuery(str string) string {
|
||||
s := noiseRegex.ReplaceAllString(str, "")
|
||||
|
||||
@@ -87,7 +87,13 @@ func TestCleanQuery(t *testing.T) {
|
||||
{"Artist - Song (Official Video)", "Artist - Song"},
|
||||
{"Song [Lyric Video]", "Song"},
|
||||
{"Song - Official Audio", "Song"},
|
||||
{"Song Official Music Video", "Song"},
|
||||
{"Clean Title", "Clean Title"},
|
||||
// Label words inside a real title must survive (regression cases).
|
||||
{"Videotape", "Videotape"},
|
||||
{"Audioslave", "Audioslave"},
|
||||
{"Video Games", "Video Games"},
|
||||
{"No Lyric", "No Lyric"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -363,7 +363,9 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
|
||||
}
|
||||
|
||||
svc, svcErr := wireMediaCtl(prog)
|
||||
if svcErr == nil && svc != nil {
|
||||
if svcErr != nil {
|
||||
applog.Warn("media control (MPRIS/NowPlaying) unavailable: %v", svcErr)
|
||||
} else if svc != nil {
|
||||
defer svc.Close()
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -1,14 +1,23 @@
|
||||
package mediactl
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
|
||||
"cliamp/internal/playback"
|
||||
)
|
||||
|
||||
func makeMetadata(t playback.Track) map[string]dbus.Variant {
|
||||
// trackPath returns a unique MPRIS track object path for a sequence number.
|
||||
// Unique per-track ids let SetPosition reject seeks aimed at a track that is
|
||||
// no longer current.
|
||||
func trackPath(seq int64) dbus.ObjectPath {
|
||||
return dbus.ObjectPath("/org/mpris/MediaPlayer2/Track/" + strconv.FormatInt(seq, 10))
|
||||
}
|
||||
|
||||
func makeMetadata(t playback.Track, trackID dbus.ObjectPath) map[string]dbus.Variant {
|
||||
m := map[string]dbus.Variant{
|
||||
"mpris:trackid": dbus.MakeVariant(dbus.ObjectPath("/org/mpris/MediaPlayer2/Track/1")),
|
||||
"mpris:trackid": dbus.MakeVariant(trackID),
|
||||
}
|
||||
if t.Title != "" {
|
||||
m["xesam:title"] = dbus.MakeVariant(t.Title)
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
|
||||
Duration: 3*time.Minute + 15*time.Second,
|
||||
}
|
||||
|
||||
got := makeMetadata(track)
|
||||
got := makeMetadata(track, trackPath(1))
|
||||
|
||||
want := map[string]dbus.Variant{
|
||||
"mpris:trackid": dbus.MakeVariant(dbus.ObjectPath("/org/mpris/MediaPlayer2/Track/1")),
|
||||
@@ -49,7 +49,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMakeMetadataOmitsEmptyOptionalFields(t *testing.T) {
|
||||
got := makeMetadata(playback.Track{})
|
||||
got := makeMetadata(playback.Track{}, trackPath(1))
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("metadata field count = %d, want 1", len(got))
|
||||
|
||||
@@ -29,6 +29,8 @@ type Service struct {
|
||||
lastTrack playback.Track
|
||||
lastVol float64
|
||||
lastCanSeek bool
|
||||
trackSeq int64 // bumped on each track change
|
||||
trackID dbus.ObjectPath // current track's MPRIS object path
|
||||
}
|
||||
|
||||
const introspectXML = `
|
||||
@@ -115,6 +117,14 @@ func (p playerIface) DoSeek(offset int64) *dbus.Error {
|
||||
}
|
||||
|
||||
func (p playerIface) SetPosition(trackID dbus.ObjectPath, position int64) *dbus.Error {
|
||||
// Ignore a seek aimed at a track that is no longer current (the MPRIS
|
||||
// spec treats a mismatched TrackId as stale).
|
||||
p.svc.mu.Lock()
|
||||
cur := p.svc.trackID
|
||||
p.svc.mu.Unlock()
|
||||
if trackID != cur {
|
||||
return nil
|
||||
}
|
||||
p.svc.send(playback.SetPositionMsg{Position: time.Duration(position) * time.Microsecond})
|
||||
return nil
|
||||
}
|
||||
@@ -136,7 +146,7 @@ func New(send func(tea.Msg)) (*Service, error) {
|
||||
return nil, fmt.Errorf("mpris: name already taken")
|
||||
}
|
||||
|
||||
svc := &Service{conn: conn, send: send}
|
||||
svc := &Service{conn: conn, send: send, trackSeq: 1, trackID: trackPath(1)}
|
||||
path := dbus.ObjectPath("/org/mpris/MediaPlayer2")
|
||||
|
||||
if err := conn.Export(root{svc}, path, "org.mpris.MediaPlayer2"); err != nil {
|
||||
@@ -166,7 +176,7 @@ func New(send func(tea.Msg)) (*Service, error) {
|
||||
},
|
||||
"org.mpris.MediaPlayer2.Player": {
|
||||
"PlaybackStatus": {Value: string(playback.StatusStopped), Writable: false, Emit: prop.EmitTrue},
|
||||
"Metadata": {Value: makeMetadata(playback.Track{}), Writable: false, Emit: prop.EmitTrue},
|
||||
"Metadata": {Value: makeMetadata(playback.Track{}, svc.trackID), Writable: false, Emit: prop.EmitTrue},
|
||||
"Volume": {Value: 1.0, Writable: true, Emit: prop.EmitTrue, Callback: func(c *prop.Change) *dbus.Error {
|
||||
v, ok := c.Value.(float64)
|
||||
if !ok {
|
||||
@@ -178,7 +188,10 @@ func New(send func(tea.Msg)) (*Service, error) {
|
||||
if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
go svc.send(playback.SetVolumeMsg{VolumeDB: linearToDb(v)})
|
||||
// Send synchronously: an extra goroutine per change lets rapid
|
||||
// volume updates apply out of order. send (prog.Send) is already
|
||||
// goroutine-safe and non-blocking.
|
||||
svc.send(playback.SetVolumeMsg{VolumeDB: linearToDb(v)})
|
||||
return nil
|
||||
}},
|
||||
"Position": {Value: int64(0), Writable: false, Emit: prop.EmitFalse},
|
||||
@@ -222,7 +235,9 @@ func (s *Service) Update(state playback.State) {
|
||||
}
|
||||
|
||||
if state.Track != s.lastTrack {
|
||||
s.props.SetMust(iface, "Metadata", makeMetadata(state.Track))
|
||||
s.trackSeq++
|
||||
s.trackID = trackPath(s.trackSeq)
|
||||
s.props.SetMust(iface, "Metadata", makeMetadata(state.Track, s.trackID))
|
||||
s.lastTrack = state.Track
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package player
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -83,6 +85,12 @@ func decodeFFmpeg(path string, sr beep.SampleRate, bitDepth int) (beep.StreamSee
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// cmd.Output captures stderr into ExitError.Stderr; surface it since
|
||||
// ffmpeg writes the actual failure reason there (-loglevel error).
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg decode: %w: %s", err, bytes.TrimSpace(ee.Stderr))
|
||||
}
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg decode: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+1
-5
@@ -238,9 +238,5 @@ func (ss *speedStreamer) searchBestOffset(expected int) int {
|
||||
|
||||
// Err forwards to the wrapped streamer's error method.
|
||||
func (ss *speedStreamer) Err() error {
|
||||
type errorer interface{ Err() error }
|
||||
if e, ok := ss.s.(errorer); ok {
|
||||
return e.Err()
|
||||
}
|
||||
return nil
|
||||
return ss.s.Err()
|
||||
}
|
||||
|
||||
+83
-7
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RepeatMode controls playlist repeat behavior.
|
||||
@@ -296,7 +297,10 @@ func (t Track) DisplayName() string {
|
||||
}
|
||||
|
||||
// Playlist manages an ordered list of tracks with shuffle and repeat support.
|
||||
// All exported methods are safe for concurrent use: the Bubbletea UI loop
|
||||
// mutates the playlist while Lua plugin goroutines read state through it.
|
||||
type Playlist struct {
|
||||
mu sync.Mutex
|
||||
tracks []Track
|
||||
order []int // indices into tracks, shuffled or sequential
|
||||
pos int // current position in order
|
||||
@@ -314,6 +318,8 @@ func New() *Playlist {
|
||||
// Replace clears the playlist and loads the given tracks, resetting
|
||||
// position, queue, and shuffle order.
|
||||
func (p *Playlist) Replace(tracks []Track) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.tracks = tracks
|
||||
p.order = make([]int, len(tracks))
|
||||
for i := range tracks {
|
||||
@@ -329,6 +335,8 @@ func (p *Playlist) Replace(tracks []Track) {
|
||||
|
||||
// Add appends tracks to the playlist.
|
||||
func (p *Playlist) Add(tracks ...Track) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
start := len(p.tracks)
|
||||
p.tracks = append(p.tracks, tracks...)
|
||||
for i := start; i < len(p.tracks); i++ {
|
||||
@@ -364,7 +372,11 @@ func (p *Playlist) Add(tracks ...Track) {
|
||||
}
|
||||
|
||||
// Len returns the number of tracks.
|
||||
func (p *Playlist) Len() int { return len(p.tracks) }
|
||||
func (p *Playlist) Len() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.tracks)
|
||||
}
|
||||
|
||||
func (p *Playlist) currentTrackIndex() int {
|
||||
if len(p.order) == 0 {
|
||||
@@ -465,6 +477,8 @@ func (p *Playlist) resolveSelectedPlayablePos() (orderPos int, trackIdx int, ok
|
||||
|
||||
// Current returns the currently selected track and its index.
|
||||
func (p *Playlist) Current() (Track, int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.tracks) == 0 {
|
||||
return Track{}, -1
|
||||
}
|
||||
@@ -474,10 +488,14 @@ func (p *Playlist) Current() (Track, int) {
|
||||
|
||||
// Index returns the track index of the current position.
|
||||
func (p *Playlist) Index() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.currentTrackIndex()
|
||||
}
|
||||
|
||||
func (p *Playlist) CurrentIsQueued() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.queuedIdx >= 0
|
||||
}
|
||||
|
||||
@@ -496,6 +514,8 @@ type SelectionActivation struct {
|
||||
// Queue state is ignored for candidate selection and left unchanged. If no
|
||||
// playable track can be activated, playlist state is unchanged.
|
||||
func (p *Playlist) ActivateSelected() (SelectionActivation, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
selectedPos := p.pos
|
||||
orderPos, idx, ok := p.resolveSelectedPlayablePos()
|
||||
if !ok {
|
||||
@@ -514,6 +534,8 @@ func (p *Playlist) ActivateSelected() (SelectionActivation, bool) {
|
||||
// Unplayable queued entries are pruned as playback advances. RepeatOne still
|
||||
// limits playback to the current track.
|
||||
func (p *Playlist) Next() (Track, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.tracks) == 0 {
|
||||
return Track{}, false
|
||||
}
|
||||
@@ -553,6 +575,8 @@ func (p *Playlist) Next() (Track, bool) {
|
||||
// PeekNext returns the next track without advancing the playlist position.
|
||||
// Returns false when the next track can't be predicted (e.g., shuffle wrap).
|
||||
func (p *Playlist) PeekNext() (Track, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.tracks) == 0 {
|
||||
return Track{}, false
|
||||
}
|
||||
@@ -579,6 +603,8 @@ func (p *Playlist) PeekNext() (Track, bool) {
|
||||
// Prev moves to the previous track, skipping unavailable tracks.
|
||||
// Wraps around with RepeatAll.
|
||||
func (p *Playlist) Prev() (Track, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.tracks) == 0 {
|
||||
return Track{}, false
|
||||
}
|
||||
@@ -606,6 +632,8 @@ func (p *Playlist) Prev() (Track, bool) {
|
||||
|
||||
// SetIndex sets the current position to the given track index.
|
||||
func (p *Playlist) SetIndex(i int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.queuedIdx = -1
|
||||
for pos, idx := range p.order {
|
||||
if idx == i {
|
||||
@@ -617,6 +645,8 @@ func (p *Playlist) SetIndex(i int) {
|
||||
|
||||
// Queue adds a track to the play-next queue by its index.
|
||||
func (p *Playlist) Queue(trackIdx int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if trackIdx >= 0 && trackIdx < len(p.tracks) {
|
||||
p.queue = append(p.queue, trackIdx)
|
||||
}
|
||||
@@ -624,6 +654,8 @@ func (p *Playlist) Queue(trackIdx int) {
|
||||
|
||||
// Dequeue removes a track from the queue. Returns true if it was found.
|
||||
func (p *Playlist) Dequeue(trackIdx int) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for i, idx := range p.queue {
|
||||
if idx == trackIdx {
|
||||
p.queue = slices.Delete(p.queue, i, i+1)
|
||||
@@ -636,6 +668,8 @@ func (p *Playlist) Dequeue(trackIdx int) bool {
|
||||
// QueuePosition returns the 1-based position of a track in the queue,
|
||||
// or 0 if the track is not queued.
|
||||
func (p *Playlist) QueuePosition(trackIdx int) int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for i, idx := range p.queue {
|
||||
if idx == trackIdx {
|
||||
return i + 1
|
||||
@@ -645,10 +679,16 @@ func (p *Playlist) QueuePosition(trackIdx int) int {
|
||||
}
|
||||
|
||||
// QueueLen returns the number of tracks in the queue.
|
||||
func (p *Playlist) QueueLen() int { return len(p.queue) }
|
||||
func (p *Playlist) QueueLen() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.queue)
|
||||
}
|
||||
|
||||
// QueueTracks returns copies of the tracks in queue order.
|
||||
func (p *Playlist) QueueTracks() []Track {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]Track, len(p.queue))
|
||||
for i, idx := range p.queue {
|
||||
out[i] = p.tracks[idx]
|
||||
@@ -657,17 +697,25 @@ func (p *Playlist) QueueTracks() []Track {
|
||||
}
|
||||
|
||||
// ClearQueue removes all entries from the play-next queue.
|
||||
func (p *Playlist) ClearQueue() { p.queue = nil }
|
||||
func (p *Playlist) ClearQueue() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.queue = nil
|
||||
}
|
||||
|
||||
// RemoveQueueAt removes the entry at the given 0-based queue position.
|
||||
func (p *Playlist) RemoveQueueAt(pos int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if pos >= 0 && pos < len(p.queue) {
|
||||
p.queue = slices.Delete(p.queue, pos, pos+1)
|
||||
}
|
||||
}
|
||||
|
||||
// MoveQueue swaps two adjacent entries in the play-next queue by position.
|
||||
// MoveQueue swaps the two entries at the given positions in the play-next queue.
|
||||
func (p *Playlist) MoveQueue(from, to int) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if from < 0 || from >= len(p.queue) || to < 0 || to >= len(p.queue) || from == to {
|
||||
return false
|
||||
}
|
||||
@@ -679,6 +727,8 @@ func (p *Playlist) MoveQueue(from, to int) bool {
|
||||
// updating order, queue, and position references so playback is unaffected.
|
||||
// When shuffle is off, the visual order becomes the new playback order.
|
||||
func (p *Playlist) Move(from, to int) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if from < 0 || from >= len(p.tracks) || to < 0 || to >= len(p.tracks) || from == to {
|
||||
return false
|
||||
}
|
||||
@@ -727,6 +777,8 @@ func (p *Playlist) Move(from, to int) bool {
|
||||
// track was removed. If the removed track was the active one, the position
|
||||
// stays at the same order slot so playback advances naturally on next.
|
||||
func (p *Playlist) Remove(idx int) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if idx < 0 || idx >= len(p.tracks) {
|
||||
return false
|
||||
}
|
||||
@@ -781,16 +833,24 @@ func (p *Playlist) Remove(idx int) bool {
|
||||
|
||||
// SetTrack replaces the track at index i.
|
||||
func (p *Playlist) SetTrack(i int, t Track) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if i >= 0 && i < len(p.tracks) {
|
||||
p.tracks[i] = t
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks returns all tracks in the playlist.
|
||||
func (p *Playlist) Tracks() []Track { return p.tracks }
|
||||
func (p *Playlist) Tracks() []Track {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.tracks
|
||||
}
|
||||
|
||||
// ToggleBookmark flips the Bookmark flag on the track at the given index.
|
||||
func (p *Playlist) ToggleBookmark(idx int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if idx >= 0 && idx < len(p.tracks) {
|
||||
p.tracks[idx].Bookmark = !p.tracks[idx].Bookmark
|
||||
}
|
||||
@@ -798,6 +858,8 @@ func (p *Playlist) ToggleBookmark(idx int) {
|
||||
|
||||
// BookmarkCount returns the number of bookmarked tracks.
|
||||
func (p *Playlist) BookmarkCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
n := 0
|
||||
for _, t := range p.tracks {
|
||||
if t.Bookmark {
|
||||
@@ -810,6 +872,8 @@ func (p *Playlist) BookmarkCount() int {
|
||||
// ToggleShuffle enables or disables shuffle mode.
|
||||
// Uses Fisher-Yates shuffle, preserving the current track at position 0.
|
||||
func (p *Playlist) ToggleShuffle() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.shuffle = !p.shuffle
|
||||
if len(p.tracks) == 0 {
|
||||
return
|
||||
@@ -846,16 +910,28 @@ func (p *Playlist) doShuffle() {
|
||||
|
||||
// CycleRepeat cycles through Off -> All -> One.
|
||||
func (p *Playlist) CycleRepeat() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.repeat = (p.repeat + 1) % 3
|
||||
}
|
||||
|
||||
// SetRepeat sets the repeat mode directly.
|
||||
func (p *Playlist) SetRepeat(mode RepeatMode) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.repeat = mode
|
||||
}
|
||||
|
||||
// Shuffled returns whether shuffle is enabled.
|
||||
func (p *Playlist) Shuffled() bool { return p.shuffle }
|
||||
func (p *Playlist) Shuffled() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.shuffle
|
||||
}
|
||||
|
||||
// Repeat returns the current repeat mode.
|
||||
func (p *Playlist) Repeat() RepeatMode { return p.repeat }
|
||||
func (p *Playlist) Repeat() RepeatMode {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.repeat
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
|
||||
"cliamp/internal/appdir"
|
||||
"cliamp/luaplugin"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
@@ -160,6 +161,9 @@ func download(url string) ([]byte, error) {
|
||||
if len(body) > maxPluginSize {
|
||||
return nil, fmt.Errorf("plugin too large (max %d bytes)", maxPluginSize)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("empty response body")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -203,6 +207,11 @@ func extractMetadata(path string) pluginInfo {
|
||||
L := lua.NewState(lua.Options{SkipOpenLibs: false})
|
||||
defer L.Close()
|
||||
|
||||
// Apply the same sandbox as the runtime: extractMetadata runs DoFile on
|
||||
// the whole plugin file (not just register()), so top-level code must not
|
||||
// have access to os.execute/io/dofile when merely listing plugins.
|
||||
luaplugin.Sandbox(L)
|
||||
|
||||
var info pluginInfo
|
||||
|
||||
// Stub out plugin.register() to capture metadata without side effects.
|
||||
|
||||
@@ -27,6 +27,9 @@ func resolveSource(source string) (urls []string, name string, err error) {
|
||||
}
|
||||
base := path.Base(u.Path)
|
||||
name = strings.TrimSuffix(base, ".lua")
|
||||
if name == "" || name == "." || name == "/" {
|
||||
return nil, "", fmt.Errorf("cannot derive a plugin name from URL %q; it should end in <name>.lua", source)
|
||||
}
|
||||
return []string{source}, name, nil
|
||||
}
|
||||
|
||||
|
||||
+19
-2
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
@@ -52,6 +53,15 @@ var httpClient = &http.Client{
|
||||
Transport: &uaTransport{rt: http.DefaultTransport},
|
||||
}
|
||||
|
||||
// sniffClient probes content types during Args classification, which runs on
|
||||
// the startup path before the TUI launches. It uses a short timeout so a slow
|
||||
// or unresponsive server can stall startup by at most a few seconds rather
|
||||
// than the 30s the feed/M3U client allows.
|
||||
var sniffClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &uaTransport{rt: http.DefaultTransport},
|
||||
}
|
||||
|
||||
// uaTransport injects the cliamp User-Agent header into every request.
|
||||
type uaTransport struct{ rt http.RoundTripper }
|
||||
|
||||
@@ -190,7 +200,7 @@ func sniffFeedURL(rawURL string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := httpClient.Head(rawURL)
|
||||
resp, err := sniffClient.Head(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -290,7 +300,9 @@ func resolveFeed(feedURL string) ([]playlist.Track, error) {
|
||||
} `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&rss); err != nil {
|
||||
// Bound the read so a huge or malicious feed can't exhaust memory.
|
||||
const maxFeedBody = 32 << 20 // 32 MB
|
||||
if err := xml.NewDecoder(io.LimitReader(resp.Body, maxFeedBody)).Decode(&rss); err != nil {
|
||||
return nil, fmt.Errorf("parsing feed: %w", err)
|
||||
}
|
||||
|
||||
@@ -614,6 +626,11 @@ func parseItunesDuration(s string) int {
|
||||
}
|
||||
|
||||
// humanizeBasename converts a URL basename like "clr-podcast-467" into "clr podcast 467".
|
||||
// A trailing known audio extension (e.g. "track.mp3") is dropped so it doesn't
|
||||
// leak into the title; non-media suffixes (e.g. "3.5-remix") are left intact.
|
||||
func humanizeBasename(s string) string {
|
||||
if ext := filepath.Ext(s); ext != "" && player.SupportedExts[strings.ToLower(ext)] {
|
||||
s = strings.TrimSuffix(s, ext)
|
||||
}
|
||||
return strings.ReplaceAll(s, "-", " ")
|
||||
}
|
||||
|
||||
+3
-4
@@ -922,10 +922,9 @@ func (m *Model) handleJumpKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
m.player.Seek(target - m.player.Position())
|
||||
m.notifyPlayback()
|
||||
if m.notifier != nil {
|
||||
m.notifier.Seeked(m.player.Position())
|
||||
}
|
||||
// finishSeek notifies plugins as well as MPRIS, matching every other
|
||||
// completed seek; the previous manual block skipped Lua plugins.
|
||||
m.finishSeek()
|
||||
m.closeJumpMode()
|
||||
return nil
|
||||
case tea.KeyBackspace:
|
||||
|
||||
+10
-1
@@ -216,7 +216,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.reconnect.at = time.Time{}
|
||||
m.player.Stop()
|
||||
if track, idx := m.playlist.Current(); idx >= 0 {
|
||||
return m, tea.Batch(m.playTrack(track), tickCmdAt(ui.TickFast))
|
||||
// Preserve any seek/lyric commands already queued this tick
|
||||
// rather than dropping them on the early return.
|
||||
batch := []tea.Cmd{m.playTrack(track), tickCmdAt(ui.TickFast)}
|
||||
if seekCmd != nil {
|
||||
batch = append(batch, seekCmd)
|
||||
}
|
||||
if lyricCmd != nil {
|
||||
batch = append(batch, lyricCmd)
|
||||
}
|
||||
return m, tea.Batch(batch...)
|
||||
}
|
||||
}
|
||||
var cmds []tea.Cmd
|
||||
|
||||
@@ -352,6 +352,16 @@ func (m Model) albumSeparator(album string, year int) string {
|
||||
return dimStyle.Render(labeledSeparator("", label))
|
||||
}
|
||||
|
||||
// navFilteredTotal returns the count to show in a nav list footer: the number
|
||||
// of filter matches when a search filter is active (whether the input bar is
|
||||
// open or the query was committed with Enter), otherwise the full count.
|
||||
func (m Model) navFilteredTotal(full int) int {
|
||||
if len(m.navBrowser.searchIdx) > 0 || m.navBrowser.search != "" {
|
||||
return len(m.navBrowser.searchIdx)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
// navScrollItems renders a filtered or unfiltered scrolled list for nav browsers.
|
||||
func (m Model) navScrollItems(total int, labelFn func(int) string) []string {
|
||||
maxVisible := max(m.plVisible, 5)
|
||||
|
||||
+7
-14
@@ -85,14 +85,12 @@ func (m Model) renderNavArtistList() []string {
|
||||
})
|
||||
lines = append(lines, items...)
|
||||
|
||||
rendered := min(len(m.navBrowser.artists)-m.navBrowser.scroll, max(m.plVisible, 5))
|
||||
total := m.navFilteredTotal(len(m.navBrowser.artists))
|
||||
rendered := min(total-m.navBrowser.scroll, max(m.plVisible, 5))
|
||||
if rendered < 0 {
|
||||
rendered = 0
|
||||
}
|
||||
footerCount := fmt.Sprintf("%d/%d", rendered, len(m.navBrowser.artists))
|
||||
if m.navBrowser.searching && m.navBrowser.search != "" {
|
||||
footerCount = fmt.Sprintf("%d/%d", len(m.navBrowser.artists), len(m.navBrowser.artists))
|
||||
}
|
||||
footerCount := fmt.Sprintf("%d/%d", rendered, total)
|
||||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %s artists", footerCount)),
|
||||
"", helpKey("←↓↑→", "Navigate ")+helpKey("Enter", "Open ")+helpKey("/", "Search"))
|
||||
|
||||
@@ -150,14 +148,12 @@ func (m Model) renderNavAlbumList(artistAlbums bool) []string {
|
||||
if m.navBrowser.albumLoading {
|
||||
lines = append(lines, loadingLine("Loading more…"))
|
||||
} else {
|
||||
rendered := min(len(m.navBrowser.albums)-m.navBrowser.scroll, max(m.plVisible, 5))
|
||||
total := m.navFilteredTotal(len(m.navBrowser.albums))
|
||||
rendered := min(total-m.navBrowser.scroll, max(m.plVisible, 5))
|
||||
if rendered < 0 {
|
||||
rendered = 0
|
||||
}
|
||||
footerCount := fmt.Sprintf("%d/%d", rendered, len(m.navBrowser.albums))
|
||||
if m.navBrowser.searching && m.navBrowser.search != "" {
|
||||
footerCount = fmt.Sprintf("%d/%d", len(m.navBrowser.albums), len(m.navBrowser.albums))
|
||||
}
|
||||
footerCount := fmt.Sprintf("%d/%d", rendered, total)
|
||||
lines = append(lines, dimStyle.Render(fmt.Sprintf(" %s albums", footerCount)))
|
||||
}
|
||||
|
||||
@@ -240,10 +236,7 @@ func (m Model) renderNavTrackList() []string {
|
||||
lines = padLines(lines, maxVisible, rendered)
|
||||
}
|
||||
|
||||
footerCount := fmt.Sprintf("%d/%d", rendered, len(m.navBrowser.tracks))
|
||||
if m.navBrowser.searching && m.navBrowser.search != "" {
|
||||
footerCount = fmt.Sprintf("%d/%d", len(m.navBrowser.tracks), len(m.navBrowser.tracks))
|
||||
}
|
||||
footerCount := fmt.Sprintf("%d/%d", rendered, m.navFilteredTotal(len(m.navBrowser.tracks)))
|
||||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %s tracks", footerCount)),
|
||||
"", helpKey("←↓↑→", "Navigate ")+
|
||||
helpKey("Enter", "Play from here ")+
|
||||
|
||||
@@ -15,6 +15,9 @@ func (v *Visualizer) renderBubbles(bands []float64) string {
|
||||
height := v.Rows
|
||||
dotRows := height * 4
|
||||
dotCols := PanelWidth * 2
|
||||
if dotRows < 4 || dotCols < 4 {
|
||||
return strings.Repeat("\n", max(0, height-1))
|
||||
}
|
||||
|
||||
grid := make([]bool, dotRows*dotCols)
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ func (v *Visualizer) renderFirework(bands []float64) string {
|
||||
height := v.Rows
|
||||
dotRows := height * 4
|
||||
dotCols := PanelWidth * 2
|
||||
if dotRows < 4 || dotCols < 4 {
|
||||
return strings.Repeat("\n", max(0, height-1))
|
||||
}
|
||||
|
||||
grid := make([]bool, dotRows*dotCols)
|
||||
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -170,7 +169,7 @@ func (d *flameDriver) Render(v *Visualizer) string {
|
||||
// tips → red, stippled above
|
||||
var t int
|
||||
switch {
|
||||
case h >= math.Min(0.65, 0.55):
|
||||
case h >= 0.55:
|
||||
t = 1 // yellow core
|
||||
default:
|
||||
t = 2 // red body / tips
|
||||
|
||||
@@ -30,6 +30,9 @@ func (v *Visualizer) renderSakura(bands []float64) string {
|
||||
height := v.Rows
|
||||
dotRows := height * 4
|
||||
dotCols := PanelWidth * 2
|
||||
if dotRows < 4 || dotCols < 4 {
|
||||
return strings.Repeat("\n", max(0, height-1))
|
||||
}
|
||||
|
||||
grid := make([]bool, dotRows*dotCols)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user