Merge pull request #280 from dweymouth/feature/jellyfin
Add Jellyfin support
This commit is contained in:
@@ -8,7 +8,15 @@ import (
|
|||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ServerType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServerTypeSubsonic ServerType = "Subsonic"
|
||||||
|
ServerTypeJellyfin ServerType = "Jellyfin"
|
||||||
|
)
|
||||||
|
|
||||||
type ServerConnection struct {
|
type ServerConnection struct {
|
||||||
|
ServerType ServerType
|
||||||
Hostname string
|
Hostname string
|
||||||
AltHostname string
|
AltHostname string
|
||||||
Username string
|
Username string
|
||||||
@@ -204,6 +212,15 @@ func ReadConfigFile(filepath, appVersionTag string) (*Config, error) {
|
|||||||
if err := toml.NewDecoder(f).Decode(c); err != nil {
|
if err := toml.NewDecoder(f).Decode(c); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill Subsonic to empty ServerType fields
|
||||||
|
// for updating configs created before multiple MediaProviders were added
|
||||||
|
for _, s := range c.Servers {
|
||||||
|
if s.ServerType == "" {
|
||||||
|
s.ServerType = ServerTypeSubsonic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Filter[T any] interface {
|
||||||
|
IsNil() bool
|
||||||
|
Matches(*T) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type baseIter[T any] struct {
|
||||||
|
filter Filter[T]
|
||||||
|
prefetchCB func(*T)
|
||||||
|
serverPos int
|
||||||
|
fetcher func(offset, limit int) ([]*T, error)
|
||||||
|
prefetched []*T
|
||||||
|
prefetchedPos int
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumFetchFn func(offset, limit int) ([]*mediaprovider.Album, error)
|
||||||
|
|
||||||
|
func NewAlbumIterator(fetchFn AlbumFetchFn, filter mediaprovider.AlbumFilter, cb func(string)) mediaprovider.AlbumIterator {
|
||||||
|
return &baseIter[mediaprovider.Album]{
|
||||||
|
prefetchCB: func(a *mediaprovider.Album) { cb(a.CoverArtID) },
|
||||||
|
filter: filter,
|
||||||
|
fetcher: fetchFn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackFetchFn func(offset, limit int) ([]*mediaprovider.Track, error)
|
||||||
|
|
||||||
|
func NewTrackIterator(fetchFn TrackFetchFn, cb func(string)) mediaprovider.TrackIterator {
|
||||||
|
return &baseIter[mediaprovider.Track]{
|
||||||
|
prefetchCB: func(a *mediaprovider.Track) { cb(a.CoverArtID) },
|
||||||
|
filter: nilFilter[mediaprovider.Track]{},
|
||||||
|
fetcher: fetchFn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *baseIter[T]) Next() *T {
|
||||||
|
if r.done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if r.prefetched != nil && r.prefetchedPos < len(r.prefetched) {
|
||||||
|
a := r.prefetched[r.prefetchedPos]
|
||||||
|
r.prefetchedPos++
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
r.prefetched = nil
|
||||||
|
for { // keep fetching until we are done or have mathcing results
|
||||||
|
items, err := r.fetcher(r.serverPos, 20)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error fetching items: %s", err.Error())
|
||||||
|
items = nil
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
r.done = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.serverPos += len(items)
|
||||||
|
if !r.filter.IsNil() {
|
||||||
|
items = sharedutil.FilterSlice(items, func(al *T) bool {
|
||||||
|
return r.filter.Matches(al)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r.prefetched = items
|
||||||
|
if len(items) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.prefetchedPos = 1
|
||||||
|
if r.prefetchCB != nil {
|
||||||
|
for _, album := range r.prefetched {
|
||||||
|
go r.prefetchCB(album)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.prefetched[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
type randomIter struct {
|
||||||
|
filter mediaprovider.AlbumFilter
|
||||||
|
prefetchCB func(coverArtID string)
|
||||||
|
albumIDSet map[string]bool
|
||||||
|
prefetched []*mediaprovider.Album
|
||||||
|
prefetchedPos int
|
||||||
|
// Random iter works in two phases - phase 1 by requesting random
|
||||||
|
// albums from the server. Since the Subsonic API provides no way
|
||||||
|
// of paginating a single random sort, we may get albums back twice.
|
||||||
|
// We use albumIDSet to keep track of which albums have already been returned.
|
||||||
|
// Once we start getting back too many already-returned albums,
|
||||||
|
// switch to requesting more albums from a deterministic sort order.
|
||||||
|
deterministicFetcher AlbumFetchFn
|
||||||
|
ramdomFetcher AlbumFetchFn
|
||||||
|
phaseTwo bool
|
||||||
|
offset int
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRandomAlbumIter(deterministicFetcher, randomFetcher AlbumFetchFn, filter mediaprovider.AlbumFilter, prefetchCoverCB func(string)) *randomIter {
|
||||||
|
return &randomIter{
|
||||||
|
filter: filter,
|
||||||
|
prefetchCB: prefetchCoverCB,
|
||||||
|
deterministicFetcher: deterministicFetcher,
|
||||||
|
ramdomFetcher: randomFetcher,
|
||||||
|
albumIDSet: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *randomIter) Next() *mediaprovider.Album {
|
||||||
|
if r.done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat fetch task until we have matching results
|
||||||
|
// or we reach the end (handled via short circuit return)
|
||||||
|
for len(r.prefetched) == 0 {
|
||||||
|
if r.phaseTwo {
|
||||||
|
// fetch albums from deterministic order
|
||||||
|
albums, err := r.deterministicFetcher(r.offset, 25)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error fetching albums: %s", err.Error())
|
||||||
|
albums = nil
|
||||||
|
}
|
||||||
|
if len(albums) == 0 {
|
||||||
|
r.done = true
|
||||||
|
r.albumIDSet = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.offset += len(albums)
|
||||||
|
for _, album := range albums {
|
||||||
|
if _, ok := r.albumIDSet[album.ID]; !ok && r.filter.Matches(album) {
|
||||||
|
r.prefetched = append(r.prefetched, album)
|
||||||
|
if r.prefetchCB != nil {
|
||||||
|
go r.prefetchCB(album.CoverArtID)
|
||||||
|
}
|
||||||
|
r.albumIDSet[album.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
albums, err := r.ramdomFetcher(0 /*offset - doesn't matter for random*/, 25)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
r.done = true
|
||||||
|
r.albumIDSet = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var hitCount int
|
||||||
|
for _, album := range albums {
|
||||||
|
if _, ok := r.albumIDSet[album.ID]; !ok {
|
||||||
|
// still need to keep track even if album is not matched
|
||||||
|
// by the filter because we need to know when to move to phase two
|
||||||
|
hitCount++
|
||||||
|
r.albumIDSet[album.ID] = true
|
||||||
|
if r.filter.Matches(album) {
|
||||||
|
r.prefetched = append(r.prefetched, album)
|
||||||
|
if r.prefetchCB != nil {
|
||||||
|
go r.prefetchCB(album.CoverArtID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successRatio := float64(hitCount) / float64(25); successRatio < 0.3 {
|
||||||
|
r.phaseTwo = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return from prefetched results
|
||||||
|
if len(r.prefetched) > 0 {
|
||||||
|
a := r.prefetched[r.prefetchedPos]
|
||||||
|
r.prefetchedPos++
|
||||||
|
if r.prefetchedPos == len(r.prefetched) {
|
||||||
|
r.prefetched = nil
|
||||||
|
r.prefetchedPos = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type nilFilter[T any] struct{}
|
||||||
|
|
||||||
|
func (n nilFilter[T]) IsNil() bool { return true }
|
||||||
|
|
||||||
|
func (n nilFilter[T]) Matches(*T) bool { return true }
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/deluan/sanitize"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// name and terms should be pre-converted to the same case
|
||||||
|
func AllTermsMatch(name string, terms []string) bool {
|
||||||
|
for _, t := range terms {
|
||||||
|
if !strings.Contains(name, t) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func RankSearchResults(results []*mediaprovider.SearchResult, fullQuery string, queryTerms []string) {
|
||||||
|
if len(queryTerms) == 0 || len(results) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeMemo := make(map[string]string, len(results))
|
||||||
|
sanitized := func(s string) string {
|
||||||
|
if x, ok := sanitizeMemo[s]; ok {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
x := strings.ToLower(sanitize.Accents(s))
|
||||||
|
sanitizeMemo[s] = x
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
a, b := results[i], results[j]
|
||||||
|
aName := sanitized(a.Name)
|
||||||
|
bName := sanitized(b.Name)
|
||||||
|
|
||||||
|
// Compare by entire query
|
||||||
|
matchesA, matchesB := strings.Contains(aName, fullQuery), strings.Contains(bName, fullQuery)
|
||||||
|
if matchesA && !matchesB {
|
||||||
|
return true // item A has a direct match with the full query and B does not
|
||||||
|
} else if matchesB && !matchesA {
|
||||||
|
return false // item B matches but not A
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare by search query terms
|
||||||
|
for _, term := range queryTerms {
|
||||||
|
firstTermIdxA, firstTermIdxB := strings.Index(aName, term), strings.Index(bName, term)
|
||||||
|
if firstTermIdxA >= 0 && firstTermIdxB < 0 {
|
||||||
|
return true // item A has a direct match with the query term and B does not
|
||||||
|
} else if firstTermIdxB >= 0 && firstTermIdxA < 0 {
|
||||||
|
return false // item B matches but not A
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstTermIdxA < firstTermIdxB {
|
||||||
|
return true // item A matches the query term starting at an earlier position
|
||||||
|
} else if firstTermIdxB < firstTermIdxA {
|
||||||
|
return false // item B matches first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Defer to item type for priority order
|
||||||
|
return a.Type < b.Type
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dweymouth/go-jellyfin"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||||
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlbumSortRecentlyAdded string = "Recently Added"
|
||||||
|
AlbumSortRandom string = "Random"
|
||||||
|
AlbumSortTitleAZ string = "Title (A-Z)"
|
||||||
|
AlbumSortArtistAZ string = "Artist (A-Z)"
|
||||||
|
AlbumSortYearAscending string = "Year (ascending)"
|
||||||
|
AlbumSortYearDescending string = "Year (descending)"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) AlbumSortOrders() []string {
|
||||||
|
return []string{
|
||||||
|
AlbumSortRecentlyAdded,
|
||||||
|
AlbumSortRandom,
|
||||||
|
AlbumSortTitleAZ,
|
||||||
|
AlbumSortArtistAZ,
|
||||||
|
AlbumSortYearAscending,
|
||||||
|
AlbumSortYearDescending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) IterateAlbums(sortOrder string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
|
||||||
|
var jfSort jellyfin.Sort
|
||||||
|
switch sortOrder {
|
||||||
|
case AlbumSortRecentlyAdded:
|
||||||
|
jfSort.Field = jellyfin.SortByDateCreated
|
||||||
|
jfSort.Mode = jellyfin.SortDesc
|
||||||
|
case AlbumSortRandom:
|
||||||
|
jfSort.Field = jellyfin.SortByRandom
|
||||||
|
case AlbumSortArtistAZ:
|
||||||
|
jfSort.Field = jellyfin.SortByArtist
|
||||||
|
jfSort.Mode = jellyfin.SortAsc
|
||||||
|
case AlbumSortTitleAZ:
|
||||||
|
jfSort.Field = jellyfin.SortByName
|
||||||
|
jfSort.Mode = jellyfin.SortAsc
|
||||||
|
case AlbumSortYearAscending:
|
||||||
|
jfSort.Field = jellyfin.SortByYear
|
||||||
|
jfSort.Mode = jellyfin.SortAsc
|
||||||
|
case AlbumSortYearDescending:
|
||||||
|
jfSort.Field = jellyfin.SortByYear
|
||||||
|
jfSort.Mode = jellyfin.SortDesc
|
||||||
|
}
|
||||||
|
jfFilt := jfFilterFromFilter(&filter)
|
||||||
|
|
||||||
|
fetcher := func(offs, limit int) ([]*mediaprovider.Album, error) {
|
||||||
|
al, err := j.client.GetAlbums(jellyfin.QueryOpts{
|
||||||
|
Sort: jfSort,
|
||||||
|
Filter: jfFilt,
|
||||||
|
Paging: jellyfin.Paging{StartIndex: offs, Limit: limit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(al, toAlbum), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sortOrder == AlbumSortRandom {
|
||||||
|
determFetcher := func(offs, limit int) ([]*mediaprovider.Album, error) {
|
||||||
|
al, err := j.client.GetAlbums(jellyfin.QueryOpts{
|
||||||
|
Sort: jellyfin.Sort{Field: "SortName", Mode: jellyfin.SortAsc},
|
||||||
|
Filter: jfFilt,
|
||||||
|
Paging: jellyfin.Paging{StartIndex: offs, Limit: limit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(al, toAlbum), nil
|
||||||
|
}
|
||||||
|
return helpers.NewRandomAlbumIter(determFetcher, fetcher, filter, j.prefetchCoverCB)
|
||||||
|
}
|
||||||
|
return helpers.NewAlbumIterator(fetcher, filter, j.prefetchCoverCB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) SearchAlbums(searchQuery string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
|
||||||
|
fetcher := func(offs, limit int) ([]*mediaprovider.Album, error) {
|
||||||
|
sr, err := j.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{StartIndex: offs, Limit: limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(sr.Albums, toAlbum), nil
|
||||||
|
}
|
||||||
|
return helpers.NewAlbumIterator(fetcher, filter, j.prefetchCoverCB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) IterateTracks(searchQuery string) mediaprovider.TrackIterator {
|
||||||
|
var fetcher helpers.TrackFetchFn
|
||||||
|
if searchQuery == "" {
|
||||||
|
fetcher = func(offs, limit int) ([]*mediaprovider.Track, error) {
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Paging = jellyfin.Paging{StartIndex: offs, Limit: limit}
|
||||||
|
s, err := j.client.GetSongs(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(s, toTrack), nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetcher = func(offs, limit int) ([]*mediaprovider.Track, error) {
|
||||||
|
sr, err := j.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{StartIndex: offs, Limit: limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(sr.Songs, toTrack), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return helpers.NewTrackIterator(fetcher, j.prefetchCoverCB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the Jellyfin filter to implement the given mediaprovider filter,
|
||||||
|
// and zeros out the now-unneeded fields in the mediaprovider filter.
|
||||||
|
func jfFilterFromFilter(filter *mediaprovider.AlbumFilter) jellyfin.Filter {
|
||||||
|
var jfFilt jellyfin.Filter
|
||||||
|
if filter.ExcludeUnfavorited {
|
||||||
|
jfFilt.Favorite = true
|
||||||
|
filter.ExcludeUnfavorited = false // Jellyfin will handle this filter
|
||||||
|
}
|
||||||
|
if filter.MinYear > 0 && filter.MaxYear > 0 {
|
||||||
|
jfFilt.YearRange = [2]int{filter.MinYear, filter.MaxYear}
|
||||||
|
filter.MinYear, filter.MaxYear = 0, 0
|
||||||
|
} else if filter.MinYear > 0 {
|
||||||
|
jfFilt.YearRange = [2]int{filter.MinYear, time.Now().Year()}
|
||||||
|
filter.MinYear, filter.MaxYear = 0, 0
|
||||||
|
} else if filter.MaxYear > 0 {
|
||||||
|
jfFilt.YearRange = [2]int{1900, filter.MaxYear}
|
||||||
|
filter.MinYear, filter.MaxYear = 0, 0
|
||||||
|
}
|
||||||
|
jfFilt.Genres = filter.Genres
|
||||||
|
filter.Genres = nil
|
||||||
|
return jfFilt
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dweymouth/go-jellyfin"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheValidDurationSeconds = 60
|
||||||
|
runTimeTicksPerSecond = 10_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
type JellyfinServer struct {
|
||||||
|
jellyfin.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JellyfinServer) Login(user, pass string) mediaprovider.LoginResponse {
|
||||||
|
if _, err := j.Ping(); err != nil {
|
||||||
|
return mediaprovider.LoginResponse{Error: err}
|
||||||
|
}
|
||||||
|
err := j.Client.Login(user, pass)
|
||||||
|
return mediaprovider.LoginResponse{
|
||||||
|
Error: err,
|
||||||
|
IsAuthError: err != nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JellyfinServer) MediaProvider() mediaprovider.MediaProvider {
|
||||||
|
return newJellyfinMediaProvider(&j.Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ mediaprovider.MediaProvider = (*jellyfinMediaProvider)(nil)
|
||||||
|
|
||||||
|
type jellyfinMediaProvider struct {
|
||||||
|
client *jellyfin.Client
|
||||||
|
prefetchCoverCB func(coverArtID string)
|
||||||
|
|
||||||
|
genresCached []*mediaprovider.Genre
|
||||||
|
genresCachedAt int64 // unix
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJellyfinMediaProvider(cli *jellyfin.Client) mediaprovider.MediaProvider {
|
||||||
|
return &jellyfinMediaProvider{
|
||||||
|
client: cli,
|
||||||
|
genresCached: make([]*mediaprovider.Genre, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) SetPrefetchCoverCallback(cb func(coverArtID string)) {
|
||||||
|
j.prefetchCoverCB = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) CreatePlaylist(name string, trackIDs []string) error {
|
||||||
|
return j.client.CreatePlaylist(name, trackIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) DeletePlaylist(id string) error {
|
||||||
|
return j.client.DeletePlaylist(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) CanMakePublicPlaylist() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) EditPlaylist(id, name, description string, public bool) error {
|
||||||
|
return j.client.UpdatePlaylistMetadata(id, name, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) AddPlaylistTracks(id string, trackIDsToAdd []string) error {
|
||||||
|
return j.client.AddSongsToPlaylist(id, trackIDsToAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) RemovePlaylistTracks(playlistID string, removeIdxs []int) error {
|
||||||
|
return j.client.RemoveSongsFromPlaylist(playlistID, removeIdxs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) ReplacePlaylistTracks(playlistID string, trackIDs []string) error {
|
||||||
|
pl, err := j.client.GetPlaylist(playlistID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
allIndexes := make([]int, pl.SongCount)
|
||||||
|
for i := range allIndexes {
|
||||||
|
allIndexes[i] = i
|
||||||
|
}
|
||||||
|
if err = j.client.RemoveSongsFromPlaylist(playlistID, allIndexes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return j.client.AddSongsToPlaylist(playlistID, trackIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetAlbum(albumID string) (*mediaprovider.AlbumWithTracks, error) {
|
||||||
|
al, err := j.client.GetAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Filter.ParentID = albumID
|
||||||
|
tr, err := j.client.GetSongs(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
album := &mediaprovider.AlbumWithTracks{}
|
||||||
|
fillAlbum(al, &album.Album)
|
||||||
|
album.Tracks = sharedutil.MapSlice(tr, toTrack)
|
||||||
|
return album, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetAlbumInfo(albumID string) (*mediaprovider.AlbumInfo, error) {
|
||||||
|
al, err := j.client.GetAlbum(albumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mediaprovider.AlbumInfo{
|
||||||
|
Notes: al.Overview,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetArtist(artistID string) (*mediaprovider.ArtistWithAlbums, error) {
|
||||||
|
ar, err := j.client.GetArtist(artistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Filter.ArtistID = artistID
|
||||||
|
al, err := j.client.GetAlbums(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artist := &mediaprovider.ArtistWithAlbums{
|
||||||
|
Albums: sharedutil.MapSlice(al, toAlbum),
|
||||||
|
}
|
||||||
|
fillArtist(ar, &artist.Artist)
|
||||||
|
return artist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetArtistInfo(artistID string) (*mediaprovider.ArtistInfo, error) {
|
||||||
|
ar, err := j.client.GetArtist(artistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
similar, err := j.client.GetSimilarArtists(artistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mediaprovider.ArtistInfo{
|
||||||
|
SimilarArtists: sharedutil.MapSlice(similar, toArtist),
|
||||||
|
Biography: ar.Overview,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetArtists() ([]*mediaprovider.Artist, error) {
|
||||||
|
ar, err := j.client.GetAlbumArtists(jellyfin.QueryOpts{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(ar, toArtist), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetTrack(trackID string) (*mediaprovider.Track, error) {
|
||||||
|
tr, err := j.client.GetSong(trackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toTrack(tr), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetTopTracks(artist mediaprovider.Artist, limit int) ([]*mediaprovider.Track, error) {
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Paging.Limit = limit
|
||||||
|
opts.Filter.ArtistID = artist.ID
|
||||||
|
opts.Sort.Field = jellyfin.SortByCommunityRating
|
||||||
|
opts.Sort.Mode = jellyfin.SortDesc
|
||||||
|
tr, err := j.client.GetSongs(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(tr, toTrack), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetRandomTracks(genreName string, limit int) ([]*mediaprovider.Track, error) {
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Paging.Limit = limit
|
||||||
|
opts.Filter.Genres = []string{genreName}
|
||||||
|
opts.Sort.Field = jellyfin.SortByRandom
|
||||||
|
tr, err := j.client.GetSongs(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(tr, toTrack), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetSimilarTracks(artistID string, limit int) ([]*mediaprovider.Track, error) {
|
||||||
|
// Jellyfin can only get similar songs based on an album
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Paging.Limit = 1
|
||||||
|
opts.Filter.ArtistID = artistID
|
||||||
|
als, err := j.client.GetAlbums(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(als) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tr, err := j.client.GetSimilarSongs(als[0].ID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(tr, toTrack), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetCoverArt(id string, size int) (image.Image, error) {
|
||||||
|
return j.client.GetItemImage(id, "Primary", size, 92)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jellyfinMediaProvider) GetFavorites() (mediaprovider.Favorites, error) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var favorites mediaprovider.Favorites
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Filter.Favorite = true
|
||||||
|
al, err := s.client.GetAlbums(opts)
|
||||||
|
if err == nil && len(al) > 0 {
|
||||||
|
favorites.Albums = sharedutil.MapSlice(al, toAlbum)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Filter.Favorite = true
|
||||||
|
ar, err := s.client.GetAlbumArtists(opts)
|
||||||
|
if err == nil && len(ar) > 0 {
|
||||||
|
favorites.Artists = sharedutil.MapSlice(ar, toArtist)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
var opts jellyfin.QueryOpts
|
||||||
|
opts.Filter.Favorite = true
|
||||||
|
tr, err := s.client.GetSongs(opts)
|
||||||
|
if err == nil && len(tr) > 0 {
|
||||||
|
favorites.Tracks = sharedutil.MapSlice(tr, toTrack)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return favorites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetGenres() ([]*mediaprovider.Genre, error) {
|
||||||
|
if j.genresCached != nil && time.Now().Unix()-j.genresCachedAt < cacheValidDurationSeconds {
|
||||||
|
return j.genresCached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := j.client.GetGenres(jellyfin.Paging{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
j.genresCached = sharedutil.MapSlice(g, func(g jellyfin.NameID) *mediaprovider.Genre {
|
||||||
|
return &mediaprovider.Genre{
|
||||||
|
Name: g.Name,
|
||||||
|
AlbumCount: -1, // unsupported by Jellyfin
|
||||||
|
TrackCount: -1, // unsupported by Jellyfin
|
||||||
|
}
|
||||||
|
})
|
||||||
|
j.genresCachedAt = time.Now().Unix()
|
||||||
|
return j.genresCached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetPlaylists() ([]*mediaprovider.Playlist, error) {
|
||||||
|
pl, err := j.client.GetPlaylists()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sharedutil.MapSlice(pl, j.toPlaylist), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.PlaylistWithTracks, error) {
|
||||||
|
tr, err := j.client.GetPlaylistSongs(playlistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pl, err := j.client.GetPlaylist(playlistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist := &mediaprovider.PlaylistWithTracks{
|
||||||
|
Tracks: sharedutil.MapSlice(tr, toTrack),
|
||||||
|
}
|
||||||
|
j.fillPlaylist(pl, &playlist.Playlist)
|
||||||
|
return playlist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) SetFavorite(params mediaprovider.RatingFavoriteParameters, favorite bool) error {
|
||||||
|
var allIDs []string
|
||||||
|
allIDs = append(allIDs, params.AlbumIDs...)
|
||||||
|
allIDs = append(allIDs, params.ArtistIDs...)
|
||||||
|
allIDs = append(allIDs, params.TrackIDs...)
|
||||||
|
|
||||||
|
// Jellyfin doesn't allow bulk setting favorites.
|
||||||
|
// To not overwhelm the server with requests, set favorite for
|
||||||
|
// only 5 items at a time concurrently
|
||||||
|
batchSize := 5
|
||||||
|
var err error
|
||||||
|
batchSetFavorite := func(offs int, wg *sync.WaitGroup) {
|
||||||
|
for i := 0; i < batchSize && offs+i < len(allIDs); i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
newErr := j.client.SetFavorite(allIDs[idx], favorite)
|
||||||
|
if err == nil && newErr != nil {
|
||||||
|
err = newErr
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}(offs + i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
numBatches := int(math.Ceil(float64(len(allIDs)) / float64(batchSize)))
|
||||||
|
for i := 0; i < numBatches; i++ {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
batchSetFavorite(i*batchSize, &wg)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) GetStreamURL(trackID string, forceRaw bool) (string, error) {
|
||||||
|
return j.client.GetStreamURL(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) DownloadTrack(trackID string) (io.Reader, error) {
|
||||||
|
url, err := j.client.GetStreamURL(trackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) ClientDecidesScrobble() bool { return false }
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) TrackBeganPlayback(trackID string) error {
|
||||||
|
return j.client.UpdatePlayStatus(trackID, jellyfin.Start, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) TrackEndedPlayback(trackID string, position int, submission bool) error {
|
||||||
|
return j.client.UpdatePlayStatus(trackID, jellyfin.Stop, int64(position)*runTimeTicksPerSecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) RescanLibrary() error {
|
||||||
|
return j.client.RefreshLibrary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTrack(ch *jellyfin.Song) *mediaprovider.Track {
|
||||||
|
if ch == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var artistNames, artistIDs []string
|
||||||
|
for _, a := range ch.Artists {
|
||||||
|
artistIDs = append(artistIDs, a.ID)
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
coverArtID := ch.AlbumID
|
||||||
|
if ch.ImageTags.Primary != "" {
|
||||||
|
coverArtID = ch.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &mediaprovider.Track{
|
||||||
|
ID: ch.Id,
|
||||||
|
CoverArtID: coverArtID,
|
||||||
|
ParentID: ch.AlbumID,
|
||||||
|
Name: ch.Name,
|
||||||
|
Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond),
|
||||||
|
TrackNumber: ch.IndexNumber,
|
||||||
|
DiscNumber: ch.DiscNumber,
|
||||||
|
//Genre: ch.Genres,
|
||||||
|
ArtistIDs: artistIDs,
|
||||||
|
ArtistNames: artistNames,
|
||||||
|
Album: ch.Album,
|
||||||
|
AlbumID: ch.AlbumID,
|
||||||
|
Year: ch.ProductionYear,
|
||||||
|
Rating: ch.UserData.Rating,
|
||||||
|
Favorite: ch.UserData.IsFavorite,
|
||||||
|
PlayCount: ch.UserData.PlayCount,
|
||||||
|
}
|
||||||
|
if len(ch.MediaSources) > 0 {
|
||||||
|
t.FilePath = ch.MediaSources[0].Path
|
||||||
|
t.Size = int64(ch.MediaSources[0].Size)
|
||||||
|
t.BitRate = ch.MediaSources[0].Bitrate / 1000
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func toArtist(a *jellyfin.Artist) *mediaprovider.Artist {
|
||||||
|
art := &mediaprovider.Artist{}
|
||||||
|
fillArtist(a, art)
|
||||||
|
return art
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillArtist(a *jellyfin.Artist, artist *mediaprovider.Artist) {
|
||||||
|
artist.AlbumCount = a.AlbumCount
|
||||||
|
artist.Favorite = a.UserData.IsFavorite
|
||||||
|
artist.ID = a.ID
|
||||||
|
artist.Name = a.Name
|
||||||
|
artist.CoverArtID = a.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAlbum(a *jellyfin.Album) *mediaprovider.Album {
|
||||||
|
album := &mediaprovider.Album{}
|
||||||
|
fillAlbum(a, album)
|
||||||
|
return album
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillAlbum(a *jellyfin.Album, album *mediaprovider.Album) {
|
||||||
|
var artistNames, artistIDs []string
|
||||||
|
for _, a := range a.Artists {
|
||||||
|
artistIDs = append(artistIDs, a.ID)
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
album.ID = a.ID
|
||||||
|
album.CoverArtID = a.ID
|
||||||
|
album.Name = a.Name
|
||||||
|
album.Duration = int(a.RunTimeTicks / runTimeTicksPerSecond)
|
||||||
|
album.ArtistIDs = artistIDs
|
||||||
|
album.ArtistNames = artistNames
|
||||||
|
album.Year = a.Year
|
||||||
|
album.TrackCount = a.ChildCount
|
||||||
|
album.Genres = a.Genres
|
||||||
|
album.Favorite = a.UserData.IsFavorite
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) toPlaylist(p *jellyfin.Playlist) *mediaprovider.Playlist {
|
||||||
|
pl := &mediaprovider.Playlist{}
|
||||||
|
j.fillPlaylist(p, pl)
|
||||||
|
return pl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jellyfinMediaProvider) fillPlaylist(p *jellyfin.Playlist, pl *mediaprovider.Playlist) {
|
||||||
|
pl.Name = p.Name
|
||||||
|
pl.ID = p.ID
|
||||||
|
pl.CoverArtID = p.ID
|
||||||
|
pl.Description = p.Overview
|
||||||
|
pl.TrackCount = p.SongCount
|
||||||
|
pl.Duration = int(p.RunTimeTicks / runTimeTicksPerSecond)
|
||||||
|
// Jellyfin does not have public playlists
|
||||||
|
pl.Owner = j.client.LoggedInUser()
|
||||||
|
pl.Public = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package jellyfin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/deluan/sanitize"
|
||||||
|
"github.com/dweymouth/go-jellyfin"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||||
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
|
||||||
|
limit := maxResults / 3
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var albums []*jellyfin.Album
|
||||||
|
var artists []*jellyfin.Artist
|
||||||
|
var songs []*jellyfin.Song
|
||||||
|
var genres []jellyfin.NameID
|
||||||
|
var playlists []*jellyfin.Playlist
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
albumResult, _ := s.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit})
|
||||||
|
albums = albumResult.Albums
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
artistResult, _ := s.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit})
|
||||||
|
artists = artistResult.Artists
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
songResult, _ := s.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit})
|
||||||
|
songs = songResult.Songs
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
querySanitized := strings.ToLower(sanitize.Accents(searchQuery))
|
||||||
|
queryLowerWords := strings.Fields(querySanitized)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
p, e := s.client.GetPlaylists()
|
||||||
|
if e == nil {
|
||||||
|
playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool {
|
||||||
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
g, e := s.client.GetGenres(jellyfin.Paging{})
|
||||||
|
if e == nil {
|
||||||
|
genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool {
|
||||||
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
results := mergeResults(albums, artists, songs, playlists, genres)
|
||||||
|
helpers.RankSearchResults(results, searchQuery, queryLowerWords)
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeResults(
|
||||||
|
albums []*jellyfin.Album,
|
||||||
|
artists []*jellyfin.Artist,
|
||||||
|
songs []*jellyfin.Song,
|
||||||
|
matchingPlaylists []*jellyfin.Playlist,
|
||||||
|
matchingGenres []jellyfin.NameID,
|
||||||
|
) []*mediaprovider.SearchResult {
|
||||||
|
var results []*mediaprovider.SearchResult
|
||||||
|
|
||||||
|
getArtistNames := func(artist jellyfin.NameID) string {
|
||||||
|
return artist.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, al := range albums {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypeAlbum,
|
||||||
|
ID: al.ID,
|
||||||
|
CoverID: al.ID,
|
||||||
|
Name: al.Name,
|
||||||
|
ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","),
|
||||||
|
Size: al.ChildCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ar := range artists {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypeArtist,
|
||||||
|
ID: ar.ID,
|
||||||
|
CoverID: ar.ID,
|
||||||
|
Name: ar.Name,
|
||||||
|
Size: ar.AlbumCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tr := range songs {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypeTrack,
|
||||||
|
ID: tr.Id,
|
||||||
|
CoverID: tr.Id,
|
||||||
|
Name: tr.Name,
|
||||||
|
ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","),
|
||||||
|
Size: int(tr.RunTimeTicks / 10_000_000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pl := range matchingPlaylists {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypePlaylist,
|
||||||
|
ID: pl.ID,
|
||||||
|
CoverID: pl.ID,
|
||||||
|
Name: pl.Name,
|
||||||
|
Size: pl.SongCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range matchingGenres {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypeGenre,
|
||||||
|
ID: g.Name,
|
||||||
|
Name: g.Name,
|
||||||
|
Size: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package mediaprovider
|
|||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlbumFilter struct {
|
type AlbumFilter struct {
|
||||||
@@ -14,6 +15,32 @@ type AlbumFilter struct {
|
|||||||
ExcludeUnfavorited bool // mut. exc. with ExcludeFavorited
|
ExcludeUnfavorited bool // mut. exc. with ExcludeFavorited
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns true if the filter is the nil filter - i.e. matches everything
|
||||||
|
func (a AlbumFilter) IsNil() bool {
|
||||||
|
return a.MinYear == 0 && a.MaxYear == 0 &&
|
||||||
|
len(a.Genres) == 0 &&
|
||||||
|
!a.ExcludeFavorited && !a.ExcludeUnfavorited
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f AlbumFilter) Matches(album *Album) bool {
|
||||||
|
if album == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.ExcludeFavorited && album.Favorite {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.ExcludeUnfavorited && !album.Favorite {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if y := album.Year; y < f.MinYear || (f.MaxYear > 0 && y > f.MaxYear) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(f.Genres) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return genresMatch(f.Genres, album.Genres)
|
||||||
|
}
|
||||||
|
|
||||||
type AlbumIterator interface {
|
type AlbumIterator interface {
|
||||||
Next() *Album
|
Next() *Album
|
||||||
}
|
}
|
||||||
@@ -34,6 +61,16 @@ type Favorites struct {
|
|||||||
Tracks []*Track
|
Tracks []*Track
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
Error error
|
||||||
|
IsAuthError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server interface {
|
||||||
|
Login(username, password string) LoginResponse
|
||||||
|
MediaProvider() MediaProvider
|
||||||
|
}
|
||||||
|
|
||||||
type MediaProvider interface {
|
type MediaProvider interface {
|
||||||
SetPrefetchCoverCallback(cb func(coverArtID string))
|
SetPrefetchCoverCallback(cb func(coverArtID string))
|
||||||
|
|
||||||
@@ -77,23 +114,47 @@ type MediaProvider interface {
|
|||||||
|
|
||||||
SetFavorite(params RatingFavoriteParameters, favorite bool) error
|
SetFavorite(params RatingFavoriteParameters, favorite bool) error
|
||||||
|
|
||||||
SetRating(params RatingFavoriteParameters, rating int) error
|
|
||||||
|
|
||||||
GetPlaylists() ([]*Playlist, error)
|
GetPlaylists() ([]*Playlist, error)
|
||||||
|
|
||||||
CreatePlaylist(name string, trackIDs []string) error
|
CreatePlaylist(name string, trackIDs []string) error
|
||||||
|
|
||||||
|
CanMakePublicPlaylist() bool
|
||||||
|
|
||||||
EditPlaylist(id, name, description string, public bool) error
|
EditPlaylist(id, name, description string, public bool) error
|
||||||
|
|
||||||
EditPlaylistTracks(id string, trackIDsToAdd []string, trackIndexesToRemove []int) error
|
AddPlaylistTracks(id string, trackIDsToAdd []string) error
|
||||||
|
|
||||||
|
RemovePlaylistTracks(id string, trackIdxsToRemove []int) error
|
||||||
|
|
||||||
ReplacePlaylistTracks(id string, trackIDs []string) error
|
ReplacePlaylistTracks(id string, trackIDs []string) error
|
||||||
|
|
||||||
DeletePlaylist(id string) error
|
DeletePlaylist(id string) error
|
||||||
|
|
||||||
Scrobble(trackID string, submission bool) error
|
// True if the `submission` parameter to TrackEndedPlayback will be respected
|
||||||
|
// If false, the begin playback scrobble registers a play count immediately
|
||||||
|
// when TrackBeganPlayback is invoked.
|
||||||
|
ClientDecidesScrobble() bool
|
||||||
|
|
||||||
|
TrackBeganPlayback(trackID string) error
|
||||||
|
|
||||||
|
TrackEndedPlayback(trackID string, positionSecs int, submission bool) error
|
||||||
|
|
||||||
DownloadTrack(trackID string) (io.Reader, error)
|
DownloadTrack(trackID string) (io.Reader, error)
|
||||||
|
|
||||||
RescanLibrary() error
|
RescanLibrary() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SupportsRating interface {
|
||||||
|
SetRating(params RatingFavoriteParameters, rating int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func genresMatch(filterGenres, albumGenres []string) bool {
|
||||||
|
for _, g1 := range filterGenres {
|
||||||
|
for _, g2 := range albumGenres {
|
||||||
|
if strings.EqualFold(g1, g2) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dweymouth/go-subsonic/subsonic"
|
"github.com/dweymouth/go-subsonic/subsonic"
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||||
"github.com/dweymouth/supersonic/sharedutil"
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,31 +61,50 @@ func filterMatches(f mediaprovider.AlbumFilter, album *subsonic.AlbumID3, ignore
|
|||||||
|
|
||||||
func (s *subsonicMediaProvider) IterateAlbums(sortOrder string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
|
func (s *subsonicMediaProvider) IterateAlbums(sortOrder string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
|
||||||
if sortOrder == "" && len(filter.Genres) == 1 {
|
if sortOrder == "" && len(filter.Genres) == 1 {
|
||||||
return s.newBaseIter("byGenre", filter, s.prefetchCoverCB, map[string]string{"genre": filter.Genres[0]})
|
genre := filter.Genres[0]
|
||||||
|
// The Subsonic API (non-OpenSubsonic) returns only the first genre for multi-genre albums,
|
||||||
|
// but servers do internally match against all the genres the album is categorized with.
|
||||||
|
// So we must not additionally filter by genre to avoid excluding results where
|
||||||
|
// the single genre returned by Subsonic isn't the one we're iterating on.
|
||||||
|
filter.Genres = nil
|
||||||
|
fetchFn := func(offset, limit int) ([]*subsonic.AlbumID3, error) {
|
||||||
|
return s.client.GetAlbumList2("byGenre",
|
||||||
|
map[string]string{"genre": genre, "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit)})
|
||||||
|
}
|
||||||
|
return helpers.NewAlbumIterator(makeFetchFn(fetchFn), filter, s.prefetchCoverCB)
|
||||||
}
|
}
|
||||||
if sortOrder == "" && filter.ExcludeUnfavorited {
|
if sortOrder == "" && filter.ExcludeUnfavorited {
|
||||||
return s.newBaseIter("starred", filter, s.prefetchCoverCB, make(map[string]string))
|
filter.ExcludeUnfavorited = false // we're already filtering by this
|
||||||
|
return s.baseIterFromSimpleSortOrder("starred", filter)
|
||||||
}
|
}
|
||||||
if sortOrder == "" {
|
if sortOrder == "" {
|
||||||
sortOrder = AlbumSortRecentlyAdded // default
|
sortOrder = AlbumSortRecentlyAdded // default
|
||||||
}
|
}
|
||||||
switch sortOrder {
|
switch sortOrder {
|
||||||
case AlbumSortRecentlyAdded:
|
case AlbumSortRecentlyAdded:
|
||||||
return s.newBaseIter("newest", filter, s.prefetchCoverCB, make(map[string]string))
|
return s.baseIterFromSimpleSortOrder("newest", filter)
|
||||||
case AlbumSortRecentlyPlayed:
|
case AlbumSortRecentlyPlayed:
|
||||||
return s.newBaseIter("recent", filter, s.prefetchCoverCB, make(map[string]string))
|
return s.baseIterFromSimpleSortOrder("recent", filter)
|
||||||
case AlbumSortFrequentlyPlayed:
|
case AlbumSortFrequentlyPlayed:
|
||||||
return s.newBaseIter("frequent", filter, s.prefetchCoverCB, make(map[string]string))
|
return s.baseIterFromSimpleSortOrder("frequent", filter)
|
||||||
case AlbumSortRandom:
|
case AlbumSortRandom:
|
||||||
return s.newRandomIter(filter, s.prefetchCoverCB)
|
return s.newRandomIter(filter, s.prefetchCoverCB)
|
||||||
case AlbumSortTitleAZ:
|
case AlbumSortTitleAZ:
|
||||||
return s.newBaseIter("alphabeticalByName", filter, s.prefetchCoverCB, make(map[string]string))
|
return s.baseIterFromSimpleSortOrder("alphabeticalByName", filter)
|
||||||
case AlbumSortArtistAZ:
|
case AlbumSortArtistAZ:
|
||||||
return s.newBaseIter("alphabeticalByArtist", filter, s.prefetchCoverCB, make(map[string]string))
|
return s.baseIterFromSimpleSortOrder("alphabeticalByArtist", filter)
|
||||||
case AlbumSortYearAscending:
|
case AlbumSortYearAscending:
|
||||||
return s.newBaseIter("byYear", filter, s.prefetchCoverCB, map[string]string{"fromYear": "0", "toYear": "3000"})
|
fetchFn := func(offset, limit int) ([]*subsonic.AlbumID3, error) {
|
||||||
|
return s.client.GetAlbumList2("byYear",
|
||||||
|
map[string]string{"fromYear": "0", "toYear": "3000", "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit)})
|
||||||
|
}
|
||||||
|
return helpers.NewAlbumIterator(makeFetchFn(fetchFn), filter, s.prefetchCoverCB)
|
||||||
case AlbumSortYearDescending:
|
case AlbumSortYearDescending:
|
||||||
return s.newBaseIter("byYear", filter, s.prefetchCoverCB, map[string]string{"fromYear": "3000", "toYear": "0"})
|
fetchFn := func(offset, limit int) ([]*subsonic.AlbumID3, error) {
|
||||||
|
return s.client.GetAlbumList2("byYear",
|
||||||
|
map[string]string{"fromYear": "3000", "toYear": "0", "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit)})
|
||||||
|
}
|
||||||
|
return helpers.NewAlbumIterator(makeFetchFn(fetchFn), filter, s.prefetchCoverCB)
|
||||||
default:
|
default:
|
||||||
log.Printf("Undefined album sort order: %s", sortOrder)
|
log.Printf("Undefined album sort order: %s", sortOrder)
|
||||||
return nil
|
return nil
|
||||||
@@ -95,71 +115,6 @@ func (s *subsonicMediaProvider) SearchAlbums(searchQuery string, filter mediapro
|
|||||||
return s.newSearchIter(searchQuery, filter, s.prefetchCoverCB)
|
return s.newSearchIter(searchQuery, filter, s.prefetchCoverCB)
|
||||||
}
|
}
|
||||||
|
|
||||||
type baseIter struct {
|
|
||||||
listType string
|
|
||||||
filter mediaprovider.AlbumFilter
|
|
||||||
prefetchCB func(string)
|
|
||||||
serverPos int
|
|
||||||
s *subsonic.Client
|
|
||||||
opts map[string]string
|
|
||||||
prefetched []*mediaprovider.Album
|
|
||||||
prefetchedPos int
|
|
||||||
done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) newBaseIter(listType string, filter mediaprovider.AlbumFilter, cb func(string), opts map[string]string) *baseIter {
|
|
||||||
return &baseIter{
|
|
||||||
prefetchCB: cb,
|
|
||||||
listType: listType,
|
|
||||||
filter: filter,
|
|
||||||
s: s.client,
|
|
||||||
opts: opts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *baseIter) Next() *mediaprovider.Album {
|
|
||||||
if r.done {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if r.prefetched != nil && r.prefetchedPos < len(r.prefetched) {
|
|
||||||
a := r.prefetched[r.prefetchedPos]
|
|
||||||
r.prefetchedPos++
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
r.prefetched = nil
|
|
||||||
for { // keep fetching until we are done or have mathcing results
|
|
||||||
r.opts["offset"] = strconv.Itoa(r.serverPos)
|
|
||||||
albums, err := r.s.GetAlbumList2(r.listType, r.opts)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error fetching albums: %s", err.Error())
|
|
||||||
albums = nil
|
|
||||||
}
|
|
||||||
if len(albums) == 0 {
|
|
||||||
r.done = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
r.serverPos += len(albums)
|
|
||||||
albums = sharedutil.FilterSlice(albums, func(al *subsonic.AlbumID3) bool {
|
|
||||||
// The Subsonic API returns only the first genre for multi-genre albums,
|
|
||||||
// but servers do internally match against all the genres the album is categorized with.
|
|
||||||
// So we must not additionally filter by genre to avoid excluding results where
|
|
||||||
// the single genre returned by Subsonic isn't the one we're iterating on.
|
|
||||||
return filterMatches(r.filter, al, r.listType == "byGenre" /*ignoreGenre*/)
|
|
||||||
})
|
|
||||||
r.prefetched = sharedutil.MapSlice(albums, toAlbum)
|
|
||||||
if len(albums) > 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.prefetchedPos = 1
|
|
||||||
if r.prefetchCB != nil {
|
|
||||||
for _, album := range r.prefetched {
|
|
||||||
go r.prefetchCB(album.CoverArtID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r.prefetched[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
type searchIter struct {
|
type searchIter struct {
|
||||||
searchIterBase
|
searchIterBase
|
||||||
|
|
||||||
@@ -258,103 +213,31 @@ func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type randomIter struct {
|
func (s *subsonicMediaProvider) newRandomIter(filter mediaprovider.AlbumFilter, cb func(string)) mediaprovider.AlbumIterator {
|
||||||
filter mediaprovider.AlbumFilter
|
return helpers.NewRandomAlbumIter(
|
||||||
prefetchCB func(coverArtID string)
|
s.fetchFnFromStandardSort("newest"),
|
||||||
albumIDSet map[string]bool
|
makeFetchFn(func(offset, limit int) ([]*subsonic.AlbumID3, error) {
|
||||||
s *subsonic.Client
|
return s.client.GetAlbumList2("random", map[string]string{"size": strconv.Itoa(limit)})
|
||||||
prefetched []*subsonic.AlbumID3
|
}),
|
||||||
prefetchedPos int
|
filter, s.prefetchCoverCB)
|
||||||
// Random iter works in two phases - phase 1 by requesting random
|
|
||||||
// albums from the server. Since the Subsonic API provides no way
|
|
||||||
// of paginating a single random sort, we may get albums back twice.
|
|
||||||
// We use albumIDSet to keep track of which albums have already been returned.
|
|
||||||
// Once we start getting back too many already-returned albums,
|
|
||||||
// switch to requesting more albums from a deterministic sort order.
|
|
||||||
phaseTwo bool
|
|
||||||
offset int
|
|
||||||
done bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) newRandomIter(filter mediaprovider.AlbumFilter, cb func(string)) *randomIter {
|
func (s *subsonicMediaProvider) baseIterFromSimpleSortOrder(sort string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
|
||||||
return &randomIter{
|
return helpers.NewAlbumIterator(s.fetchFnFromStandardSort(sort), filter, s.prefetchCoverCB)
|
||||||
filter: filter,
|
|
||||||
prefetchCB: cb,
|
|
||||||
s: s.client,
|
|
||||||
albumIDSet: make(map[string]bool),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *randomIter) Next() *mediaprovider.Album {
|
func (s *subsonicMediaProvider) fetchFnFromStandardSort(sort string) helpers.AlbumFetchFn {
|
||||||
if r.done {
|
return makeFetchFn(func(offset, limit int) ([]*subsonic.AlbumID3, error) {
|
||||||
return nil
|
return s.client.GetAlbumList2(sort, map[string]string{"size": strconv.Itoa(limit), "offset": strconv.Itoa(offset)})
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// repeat fetch task until we have matching results
|
func makeFetchFn(subsonicFetchFn func(offset, limit int) ([]*subsonic.AlbumID3, error)) helpers.AlbumFetchFn {
|
||||||
// or we reach the end (handled via short circuit return)
|
return func(offset, limit int) ([]*mediaprovider.Album, error) {
|
||||||
for len(r.prefetched) == 0 {
|
al, err := subsonicFetchFn(offset, limit)
|
||||||
if r.phaseTwo {
|
if err != nil {
|
||||||
// fetch albums from deterministic order
|
return nil, err
|
||||||
albums, err := r.s.GetAlbumList2("newest", map[string]string{"size": "25", "offset": strconv.Itoa(r.offset)})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error fetching albums: %s", err.Error())
|
|
||||||
albums = nil
|
|
||||||
}
|
|
||||||
if len(albums) == 0 {
|
|
||||||
r.done = true
|
|
||||||
r.albumIDSet = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
r.offset += len(albums)
|
|
||||||
for _, album := range albums {
|
|
||||||
if _, ok := r.albumIDSet[album.ID]; !ok && filterMatches(r.filter, album, false) {
|
|
||||||
r.prefetched = append(r.prefetched, album)
|
|
||||||
if r.prefetchCB != nil {
|
|
||||||
go r.prefetchCB(album.CoverArt)
|
|
||||||
}
|
|
||||||
r.albumIDSet[album.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
albums, err := r.s.GetAlbumList2("random", map[string]string{"size": "25"})
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
r.done = true
|
|
||||||
r.albumIDSet = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var hitCount int
|
|
||||||
for _, album := range albums {
|
|
||||||
if _, ok := r.albumIDSet[album.ID]; !ok {
|
|
||||||
// still need to keep track even if album is not matched
|
|
||||||
// by the filter because we need to know when to move to phase two
|
|
||||||
hitCount++
|
|
||||||
r.albumIDSet[album.ID] = true
|
|
||||||
if filterMatches(r.filter, album, false) {
|
|
||||||
r.prefetched = append(r.prefetched, album)
|
|
||||||
if r.prefetchCB != nil {
|
|
||||||
go r.prefetchCB(album.CoverArt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if successRatio := float64(hitCount) / float64(25); successRatio < 0.3 {
|
|
||||||
r.phaseTwo = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return sharedutil.MapSlice(al, toAlbum), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// return from prefetched results
|
|
||||||
if len(r.prefetched) > 0 {
|
|
||||||
a := r.prefetched[r.prefetchedPos]
|
|
||||||
r.prefetchedPos++
|
|
||||||
if r.prefetchedPos == len(r.prefetched) {
|
|
||||||
r.prefetched = nil
|
|
||||||
r.prefetchedPos = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return toAlbum(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package subsonic
|
package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -9,6 +8,7 @@ import (
|
|||||||
"github.com/deluan/sanitize"
|
"github.com/deluan/sanitize"
|
||||||
"github.com/dweymouth/go-subsonic/subsonic"
|
"github.com/dweymouth/go-subsonic/subsonic"
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||||
"github.com/dweymouth/supersonic/sharedutil"
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
|||||||
p, e := s.client.GetPlaylists(nil)
|
p, e := s.client.GetPlaylists(nil)
|
||||||
if e == nil {
|
if e == nil {
|
||||||
playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool {
|
playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool {
|
||||||
return allTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
wg.Done()
|
wg.Done()
|
||||||
@@ -54,7 +54,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
|||||||
g, e := s.client.GetGenres()
|
g, e := s.client.GetGenres()
|
||||||
if e == nil {
|
if e == nil {
|
||||||
genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool {
|
genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool {
|
||||||
return allTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
wg.Done()
|
wg.Done()
|
||||||
@@ -66,23 +66,13 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
results := mergeResults(result, playlists, genres)
|
results := mergeResults(result, playlists, genres)
|
||||||
rankResults(results, querySanitized, queryLowerWords)
|
helpers.RankSearchResults(results, querySanitized, queryLowerWords)
|
||||||
if len(results) > maxResults {
|
if len(results) > maxResults {
|
||||||
results = results[:maxResults]
|
results = results[:maxResults]
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// name and terms should be pre-converted to the same case
|
|
||||||
func allTermsMatch(name string, terms []string) bool {
|
|
||||||
for _, t := range terms {
|
|
||||||
if !strings.Contains(name, t) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeResults(
|
func mergeResults(
|
||||||
searchResult *subsonic.SearchResult3,
|
searchResult *subsonic.SearchResult3,
|
||||||
matchingPlaylists []*subsonic.Playlist,
|
matchingPlaylists []*subsonic.Playlist,
|
||||||
@@ -144,54 +134,6 @@ func mergeResults(
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func rankResults(results []*mediaprovider.SearchResult, fullQuery string, queryTerms []string) {
|
|
||||||
if len(queryTerms) == 0 || len(results) < 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitizeMemo := make(map[string]string, len(results))
|
|
||||||
sanitized := func(s string) string {
|
|
||||||
if x, ok := sanitizeMemo[s]; ok {
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
x := strings.ToLower(sanitize.Accents(s))
|
|
||||||
sanitizeMemo[s] = x
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(results, func(i, j int) bool {
|
|
||||||
a, b := results[i], results[j]
|
|
||||||
aName := sanitized(a.Name)
|
|
||||||
bName := sanitized(b.Name)
|
|
||||||
|
|
||||||
// Compare by entire query
|
|
||||||
matchesA, matchesB := strings.Contains(aName, fullQuery), strings.Contains(bName, fullQuery)
|
|
||||||
if matchesA && !matchesB {
|
|
||||||
return true // item A has a direct match with the full query and B does not
|
|
||||||
} else if matchesB && !matchesA {
|
|
||||||
return false // item B matches but not A
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare by search query terms
|
|
||||||
for _, term := range queryTerms {
|
|
||||||
firstTermIdxA, firstTermIdxB := strings.Index(aName, term), strings.Index(bName, term)
|
|
||||||
if firstTermIdxA >= 0 && firstTermIdxB < 0 {
|
|
||||||
return true // item A has a direct match with the query term and B does not
|
|
||||||
} else if firstTermIdxB >= 0 && firstTermIdxA < 0 {
|
|
||||||
return false // item B matches but not A
|
|
||||||
}
|
|
||||||
|
|
||||||
if firstTermIdxA < firstTermIdxB {
|
|
||||||
return true // item A matches the query term starting at an earlier position
|
|
||||||
} else if firstTermIdxB < firstTermIdxA {
|
|
||||||
return false // item B matches first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Defer to item type for priority order
|
|
||||||
return a.Type < b.Type
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// select Subsonic single-valued name or join OpenSubsonic multi-valued names
|
// select Subsonic single-valued name or join OpenSubsonic multi-valued names
|
||||||
func getNameString(singleName string, idNames []subsonic.IDName) string {
|
func getNameString(singleName string, idNames []subsonic.IDName) string {
|
||||||
if len(idNames) == 0 {
|
if len(idNames) == 0 {
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ func (s *subsonicMediaProvider) DeletePlaylist(id string) error {
|
|||||||
return s.client.DeletePlaylist(id)
|
return s.client.DeletePlaylist(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) CanMakePublicPlaylist() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) EditPlaylist(id, name, description string, public bool) error {
|
func (s *subsonicMediaProvider) EditPlaylist(id, name, description string, public bool) error {
|
||||||
return s.client.UpdatePlaylist(id, map[string]string{
|
return s.client.UpdatePlaylist(id, map[string]string{
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -51,8 +55,12 @@ func (s *subsonicMediaProvider) EditPlaylist(id, name, description string, publi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) EditPlaylistTracks(id string, trackIDsToAdd []string, trackIndexesToRemove []int) error {
|
func (s *subsonicMediaProvider) AddPlaylistTracks(id string, trackIDsToAdd []string) error {
|
||||||
return s.client.UpdatePlaylistTracks(id, trackIDsToAdd, trackIndexesToRemove)
|
return s.client.UpdatePlaylistTracks(id, trackIDsToAdd, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) RemovePlaylistTracks(id string, removeIdxs []int) error {
|
||||||
|
return s.client.UpdatePlaylistTracks(id, nil, removeIdxs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) GetTrack(trackID string) (*mediaprovider.Track, error) {
|
func (s *subsonicMediaProvider) GetTrack(trackID string) (*mediaprovider.Track, error) {
|
||||||
@@ -248,10 +256,21 @@ func (s *subsonicMediaProvider) ReplacePlaylistTracks(playlistID string, trackID
|
|||||||
return s.client.CreatePlaylistWithTracks(trackIDs, map[string]string{"playlistId": playlistID})
|
return s.client.CreatePlaylistWithTracks(trackIDs, map[string]string{"playlistId": playlistID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) Scrobble(trackID string, submission bool) error {
|
func (s *subsonicMediaProvider) ClientDecidesScrobble() bool { return true }
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) TrackBeganPlayback(trackID string) error {
|
||||||
return s.client.Scrobble(trackID, map[string]string{
|
return s.client.Scrobble(trackID, map[string]string{
|
||||||
"time": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
"time": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||||
"submission": strconv.FormatBool(submission)})
|
"submission": "false"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) TrackEndedPlayback(trackID string, _ int, submission bool) error {
|
||||||
|
if !submission {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.client.Scrobble(trackID, map[string]string{
|
||||||
|
"time": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||||
|
"submission": "true"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *subsonicMediaProvider) SetFavorite(params mediaprovider.RatingFavoriteParameters, favorite bool) error {
|
func (s *subsonicMediaProvider) SetFavorite(params mediaprovider.RatingFavoriteParameters, favorite bool) error {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
subsonicCli "github.com/dweymouth/go-subsonic/subsonic"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SubsonicServer struct {
|
||||||
|
subsonicCli.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubsonicServer) Login(username, password string) mediaprovider.LoginResponse {
|
||||||
|
s.User = username
|
||||||
|
err := s.Client.Authenticate(password)
|
||||||
|
return mediaprovider.LoginResponse{
|
||||||
|
Error: err,
|
||||||
|
IsAuthError: err == subsonicCli.ErrAuthenticationFailure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubsonicServer) MediaProvider() mediaprovider.MediaProvider {
|
||||||
|
return SubsonicMediaProvider(&s.Client)
|
||||||
|
}
|
||||||
+27
-13
@@ -35,9 +35,10 @@ type PlaybackManager struct {
|
|||||||
sm *ServerManager
|
sm *ServerManager
|
||||||
player *player.Player
|
player *player.Player
|
||||||
|
|
||||||
playTimeStopwatch util.Stopwatch
|
playTimeStopwatch util.Stopwatch
|
||||||
curTrackTime float64
|
curTrackTime float64
|
||||||
callbacksDisabled bool
|
latestTrackPosition float64 // cleared by checkScrobble
|
||||||
|
callbacksDisabled bool
|
||||||
|
|
||||||
playQueue []*mediaprovider.Track
|
playQueue []*mediaprovider.Track
|
||||||
nowPlayingIdx int64
|
nowPlayingIdx int64
|
||||||
@@ -73,15 +74,15 @@ func NewPlaybackManager(
|
|||||||
if tracknum >= int64(len(pm.playQueue)) {
|
if tracknum >= int64(len(pm.playQueue)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pm.checkScrobble()
|
pm.checkScrobble() // scrobble the previous song if needed
|
||||||
if pm.player.GetStatus().State == player.Playing {
|
if pm.player.GetStatus().State == player.Playing {
|
||||||
pm.playTimeStopwatch.Start()
|
pm.playTimeStopwatch.Start()
|
||||||
}
|
}
|
||||||
pm.nowPlayingIdx = tracknum
|
pm.nowPlayingIdx = tracknum
|
||||||
pm.curTrackTime = float64(pm.playQueue[pm.nowPlayingIdx].Duration)
|
pm.curTrackTime = float64(pm.playQueue[pm.nowPlayingIdx].Duration)
|
||||||
|
pm.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
|
||||||
pm.invokeOnSongChangeCallbacks()
|
pm.invokeOnSongChangeCallbacks()
|
||||||
pm.doUpdateTimePos()
|
pm.doUpdateTimePos()
|
||||||
pm.sendNowPlayingScrobble()
|
|
||||||
})
|
})
|
||||||
p.OnSeek(func() {
|
p.OnSeek(func() {
|
||||||
pm.doUpdateTimePos()
|
pm.doUpdateTimePos()
|
||||||
@@ -380,13 +381,17 @@ func (p *PlaybackManager) checkScrobble() {
|
|||||||
pcnt := playDur.Seconds() / p.curTrackTime * 100
|
pcnt := playDur.Seconds() / p.curTrackTime * 100
|
||||||
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
|
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
|
||||||
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
|
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
|
||||||
if timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent) {
|
|
||||||
song := p.playQueue[p.nowPlayingIdx]
|
track := p.playQueue[p.nowPlayingIdx]
|
||||||
log.Printf("Scrobbling %q", song.Name)
|
var submission bool
|
||||||
song.PlayCount += 1
|
server := p.sm.Server
|
||||||
p.lastScrobbled = song
|
if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) {
|
||||||
go p.sm.Server.Scrobble(song.ID, true)
|
track.PlayCount += 1
|
||||||
|
p.lastScrobbled = track
|
||||||
|
submission = true
|
||||||
}
|
}
|
||||||
|
go server.TrackEndedPlayback(track.ID, int(p.latestTrackPosition), submission)
|
||||||
|
p.latestTrackPosition = 0
|
||||||
p.playTimeStopwatch.Reset()
|
p.playTimeStopwatch.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,8 +399,14 @@ func (p *PlaybackManager) sendNowPlayingScrobble() {
|
|||||||
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
song := p.playQueue[p.nowPlayingIdx]
|
track := p.playQueue[p.nowPlayingIdx]
|
||||||
go p.sm.Server.Scrobble(song.ID, false)
|
server := p.sm.Server
|
||||||
|
if !server.ClientDecidesScrobble() {
|
||||||
|
// server will count track as scrobbled as soon as it starts playing
|
||||||
|
p.lastScrobbled = track
|
||||||
|
track.PlayCount += 1
|
||||||
|
}
|
||||||
|
go p.sm.Server.TrackBeganPlayback(track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlaybackManager) invokeOnSongChangeCallbacks() {
|
func (p *PlaybackManager) invokeOnSongChangeCallbacks() {
|
||||||
@@ -433,6 +444,9 @@ func (p *PlaybackManager) doUpdateTimePos() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s := p.player.GetStatus()
|
s := p.player.GetStatus()
|
||||||
|
if s.TimePos > p.latestTrackPosition {
|
||||||
|
p.latestTrackPosition = s.TimePos
|
||||||
|
}
|
||||||
for _, cb := range p.onPlayTimeUpdate {
|
for _, cb := range p.onPlayTimeUpdate {
|
||||||
cb(s.TimePos, s.Duration)
|
cb(s.TimePos, s.Duration)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/dweymouth/go-subsonic/subsonic"
|
|
||||||
)
|
|
||||||
|
|
||||||
type searchIterBase struct {
|
|
||||||
query string
|
|
||||||
artistOffset int
|
|
||||||
albumOffset int
|
|
||||||
songOffset int
|
|
||||||
s *subsonic.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *searchIterBase) fetchResults() *subsonic.SearchResult3 {
|
|
||||||
searchOpts := map[string]string{
|
|
||||||
"artistOffset": strconv.Itoa(s.artistOffset),
|
|
||||||
"albumOffset": strconv.Itoa(s.albumOffset),
|
|
||||||
"songOffset": strconv.Itoa(s.songOffset),
|
|
||||||
}
|
|
||||||
results, err := s.s.Search3(s.query, searchOpts)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
results = nil
|
|
||||||
}
|
|
||||||
if results == nil || len(results.Album)+len(results.Artist)+len(results.Song) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
+55
-23
@@ -6,9 +6,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dweymouth/go-jellyfin"
|
||||||
"github.com/dweymouth/go-subsonic/subsonic"
|
"github.com/dweymouth/go-subsonic/subsonic"
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
jellyfinMP "github.com/dweymouth/supersonic/backend/mediaprovider/jellyfin"
|
||||||
subsonicMP "github.com/dweymouth/supersonic/backend/mediaprovider/subsonic"
|
subsonicMP "github.com/dweymouth/supersonic/backend/mediaprovider/subsonic"
|
||||||
|
"github.com/dweymouth/supersonic/res"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/zalando/go-keyring"
|
"github.com/zalando/go-keyring"
|
||||||
)
|
)
|
||||||
@@ -43,7 +46,7 @@ func (s *ServerManager) ConnectToServer(conf *ServerConfig, password string) err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.Server = subsonicMP.SubsonicMediaProvider(cli)
|
s.Server = cli.MediaProvider()
|
||||||
s.Server.SetPrefetchCoverCallback(s.prefetchCoverCB)
|
s.Server.SetPrefetchCoverCallback(s.prefetchCoverCB)
|
||||||
s.LoggedInUser = conf.Username
|
s.LoggedInUser = conf.Username
|
||||||
s.ServerID = conf.ID
|
s.ServerID = conf.ID
|
||||||
@@ -156,27 +159,56 @@ func (s *ServerManager) SetServerPassword(server *ServerConfig, password string)
|
|||||||
return keyring.Set(s.appName, server.ID.String(), password)
|
return keyring.Set(s.appName, server.ID.String(), password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServerManager) connect(connection ServerConnection, password string) (*subsonic.Client, error) {
|
func (s *ServerManager) connect(connection ServerConnection, password string) (mediaprovider.Server, error) {
|
||||||
cli := &subsonic.Client{
|
var cli, altCli mediaprovider.Server
|
||||||
Client: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
BaseUrl: connection.Hostname,
|
if connection.ServerType == ServerTypeJellyfin {
|
||||||
User: connection.Username,
|
cli = &jellyfinMP.JellyfinServer{
|
||||||
PasswordAuth: connection.LegacyAuth,
|
Client: jellyfin.Client{
|
||||||
ClientName: "supersonic",
|
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
BaseURL: connection.Hostname,
|
||||||
altCli := &subsonic.Client{
|
ClientName: res.AppName,
|
||||||
Client: &http.Client{Timeout: 10 * time.Second},
|
ClientVersion: res.AppVersion,
|
||||||
BaseUrl: connection.AltHostname,
|
},
|
||||||
User: connection.Username,
|
|
||||||
PasswordAuth: connection.LegacyAuth,
|
|
||||||
ClientName: "supersonic",
|
|
||||||
}
|
|
||||||
pingChan := make(chan bool, 2) // false for primary hostname, true for alternate
|
|
||||||
pingFunc := func(delay time.Duration, cli *subsonic.Client, val bool) {
|
|
||||||
<-time.After(delay)
|
|
||||||
if err := cli.Authenticate(password); err == nil {
|
|
||||||
pingChan <- val
|
|
||||||
}
|
}
|
||||||
|
altCli = &jellyfinMP.JellyfinServer{
|
||||||
|
Client: jellyfin.Client{
|
||||||
|
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
BaseURL: connection.AltHostname,
|
||||||
|
ClientName: res.AppName,
|
||||||
|
ClientVersion: res.AppVersion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cli = &subsonicMP.SubsonicServer{
|
||||||
|
Client: subsonic.Client{
|
||||||
|
Client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
BaseUrl: connection.Hostname,
|
||||||
|
User: connection.Username,
|
||||||
|
PasswordAuth: connection.LegacyAuth,
|
||||||
|
ClientName: res.AppName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
altCli = &subsonicMP.SubsonicServer{
|
||||||
|
Client: subsonic.Client{
|
||||||
|
Client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
BaseUrl: connection.AltHostname,
|
||||||
|
User: connection.Username,
|
||||||
|
PasswordAuth: connection.LegacyAuth,
|
||||||
|
ClientName: res.AppName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var authError error
|
||||||
|
pingChan := make(chan bool, 2) // false for primary hostname, true for alternate
|
||||||
|
pingFunc := func(delay time.Duration, cli mediaprovider.Server, val bool) {
|
||||||
|
<-time.After(delay)
|
||||||
|
resp := cli.Login(connection.Username, password)
|
||||||
|
if resp.Error != nil && !resp.IsAuthError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authError = resp.Error
|
||||||
|
pingChan <- val // reached the server
|
||||||
}
|
}
|
||||||
go pingFunc(0, cli, false)
|
go pingFunc(0, cli, false)
|
||||||
if connection.AltHostname != "" {
|
if connection.AltHostname != "" {
|
||||||
@@ -190,8 +222,8 @@ func (s *ServerManager) connect(connection ServerConnection, password string) (*
|
|||||||
return nil, ErrUnreachable
|
return nil, ErrUnreachable
|
||||||
case altPing := <-pingChan:
|
case altPing := <-pingChan:
|
||||||
if altPing {
|
if altPing {
|
||||||
return altCli, nil
|
return altCli, authError
|
||||||
}
|
}
|
||||||
return cli, nil
|
return cli, authError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ require (
|
|||||||
fyne.io/fyne/v2 v2.4.1
|
fyne.io/fyne/v2 v2.4.1
|
||||||
github.com/20after4/configdir v0.1.1
|
github.com/20after4/configdir v0.1.1
|
||||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
||||||
|
github.com/dweymouth/go-jellyfin v0.0.0-20231115024427-f39ad02e465a
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363
|
github.com/dweymouth/go-subsonic v0.0.0-20231115012731-a3d1f90274c1
|
||||||
github.com/fsnotify/fsnotify v1.6.0
|
github.com/fsnotify/fsnotify v1.6.0
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
@@ -38,10 +39,10 @@ require (
|
|||||||
github.com/stretchr/testify v1.8.4 // indirect
|
github.com/stretchr/testify v1.8.4 // indirect
|
||||||
github.com/tevino/abool v1.2.0 // indirect
|
github.com/tevino/abool v1.2.0 // indirect
|
||||||
github.com/yuin/goldmark v1.5.5 // indirect
|
github.com/yuin/goldmark v1.5.5 // indirect
|
||||||
golang.org/x/image v0.13.0 // indirect
|
golang.org/x/image v0.14.0 // indirect
|
||||||
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda // indirect
|
golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda // indirect
|
||||||
golang.org/x/sys v0.11.0 // indirect
|
golang.org/x/sys v0.11.0 // indirect
|
||||||
golang.org/x/text v0.13.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect
|
honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,10 +71,12 @@ github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+
|
|||||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555 h1:8S+d0LuwdTUEipEzFeXp8rNwTQD47dBdDOg9+FI1+Vw=
|
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555 h1:8S+d0LuwdTUEipEzFeXp8rNwTQD47dBdDOg9+FI1+Vw=
|
||||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555/go.mod h1:AWM1iPM2YfliduZ4u/kQzP9E6ARIWm0gg+57GpYzWro=
|
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555/go.mod h1:AWM1iPM2YfliduZ4u/kQzP9E6ARIWm0gg+57GpYzWro=
|
||||||
|
github.com/dweymouth/go-jellyfin v0.0.0-20231115024427-f39ad02e465a h1:ol9UFYBik7tidWgQjBy3kBEv8cEO1JmG9JH3cgS0MYU=
|
||||||
|
github.com/dweymouth/go-jellyfin v0.0.0-20231115024427-f39ad02e465a/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU=
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363 h1:MIH7MAWWPPVRKEKxz+RJubn+ycyQPimHn1Zvoxs1KRI=
|
github.com/dweymouth/go-subsonic v0.0.0-20231115012731-a3d1f90274c1 h1:1I5/hlV4lQ0B0NdBZvwWSqL8hvvKhRRbJK6fGsxFSLs=
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363/go.mod h1:dVriurACA/XTnE7BgSOTapYOtxMq7jTOVExrIbwi84c=
|
github.com/dweymouth/go-subsonic v0.0.0-20231115012731-a3d1f90274c1/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
@@ -335,8 +337,9 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
|
|||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
|
|
||||||
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
|
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
|
||||||
|
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||||
|
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
@@ -495,8 +498,9 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ func newAlbumPage(
|
|||||||
}
|
}
|
||||||
a.tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
|
a.tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
|
||||||
a.tracklist.SetSorting(sort)
|
a.tracklist.SetSorting(sort)
|
||||||
|
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||||
|
a.tracklist.Options.DisableRating = !canRate
|
||||||
a.tracklist.OnVisibleColumnsChanged = func(cols []string) {
|
a.tracklist.OnVisibleColumnsChanged = func(cols []string) {
|
||||||
a.cfg.TracklistColumns = cols
|
a.cfg.TracklistColumns = cols
|
||||||
}
|
}
|
||||||
@@ -154,9 +156,7 @@ func (a *AlbumPage) load() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.header.Update(album, a.im)
|
a.header.Update(album, a.im)
|
||||||
a.tracklist.Options = widgets.TracklistOptions{
|
a.tracklist.Options.ShowDiscNumber = len(album.Tracks) > 0 && album.Tracks[0].DiscNumber != album.Tracks[len(album.Tracks)-1].DiscNumber
|
||||||
ShowDiscNumber: album.Tracks[0].DiscNumber != album.Tracks[len(album.Tracks)-1].DiscNumber,
|
|
||||||
}
|
|
||||||
a.tracks = album.Tracks
|
a.tracks = album.Tracks
|
||||||
a.tracklist.SetTracks(album.Tracks)
|
a.tracklist.SetTracks(album.Tracks)
|
||||||
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
||||||
@@ -314,8 +314,10 @@ func (a *AlbumPageHeader) showPopUpCover() {
|
|||||||
|
|
||||||
func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string {
|
func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string {
|
||||||
var discs string
|
var discs string
|
||||||
if discCount := a.Tracks[len(a.Tracks)-1].DiscNumber; discCount > 1 {
|
if len(a.Tracks) > 0 {
|
||||||
discs = fmt.Sprintf("%d discs · ", discCount)
|
if discCount := a.Tracks[len(a.Tracks)-1].DiscNumber; discCount > 1 {
|
||||||
|
discs = fmt.Sprintf("%d discs · ", discCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tracks := "tracks"
|
tracks := "tracks"
|
||||||
if a.TrackCount == 1 {
|
if a.TrackCount == 1 {
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ func (a *albumsPageAdapter) PlaceholderResource() fyne.Resource { return myTheme
|
|||||||
func (a *albumsPageAdapter) Route() controller.Route { return controller.AlbumsRoute() }
|
func (a *albumsPageAdapter) Route() controller.Route { return controller.AlbumsRoute() }
|
||||||
|
|
||||||
func (a *albumsPageAdapter) SortOrders() ([]string, string) {
|
func (a *albumsPageAdapter) SortOrders() ([]string, string) {
|
||||||
orders := a.contr.App.ServerManager.Server.AlbumSortOrders()
|
orders := a.mp.AlbumSortOrders()
|
||||||
if !sharedutil.SliceContains(a.mp.AlbumSortOrders(), a.cfg.SortOrder) {
|
sortOrder := a.cfg.SortOrder
|
||||||
a.cfg.SortOrder = string(a.mp.AlbumSortOrders()[0])
|
if !sharedutil.SliceContains(orders, sortOrder) {
|
||||||
|
sortOrder = string(orders[0])
|
||||||
}
|
}
|
||||||
return orders, a.cfg.SortOrder
|
return orders, sortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *albumsPageAdapter) SaveSortOrder(order string) {
|
func (a *albumsPageAdapter) SaveSortOrder(order string) {
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ func (a *ArtistPage) showTopTracks() {
|
|||||||
tl = widgets.NewTracklist(ts)
|
tl = widgets.NewTracklist(ts)
|
||||||
}
|
}
|
||||||
tl.Options = widgets.TracklistOptions{AutoNumber: true}
|
tl.Options = widgets.TracklistOptions{AutoNumber: true}
|
||||||
|
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||||
|
tl.Options.DisableRating = !canRate
|
||||||
tl.SetVisibleColumns(a.cfg.TracklistColumns)
|
tl.SetVisibleColumns(a.cfg.TracklistColumns)
|
||||||
tl.SetSorting(a.trackSort)
|
tl.SetSorting(a.trackSort)
|
||||||
tl.OnVisibleColumnsChanged = func(cols []string) {
|
tl.OnVisibleColumnsChanged = func(cols []string) {
|
||||||
@@ -335,6 +337,14 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums) {
|
|||||||
a.artistID = artist.ID
|
a.artistID = artist.ID
|
||||||
a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name
|
a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name
|
||||||
a.titleDisp.Refresh()
|
a.titleDisp.Refresh()
|
||||||
|
if artist.CoverArtID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if im, err := a.artistPage.im.GetCoverThumbnail(artist.CoverArtID); err != nil {
|
||||||
|
log.Printf("failed to load artist image: %v", err)
|
||||||
|
} else {
|
||||||
|
a.artistImage.SetImage(im, true /*tappable*/)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) {
|
func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) {
|
||||||
|
|||||||
@@ -370,6 +370,8 @@ func (a *FavoritesPage) onShowFavoriteSongs() {
|
|||||||
tracklist = widgets.NewTracklist(fav.Tracks)
|
tracklist = widgets.NewTracklist(fav.Tracks)
|
||||||
}
|
}
|
||||||
tracklist.Options = widgets.TracklistOptions{AutoNumber: true}
|
tracklist.Options = widgets.TracklistOptions{AutoNumber: true}
|
||||||
|
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||||
|
tracklist.Options.DisableRating = !canRate
|
||||||
tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
|
tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
|
||||||
tracklist.SetSorting(a.trackSort)
|
tracklist.SetSorting(a.trackSort)
|
||||||
tracklist.OnVisibleColumnsChanged = func(cols []string) {
|
tracklist.OnVisibleColumnsChanged = func(cols []string) {
|
||||||
|
|||||||
@@ -196,8 +196,16 @@ func NewGenreList(sorting widgets.ListHeaderSort) *GenreList {
|
|||||||
row := item.(*GenreListRow)
|
row := item.(*GenreListRow)
|
||||||
row.Item = a.genres[id]
|
row.Item = a.genres[id]
|
||||||
row.nameLabel.Text = row.Item.Name
|
row.nameLabel.Text = row.Item.Name
|
||||||
row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount)
|
if row.Item.AlbumCount >= 0 {
|
||||||
row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount)
|
row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount)
|
||||||
|
} else {
|
||||||
|
row.albumCountLabel.Text = ""
|
||||||
|
}
|
||||||
|
if row.Item.TrackCount >= 0 {
|
||||||
|
row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount)
|
||||||
|
} else {
|
||||||
|
row.trackCountLabel.Text = ""
|
||||||
|
}
|
||||||
row.Refresh()
|
row.Refresh()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ type NowPlayingPage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type nowPlayingPageState struct {
|
type nowPlayingPageState struct {
|
||||||
contr *controller.Controller
|
contr *controller.Controller
|
||||||
pool *util.WidgetPool
|
pool *util.WidgetPool
|
||||||
conf *backend.NowPlayingPageConfig
|
conf *backend.NowPlayingPageConfig
|
||||||
pm *backend.PlaybackManager
|
pm *backend.PlaybackManager
|
||||||
p *player.Player
|
p *player.Player
|
||||||
|
canRate bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNowPlayingPage(
|
func NewNowPlayingPage(
|
||||||
@@ -51,9 +52,10 @@ func NewNowPlayingPage(
|
|||||||
conf *backend.NowPlayingPageConfig,
|
conf *backend.NowPlayingPageConfig,
|
||||||
pm *backend.PlaybackManager,
|
pm *backend.PlaybackManager,
|
||||||
p *player.Player, // TODO: once other player backends are supported (eg uPnP), refactor
|
p *player.Player, // TODO: once other player backends are supported (eg uPnP), refactor
|
||||||
|
canRate bool,
|
||||||
) *NowPlayingPage {
|
) *NowPlayingPage {
|
||||||
a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{
|
a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{
|
||||||
contr: contr, pool: pool, conf: conf, pm: pm, p: p,
|
contr: contr, pool: pool, conf: conf, pm: pm, p: p, canRate: canRate,
|
||||||
}}
|
}}
|
||||||
a.ExtendBaseWidget(a)
|
a.ExtendBaseWidget(a)
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ func NewNowPlayingPage(
|
|||||||
a.tracklist.Options = widgets.TracklistOptions{
|
a.tracklist.Options = widgets.TracklistOptions{
|
||||||
AutoNumber: true,
|
AutoNumber: true,
|
||||||
DisablePlaybackMenu: true,
|
DisablePlaybackMenu: true,
|
||||||
|
DisableRating: !canRate,
|
||||||
AuxiliaryMenuItems: []*fyne.MenuItem{
|
AuxiliaryMenuItems: []*fyne.MenuItem{
|
||||||
fyne.NewMenuItem("Remove from queue", a.onRemoveSelectedFromQueue),
|
fyne.NewMenuItem("Remove from queue", a.onRemoveSelectedFromQueue),
|
||||||
},
|
},
|
||||||
@@ -213,5 +216,5 @@ func (a *NowPlayingPage) load(highlightedTrackID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *nowPlayingPageState) Restore() Page {
|
func (s *nowPlayingPageState) Restore() Page {
|
||||||
return NewNowPlayingPage("", s.contr, s.pool, s.conf, s.pm, s.p)
|
return NewNowPlayingPage("", s.contr, s.pool, s.conf, s.pm, s.p, s.canRate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ func newPlaylistPage(
|
|||||||
fyne.NewMenuItem("Move down", a.onMoveSelectedDown),
|
fyne.NewMenuItem("Move down", a.onMoveSelectedDown),
|
||||||
fyne.NewMenuItem("Move to bottom", a.onMoveSelectedToBottom),
|
fyne.NewMenuItem("Move to bottom", a.onMoveSelectedToBottom),
|
||||||
}...)
|
}...)
|
||||||
|
_, canRate := a.sm.Server.(mediaprovider.SupportsRating)
|
||||||
a.tracklist.Options = widgets.TracklistOptions{
|
a.tracklist.Options = widgets.TracklistOptions{
|
||||||
|
DisableRating: !canRate,
|
||||||
AuxiliaryMenuItems: []*fyne.MenuItem{reorderMenu,
|
AuxiliaryMenuItems: []*fyne.MenuItem{reorderMenu,
|
||||||
fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist)},
|
fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist)},
|
||||||
}
|
}
|
||||||
@@ -210,14 +212,15 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
|
func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
|
||||||
sel := sharedutil.ToSet(a.tracklist.SelectedTrackIDs())
|
ids := a.tracklist.SelectedTrackIDs()
|
||||||
|
sel := sharedutil.ToSet(ids)
|
||||||
idxs := make([]int, 0, len(sel))
|
idxs := make([]int, 0, len(sel))
|
||||||
for i, tr := range a.tracks {
|
for i, tr := range a.tracks {
|
||||||
if _, ok := sel[tr.ID]; ok {
|
if _, ok := sel[tr.ID]; ok {
|
||||||
idxs = append(idxs, i)
|
idxs = append(idxs, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.sm.Server.EditPlaylistTracks(a.playlistID, nil, idxs)
|
a.sm.Server.RemovePlaylistTracks(a.playlistID, idxs)
|
||||||
a.tracklist.UnselectAll()
|
a.tracklist.UnselectAll()
|
||||||
a.Reload()
|
a.Reload()
|
||||||
}
|
}
|
||||||
@@ -283,6 +286,9 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
|
|||||||
sharedutil.TracksToIDs(a.page.tracks))
|
sharedutil.TracksToIDs(a.page.tracks))
|
||||||
}),
|
}),
|
||||||
fyne.NewMenuItem("Download...", func() {
|
fyne.NewMenuItem("Download...", func() {
|
||||||
|
if a.playlistInfo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.page.contr.ShowDownloadDialog(a.page.tracks, a.playlistInfo.Name)
|
a.page.contr.ShowDownloadDialog(a.page.tracks, a.playlistInfo.Name)
|
||||||
}))
|
}))
|
||||||
pop = widget.NewPopUpMenu(menu, fyne.CurrentApp().Driver().CanvasForObject(a))
|
pop = widget.NewPopUpMenu(menu, fyne.CurrentApp().Driver().CanvasForObject(a))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package browsing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/dweymouth/supersonic/backend"
|
"github.com/dweymouth/supersonic/backend"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
"github.com/dweymouth/supersonic/ui/controller"
|
"github.com/dweymouth/supersonic/ui/controller"
|
||||||
"github.com/dweymouth/supersonic/ui/util"
|
"github.com/dweymouth/supersonic/ui/util"
|
||||||
)
|
)
|
||||||
@@ -45,7 +46,8 @@ func (r Router) CreatePage(rte controller.Route) Page {
|
|||||||
case controller.Genres:
|
case controller.Genres:
|
||||||
return NewGenresPage(r.Controller, r.App.ServerManager.Server)
|
return NewGenresPage(r.Controller, r.App.ServerManager.Server)
|
||||||
case controller.NowPlaying:
|
case controller.NowPlaying:
|
||||||
return NewNowPlayingPage(rte.Arg, r.Controller, r.widgetPool, &r.App.Config.NowPlayingPage, r.App.PlaybackManager, r.App.Player)
|
_, canRate := r.App.ServerManager.Server.(mediaprovider.SupportsRating)
|
||||||
|
return NewNowPlayingPage(rte.Arg, r.Controller, r.widgetPool, &r.App.Config.NowPlayingPage, r.App.PlaybackManager, r.App.Player, canRate)
|
||||||
case controller.Playlist:
|
case controller.Playlist:
|
||||||
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
|
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
|
||||||
case controller.Playlists:
|
case controller.Playlists:
|
||||||
|
|||||||
@@ -39,20 +39,18 @@ type tracksPageState struct {
|
|||||||
contr *controller.Controller
|
contr *controller.Controller
|
||||||
conf *backend.TracksPageConfig
|
conf *backend.TracksPageConfig
|
||||||
mp mediaprovider.MediaProvider
|
mp mediaprovider.MediaProvider
|
||||||
|
canRate bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, pool *util.WidgetPool, mp mediaprovider.MediaProvider) *TracksPage {
|
func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, pool *util.WidgetPool, mp mediaprovider.MediaProvider) *TracksPage {
|
||||||
t := &TracksPage{tracksPageState: tracksPageState{contr: contr, conf: conf, widgetPool: pool, mp: mp}}
|
t := &TracksPage{tracksPageState: tracksPageState{contr: contr, conf: conf, widgetPool: pool, mp: mp}}
|
||||||
t.ExtendBaseWidget(t)
|
t.ExtendBaseWidget(t)
|
||||||
|
|
||||||
if tl := t.widgetPool.Obtain(util.WidgetTypeTracklist); tl != nil {
|
t.tracklist = t.obtainTracklist()
|
||||||
t.tracklist = tl.(*widgets.Tracklist)
|
_, t.canRate = mp.(mediaprovider.SupportsRating)
|
||||||
t.tracklist.Reset()
|
|
||||||
} else {
|
|
||||||
t.tracklist = widgets.NewTracklist(nil)
|
|
||||||
}
|
|
||||||
t.tracklist.Options = widgets.TracklistOptions{
|
t.tracklist.Options = widgets.TracklistOptions{
|
||||||
DisableSorting: true,
|
DisableSorting: true,
|
||||||
|
DisableRating: !t.canRate,
|
||||||
AutoNumber: true,
|
AutoNumber: true,
|
||||||
}
|
}
|
||||||
t.tracklist.SetVisibleColumns(conf.TracklistColumns)
|
t.tracklist.SetVisibleColumns(conf.TracklistColumns)
|
||||||
@@ -128,8 +126,12 @@ func (t *TracksPage) OnSearched(query string) {
|
|||||||
|
|
||||||
func (t *TracksPage) doSearch(query string) {
|
func (t *TracksPage) doSearch(query string) {
|
||||||
if t.searchTracklist == nil {
|
if t.searchTracklist == nil {
|
||||||
t.searchTracklist = widgets.NewTracklist(nil)
|
t.searchTracklist = t.obtainTracklist()
|
||||||
t.searchTracklist.Options = widgets.TracklistOptions{AutoNumber: true}
|
t.searchTracklist.Options = widgets.TracklistOptions{
|
||||||
|
AutoNumber: true,
|
||||||
|
DisableSorting: true,
|
||||||
|
DisableRating: !t.canRate,
|
||||||
|
}
|
||||||
t.searchTracklist.SetVisibleColumns(t.conf.TracklistColumns)
|
t.searchTracklist.SetVisibleColumns(t.conf.TracklistColumns)
|
||||||
t.searchTracklist.SetNowPlaying(t.nowPlayingID)
|
t.searchTracklist.SetNowPlaying(t.nowPlayingID)
|
||||||
t.searchTracklist.OnVisibleColumnsChanged = func(cols []string) {
|
t.searchTracklist.OnVisibleColumnsChanged = func(cols []string) {
|
||||||
@@ -176,3 +178,12 @@ func (s *tracksPageState) Restore() Page {
|
|||||||
func (t *TracksPage) playRandomSongs() {
|
func (t *TracksPage) playRandomSongs() {
|
||||||
t.contr.App.PlaybackManager.PlayRandomSongs("")
|
t.contr.App.PlaybackManager.PlayRandomSongs("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *TracksPage) obtainTracklist() *widgets.Tracklist {
|
||||||
|
if tl := t.widgetPool.Obtain(util.WidgetTypeTracklist); tl != nil {
|
||||||
|
tracklist := tl.(*widgets.Tracklist)
|
||||||
|
tracklist.Reset()
|
||||||
|
return tracklist
|
||||||
|
}
|
||||||
|
return widgets.NewTracklist(nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ func (m *Controller) PromptForFirstServer() {
|
|||||||
pop.Hide()
|
pop.Hide()
|
||||||
m.doModalClosed()
|
m.doModalClosed()
|
||||||
conn := backend.ServerConnection{
|
conn := backend.ServerConnection{
|
||||||
|
ServerType: d.ServerType,
|
||||||
Hostname: d.Host,
|
Hostname: d.Host,
|
||||||
AltHostname: d.AltHost,
|
AltHostname: d.AltHost,
|
||||||
Username: d.Username,
|
Username: d.Username,
|
||||||
@@ -268,8 +269,8 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
|||||||
if playlistChoice < 0 {
|
if playlistChoice < 0 {
|
||||||
go m.App.ServerManager.Server.CreatePlaylist(newPlaylistName, trackIDs)
|
go m.App.ServerManager.Server.CreatePlaylist(newPlaylistName, trackIDs)
|
||||||
} else {
|
} else {
|
||||||
go m.App.ServerManager.Server.EditPlaylistTracks(
|
go m.App.ServerManager.Server.AddPlaylistTracks(
|
||||||
pls[playlistChoice].ID, trackIDs, nil /*tracksToRemove*/)
|
pls[playlistChoice].ID, trackIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.haveModal = true
|
m.haveModal = true
|
||||||
@@ -278,7 +279,8 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
|
func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
|
||||||
dlg := dialogs.NewEditPlaylistDialog(playlist)
|
canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist()
|
||||||
|
dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic)
|
||||||
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
|
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
|
||||||
m.ClosePopUpOnEscape(pop)
|
m.ClosePopUpOnEscape(pop)
|
||||||
dlg.OnCanceled = func() {
|
dlg.OnCanceled = func() {
|
||||||
@@ -398,6 +400,7 @@ func (m *Controller) PromptForLoginAndConnect() {
|
|||||||
// connection is good
|
// connection is good
|
||||||
newPop.Hide()
|
newPop.Hide()
|
||||||
conn := backend.ServerConnection{
|
conn := backend.ServerConnection{
|
||||||
|
ServerType: newD.ServerType,
|
||||||
Hostname: newD.Host,
|
Hostname: newD.Host,
|
||||||
AltHostname: newD.AltHost,
|
AltHostname: newD.AltHost,
|
||||||
Username: newD.Username,
|
Username: newD.Username,
|
||||||
@@ -458,7 +461,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
|||||||
}
|
}
|
||||||
|
|
||||||
bands := c.App.Player.Equalizer().BandFrequencies()
|
bands := c.App.Player.Equalizer().BandFrequencies()
|
||||||
dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, bands, c.MainWindow)
|
dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, bands, c.App.ServerManager.Server.ClientDecidesScrobble(), c.MainWindow)
|
||||||
dlg.OnReplayGainSettingsChanged = func() {
|
dlg.OnReplayGainSettingsChanged = func() {
|
||||||
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
|
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
|
||||||
}
|
}
|
||||||
@@ -539,6 +542,7 @@ func (c *Controller) tryConnectToServer(server *backend.ServerConfig, password s
|
|||||||
func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool {
|
func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool {
|
||||||
dlg.SetInfoText("Testing connection...")
|
dlg.SetInfoText("Testing connection...")
|
||||||
conn := backend.ServerConnection{
|
conn := backend.ServerConnection{
|
||||||
|
ServerType: dlg.ServerType,
|
||||||
Hostname: dlg.Host,
|
Hostname: dlg.Host,
|
||||||
AltHostname: dlg.AltHost,
|
AltHostname: dlg.AltHost,
|
||||||
Username: dlg.Username,
|
Username: dlg.Username,
|
||||||
@@ -574,7 +578,11 @@ func (c *Controller) SetTrackFavorites(trackIDs []string, favorite bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) SetTrackRatings(trackIDs []string, rating int) {
|
func (c *Controller) SetTrackRatings(trackIDs []string, rating int) {
|
||||||
go c.App.ServerManager.Server.SetRating(mediaprovider.RatingFavoriteParameters{
|
r, ok := c.App.ServerManager.Server.(mediaprovider.SupportsRating)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go r.SetRating(mediaprovider.RatingFavoriteParameters{
|
||||||
TrackIDs: trackIDs,
|
TrackIDs: trackIDs,
|
||||||
}, rating)
|
}, rating)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
type AddEditServerDialog struct {
|
type AddEditServerDialog struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
|
|
||||||
|
ServerType backend.ServerType
|
||||||
Nickname string
|
Nickname string
|
||||||
Host string
|
Host string
|
||||||
AltHost string
|
AltHost string
|
||||||
@@ -35,6 +36,7 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
|
|||||||
a := &AddEditServerDialog{}
|
a := &AddEditServerDialog{}
|
||||||
a.ExtendBaseWidget(a)
|
a.ExtendBaseWidget(a)
|
||||||
if prefillServer != nil {
|
if prefillServer != nil {
|
||||||
|
a.ServerType = prefillServer.ServerType
|
||||||
a.Nickname = prefillServer.Nickname
|
a.Nickname = prefillServer.Nickname
|
||||||
a.Host = prefillServer.Hostname
|
a.Host = prefillServer.Hostname
|
||||||
a.AltHost = prefillServer.AltHostname
|
a.AltHost = prefillServer.AltHostname
|
||||||
@@ -44,6 +46,21 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
|
|||||||
|
|
||||||
titleLabel := widget.NewLabel(title)
|
titleLabel := widget.NewLabel(title)
|
||||||
titleLabel.TextStyle.Bold = true
|
titleLabel.TextStyle.Bold = true
|
||||||
|
legacyAuthCheck := widget.NewCheckWithData("Use legacy authentication", binding.BindBool(&a.LegacyAuth))
|
||||||
|
serverTypeChoice := widget.NewRadioGroup([]string{"Subsonic", "Jellyfin"}, func(s string) {
|
||||||
|
a.ServerType = backend.ServerType(s)
|
||||||
|
if s == string(backend.ServerTypeSubsonic) {
|
||||||
|
legacyAuthCheck.Show()
|
||||||
|
} else {
|
||||||
|
legacyAuthCheck.Hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
serverTypeChoice.Horizontal = true
|
||||||
|
selected := backend.ServerTypeSubsonic
|
||||||
|
if a.ServerType == backend.ServerTypeJellyfin {
|
||||||
|
selected = backend.ServerTypeJellyfin
|
||||||
|
}
|
||||||
|
serverTypeChoice.Selected = string(selected)
|
||||||
a.passField = widget.NewPasswordEntry()
|
a.passField = widget.NewPasswordEntry()
|
||||||
a.passField.OnSubmitted = func(_ string) { a.doSubmit() }
|
a.passField.OnSubmitted = func(_ string) { a.doSubmit() }
|
||||||
userField := widget.NewEntryWithData(binding.BindString(&a.Username))
|
userField := widget.NewEntryWithData(binding.BindString(&a.Username))
|
||||||
@@ -62,8 +79,6 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
|
|||||||
a.promptText = widget.NewRichTextWithText("")
|
a.promptText = widget.NewRichTextWithText("")
|
||||||
a.promptText.Hidden = true
|
a.promptText.Hidden = true
|
||||||
|
|
||||||
legacyAuthCheck := widget.NewCheckWithData("Use legacy authentication", binding.BindBool(&a.LegacyAuth))
|
|
||||||
|
|
||||||
var bottomRow *fyne.Container
|
var bottomRow *fyne.Container
|
||||||
if cancelable {
|
if cancelable {
|
||||||
bottomRow = container.NewHBox(
|
bottomRow = container.NewHBox(
|
||||||
@@ -81,6 +96,8 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
|
|||||||
a.container = container.NewVBox(
|
a.container = container.NewVBox(
|
||||||
container.NewHBox(layout.NewSpacer(), titleLabel, layout.NewSpacer()),
|
container.NewHBox(layout.NewSpacer(), titleLabel, layout.NewSpacer()),
|
||||||
container.New(layout.NewFormLayout(),
|
container.New(layout.NewFormLayout(),
|
||||||
|
widget.NewLabel("Type"),
|
||||||
|
serverTypeChoice,
|
||||||
widget.NewLabel("Nickname"),
|
widget.NewLabel("Nickname"),
|
||||||
nickField,
|
nickField,
|
||||||
widget.NewLabel("Hostname"),
|
widget.NewLabel("Hostname"),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type EditPlaylistDialog struct {
|
|||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEditPlaylistDialog(playlist *mediaprovider.Playlist) *EditPlaylistDialog {
|
func NewEditPlaylistDialog(playlist *mediaprovider.Playlist, showPublicCheck bool) *EditPlaylistDialog {
|
||||||
e := &EditPlaylistDialog{
|
e := &EditPlaylistDialog{
|
||||||
IsPublic: playlist.Public,
|
IsPublic: playlist.Public,
|
||||||
Name: playlist.Name,
|
Name: playlist.Name,
|
||||||
@@ -32,6 +32,7 @@ func NewEditPlaylistDialog(playlist *mediaprovider.Playlist) *EditPlaylistDialog
|
|||||||
e.ExtendBaseWidget(e)
|
e.ExtendBaseWidget(e)
|
||||||
|
|
||||||
isPublicCheck := widget.NewCheckWithData("Public", binding.BindBool(&e.IsPublic))
|
isPublicCheck := widget.NewCheckWithData("Public", binding.BindBool(&e.IsPublic))
|
||||||
|
isPublicCheck.Hidden = !showPublicCheck
|
||||||
nameEntry := widget.NewEntryWithData(binding.BindString(&e.Name))
|
nameEntry := widget.NewEntryWithData(binding.BindString(&e.Name))
|
||||||
descriptionEntry := widget.NewEntryWithData(binding.BindString(&e.Description))
|
descriptionEntry := widget.NewEntryWithData(binding.BindString(&e.Description))
|
||||||
deleteBtn := widget.NewButton("Delete Playlist", func() {
|
deleteBtn := widget.NewButton("Delete Playlist", func() {
|
||||||
|
|||||||
@@ -217,21 +217,29 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) {
|
|||||||
case mediaprovider.ContentTypePlaylist:
|
case mediaprovider.ContentTypePlaylist:
|
||||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
|
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
|
||||||
case mediaprovider.ContentTypeGenre:
|
case mediaprovider.ContentTypeGenre:
|
||||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
|
if result.Size > 0 {
|
||||||
|
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
|
||||||
|
} else {
|
||||||
|
secondaryText = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
q.secondary.Segments = []widget.RichTextSegment{
|
q.secondary.Segments = []widget.RichTextSegment{
|
||||||
&widget.TextSegment{
|
&widget.TextSegment{
|
||||||
Text: result.Type.String(),
|
Text: result.Type.String(),
|
||||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
|
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
|
||||||
},
|
},
|
||||||
&widget.TextSegment{
|
}
|
||||||
Text: " · ",
|
if secondaryText != "" {
|
||||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
q.secondary.Segments = append(q.secondary.Segments,
|
||||||
},
|
&widget.TextSegment{
|
||||||
&widget.TextSegment{
|
Text: " · ",
|
||||||
Text: secondaryText,
|
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
},
|
||||||
},
|
&widget.TextSegment{
|
||||||
|
Text: secondaryText,
|
||||||
|
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
q.secondary.Refresh()
|
q.secondary.Refresh()
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type SettingsDialog struct {
|
|||||||
themeFiles map[string]string // filename -> displayName
|
themeFiles map[string]string // filename -> displayName
|
||||||
promptText *widget.RichText
|
promptText *widget.RichText
|
||||||
|
|
||||||
|
clientDecidesScrobble bool
|
||||||
|
|
||||||
content fyne.CanvasObject
|
content fyne.CanvasObject
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +54,10 @@ func NewSettingsDialog(
|
|||||||
audioDeviceList []player.AudioDevice,
|
audioDeviceList []player.AudioDevice,
|
||||||
themeFileList map[string]string,
|
themeFileList map[string]string,
|
||||||
equalizerBands []string,
|
equalizerBands []string,
|
||||||
|
clientDecidesScrobble bool,
|
||||||
window fyne.Window,
|
window fyne.Window,
|
||||||
) *SettingsDialog {
|
) *SettingsDialog {
|
||||||
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList}
|
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList, clientDecidesScrobble: clientDecidesScrobble}
|
||||||
s.ExtendBaseWidget(s)
|
s.ExtendBaseWidget(s)
|
||||||
|
|
||||||
tabs := container.NewAppTabs(
|
tabs := container.NewAppTabs(
|
||||||
@@ -188,7 +191,9 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
durationEntry.Disable()
|
durationEntry.Disable()
|
||||||
} else {
|
} else {
|
||||||
durationEntry.Text = lastScrobbleText
|
durationEntry.Text = lastScrobbleText
|
||||||
durationEntry.Enable()
|
if s.clientDecidesScrobble {
|
||||||
|
durationEntry.Enable()
|
||||||
|
}
|
||||||
durationEntry.Refresh()
|
durationEntry.Refresh()
|
||||||
durationEntry.OnChanged(durationEntry.Text)
|
durationEntry.OnChanged(durationEntry.Text)
|
||||||
}
|
}
|
||||||
@@ -197,6 +202,10 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
if !s.config.Scrobbling.Enabled {
|
if !s.config.Scrobbling.Enabled {
|
||||||
durationEnabled.Disable()
|
durationEnabled.Disable()
|
||||||
}
|
}
|
||||||
|
if !s.clientDecidesScrobble {
|
||||||
|
percentEntry.Disable()
|
||||||
|
durationEnabled.Disable()
|
||||||
|
}
|
||||||
|
|
||||||
scrobbleEnabled := widget.NewCheck("Send playback statistics to server", func(checked bool) {
|
scrobbleEnabled := widget.NewCheck("Send playback statistics to server", func(checked bool) {
|
||||||
s.config.Scrobbling.Enabled = checked
|
s.config.Scrobbling.Enabled = checked
|
||||||
@@ -205,9 +214,11 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
durationEnabled.Disable()
|
durationEnabled.Disable()
|
||||||
durationEntry.Disable()
|
durationEntry.Disable()
|
||||||
} else {
|
} else {
|
||||||
percentEntry.Enable()
|
if s.clientDecidesScrobble {
|
||||||
durationEnabled.Enable()
|
percentEntry.Enable()
|
||||||
if durationEnabled.Checked {
|
durationEnabled.Enable()
|
||||||
|
}
|
||||||
|
if durationEnabled.Checked && s.clientDecidesScrobble {
|
||||||
durationEntry.Enable()
|
durationEntry.Enable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -94,19 +94,19 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
|
|||||||
app.ServerManager.OnServerConnected(func() {
|
app.ServerManager.OnServerConnected(func() {
|
||||||
m.BrowsingPane.EnableNavigationButtons()
|
m.BrowsingPane.EnableNavigationButtons()
|
||||||
m.Router.NavigateTo(m.StartupPage())
|
m.Router.NavigateTo(m.StartupPage())
|
||||||
|
_, canRate := m.App.ServerManager.Server.(mediaprovider.SupportsRating)
|
||||||
|
m.BottomPanel.NowPlaying.DisableRating = !canRate
|
||||||
// check if launching new version, else if found available update on startup
|
// check if launching new version, else if found available update on startup
|
||||||
if l := app.Config.Application.LastLaunchedVersion; app.VersionTag() != l {
|
if l := app.Config.Application.LastLaunchedVersion; app.VersionTag() != l {
|
||||||
if !app.IsFirstLaunch() {
|
if !app.IsFirstLaunch() {
|
||||||
m.ShowWhatsNewDialog()
|
m.ShowWhatsNewDialog()
|
||||||
}
|
}
|
||||||
m.App.Config.Application.LastLaunchedVersion = app.VersionTag()
|
m.App.Config.Application.LastLaunchedVersion = app.VersionTag()
|
||||||
m.App.SaveConfigFile()
|
|
||||||
} else if t := app.UpdateChecker.VersionTagFound(); t != "" && t != app.Config.Application.LastCheckedVersion {
|
} else if t := app.UpdateChecker.VersionTagFound(); t != "" && t != app.Config.Application.LastCheckedVersion {
|
||||||
if t != app.VersionTag() {
|
if t != app.VersionTag() {
|
||||||
m.ShowNewVersionDialog(displayAppName, t)
|
m.ShowNewVersionDialog(displayAppName, t)
|
||||||
}
|
}
|
||||||
m.App.Config.Application.LastCheckedVersion = t
|
m.App.Config.Application.LastCheckedVersion = t
|
||||||
m.App.SaveConfigFile()
|
|
||||||
}
|
}
|
||||||
// register callback for the ongoing periodic update check
|
// register callback for the ongoing periodic update check
|
||||||
m.App.UpdateChecker.OnUpdatedVersionFound = func() {
|
m.App.UpdateChecker.OnUpdatedVersionFound = func() {
|
||||||
@@ -116,6 +116,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
|
|||||||
}
|
}
|
||||||
m.App.Config.Application.LastCheckedVersion = t
|
m.App.Config.Application.LastCheckedVersion = t
|
||||||
}
|
}
|
||||||
|
m.App.SaveConfigFile()
|
||||||
})
|
})
|
||||||
app.ServerManager.OnLogout(func() {
|
app.ServerManager.OnLogout(func() {
|
||||||
m.BrowsingPane.DisableNavigationButtons()
|
m.BrowsingPane.DisableNavigationButtons()
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import (
|
|||||||
type NowPlayingCard struct {
|
type NowPlayingCard struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
|
|
||||||
|
DisableRating bool
|
||||||
|
|
||||||
trackName *widget.Hyperlink
|
trackName *widget.Hyperlink
|
||||||
artistName *MultiHyperlink
|
artistName *MultiHyperlink
|
||||||
albumName *widget.Hyperlink
|
albumName *widget.Hyperlink
|
||||||
cover *TappableImage
|
cover *TappableImage
|
||||||
menu *widget.PopUpMenu
|
menu *widget.PopUpMenu
|
||||||
|
ratingMenu *fyne.MenuItem
|
||||||
|
|
||||||
OnTrackNameTapped func()
|
OnTrackNameTapped func()
|
||||||
OnArtistNameTapped func(artistID string)
|
OnArtistNameTapped func(artistID string)
|
||||||
@@ -125,13 +128,14 @@ func (n *NowPlayingCard) Update(track string, artists, artistIDs []string, album
|
|||||||
|
|
||||||
func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) {
|
func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) {
|
||||||
if n.menu == nil {
|
if n.menu == nil {
|
||||||
ratingMenu := util.NewRatingSubmenu(n.onSetRating)
|
n.ratingMenu = util.NewRatingSubmenu(n.onSetRating)
|
||||||
m := fyne.NewMenu("",
|
m := fyne.NewMenu("",
|
||||||
fyne.NewMenuItem("Set favorite", func() { n.onSetFavorite(true) }),
|
fyne.NewMenuItem("Set favorite", func() { n.onSetFavorite(true) }),
|
||||||
fyne.NewMenuItem("Unset favorite", func() { n.onSetFavorite(false) }),
|
fyne.NewMenuItem("Unset favorite", func() { n.onSetFavorite(false) }),
|
||||||
ratingMenu,
|
n.ratingMenu,
|
||||||
fyne.NewMenuItem("Add to playlist...", func() { n.onAddToPlaylist() }))
|
fyne.NewMenuItem("Add to playlist...", func() { n.onAddToPlaylist() }))
|
||||||
n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n))
|
n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n))
|
||||||
}
|
}
|
||||||
|
n.ratingMenu.Disabled = n.DisableRating
|
||||||
n.menu.ShowAtPosition(e.AbsolutePosition)
|
n.menu.ShowAtPosition(e.AbsolutePosition)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
themedResStarFilled = theme.NewThemedResource(res.ResStarFilledSvg)
|
themedResStarFilled = theme.NewThemedResource(res.ResStarFilledSvg)
|
||||||
themedResStarOutline = theme.NewThemedResource(res.ResStarOutlineSvg)
|
themedResStarOutline = theme.NewThemedResource(res.ResStarOutlineSvg)
|
||||||
|
themedDisabledStarOutline = theme.NewDisabledResource(res.ResStarOutlineSvg)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var _ fyne.Disableable = (*StarRating)(nil)
|
||||||
|
|
||||||
type StarRating struct {
|
type StarRating struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
|
|
||||||
Rating int
|
IsDisabled bool
|
||||||
StarSize float32
|
Rating int
|
||||||
|
StarSize float32
|
||||||
|
|
||||||
OnRatingChanged func(int)
|
OnRatingChanged func(int)
|
||||||
|
|
||||||
@@ -44,7 +48,9 @@ func (s *StarRating) createContainer() {
|
|||||||
})
|
})
|
||||||
var im *canvas.Image
|
var im *canvas.Image
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
if s.Rating > i {
|
if s.IsDisabled {
|
||||||
|
im = canvas.NewImageFromResource(themedDisabledStarOutline)
|
||||||
|
} else if s.Rating > i {
|
||||||
im = canvas.NewImageFromResource(themedResStarFilled)
|
im = canvas.NewImageFromResource(themedResStarFilled)
|
||||||
} else {
|
} else {
|
||||||
im = canvas.NewImageFromResource(themedResStarOutline)
|
im = canvas.NewImageFromResource(themedResStarOutline)
|
||||||
@@ -61,6 +67,9 @@ func (s *StarRating) MouseIn(e *desktop.MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *StarRating) MouseMoved(e *desktop.MouseEvent) {
|
func (s *StarRating) MouseMoved(e *desktop.MouseEvent) {
|
||||||
|
if s.IsDisabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
hoverRating := int(math.Ceil(5 * float64(e.Position.X/s.Size().Width)))
|
hoverRating := int(math.Ceil(5 * float64(e.Position.X/s.Size().Width)))
|
||||||
if s.mouseHoverRating != hoverRating {
|
if s.mouseHoverRating != hoverRating {
|
||||||
s.holdRating = false
|
s.holdRating = false
|
||||||
@@ -69,7 +78,24 @@ func (s *StarRating) MouseMoved(e *desktop.MouseEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *StarRating) Disable() {
|
||||||
|
s.IsDisabled = true
|
||||||
|
s.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StarRating) Enable() {
|
||||||
|
s.IsDisabled = false
|
||||||
|
s.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StarRating) Disabled() bool {
|
||||||
|
return s.IsDisabled
|
||||||
|
}
|
||||||
|
|
||||||
func (s *StarRating) MouseOut() {
|
func (s *StarRating) MouseOut() {
|
||||||
|
if s.IsDisabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
s.mouseHoverRating = 0
|
s.mouseHoverRating = 0
|
||||||
s.holdRating = false
|
s.holdRating = false
|
||||||
s.Refresh()
|
s.Refresh()
|
||||||
@@ -105,7 +131,9 @@ func (s *StarRating) Refresh() {
|
|||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
im := s.container.Objects[i].(*canvas.Image)
|
im := s.container.Objects[i].(*canvas.Image)
|
||||||
im.SetMinSize(fyne.NewSize(s.StarSize, s.StarSize))
|
im.SetMinSize(fyne.NewSize(s.StarSize, s.StarSize))
|
||||||
if rating > i {
|
if s.IsDisabled {
|
||||||
|
im.Resource = themedDisabledStarOutline
|
||||||
|
} else if rating > i {
|
||||||
im.Resource = themedResStarFilled
|
im.Resource = themedResStarFilled
|
||||||
} else {
|
} else {
|
||||||
im.Resource = themedResStarOutline
|
im.Resource = themedResStarOutline
|
||||||
|
|||||||
+15
-10
@@ -66,6 +66,9 @@ type TracklistOptions struct {
|
|||||||
|
|
||||||
// Disables sorting the tracklist by clicking individual columns.
|
// Disables sorting the tracklist by clicking individual columns.
|
||||||
DisableSorting bool
|
DisableSorting bool
|
||||||
|
|
||||||
|
// Disables the five star rating widget.
|
||||||
|
DisableRating bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tracklist struct {
|
type Tracklist struct {
|
||||||
@@ -96,12 +99,13 @@ type Tracklist struct {
|
|||||||
tracks []*trackModel
|
tracks []*trackModel
|
||||||
tracksOrigOrder []*trackModel
|
tracksOrigOrder []*trackModel
|
||||||
|
|
||||||
nowPlayingID string
|
nowPlayingID string
|
||||||
colLayout *layouts.ColumnsLayout
|
colLayout *layouts.ColumnsLayout
|
||||||
hdr *ListHeader
|
hdr *ListHeader
|
||||||
list *DisabledList
|
list *DisabledList
|
||||||
ctxMenu *fyne.Menu
|
ctxMenu *fyne.Menu
|
||||||
container *fyne.Container
|
ratingSubmenu *fyne.MenuItem
|
||||||
|
container *fyne.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
type trackModel struct {
|
type trackModel struct {
|
||||||
@@ -562,15 +566,16 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
|||||||
fyne.NewMenuItem("Unset favorite", func() {
|
fyne.NewMenuItem("Unset favorite", func() {
|
||||||
t.onSetFavorites(t.selectedTracks(), false, true)
|
t.onSetFavorites(t.selectedTracks(), false, true)
|
||||||
}))
|
}))
|
||||||
ratingMenu := util.NewRatingSubmenu(func(rating int) {
|
t.ratingSubmenu = util.NewRatingSubmenu(func(rating int) {
|
||||||
t.onSetRatings(t.selectedTracks(), rating, true)
|
t.onSetRatings(t.selectedTracks(), rating, true)
|
||||||
})
|
})
|
||||||
t.ctxMenu.Items = append(t.ctxMenu.Items, ratingMenu)
|
t.ctxMenu.Items = append(t.ctxMenu.Items, t.ratingSubmenu)
|
||||||
if len(t.Options.AuxiliaryMenuItems) > 0 {
|
if len(t.Options.AuxiliaryMenuItems) > 0 {
|
||||||
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
|
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
|
||||||
t.ctxMenu.Items = append(t.ctxMenu.Items, t.Options.AuxiliaryMenuItems...)
|
t.ctxMenu.Items = append(t.ctxMenu.Items, t.Options.AuxiliaryMenuItems...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
t.ratingSubmenu.Disabled = t.Options.DisableRating
|
||||||
widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition)
|
widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,7 +698,6 @@ type TrackRow struct {
|
|||||||
trackIdx int
|
trackIdx int
|
||||||
trackNum int
|
trackNum int
|
||||||
trackID string
|
trackID string
|
||||||
artistID string
|
|
||||||
albumID string
|
albumID string
|
||||||
isPlaying bool
|
isPlaying bool
|
||||||
isFavorite bool
|
isFavorite bool
|
||||||
@@ -733,6 +737,7 @@ func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow
|
|||||||
favorite.OnTapped = t.toggleFavorited
|
favorite.OnTapped = t.toggleFavorited
|
||||||
t.favorite = container.NewCenter(favorite)
|
t.favorite = container.NewCenter(favorite)
|
||||||
t.rating = NewStarRating()
|
t.rating = NewStarRating()
|
||||||
|
t.rating.IsDisabled = t.tracklist.Options.DisableRating
|
||||||
t.rating.StarSize = 16
|
t.rating.StarSize = 16
|
||||||
t.rating.OnRatingChanged = t.setTrackRating
|
t.rating.OnRatingChanged = t.setTrackRating
|
||||||
t.plays = newTrailingAlignLabel()
|
t.plays = newTrailingAlignLabel()
|
||||||
@@ -775,7 +780,6 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) {
|
|||||||
t.Focused = false
|
t.Focused = false
|
||||||
}
|
}
|
||||||
t.trackID = tr.ID
|
t.trackID = tr.ID
|
||||||
t.artistID = tr.ArtistIDs[0]
|
|
||||||
t.albumID = tr.AlbumID
|
t.albumID = tr.AlbumID
|
||||||
|
|
||||||
t.name.Segments[0].(*widget.TextSegment).Text = tr.Name
|
t.name.Segments[0].(*widget.TextSegment).Text = tr.Name
|
||||||
@@ -844,6 +848,7 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) {
|
|||||||
t.dur.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnTime)]
|
t.dur.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnTime)]
|
||||||
t.year.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnYear)]
|
t.year.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnYear)]
|
||||||
t.favorite.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnFavorite)]
|
t.favorite.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnFavorite)]
|
||||||
|
t.rating.IsDisabled = t.tracklist.Options.DisableRating
|
||||||
t.rating.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnRating)]
|
t.rating.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnRating)]
|
||||||
t.plays.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnPlays)]
|
t.plays.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnPlays)]
|
||||||
t.bitrate.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnBitrate)]
|
t.bitrate.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnBitrate)]
|
||||||
|
|||||||
Reference in New Issue
Block a user