chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package appdir
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Dir returns the cliamp configuration directory.
|
||||
//
|
||||
// Resolution order:
|
||||
// - CLIAMP_CONFIG_DIR (explicit override)
|
||||
// - XDG_CONFIG_HOME/cliamp
|
||||
// - HOME/.config/cliamp
|
||||
// - on Windows: APPDATA/cliamp
|
||||
// - fallback: os.UserHomeDir()/.config/cliamp
|
||||
func Dir() (string, error) {
|
||||
if dir, ok := os.LookupEnv("CLIAMP_CONFIG_DIR"); ok && dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
if xdg, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok && xdg != "" {
|
||||
return filepath.Join(xdg, "cliamp"), nil
|
||||
}
|
||||
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
|
||||
return filepath.Join(home, ".config", "cliamp"), nil
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if appData, ok := os.LookupEnv("APPDATA"); ok && appData != "" {
|
||||
return filepath.Join(appData, "cliamp"), nil
|
||||
}
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "cliamp"), nil
|
||||
}
|
||||
|
||||
// PluginDir returns the cliamp plugin directory.
|
||||
func PluginDir() (string, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "plugins"), nil
|
||||
}
|
||||
|
||||
// DataDir returns the cliamp data directory (~/.local/share/cliamp), used for
|
||||
// state that is not user-edited config: plugin stores, downloaded assets, etc.
|
||||
func DataDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".local", "share", "cliamp"), nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package appdir
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want func(tempDir string) string
|
||||
windowsOnly bool
|
||||
}{
|
||||
{
|
||||
name: "home config",
|
||||
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "APPDATA": "", "HOME": "TEMPDIR"},
|
||||
want: func(tmp string) string { return filepath.Join(tmp, ".config", "cliamp") },
|
||||
},
|
||||
{
|
||||
name: "xdg config",
|
||||
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "HOME": "", "APPDATA": "", "XDG_CONFIG_HOME": "TEMPDIR"},
|
||||
want: func(tmp string) string { return filepath.Join(tmp, "cliamp") },
|
||||
},
|
||||
{
|
||||
name: "appdata on windows when home missing",
|
||||
windowsOnly: true,
|
||||
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "HOME": "", "APPDATA": "TEMPDIR"},
|
||||
want: func(tmp string) string { return filepath.Join(tmp, "cliamp") },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.windowsOnly && runtime.GOOS != "windows" {
|
||||
t.Skip("Windows-specific fallback")
|
||||
}
|
||||
var tempDir string
|
||||
for k, v := range tt.env {
|
||||
if v == "TEMPDIR" {
|
||||
tempDir = t.TempDir()
|
||||
t.Setenv(k, tempDir)
|
||||
} else {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
got, err := Dir()
|
||||
if err != nil {
|
||||
t.Fatalf("Dir() error: %v", err)
|
||||
}
|
||||
want := tt.want(tempDir)
|
||||
if got != want {
|
||||
t.Fatalf("Dir() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDir(t *testing.T) {
|
||||
t.Setenv("CLIAMP_CONFIG_DIR", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("APPDATA", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
dir, err := PluginDir()
|
||||
if err != nil {
|
||||
t.Fatalf("PluginDir() error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(dir, filepath.Join("cliamp", "plugins")) {
|
||||
t.Fatalf("PluginDir() = %q, expected to end with cliamp/plugins", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDirIsSubdirOfDir(t *testing.T) {
|
||||
t.Setenv("CLIAMP_CONFIG_DIR", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("APPDATA", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
base, _ := Dir()
|
||||
plugin, _ := PluginDir()
|
||||
|
||||
if !strings.HasPrefix(plugin, base) {
|
||||
t.Fatalf("PluginDir %q should be under Dir %q", plugin, base)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package appmeta
|
||||
|
||||
var (
|
||||
clientName = "cliamp"
|
||||
deviceName = "cliamp"
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
func SetVersion(v string) {
|
||||
if v != "" {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
|
||||
func ClientName() string { return clientName }
|
||||
|
||||
func DeviceName() string { return deviceName }
|
||||
|
||||
func Version() string { return version }
|
||||
@@ -0,0 +1,33 @@
|
||||
package appmeta
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
if got := ClientName(); got != "cliamp" {
|
||||
t.Fatalf("ClientName() = %q, want %q", got, "cliamp")
|
||||
}
|
||||
if got := DeviceName(); got != "cliamp" {
|
||||
t.Fatalf("DeviceName() = %q, want %q", got, "cliamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVersion(t *testing.T) {
|
||||
original := Version()
|
||||
defer SetVersion(original)
|
||||
|
||||
SetVersion("1.2.3")
|
||||
if got := Version(); got != "1.2.3" {
|
||||
t.Fatalf("Version() = %q, want %q", got, "1.2.3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVersionEmpty(t *testing.T) {
|
||||
original := Version()
|
||||
defer SetVersion(original)
|
||||
|
||||
SetVersion("test")
|
||||
SetVersion("") // should be a no-op
|
||||
if got := Version(); got != "test" {
|
||||
t.Fatalf("empty SetVersion should be no-op, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Open tries to open a URL in the user's default browser.
|
||||
func Open(u string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return exec.Command("open", u).Start()
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", u).Start()
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", u).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenWithUnreachablePATH confirms Open returns a "not found" error when
|
||||
// xdg-open / open / rundll32 cannot be located. We set PATH to an empty
|
||||
// temp dir so the call can't actually spawn a browser — otherwise running
|
||||
// the tests would pop open a real URL in the user's desktop browser.
|
||||
func TestOpenWithUnreachablePATH(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir()) // no executables here
|
||||
err := Open("about:blank")
|
||||
if err == nil {
|
||||
t.Error("Open should error when the dispatcher binary is not on PATH")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenUnsupportedPlatform verifies the default case. We can only reach
|
||||
// it on non-Linux/Darwin/Windows hosts; elsewhere we skip rather than try
|
||||
// to duplicate the switch body.
|
||||
func TestOpenUnsupportedPlatform(t *testing.T) {
|
||||
switch runtime.GOOS {
|
||||
case "linux", "darwin", "windows":
|
||||
t.Skipf("host is %s, cannot exercise unsupported-platform branch without build tags", runtime.GOOS)
|
||||
}
|
||||
err := Open("about:blank")
|
||||
if err == nil {
|
||||
t.Error("Open on unsupported platform should return error")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
"github.com/bjarneo/cliamp/playlist"
|
||||
"github.com/bjarneo/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"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/appmeta"
|
||||
"github.com/bjarneo/cliamp/playlist"
|
||||
"github.com/bjarneo/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"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/appmeta"
|
||||
"github.com/bjarneo/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,64 @@
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WriteFileAtomic replaces path only after data has been written and synced.
|
||||
func WriteFileAtomic(path string, data []byte, perm os.FileMode) (err error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("secure directory: %w", err)
|
||||
}
|
||||
if info, statErr := os.Stat(path); statErr == nil {
|
||||
perm &= info.Mode().Perm()
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return fmt.Errorf("inspect existing file: %w", statErr)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
if tmp != nil {
|
||||
if closeErr := tmp.Close(); err == nil && closeErr != nil {
|
||||
err = fmt.Errorf("close temporary file: %w", closeErr)
|
||||
}
|
||||
}
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
if err = tmp.Chmod(perm); err != nil {
|
||||
return fmt.Errorf("set temporary file permissions: %w", err)
|
||||
}
|
||||
if _, err = tmp.Write(data); err != nil {
|
||||
return fmt.Errorf("write temporary file: %w", err)
|
||||
}
|
||||
if err = tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("sync temporary file: %w", err)
|
||||
}
|
||||
if err = tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary file: %w", err)
|
||||
}
|
||||
tmp = nil
|
||||
if err = os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("replace file: %w", err)
|
||||
}
|
||||
|
||||
d, openErr := os.Open(dir)
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open parent directory: %w", openErr)
|
||||
}
|
||||
defer d.Close()
|
||||
if err = d.Sync(); err != nil {
|
||||
return fmt.Errorf("sync parent directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteFileAtomicPreservesStricterMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "secret")
|
||||
if err := os.WriteFile(path, []byte("old"), 0o400); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteFileAtomic(path, []byte("new"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o400 {
|
||||
t.Errorf("mode = %o, want 400", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package fileutil provides file operation utilities.
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CopyFile copies the file at src to dst. If dst already exists it is
|
||||
// overwritten. Partial files are cleaned up on error.
|
||||
func CopyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
os.Remove(dst) // clean up partial file
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(dst)
|
||||
return closeErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
content := []byte("hello world")
|
||||
if err := os.WriteFile(src, content, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CopyFile(src, dst); err != nil {
|
||||
t.Fatalf("CopyFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("reading dst: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Fatalf("got %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileMissingSrc(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := CopyFile(filepath.Join(dir, "nonexistent"), filepath.Join(dir, "dst"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
os.WriteFile(src, []byte("new"), 0o644)
|
||||
os.WriteFile(dst, []byte("old"), 0o644)
|
||||
|
||||
if err := CopyFile(src, dst); err != nil {
|
||||
t.Fatalf("CopyFile: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(dst)
|
||||
if string(got) != "new" {
|
||||
t.Fatalf("got %q, want %q", got, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileEmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "empty.txt")
|
||||
dst := filepath.Join(dir, "dst.txt")
|
||||
|
||||
os.WriteFile(src, []byte{}, 0o644)
|
||||
|
||||
if err := CopyFile(src, dst); err != nil {
|
||||
t.Fatalf("CopyFile: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(dst)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty file, got %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package fuzzy provides lightweight case-insensitive fuzzy subsequence
|
||||
// matching with relevance scoring. It ranks in-memory search results (the
|
||||
// playlist search and the file browser filter) without an external dependency:
|
||||
// a greedy subsequence scan with a few positional bonuses is enough to rank
|
||||
// track and file names, and stays small and allocation-light on the hot path
|
||||
// of every keystroke.
|
||||
package fuzzy
|
||||
|
||||
import "unicode"
|
||||
|
||||
// Scoring weights. In order of preference, a candidate ranks higher when the
|
||||
// match starts at the very beginning of the string, follows a word boundary,
|
||||
// or forms a run of consecutive matched characters.
|
||||
const (
|
||||
scoreMatch = 1 // base score for each matched rune
|
||||
bonusFirstChar = 6 // matched rune is the first rune of the target
|
||||
bonusBoundary = 4 // matched rune follows a word separator
|
||||
bonusConsecutive = 4 // matched rune immediately follows the previous match
|
||||
)
|
||||
|
||||
// isSeparator reports whether r delimits a word for boundary scoring.
|
||||
func isSeparator(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '-', '_', '/', '\\', '.', ':', '(', ')', '[', ']':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Match reports whether query is a case-insensitive subsequence of target and,
|
||||
// if so, a relevance score where higher is a better match. An empty query
|
||||
// matches every target with score 0. ok is false when query is not a
|
||||
// subsequence of target.
|
||||
func Match(query, target string) (score int, ok bool) {
|
||||
if query == "" {
|
||||
return 0, true
|
||||
}
|
||||
q := []rune(query)
|
||||
qi := 0
|
||||
qlow := unicode.ToLower(q[0]) // current query rune, folded once per advance
|
||||
ti := 0 // rune index within target
|
||||
lastMatch := -2 // rune index of the previous matched rune
|
||||
var prev rune // previous target rune, for boundary detection
|
||||
for _, r := range target {
|
||||
if unicode.ToLower(r) == qlow {
|
||||
score += scoreMatch
|
||||
switch {
|
||||
case ti == 0:
|
||||
score += bonusFirstChar
|
||||
case isSeparator(prev):
|
||||
score += bonusBoundary
|
||||
}
|
||||
if ti == lastMatch+1 {
|
||||
score += bonusConsecutive
|
||||
}
|
||||
lastMatch = ti
|
||||
qi++
|
||||
if qi == len(q) {
|
||||
return score, true
|
||||
}
|
||||
qlow = unicode.ToLower(q[qi])
|
||||
}
|
||||
prev = r
|
||||
ti++
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package fuzzy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{"empty query matches", "", "anything", true},
|
||||
{"exact substring", "love", "Love Story", true},
|
||||
{"case insensitive", "LOVE", "love story", true},
|
||||
{"non-contiguous subsequence", "lst", "Love Story", true},
|
||||
{"missing char", "lovex", "Love Story", false},
|
||||
{"out of order", "ba", "ab", false},
|
||||
{"empty target non-empty query", "x", "", false},
|
||||
{"unicode subsequence", "café", "Le Café", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, ok := Match(tt.query, tt.target); ok != tt.want {
|
||||
t.Fatalf("Match(%q, %q) ok = %v, want %v", tt.query, tt.target, ok, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchRanking(t *testing.T) {
|
||||
// Each case asserts that `query` scores strictly higher against `high`
|
||||
// than against `low` (both must match).
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
high, low string
|
||||
}{
|
||||
{"prefix beats interior", "lov", "Love", "Beloved"},
|
||||
{"word boundary beats mid-word", "st", "Love Story", "august"},
|
||||
{"consecutive beats scattered", "abc", "abcdef", "axbxcx"},
|
||||
{"start beats later", "fo", "Foo Bar", "Bar Foo"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hi, okHi := Match(tt.query, tt.high)
|
||||
lo, okLo := Match(tt.query, tt.low)
|
||||
if !okHi || !okLo {
|
||||
t.Fatalf("both should match: high ok=%v, low ok=%v", okHi, okLo)
|
||||
}
|
||||
if hi <= lo {
|
||||
t.Fatalf("Match(%q,%q)=%d should beat Match(%q,%q)=%d",
|
||||
tt.query, tt.high, hi, tt.query, tt.low, lo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package httpclient provides a shared HTTP client configured for audio streaming.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Streaming is a shared HTTP client for audio streaming connections.
|
||||
// It sets a generous header timeout but no overall timeout, so infinite
|
||||
// live streams (Icecast/SHOUTcast) aren't killed. HTTP/2 is explicitly
|
||||
// disabled via TLSNextProto because Icecast/SHOUTcast servers don't
|
||||
// support it — Go's default ALPN negotiation causes EOF.
|
||||
//
|
||||
// Proxy is read from the environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
|
||||
// so users behind corporate or local proxies aren't bypassed; the rest of
|
||||
// the codebase uses http.DefaultTransport, which already honors these vars.
|
||||
var Streaming = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStreamingClientExists(t *testing.T) {
|
||||
if Streaming == nil {
|
||||
t.Fatal("Streaming client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingHeaderTimeout(t *testing.T) {
|
||||
tr, ok := Streaming.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("Transport is not *http.Transport")
|
||||
}
|
||||
want := 30 * time.Second
|
||||
if tr.ResponseHeaderTimeout != want {
|
||||
t.Fatalf("ResponseHeaderTimeout = %v, want %v", tr.ResponseHeaderTimeout, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingHTTP2Disabled(t *testing.T) {
|
||||
tr, ok := Streaming.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("Transport is not *http.Transport")
|
||||
}
|
||||
if tr.TLSNextProto == nil {
|
||||
t.Fatal("TLSNextProto is nil, should be empty map to disable HTTP/2")
|
||||
}
|
||||
if len(tr.TLSNextProto) != 0 {
|
||||
t.Fatalf("TLSNextProto has %d entries, want 0", len(tr.TLSNextProto))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingNoOverallTimeout(t *testing.T) {
|
||||
if Streaming.Timeout != 0 {
|
||||
t.Fatalf("Timeout = %v, want 0 (infinite streams)", Streaming.Timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package playback
|
||||
|
||||
import "time"
|
||||
|
||||
type (
|
||||
PlayPauseMsg struct{}
|
||||
PlayMsg struct{}
|
||||
PauseMsg struct{}
|
||||
NextMsg struct{}
|
||||
PrevMsg struct{}
|
||||
StopMsg struct{}
|
||||
QuitMsg struct{}
|
||||
SeekMsg struct{ Offset time.Duration }
|
||||
SetPositionMsg struct {
|
||||
Position time.Duration
|
||||
}
|
||||
SetVolumeMsg struct{ VolumeDB float64 }
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusStopped Status = "Stopped"
|
||||
StatusPlaying Status = "Playing"
|
||||
StatusPaused Status = "Paused"
|
||||
)
|
||||
|
||||
type Track struct {
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
Genre string
|
||||
TrackNumber int
|
||||
URL string
|
||||
ArtURL string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Status Status
|
||||
Track Track
|
||||
VolumeDB float64
|
||||
Position time.Duration
|
||||
Seekable bool
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Update(State)
|
||||
Seeked(time.Duration)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStatusConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
s Status
|
||||
want string
|
||||
}{
|
||||
{StatusStopped, "Stopped"},
|
||||
{StatusPlaying, "Playing"},
|
||||
{StatusPaused, "Paused"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if string(tt.s) != tt.want {
|
||||
t.Errorf("Status = %q, want %q", string(tt.s), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeekMsgOffset(t *testing.T) {
|
||||
m := SeekMsg{Offset: 5 * time.Second}
|
||||
if m.Offset != 5*time.Second {
|
||||
t.Errorf("Offset = %v, want 5s", m.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPositionMsgPosition(t *testing.T) {
|
||||
m := SetPositionMsg{Position: 42 * time.Second}
|
||||
if m.Position != 42*time.Second {
|
||||
t.Errorf("Position = %v, want 42s", m.Position)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVolumeMsgDB(t *testing.T) {
|
||||
m := SetVolumeMsg{VolumeDB: -6.0}
|
||||
if m.VolumeDB != -6.0 {
|
||||
t.Errorf("VolumeDB = %f, want -6.0", m.VolumeDB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFields(t *testing.T) {
|
||||
s := State{
|
||||
Status: StatusPlaying,
|
||||
Track: Track{
|
||||
Title: "Song",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
Genre: "Rock",
|
||||
TrackNumber: 3,
|
||||
URL: "file:///song.mp3",
|
||||
ArtURL: "file:///cover.jpg",
|
||||
Duration: 3 * time.Minute,
|
||||
},
|
||||
VolumeDB: -3.0,
|
||||
Position: time.Minute,
|
||||
Seekable: true,
|
||||
}
|
||||
if s.Status != StatusPlaying {
|
||||
t.Errorf("Status = %q, want Playing", s.Status)
|
||||
}
|
||||
if s.Track.Title != "Song" {
|
||||
t.Errorf("Track.Title = %q, want Song", s.Track.Title)
|
||||
}
|
||||
if s.Track.Duration != 3*time.Minute {
|
||||
t.Errorf("Track.Duration = %v, want 3m", s.Track.Duration)
|
||||
}
|
||||
if !s.Seekable {
|
||||
t.Error("Seekable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeNotifier confirms the Notifier interface is satisfiable.
|
||||
type fakeNotifier struct {
|
||||
updates []State
|
||||
seeks []time.Duration
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) Update(s State) { f.updates = append(f.updates, s) }
|
||||
func (f *fakeNotifier) Seeked(d time.Duration) { f.seeks = append(f.seeks, d) }
|
||||
|
||||
func TestNotifierInterface(t *testing.T) {
|
||||
var n Notifier = &fakeNotifier{}
|
||||
n.Update(State{Status: StatusPlaying})
|
||||
n.Seeked(time.Second)
|
||||
|
||||
f := n.(*fakeNotifier)
|
||||
if len(f.updates) != 1 || f.updates[0].Status != StatusPlaying {
|
||||
t.Errorf("updates = %+v, want one Playing", f.updates)
|
||||
}
|
||||
if len(f.seeks) != 1 || f.seeks[0] != time.Second {
|
||||
t.Errorf("seeks = %v, want [1s]", f.seeks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package plugintrust persists approvals for Lua plugin content.
|
||||
package plugintrust
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/fileutil"
|
||||
)
|
||||
|
||||
const manifestName = ".trust.json"
|
||||
|
||||
var (
|
||||
ErrUntrusted = errors.New("plugin is not trusted")
|
||||
ErrHashMismatch = errors.New("plugin content changed since approval")
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
Version int `json:"version"`
|
||||
Plugins map[string]string `json:"plugins"`
|
||||
}
|
||||
|
||||
func HashFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open plugin: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", fmt.Errorf("hash plugin: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func Load(dir string) (Manifest, error) {
|
||||
m := Manifest{Version: 1, Plugins: make(map[string]string)}
|
||||
data, err := os.ReadFile(filepath.Join(dir, manifestName))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return m, nil
|
||||
}
|
||||
if err != nil {
|
||||
return m, fmt.Errorf("read plugin trust manifest: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return m, fmt.Errorf("parse plugin trust manifest: %w", err)
|
||||
}
|
||||
if m.Version != 1 || m.Plugins == nil {
|
||||
return m, errors.New("unsupported plugin trust manifest")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func Save(dir string, m Manifest) error {
|
||||
m.Version = 1
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode plugin trust manifest: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return fileutil.WriteFileAtomic(filepath.Join(dir, manifestName), data, 0o600)
|
||||
}
|
||||
|
||||
func Approve(dir, name, path string) (string, error) {
|
||||
hash, err := HashFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m, err := Load(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.Plugins[name] = hash
|
||||
if err := Save(dir, m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func Verify(m Manifest, name, path string) error {
|
||||
want, ok := m.Plugins[name]
|
||||
if !ok {
|
||||
return ErrUntrusted
|
||||
}
|
||||
got, err := HashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got != want {
|
||||
return ErrHashMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package plugintrust
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApprovalLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "example.lua")
|
||||
if err := os.WriteFile(path, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Verify(m, "example", path); !errors.Is(err, ErrUntrusted) {
|
||||
t.Fatalf("Verify before approval = %v, want ErrUntrusted", err)
|
||||
}
|
||||
if _, err := Approve(dir, "example", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err = Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Verify(m, "example", path); err != nil {
|
||||
t.Fatalf("Verify approved plugin: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("changed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Verify(m, "example", path); !errors.Is(err, ErrHashMismatch) {
|
||||
t.Fatalf("Verify changed plugin = %v, want ErrHashMismatch", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(filepath.Join(dir, manifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Errorf("manifest mode = %o, want 600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsTamperedManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, manifestName), []byte(`{"version":99,"plugins":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(dir); err == nil {
|
||||
t.Fatal("Load accepted unsupported manifest")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package resume persists the last-played track and position so playback
|
||||
// can be resumed on the next launch.
|
||||
package resume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/appdir"
|
||||
)
|
||||
|
||||
// State holds enough information to resume a previous playback session.
|
||||
type State struct {
|
||||
Path string `json:"path"`
|
||||
PositionSec int `json:"position_sec"`
|
||||
Playlist string `json:"playlist,omitempty"`
|
||||
}
|
||||
|
||||
func stateFile() (string, error) {
|
||||
dir, err := appdir.Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "resume.json"), nil
|
||||
}
|
||||
|
||||
// Save writes the resume state to disk. No-ops for empty path or zero/negative
|
||||
// position to avoid overwriting a valid resume file with useless data.
|
||||
// Errors are silently ignored so a failed write never disrupts normal exit.
|
||||
func Save(path string, positionSec int, playlist string) {
|
||||
if path == "" || positionSec <= 0 {
|
||||
return
|
||||
}
|
||||
f, err := stateFile()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(State{Path: path, PositionSec: positionSec, Playlist: playlist})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(f), 0o755)
|
||||
_ = os.WriteFile(f, data, 0o600)
|
||||
}
|
||||
|
||||
// Load reads the resume state from disk. Returns a zero State if the file
|
||||
// does not exist or cannot be parsed.
|
||||
func Load() State {
|
||||
f, err := stateFile()
|
||||
if err != nil {
|
||||
return State{}
|
||||
}
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return State{}
|
||||
}
|
||||
var s State
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return State{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withTempHome sets HOME so appdir.Dir() points inside a temp directory,
|
||||
// restoring the original on cleanup.
|
||||
func withTempHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
withTempHome(t)
|
||||
|
||||
Save("/music/song.mp3", 42, "main")
|
||||
|
||||
got := Load()
|
||||
if got.Path != "/music/song.mp3" {
|
||||
t.Errorf("Path = %q, want /music/song.mp3", got.Path)
|
||||
}
|
||||
if got.PositionSec != 42 {
|
||||
t.Errorf("PositionSec = %d, want 42", got.PositionSec)
|
||||
}
|
||||
if got.Playlist != "main" {
|
||||
t.Errorf("Playlist = %q, want main", got.Playlist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveIgnoresEmptyPath(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
Save("", 10, "p")
|
||||
|
||||
f := filepath.Join(home, ".config", "cliamp", "resume.json")
|
||||
if _, err := os.Stat(f); !os.IsNotExist(err) {
|
||||
t.Errorf("resume.json should not exist for empty path, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveIgnoresNonPositivePosition(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
Save("/music/song.mp3", 0, "p")
|
||||
Save("/music/song.mp3", -5, "p")
|
||||
|
||||
f := filepath.Join(home, ".config", "cliamp", "resume.json")
|
||||
if _, err := os.Stat(f); !os.IsNotExist(err) {
|
||||
t.Errorf("resume.json should not exist for non-positive position, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFileReturnsZero(t *testing.T) {
|
||||
withTempHome(t)
|
||||
got := Load()
|
||||
if got != (State{}) {
|
||||
t.Errorf("Load() = %+v, want zero State", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCorruptFileReturnsZero(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
dir := filepath.Join(home, ".config", "cliamp")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "resume.json"), []byte("not json {{"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got := Load()
|
||||
if got != (State{}) {
|
||||
t.Errorf("Load() = %+v, want zero State for corrupt file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCreatesParentDirectory(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
|
||||
// Parent directory doesn't exist yet.
|
||||
parent := filepath.Join(home, ".config", "cliamp")
|
||||
if _, err := os.Stat(parent); !os.IsNotExist(err) {
|
||||
t.Fatalf("precondition: parent should not exist, got err=%v", err)
|
||||
}
|
||||
|
||||
Save("/music/song.mp3", 1, "")
|
||||
|
||||
if info, err := os.Stat(parent); err != nil || !info.IsDir() {
|
||||
t.Errorf("Save should create parent directory, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveWriteFileIsReadable(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
Save("/music/a.mp3", 77, "pl")
|
||||
|
||||
f := filepath.Join(home, ".config", "cliamp", "resume.json")
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("resume.json is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOverwritesPrevious(t *testing.T) {
|
||||
withTempHome(t)
|
||||
|
||||
Save("/a.mp3", 10, "one")
|
||||
Save("/b.mp3", 20, "two")
|
||||
|
||||
got := Load()
|
||||
if got.Path != "/b.mp3" || got.PositionSec != 20 || got.Playlist != "two" {
|
||||
t.Errorf("Load() = %+v, want Path=/b.mp3 PositionSec=20 Playlist=two", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Unsetenv("CLIAMP_CONFIG_DIR")
|
||||
os.Unsetenv("XDG_CONFIG_HOME")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package sshurl parses ssh:// URLs into components for use with the ssh binary.
|
||||
package sshurl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Parsed holds the components of an ssh:// URL.
|
||||
type Parsed struct {
|
||||
Host string // hostname or user@hostname
|
||||
Port string // port number, or "" for default
|
||||
Path string // absolute remote path (starts with /)
|
||||
}
|
||||
|
||||
// SSHArgs returns the ssh command arguments for connecting to this host.
|
||||
// If a port is specified, -p is included.
|
||||
func (p Parsed) SSHArgs() []string {
|
||||
args := []string{"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=5"}
|
||||
if p.Port != "" {
|
||||
args = append(args, "-p", p.Port)
|
||||
}
|
||||
args = append(args, p.Host)
|
||||
return args
|
||||
}
|
||||
|
||||
// Parse parses an ssh:// URL into host, port, and path components.
|
||||
// Accepts formats like ssh://host/path, ssh://user@host/path, ssh://host:2222/path.
|
||||
func Parse(raw string) (Parsed, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return Parsed{}, fmt.Errorf("invalid ssh URL %q: %w", raw, err)
|
||||
}
|
||||
if u.Scheme != "ssh" {
|
||||
return Parsed{}, fmt.Errorf("expected ssh:// scheme, got %q", u.Scheme)
|
||||
}
|
||||
if u.Path == "" {
|
||||
return Parsed{}, fmt.Errorf("ssh URL missing path: %s", raw)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if u.User != nil {
|
||||
host = u.User.Username() + "@" + host
|
||||
}
|
||||
|
||||
port := u.Port()
|
||||
// net/url splits host:port correctly; verify with SplitHostPort for edge cases.
|
||||
if port == "" && u.Host != host {
|
||||
if _, p, err := net.SplitHostPort(u.Host); err == nil {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
|
||||
return Parsed{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Path: u.Path,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package sshurl
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantHost string
|
||||
wantPort string
|
||||
wantPath string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic",
|
||||
input: "ssh://myhost/path/to/music",
|
||||
wantHost: "myhost",
|
||||
wantPort: "",
|
||||
wantPath: "/path/to/music",
|
||||
},
|
||||
{
|
||||
name: "with user",
|
||||
input: "ssh://user@myhost/path/to/music",
|
||||
wantHost: "user@myhost",
|
||||
wantPort: "",
|
||||
wantPath: "/path/to/music",
|
||||
},
|
||||
{
|
||||
name: "with port",
|
||||
input: "ssh://myhost:2222/path/to/music",
|
||||
wantHost: "myhost",
|
||||
wantPort: "2222",
|
||||
wantPath: "/path/to/music",
|
||||
},
|
||||
{
|
||||
name: "with user and port",
|
||||
input: "ssh://user@myhost:2222/path/to/music",
|
||||
wantHost: "user@myhost",
|
||||
wantPort: "2222",
|
||||
wantPath: "/path/to/music",
|
||||
},
|
||||
{
|
||||
name: "wrong scheme",
|
||||
input: "http://myhost/path",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing path",
|
||||
input: "ssh://myhost",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed, err := Parse(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Host != tt.wantHost {
|
||||
t.Errorf("Host = %q, want %q", parsed.Host, tt.wantHost)
|
||||
}
|
||||
if parsed.Port != tt.wantPort {
|
||||
t.Errorf("Port = %q, want %q", parsed.Port, tt.wantPort)
|
||||
}
|
||||
if parsed.Path != tt.wantPath {
|
||||
t.Errorf("Path = %q, want %q", parsed.Path, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parsed Parsed
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no port",
|
||||
parsed: Parsed{Host: "myhost", Port: "", Path: "/music"},
|
||||
want: []string{"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=5", "myhost"},
|
||||
},
|
||||
{
|
||||
name: "with port",
|
||||
parsed: Parsed{Host: "user@myhost", Port: "2222", Path: "/music"},
|
||||
want: []string{"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=5", "-p", "2222", "user@myhost"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.parsed.SSHArgs()
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("SSHArgs() = %v (len %d), want %v (len %d)", got, len(got), tt.want, len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("SSHArgs()[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package tomlutil provides shared helpers for minimal TOML parsing
|
||||
// used by the local and radio providers.
|
||||
package tomlutil
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Unquote strips surrounding double quotes from a TOML string value,
|
||||
// handling escape sequences (written by Go's %q format verb).
|
||||
func Unquote(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
if u, err := strconv.Unquote(s); err == nil {
|
||||
return u
|
||||
}
|
||||
// Fall back to naive strip if Unquote fails.
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package tomlutil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnquoteDoubleQuoted(t *testing.T) {
|
||||
if got := Unquote(`"hello"`); got != "hello" {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", `"hello"`, got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnquoteEscapeSequences(t *testing.T) {
|
||||
if got := Unquote(`"line\nnewline"`); got != "line\nnewline" {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", `"line\nnewline"`, got, "line\nnewline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnquoteNoQuotes(t *testing.T) {
|
||||
if got := Unquote("bare"); got != "bare" {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", "bare", got, "bare")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnquoteEmpty(t *testing.T) {
|
||||
if got := Unquote(""); got != "" {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", "", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnquoteSingleChar(t *testing.T) {
|
||||
if got := Unquote("x"); got != "x" {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", "x", got, "x")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnquoteEmptyQuoted(t *testing.T) {
|
||||
if got := Unquote(`""`); got != "" {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", `""`, got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnquoteUnicodeEscape(t *testing.T) {
|
||||
if got := Unquote(`"\u0041"`); got != "A" {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", `"\u0041"`, got, "A")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnquoteMalformedFallback(t *testing.T) {
|
||||
// Invalid escape sequence — strconv.Unquote fails, naive strip is used.
|
||||
input := `"\z"`
|
||||
got := Unquote(input)
|
||||
if got != `\z` {
|
||||
t.Fatalf("Unquote(%q) = %q, want %q", input, got, `\z`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user