745 lines
19 KiB
Go
745 lines
19 KiB
Go
// 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
|
|
}
|