chore: import upstream snapshot with attribution
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+56
View File
@@ -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
}
+89
View File
@@ -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)
}
}
+19
View File
@@ -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 }
+33
View File
@@ -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)
}
}
+21
View File
@@ -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")
}
}
+32
View File
@@ -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")
}
}
+744
View File
@@ -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
}
+349
View File
@@ -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")
}
}
+111
View File
@@ -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
}
+64
View File
@@ -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
}
+24
View File
@@ -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)
}
}
+34
View File
@@ -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
}
+73
View File
@@ -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))
}
}
+67
View File
@@ -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
}
+56
View File
@@ -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)
}
})
}
}
+25
View File
@@ -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),
},
}
+43
View File
@@ -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)
}
}
+50
View File
@@ -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)
}
+97
View File
@@ -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)
}
}
+99
View File
@@ -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
}
+58
View File
@@ -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")
}
}
+63
View File
@@ -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
}
+120
View File
@@ -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)
}
}
+12
View File
@@ -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())
}
+60
View File
@@ -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
}
+108
View File
@@ -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])
}
}
})
}
}
+39
View File
@@ -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()
}
+53
View File
@@ -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)
}
}
+18
View File
@@ -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
}
+54
View File
@@ -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`)
}
}