Merge pull request #280 from dweymouth/feature/jellyfin

Add Jellyfin support
This commit is contained in:
Drew Weymouth
2023-11-14 19:02:46 -08:00
committed by GitHub
34 changed files with 1485 additions and 383 deletions
+17
View File
@@ -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
}
+192
View File
@@ -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 }
+67
View File
@@ -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
})
}
+140
View File
@@ -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
}
+140
View File
@@ -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
}
+65 -4
View File
@@ -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
}
+49 -166
View File
@@ -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
}
+4 -62
View File
@@ -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
View File
@@ -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)
}
-33
View File
@@ -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
View File
@@ -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
}
}
+4 -3
View File
@@ -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
)
+8 -4
View File
@@ -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=
+7 -5
View File
@@ -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 {
+5 -4
View File
@@ -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) {
+10
View File
@@ -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) {
+2
View File
@@ -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) {
+10 -2
View File
@@ -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()
},
)
+10 -7
View File
@@ -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)
}
+8 -2
View File
@@ -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))
+3 -1
View File
@@ -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:
+19 -8
View File
@@ -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)
}
+13 -5
View File
@@ -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)
+19 -2
View File
@@ -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"),
+2 -1
View File
@@ -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() {
+17 -9
View File
@@ -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()
+16 -5
View File
@@ -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
View File
@@ -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()
+6 -2
View File
@@ -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)
}
+34 -6
View File
@@ -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
View File
@@ -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)]