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"
|
||||
)
|
||||
|
||||
type ServerType string
|
||||
|
||||
const (
|
||||
ServerTypeSubsonic ServerType = "Subsonic"
|
||||
ServerTypeJellyfin ServerType = "Jellyfin"
|
||||
)
|
||||
|
||||
type ServerConnection struct {
|
||||
ServerType ServerType
|
||||
Hostname string
|
||||
AltHostname string
|
||||
Username string
|
||||
@@ -204,6 +212,15 @@ func ReadConfigFile(filepath, appVersionTag string) (*Config, error) {
|
||||
if err := toml.NewDecoder(f).Decode(c); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
"image"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AlbumFilter struct {
|
||||
@@ -14,6 +15,32 @@ type AlbumFilter struct {
|
||||
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 {
|
||||
Next() *Album
|
||||
}
|
||||
@@ -34,6 +61,16 @@ type Favorites struct {
|
||||
Tracks []*Track
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Error error
|
||||
IsAuthError bool
|
||||
}
|
||||
|
||||
type Server interface {
|
||||
Login(username, password string) LoginResponse
|
||||
MediaProvider() MediaProvider
|
||||
}
|
||||
|
||||
type MediaProvider interface {
|
||||
SetPrefetchCoverCallback(cb func(coverArtID string))
|
||||
|
||||
@@ -77,23 +114,47 @@ type MediaProvider interface {
|
||||
|
||||
SetFavorite(params RatingFavoriteParameters, favorite bool) error
|
||||
|
||||
SetRating(params RatingFavoriteParameters, rating int) error
|
||||
|
||||
GetPlaylists() ([]*Playlist, error)
|
||||
|
||||
CreatePlaylist(name string, trackIDs []string) error
|
||||
|
||||
CanMakePublicPlaylist() bool
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||
"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 {
|
||||
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 {
|
||||
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 == "" {
|
||||
sortOrder = AlbumSortRecentlyAdded // default
|
||||
}
|
||||
switch sortOrder {
|
||||
case AlbumSortRecentlyAdded:
|
||||
return s.newBaseIter("newest", filter, s.prefetchCoverCB, make(map[string]string))
|
||||
return s.baseIterFromSimpleSortOrder("newest", filter)
|
||||
case AlbumSortRecentlyPlayed:
|
||||
return s.newBaseIter("recent", filter, s.prefetchCoverCB, make(map[string]string))
|
||||
return s.baseIterFromSimpleSortOrder("recent", filter)
|
||||
case AlbumSortFrequentlyPlayed:
|
||||
return s.newBaseIter("frequent", filter, s.prefetchCoverCB, make(map[string]string))
|
||||
return s.baseIterFromSimpleSortOrder("frequent", filter)
|
||||
case AlbumSortRandom:
|
||||
return s.newRandomIter(filter, s.prefetchCoverCB)
|
||||
case AlbumSortTitleAZ:
|
||||
return s.newBaseIter("alphabeticalByName", filter, s.prefetchCoverCB, make(map[string]string))
|
||||
return s.baseIterFromSimpleSortOrder("alphabeticalByName", filter)
|
||||
case AlbumSortArtistAZ:
|
||||
return s.newBaseIter("alphabeticalByArtist", filter, s.prefetchCoverCB, make(map[string]string))
|
||||
return s.baseIterFromSimpleSortOrder("alphabeticalByArtist", filter)
|
||||
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:
|
||||
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:
|
||||
log.Printf("Undefined album sort order: %s", sortOrder)
|
||||
return nil
|
||||
@@ -95,71 +115,6 @@ func (s *subsonicMediaProvider) SearchAlbums(searchQuery string, filter mediapro
|
||||
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 {
|
||||
searchIterBase
|
||||
|
||||
@@ -258,103 +213,31 @@ func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) {
|
||||
}
|
||||
}
|
||||
|
||||
type randomIter struct {
|
||||
filter mediaprovider.AlbumFilter
|
||||
prefetchCB func(coverArtID string)
|
||||
albumIDSet map[string]bool
|
||||
s *subsonic.Client
|
||||
prefetched []*subsonic.AlbumID3
|
||||
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.
|
||||
phaseTwo bool
|
||||
offset int
|
||||
done bool
|
||||
func (s *subsonicMediaProvider) newRandomIter(filter mediaprovider.AlbumFilter, cb func(string)) mediaprovider.AlbumIterator {
|
||||
return helpers.NewRandomAlbumIter(
|
||||
s.fetchFnFromStandardSort("newest"),
|
||||
makeFetchFn(func(offset, limit int) ([]*subsonic.AlbumID3, error) {
|
||||
return s.client.GetAlbumList2("random", map[string]string{"size": strconv.Itoa(limit)})
|
||||
}),
|
||||
filter, s.prefetchCoverCB)
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) newRandomIter(filter mediaprovider.AlbumFilter, cb func(string)) *randomIter {
|
||||
return &randomIter{
|
||||
filter: filter,
|
||||
prefetchCB: cb,
|
||||
s: s.client,
|
||||
albumIDSet: make(map[string]bool),
|
||||
}
|
||||
func (s *subsonicMediaProvider) baseIterFromSimpleSortOrder(sort string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
|
||||
return helpers.NewAlbumIterator(s.fetchFnFromStandardSort(sort), filter, s.prefetchCoverCB)
|
||||
}
|
||||
|
||||
func (r *randomIter) Next() *mediaprovider.Album {
|
||||
if r.done {
|
||||
return nil
|
||||
}
|
||||
func (s *subsonicMediaProvider) fetchFnFromStandardSort(sort string) helpers.AlbumFetchFn {
|
||||
return makeFetchFn(func(offset, limit int) ([]*subsonic.AlbumID3, error) {
|
||||
return s.client.GetAlbumList2(sort, map[string]string{"size": strconv.Itoa(limit), "offset": strconv.Itoa(offset)})
|
||||
})
|
||||
}
|
||||
|
||||
// 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.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
|
||||
}
|
||||
func makeFetchFn(subsonicFetchFn func(offset, limit int) ([]*subsonic.AlbumID3, error)) helpers.AlbumFetchFn {
|
||||
return func(offset, limit int) ([]*mediaprovider.Album, error) {
|
||||
al, err := subsonicFetchFn(offset, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -9,6 +8,7 @@ import (
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
||||
p, e := s.client.GetPlaylists(nil)
|
||||
if e == nil {
|
||||
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()
|
||||
@@ -54,7 +54,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
||||
g, e := s.client.GetGenres()
|
||||
if e == nil {
|
||||
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()
|
||||
@@ -66,23 +66,13 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
||||
}
|
||||
|
||||
results := mergeResults(result, playlists, genres)
|
||||
rankResults(results, querySanitized, queryLowerWords)
|
||||
helpers.RankSearchResults(results, querySanitized, queryLowerWords)
|
||||
if len(results) > maxResults {
|
||||
results = results[:maxResults]
|
||||
}
|
||||
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(
|
||||
searchResult *subsonic.SearchResult3,
|
||||
matchingPlaylists []*subsonic.Playlist,
|
||||
@@ -144,54 +134,6 @@ func mergeResults(
|
||||
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
|
||||
func getNameString(singleName string, idNames []subsonic.IDName) string {
|
||||
if len(idNames) == 0 {
|
||||
|
||||
@@ -43,6 +43,10 @@ func (s *subsonicMediaProvider) DeletePlaylist(id string) error {
|
||||
return s.client.DeletePlaylist(id)
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) CanMakePublicPlaylist() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) EditPlaylist(id, name, description string, public bool) error {
|
||||
return s.client.UpdatePlaylist(id, map[string]string{
|
||||
"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 {
|
||||
return s.client.UpdatePlaylistTracks(id, trackIDsToAdd, trackIndexesToRemove)
|
||||
func (s *subsonicMediaProvider) AddPlaylistTracks(id string, trackIDsToAdd []string) error {
|
||||
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) {
|
||||
@@ -248,10 +256,21 @@ func (s *subsonicMediaProvider) ReplacePlaylistTracks(playlistID string, trackID
|
||||
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{
|
||||
"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 {
|
||||
|
||||
@@ -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
|
||||
player *player.Player
|
||||
|
||||
playTimeStopwatch util.Stopwatch
|
||||
curTrackTime float64
|
||||
callbacksDisabled bool
|
||||
playTimeStopwatch util.Stopwatch
|
||||
curTrackTime float64
|
||||
latestTrackPosition float64 // cleared by checkScrobble
|
||||
callbacksDisabled bool
|
||||
|
||||
playQueue []*mediaprovider.Track
|
||||
nowPlayingIdx int64
|
||||
@@ -73,15 +74,15 @@ func NewPlaybackManager(
|
||||
if tracknum >= int64(len(pm.playQueue)) {
|
||||
return
|
||||
}
|
||||
pm.checkScrobble()
|
||||
pm.checkScrobble() // scrobble the previous song if needed
|
||||
if pm.player.GetStatus().State == player.Playing {
|
||||
pm.playTimeStopwatch.Start()
|
||||
}
|
||||
pm.nowPlayingIdx = tracknum
|
||||
pm.curTrackTime = float64(pm.playQueue[pm.nowPlayingIdx].Duration)
|
||||
pm.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled
|
||||
pm.invokeOnSongChangeCallbacks()
|
||||
pm.doUpdateTimePos()
|
||||
pm.sendNowPlayingScrobble()
|
||||
})
|
||||
p.OnSeek(func() {
|
||||
pm.doUpdateTimePos()
|
||||
@@ -380,13 +381,17 @@ func (p *PlaybackManager) checkScrobble() {
|
||||
pcnt := playDur.Seconds() / p.curTrackTime * 100
|
||||
timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 &&
|
||||
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
|
||||
if timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent) {
|
||||
song := p.playQueue[p.nowPlayingIdx]
|
||||
log.Printf("Scrobbling %q", song.Name)
|
||||
song.PlayCount += 1
|
||||
p.lastScrobbled = song
|
||||
go p.sm.Server.Scrobble(song.ID, true)
|
||||
|
||||
track := p.playQueue[p.nowPlayingIdx]
|
||||
var submission bool
|
||||
server := p.sm.Server
|
||||
if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) {
|
||||
track.PlayCount += 1
|
||||
p.lastScrobbled = track
|
||||
submission = true
|
||||
}
|
||||
go server.TrackEndedPlayback(track.ID, int(p.latestTrackPosition), submission)
|
||||
p.latestTrackPosition = 0
|
||||
p.playTimeStopwatch.Reset()
|
||||
}
|
||||
|
||||
@@ -394,8 +399,14 @@ func (p *PlaybackManager) sendNowPlayingScrobble() {
|
||||
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
||||
return
|
||||
}
|
||||
song := p.playQueue[p.nowPlayingIdx]
|
||||
go p.sm.Server.Scrobble(song.ID, false)
|
||||
track := p.playQueue[p.nowPlayingIdx]
|
||||
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() {
|
||||
@@ -433,6 +444,9 @@ func (p *PlaybackManager) doUpdateTimePos() {
|
||||
return
|
||||
}
|
||||
s := p.player.GetStatus()
|
||||
if s.TimePos > p.latestTrackPosition {
|
||||
p.latestTrackPosition = s.TimePos
|
||||
}
|
||||
for _, cb := range p.onPlayTimeUpdate {
|
||||
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"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/go-jellyfin"
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
jellyfinMP "github.com/dweymouth/supersonic/backend/mediaprovider/jellyfin"
|
||||
subsonicMP "github.com/dweymouth/supersonic/backend/mediaprovider/subsonic"
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/google/uuid"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
@@ -43,7 +46,7 @@ func (s *ServerManager) ConnectToServer(conf *ServerConfig, password string) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Server = subsonicMP.SubsonicMediaProvider(cli)
|
||||
s.Server = cli.MediaProvider()
|
||||
s.Server.SetPrefetchCoverCallback(s.prefetchCoverCB)
|
||||
s.LoggedInUser = conf.Username
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *ServerManager) connect(connection ServerConnection, password string) (*subsonic.Client, error) {
|
||||
cli := &subsonic.Client{
|
||||
Client: &http.Client{Timeout: 10 * time.Second},
|
||||
BaseUrl: connection.Hostname,
|
||||
User: connection.Username,
|
||||
PasswordAuth: connection.LegacyAuth,
|
||||
ClientName: "supersonic",
|
||||
}
|
||||
altCli := &subsonic.Client{
|
||||
Client: &http.Client{Timeout: 10 * time.Second},
|
||||
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
|
||||
func (s *ServerManager) connect(connection ServerConnection, password string) (mediaprovider.Server, error) {
|
||||
var cli, altCli mediaprovider.Server
|
||||
|
||||
if connection.ServerType == ServerTypeJellyfin {
|
||||
cli = &jellyfinMP.JellyfinServer{
|
||||
Client: jellyfin.Client{
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
BaseURL: connection.Hostname,
|
||||
ClientName: res.AppName,
|
||||
ClientVersion: res.AppVersion,
|
||||
},
|
||||
}
|
||||
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)
|
||||
if connection.AltHostname != "" {
|
||||
@@ -190,8 +222,8 @@ func (s *ServerManager) connect(connection ServerConnection, password string) (*
|
||||
return nil, ErrUnreachable
|
||||
case altPing := <-pingChan:
|
||||
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
|
||||
github.com/20after4/configdir v0.1.1
|
||||
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-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/godbus/dbus/v5 v5.1.0
|
||||
github.com/google/uuid v1.3.0
|
||||
@@ -38,10 +39,10 @@ require (
|
||||
github.com/stretchr/testify v1.8.4 // indirect
|
||||
github.com/tevino/abool v1.2.0 // 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/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
|
||||
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/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/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/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-20231105161622-54b5aec28363/go.mod h1:dVriurACA/XTnE7BgSOTapYOtxMq7jTOVExrIbwi84c=
|
||||
github.com/dweymouth/go-subsonic v0.0.0-20231115012731-a3d1f90274c1 h1:1I5/hlV4lQ0B0NdBZvwWSqL8hvvKhRRbJK6fGsxFSLs=
|
||||
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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
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/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.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.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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
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.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.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.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-20190308202827-9d24e82272b4/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.SetSorting(sort)
|
||||
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||
a.tracklist.Options.DisableRating = !canRate
|
||||
a.tracklist.OnVisibleColumnsChanged = func(cols []string) {
|
||||
a.cfg.TracklistColumns = cols
|
||||
}
|
||||
@@ -154,9 +156,7 @@ func (a *AlbumPage) load() {
|
||||
return
|
||||
}
|
||||
a.header.Update(album, a.im)
|
||||
a.tracklist.Options = widgets.TracklistOptions{
|
||||
ShowDiscNumber: album.Tracks[0].DiscNumber != album.Tracks[len(album.Tracks)-1].DiscNumber,
|
||||
}
|
||||
a.tracklist.Options.ShowDiscNumber = len(album.Tracks) > 0 && album.Tracks[0].DiscNumber != album.Tracks[len(album.Tracks)-1].DiscNumber
|
||||
a.tracks = album.Tracks
|
||||
a.tracklist.SetTracks(album.Tracks)
|
||||
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
||||
@@ -314,8 +314,10 @@ func (a *AlbumPageHeader) showPopUpCover() {
|
||||
|
||||
func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string {
|
||||
var discs string
|
||||
if discCount := a.Tracks[len(a.Tracks)-1].DiscNumber; discCount > 1 {
|
||||
discs = fmt.Sprintf("%d discs · ", discCount)
|
||||
if len(a.Tracks) > 0 {
|
||||
if discCount := a.Tracks[len(a.Tracks)-1].DiscNumber; discCount > 1 {
|
||||
discs = fmt.Sprintf("%d discs · ", discCount)
|
||||
}
|
||||
}
|
||||
tracks := "tracks"
|
||||
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) SortOrders() ([]string, string) {
|
||||
orders := a.contr.App.ServerManager.Server.AlbumSortOrders()
|
||||
if !sharedutil.SliceContains(a.mp.AlbumSortOrders(), a.cfg.SortOrder) {
|
||||
a.cfg.SortOrder = string(a.mp.AlbumSortOrders()[0])
|
||||
orders := a.mp.AlbumSortOrders()
|
||||
sortOrder := a.cfg.SortOrder
|
||||
if !sharedutil.SliceContains(orders, sortOrder) {
|
||||
sortOrder = string(orders[0])
|
||||
}
|
||||
return orders, a.cfg.SortOrder
|
||||
return orders, sortOrder
|
||||
}
|
||||
|
||||
func (a *albumsPageAdapter) SaveSortOrder(order string) {
|
||||
|
||||
@@ -229,6 +229,8 @@ func (a *ArtistPage) showTopTracks() {
|
||||
tl = widgets.NewTracklist(ts)
|
||||
}
|
||||
tl.Options = widgets.TracklistOptions{AutoNumber: true}
|
||||
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||
tl.Options.DisableRating = !canRate
|
||||
tl.SetVisibleColumns(a.cfg.TracklistColumns)
|
||||
tl.SetSorting(a.trackSort)
|
||||
tl.OnVisibleColumnsChanged = func(cols []string) {
|
||||
@@ -335,6 +337,14 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums) {
|
||||
a.artistID = artist.ID
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name
|
||||
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) {
|
||||
|
||||
@@ -370,6 +370,8 @@ func (a *FavoritesPage) onShowFavoriteSongs() {
|
||||
tracklist = widgets.NewTracklist(fav.Tracks)
|
||||
}
|
||||
tracklist.Options = widgets.TracklistOptions{AutoNumber: true}
|
||||
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||
tracklist.Options.DisableRating = !canRate
|
||||
tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
|
||||
tracklist.SetSorting(a.trackSort)
|
||||
tracklist.OnVisibleColumnsChanged = func(cols []string) {
|
||||
|
||||
@@ -196,8 +196,16 @@ func NewGenreList(sorting widgets.ListHeaderSort) *GenreList {
|
||||
row := item.(*GenreListRow)
|
||||
row.Item = a.genres[id]
|
||||
row.nameLabel.Text = row.Item.Name
|
||||
row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount)
|
||||
row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount)
|
||||
if row.Item.AlbumCount >= 0 {
|
||||
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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -37,11 +37,12 @@ type NowPlayingPage struct {
|
||||
}
|
||||
|
||||
type nowPlayingPageState struct {
|
||||
contr *controller.Controller
|
||||
pool *util.WidgetPool
|
||||
conf *backend.NowPlayingPageConfig
|
||||
pm *backend.PlaybackManager
|
||||
p *player.Player
|
||||
contr *controller.Controller
|
||||
pool *util.WidgetPool
|
||||
conf *backend.NowPlayingPageConfig
|
||||
pm *backend.PlaybackManager
|
||||
p *player.Player
|
||||
canRate bool
|
||||
}
|
||||
|
||||
func NewNowPlayingPage(
|
||||
@@ -51,9 +52,10 @@ func NewNowPlayingPage(
|
||||
conf *backend.NowPlayingPageConfig,
|
||||
pm *backend.PlaybackManager,
|
||||
p *player.Player, // TODO: once other player backends are supported (eg uPnP), refactor
|
||||
canRate bool,
|
||||
) *NowPlayingPage {
|
||||
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)
|
||||
|
||||
@@ -74,6 +76,7 @@ func NewNowPlayingPage(
|
||||
a.tracklist.Options = widgets.TracklistOptions{
|
||||
AutoNumber: true,
|
||||
DisablePlaybackMenu: true,
|
||||
DisableRating: !canRate,
|
||||
AuxiliaryMenuItems: []*fyne.MenuItem{
|
||||
fyne.NewMenuItem("Remove from queue", a.onRemoveSelectedFromQueue),
|
||||
},
|
||||
@@ -213,5 +216,5 @@ func (a *NowPlayingPage) load(highlightedTrackID string) {
|
||||
}
|
||||
|
||||
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 to bottom", a.onMoveSelectedToBottom),
|
||||
}...)
|
||||
_, canRate := a.sm.Server.(mediaprovider.SupportsRating)
|
||||
a.tracklist.Options = widgets.TracklistOptions{
|
||||
DisableRating: !canRate,
|
||||
AuxiliaryMenuItems: []*fyne.MenuItem{reorderMenu,
|
||||
fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist)},
|
||||
}
|
||||
@@ -210,14 +212,15 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
|
||||
}
|
||||
|
||||
func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
|
||||
sel := sharedutil.ToSet(a.tracklist.SelectedTrackIDs())
|
||||
ids := a.tracklist.SelectedTrackIDs()
|
||||
sel := sharedutil.ToSet(ids)
|
||||
idxs := make([]int, 0, len(sel))
|
||||
for i, tr := range a.tracks {
|
||||
if _, ok := sel[tr.ID]; ok {
|
||||
idxs = append(idxs, i)
|
||||
}
|
||||
}
|
||||
a.sm.Server.EditPlaylistTracks(a.playlistID, nil, idxs)
|
||||
a.sm.Server.RemovePlaylistTracks(a.playlistID, idxs)
|
||||
a.tracklist.UnselectAll()
|
||||
a.Reload()
|
||||
}
|
||||
@@ -283,6 +286,9 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
|
||||
sharedutil.TracksToIDs(a.page.tracks))
|
||||
}),
|
||||
fyne.NewMenuItem("Download...", func() {
|
||||
if a.playlistInfo == nil {
|
||||
return
|
||||
}
|
||||
a.page.contr.ShowDownloadDialog(a.page.tracks, a.playlistInfo.Name)
|
||||
}))
|
||||
pop = widget.NewPopUpMenu(menu, fyne.CurrentApp().Driver().CanvasForObject(a))
|
||||
|
||||
@@ -2,6 +2,7 @@ package browsing
|
||||
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
)
|
||||
@@ -45,7 +46,8 @@ func (r Router) CreatePage(rte controller.Route) Page {
|
||||
case controller.Genres:
|
||||
return NewGenresPage(r.Controller, r.App.ServerManager.Server)
|
||||
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:
|
||||
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
|
||||
case controller.Playlists:
|
||||
|
||||
@@ -39,20 +39,18 @@ type tracksPageState struct {
|
||||
contr *controller.Controller
|
||||
conf *backend.TracksPageConfig
|
||||
mp mediaprovider.MediaProvider
|
||||
canRate bool
|
||||
}
|
||||
|
||||
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.ExtendBaseWidget(t)
|
||||
|
||||
if tl := t.widgetPool.Obtain(util.WidgetTypeTracklist); tl != nil {
|
||||
t.tracklist = tl.(*widgets.Tracklist)
|
||||
t.tracklist.Reset()
|
||||
} else {
|
||||
t.tracklist = widgets.NewTracklist(nil)
|
||||
}
|
||||
t.tracklist = t.obtainTracklist()
|
||||
_, t.canRate = mp.(mediaprovider.SupportsRating)
|
||||
t.tracklist.Options = widgets.TracklistOptions{
|
||||
DisableSorting: true,
|
||||
DisableRating: !t.canRate,
|
||||
AutoNumber: true,
|
||||
}
|
||||
t.tracklist.SetVisibleColumns(conf.TracklistColumns)
|
||||
@@ -128,8 +126,12 @@ func (t *TracksPage) OnSearched(query string) {
|
||||
|
||||
func (t *TracksPage) doSearch(query string) {
|
||||
if t.searchTracklist == nil {
|
||||
t.searchTracklist = widgets.NewTracklist(nil)
|
||||
t.searchTracklist.Options = widgets.TracklistOptions{AutoNumber: true}
|
||||
t.searchTracklist = t.obtainTracklist()
|
||||
t.searchTracklist.Options = widgets.TracklistOptions{
|
||||
AutoNumber: true,
|
||||
DisableSorting: true,
|
||||
DisableRating: !t.canRate,
|
||||
}
|
||||
t.searchTracklist.SetVisibleColumns(t.conf.TracklistColumns)
|
||||
t.searchTracklist.SetNowPlaying(t.nowPlayingID)
|
||||
t.searchTracklist.OnVisibleColumnsChanged = func(cols []string) {
|
||||
@@ -176,3 +178,12 @@ func (s *tracksPageState) Restore() Page {
|
||||
func (t *TracksPage) 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()
|
||||
m.doModalClosed()
|
||||
conn := backend.ServerConnection{
|
||||
ServerType: d.ServerType,
|
||||
Hostname: d.Host,
|
||||
AltHostname: d.AltHost,
|
||||
Username: d.Username,
|
||||
@@ -268,8 +269,8 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
||||
if playlistChoice < 0 {
|
||||
go m.App.ServerManager.Server.CreatePlaylist(newPlaylistName, trackIDs)
|
||||
} else {
|
||||
go m.App.ServerManager.Server.EditPlaylistTracks(
|
||||
pls[playlistChoice].ID, trackIDs, nil /*tracksToRemove*/)
|
||||
go m.App.ServerManager.Server.AddPlaylistTracks(
|
||||
pls[playlistChoice].ID, trackIDs)
|
||||
}
|
||||
}
|
||||
m.haveModal = true
|
||||
@@ -278,7 +279,8 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
||||
}
|
||||
|
||||
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())
|
||||
m.ClosePopUpOnEscape(pop)
|
||||
dlg.OnCanceled = func() {
|
||||
@@ -398,6 +400,7 @@ func (m *Controller) PromptForLoginAndConnect() {
|
||||
// connection is good
|
||||
newPop.Hide()
|
||||
conn := backend.ServerConnection{
|
||||
ServerType: newD.ServerType,
|
||||
Hostname: newD.Host,
|
||||
AltHostname: newD.AltHost,
|
||||
Username: newD.Username,
|
||||
@@ -458,7 +461,7 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
||||
}
|
||||
|
||||
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() {
|
||||
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 {
|
||||
dlg.SetInfoText("Testing connection...")
|
||||
conn := backend.ServerConnection{
|
||||
ServerType: dlg.ServerType,
|
||||
Hostname: dlg.Host,
|
||||
AltHostname: dlg.AltHost,
|
||||
Username: dlg.Username,
|
||||
@@ -574,7 +578,11 @@ func (c *Controller) SetTrackFavorites(trackIDs []string, favorite bool) {
|
||||
}
|
||||
|
||||
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,
|
||||
}, rating)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type AddEditServerDialog struct {
|
||||
widget.BaseWidget
|
||||
|
||||
ServerType backend.ServerType
|
||||
Nickname string
|
||||
Host string
|
||||
AltHost string
|
||||
@@ -35,6 +36,7 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
|
||||
a := &AddEditServerDialog{}
|
||||
a.ExtendBaseWidget(a)
|
||||
if prefillServer != nil {
|
||||
a.ServerType = prefillServer.ServerType
|
||||
a.Nickname = prefillServer.Nickname
|
||||
a.Host = prefillServer.Hostname
|
||||
a.AltHost = prefillServer.AltHostname
|
||||
@@ -44,6 +46,21 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
|
||||
|
||||
titleLabel := widget.NewLabel(title)
|
||||
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.OnSubmitted = func(_ string) { a.doSubmit() }
|
||||
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.Hidden = true
|
||||
|
||||
legacyAuthCheck := widget.NewCheckWithData("Use legacy authentication", binding.BindBool(&a.LegacyAuth))
|
||||
|
||||
var bottomRow *fyne.Container
|
||||
if cancelable {
|
||||
bottomRow = container.NewHBox(
|
||||
@@ -81,6 +96,8 @@ func NewAddEditServerDialog(title string, cancelable bool, prefillServer *backen
|
||||
a.container = container.NewVBox(
|
||||
container.NewHBox(layout.NewSpacer(), titleLabel, layout.NewSpacer()),
|
||||
container.New(layout.NewFormLayout(),
|
||||
widget.NewLabel("Type"),
|
||||
serverTypeChoice,
|
||||
widget.NewLabel("Nickname"),
|
||||
nickField,
|
||||
widget.NewLabel("Hostname"),
|
||||
|
||||
@@ -23,7 +23,7 @@ type EditPlaylistDialog struct {
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewEditPlaylistDialog(playlist *mediaprovider.Playlist) *EditPlaylistDialog {
|
||||
func NewEditPlaylistDialog(playlist *mediaprovider.Playlist, showPublicCheck bool) *EditPlaylistDialog {
|
||||
e := &EditPlaylistDialog{
|
||||
IsPublic: playlist.Public,
|
||||
Name: playlist.Name,
|
||||
@@ -32,6 +32,7 @@ func NewEditPlaylistDialog(playlist *mediaprovider.Playlist) *EditPlaylistDialog
|
||||
e.ExtendBaseWidget(e)
|
||||
|
||||
isPublicCheck := widget.NewCheckWithData("Public", binding.BindBool(&e.IsPublic))
|
||||
isPublicCheck.Hidden = !showPublicCheck
|
||||
nameEntry := widget.NewEntryWithData(binding.BindString(&e.Name))
|
||||
descriptionEntry := widget.NewEntryWithData(binding.BindString(&e.Description))
|
||||
deleteBtn := widget.NewButton("Delete Playlist", func() {
|
||||
|
||||
@@ -217,21 +217,29 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) {
|
||||
case mediaprovider.ContentTypePlaylist:
|
||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
|
||||
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{
|
||||
&widget.TextSegment{
|
||||
Text: result.Type.String(),
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
|
||||
},
|
||||
&widget.TextSegment{
|
||||
Text: " · ",
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||
},
|
||||
&widget.TextSegment{
|
||||
Text: secondaryText,
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||
},
|
||||
}
|
||||
if secondaryText != "" {
|
||||
q.secondary.Segments = append(q.secondary.Segments,
|
||||
&widget.TextSegment{
|
||||
Text: " · ",
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||
},
|
||||
&widget.TextSegment{
|
||||
Text: secondaryText,
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
q.secondary.Refresh()
|
||||
|
||||
@@ -43,6 +43,8 @@ type SettingsDialog struct {
|
||||
themeFiles map[string]string // filename -> displayName
|
||||
promptText *widget.RichText
|
||||
|
||||
clientDecidesScrobble bool
|
||||
|
||||
content fyne.CanvasObject
|
||||
}
|
||||
|
||||
@@ -52,9 +54,10 @@ func NewSettingsDialog(
|
||||
audioDeviceList []player.AudioDevice,
|
||||
themeFileList map[string]string,
|
||||
equalizerBands []string,
|
||||
clientDecidesScrobble bool,
|
||||
window fyne.Window,
|
||||
) *SettingsDialog {
|
||||
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList}
|
||||
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList, clientDecidesScrobble: clientDecidesScrobble}
|
||||
s.ExtendBaseWidget(s)
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
@@ -188,7 +191,9 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
||||
durationEntry.Disable()
|
||||
} else {
|
||||
durationEntry.Text = lastScrobbleText
|
||||
durationEntry.Enable()
|
||||
if s.clientDecidesScrobble {
|
||||
durationEntry.Enable()
|
||||
}
|
||||
durationEntry.Refresh()
|
||||
durationEntry.OnChanged(durationEntry.Text)
|
||||
}
|
||||
@@ -197,6 +202,10 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
||||
if !s.config.Scrobbling.Enabled {
|
||||
durationEnabled.Disable()
|
||||
}
|
||||
if !s.clientDecidesScrobble {
|
||||
percentEntry.Disable()
|
||||
durationEnabled.Disable()
|
||||
}
|
||||
|
||||
scrobbleEnabled := widget.NewCheck("Send playback statistics to server", func(checked bool) {
|
||||
s.config.Scrobbling.Enabled = checked
|
||||
@@ -205,9 +214,11 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
||||
durationEnabled.Disable()
|
||||
durationEntry.Disable()
|
||||
} else {
|
||||
percentEntry.Enable()
|
||||
durationEnabled.Enable()
|
||||
if durationEnabled.Checked {
|
||||
if s.clientDecidesScrobble {
|
||||
percentEntry.Enable()
|
||||
durationEnabled.Enable()
|
||||
}
|
||||
if durationEnabled.Checked && s.clientDecidesScrobble {
|
||||
durationEntry.Enable()
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -94,19 +94,19 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
|
||||
app.ServerManager.OnServerConnected(func() {
|
||||
m.BrowsingPane.EnableNavigationButtons()
|
||||
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
|
||||
if l := app.Config.Application.LastLaunchedVersion; app.VersionTag() != l {
|
||||
if !app.IsFirstLaunch() {
|
||||
m.ShowWhatsNewDialog()
|
||||
}
|
||||
m.App.Config.Application.LastLaunchedVersion = app.VersionTag()
|
||||
m.App.SaveConfigFile()
|
||||
} else if t := app.UpdateChecker.VersionTagFound(); t != "" && t != app.Config.Application.LastCheckedVersion {
|
||||
if t != app.VersionTag() {
|
||||
m.ShowNewVersionDialog(displayAppName, t)
|
||||
}
|
||||
m.App.Config.Application.LastCheckedVersion = t
|
||||
m.App.SaveConfigFile()
|
||||
}
|
||||
// register callback for the ongoing periodic update check
|
||||
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.SaveConfigFile()
|
||||
})
|
||||
app.ServerManager.OnLogout(func() {
|
||||
m.BrowsingPane.DisableNavigationButtons()
|
||||
|
||||
@@ -17,11 +17,14 @@ import (
|
||||
type NowPlayingCard struct {
|
||||
widget.BaseWidget
|
||||
|
||||
DisableRating bool
|
||||
|
||||
trackName *widget.Hyperlink
|
||||
artistName *MultiHyperlink
|
||||
albumName *widget.Hyperlink
|
||||
cover *TappableImage
|
||||
menu *widget.PopUpMenu
|
||||
ratingMenu *fyne.MenuItem
|
||||
|
||||
OnTrackNameTapped func()
|
||||
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) {
|
||||
if n.menu == nil {
|
||||
ratingMenu := util.NewRatingSubmenu(n.onSetRating)
|
||||
n.ratingMenu = util.NewRatingSubmenu(n.onSetRating)
|
||||
m := fyne.NewMenu("",
|
||||
fyne.NewMenuItem("Set favorite", func() { n.onSetFavorite(true) }),
|
||||
fyne.NewMenuItem("Unset favorite", func() { n.onSetFavorite(false) }),
|
||||
ratingMenu,
|
||||
n.ratingMenu,
|
||||
fyne.NewMenuItem("Add to playlist...", func() { n.onAddToPlaylist() }))
|
||||
n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n))
|
||||
}
|
||||
n.ratingMenu.Disabled = n.DisableRating
|
||||
n.menu.ShowAtPosition(e.AbsolutePosition)
|
||||
}
|
||||
|
||||
@@ -15,15 +15,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
themedResStarFilled = theme.NewThemedResource(res.ResStarFilledSvg)
|
||||
themedResStarOutline = theme.NewThemedResource(res.ResStarOutlineSvg)
|
||||
themedResStarFilled = theme.NewThemedResource(res.ResStarFilledSvg)
|
||||
themedResStarOutline = theme.NewThemedResource(res.ResStarOutlineSvg)
|
||||
themedDisabledStarOutline = theme.NewDisabledResource(res.ResStarOutlineSvg)
|
||||
)
|
||||
|
||||
var _ fyne.Disableable = (*StarRating)(nil)
|
||||
|
||||
type StarRating struct {
|
||||
widget.BaseWidget
|
||||
|
||||
Rating int
|
||||
StarSize float32
|
||||
IsDisabled bool
|
||||
Rating int
|
||||
StarSize float32
|
||||
|
||||
OnRatingChanged func(int)
|
||||
|
||||
@@ -44,7 +48,9 @@ func (s *StarRating) createContainer() {
|
||||
})
|
||||
var im *canvas.Image
|
||||
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)
|
||||
} else {
|
||||
im = canvas.NewImageFromResource(themedResStarOutline)
|
||||
@@ -61,6 +67,9 @@ func (s *StarRating) MouseIn(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)))
|
||||
if s.mouseHoverRating != hoverRating {
|
||||
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() {
|
||||
if s.IsDisabled {
|
||||
return
|
||||
}
|
||||
s.mouseHoverRating = 0
|
||||
s.holdRating = false
|
||||
s.Refresh()
|
||||
@@ -105,7 +131,9 @@ func (s *StarRating) Refresh() {
|
||||
for i := 0; i < 5; i++ {
|
||||
im := s.container.Objects[i].(*canvas.Image)
|
||||
im.SetMinSize(fyne.NewSize(s.StarSize, s.StarSize))
|
||||
if rating > i {
|
||||
if s.IsDisabled {
|
||||
im.Resource = themedDisabledStarOutline
|
||||
} else if rating > i {
|
||||
im.Resource = themedResStarFilled
|
||||
} else {
|
||||
im.Resource = themedResStarOutline
|
||||
|
||||
+15
-10
@@ -66,6 +66,9 @@ type TracklistOptions struct {
|
||||
|
||||
// Disables sorting the tracklist by clicking individual columns.
|
||||
DisableSorting bool
|
||||
|
||||
// Disables the five star rating widget.
|
||||
DisableRating bool
|
||||
}
|
||||
|
||||
type Tracklist struct {
|
||||
@@ -96,12 +99,13 @@ type Tracklist struct {
|
||||
tracks []*trackModel
|
||||
tracksOrigOrder []*trackModel
|
||||
|
||||
nowPlayingID string
|
||||
colLayout *layouts.ColumnsLayout
|
||||
hdr *ListHeader
|
||||
list *DisabledList
|
||||
ctxMenu *fyne.Menu
|
||||
container *fyne.Container
|
||||
nowPlayingID string
|
||||
colLayout *layouts.ColumnsLayout
|
||||
hdr *ListHeader
|
||||
list *DisabledList
|
||||
ctxMenu *fyne.Menu
|
||||
ratingSubmenu *fyne.MenuItem
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
type trackModel struct {
|
||||
@@ -562,15 +566,16 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
|
||||
fyne.NewMenuItem("Unset favorite", func() {
|
||||
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.ctxMenu.Items = append(t.ctxMenu.Items, ratingMenu)
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, t.ratingSubmenu)
|
||||
if len(t.Options.AuxiliaryMenuItems) > 0 {
|
||||
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -693,7 +698,6 @@ type TrackRow struct {
|
||||
trackIdx int
|
||||
trackNum int
|
||||
trackID string
|
||||
artistID string
|
||||
albumID string
|
||||
isPlaying bool
|
||||
isFavorite bool
|
||||
@@ -733,6 +737,7 @@ func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow
|
||||
favorite.OnTapped = t.toggleFavorited
|
||||
t.favorite = container.NewCenter(favorite)
|
||||
t.rating = NewStarRating()
|
||||
t.rating.IsDisabled = t.tracklist.Options.DisableRating
|
||||
t.rating.StarSize = 16
|
||||
t.rating.OnRatingChanged = t.setTrackRating
|
||||
t.plays = newTrailingAlignLabel()
|
||||
@@ -775,7 +780,6 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) {
|
||||
t.Focused = false
|
||||
}
|
||||
t.trackID = tr.ID
|
||||
t.artistID = tr.ArtistIDs[0]
|
||||
t.albumID = tr.AlbumID
|
||||
|
||||
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.year.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnYear)]
|
||||
t.favorite.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnFavorite)]
|
||||
t.rating.IsDisabled = t.tracklist.Options.DisableRating
|
||||
t.rating.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnRating)]
|
||||
t.plays.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnPlays)]
|
||||
t.bitrate.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnBitrate)]
|
||||
|
||||
Reference in New Issue
Block a user