diff --git a/backend/config.go b/backend/config.go index 3ffc073..87f03aa 100644 --- a/backend/config.go +++ b/backend/config.go @@ -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 } diff --git a/backend/mediaprovider/helpers/iterators.go b/backend/mediaprovider/helpers/iterators.go new file mode 100644 index 0000000..77170b6 --- /dev/null +++ b/backend/mediaprovider/helpers/iterators.go @@ -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 } diff --git a/backend/mediaprovider/helpers/search.go b/backend/mediaprovider/helpers/search.go new file mode 100644 index 0000000..8fcdc93 --- /dev/null +++ b/backend/mediaprovider/helpers/search.go @@ -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 + }) +} diff --git a/backend/mediaprovider/jellyfin/iterators.go b/backend/mediaprovider/jellyfin/iterators.go new file mode 100644 index 0000000..e7f299c --- /dev/null +++ b/backend/mediaprovider/jellyfin/iterators.go @@ -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 +} diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go new file mode 100644 index 0000000..8ce6e43 --- /dev/null +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -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 +} diff --git a/backend/mediaprovider/jellyfin/searchall.go b/backend/mediaprovider/jellyfin/searchall.go new file mode 100644 index 0000000..09908d2 --- /dev/null +++ b/backend/mediaprovider/jellyfin/searchall.go @@ -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 +} diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 837afb4..0068df3 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -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 +} diff --git a/backend/mediaprovider/subsonic/albumiterator.go b/backend/mediaprovider/subsonic/albumiterator.go index df67587..6818972 100644 --- a/backend/mediaprovider/subsonic/albumiterator.go +++ b/backend/mediaprovider/subsonic/albumiterator.go @@ -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 } diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index 79433c2..8c47963 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -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 { diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index b546e67..44b51d2 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -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 { diff --git a/backend/mediaprovider/subsonic/subsonicserver.go b/backend/mediaprovider/subsonic/subsonicserver.go new file mode 100644 index 0000000..b64fe07 --- /dev/null +++ b/backend/mediaprovider/subsonic/subsonicserver.go @@ -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) +} diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 27fac46..a984f68 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -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) } diff --git a/backend/searchiterbase.go b/backend/searchiterbase.go deleted file mode 100644 index 4fa880f..0000000 --- a/backend/searchiterbase.go +++ /dev/null @@ -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 -} diff --git a/backend/servermanager.go b/backend/servermanager.go index 1efd86f..265381e 100644 --- a/backend/servermanager.go +++ b/backend/servermanager.go @@ -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 } } diff --git a/go.mod b/go.mod index 9072fce..adc2715 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 92fe015..df9c606 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index 3b8225a..883aab8 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -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 { diff --git a/ui/browsing/albumspage.go b/ui/browsing/albumspage.go index 50629ad..c93074c 100644 --- a/ui/browsing/albumspage.go +++ b/ui/browsing/albumspage.go @@ -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) { diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 2f08101..e3a329a 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -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) { diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index c7c3714..8866218 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -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) { diff --git a/ui/browsing/genrespage.go b/ui/browsing/genrespage.go index dbc1702..f753066 100644 --- a/ui/browsing/genrespage.go +++ b/ui/browsing/genrespage.go @@ -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() }, ) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 58bbb50..5884c5f 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -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) } diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 19fab2d..e58820e 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -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)) diff --git a/ui/browsing/router.go b/ui/browsing/router.go index 631d6f8..dc2a220 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -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: diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index f046ed8..641b895 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -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) +} diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 25cbb64..24f70c9 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -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) diff --git a/ui/dialogs/addeditserverdialog.go b/ui/dialogs/addeditserverdialog.go index e6b8c9f..1806283 100644 --- a/ui/dialogs/addeditserverdialog.go +++ b/ui/dialogs/addeditserverdialog.go @@ -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"), diff --git a/ui/dialogs/editplaylistdialog.go b/ui/dialogs/editplaylistdialog.go index f9492e0..20320b7 100644 --- a/ui/dialogs/editplaylistdialog.go +++ b/ui/dialogs/editplaylistdialog.go @@ -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() { diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 52ba02d..2200814 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -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() diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index 6d02860..c6e0396 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -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() } } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index ee19a1d..56156bd 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -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() diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index 0358547..42595fe 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -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) } diff --git a/ui/widgets/starrating.go b/ui/widgets/starrating.go index 8dd7e16..d54814c 100644 --- a/ui/widgets/starrating.go +++ b/ui/widgets/starrating.go @@ -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 diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index ed67833..8f7d9d8 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -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)]