From 2867d13498fb768fd2e0b9f00ecd85a7f7ae05a6 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 12 Nov 2023 08:59:38 -0800 Subject: [PATCH] more Jellyfin integration work --- .../mediaprovider/jellyfin/albumiterator.go | 42 +++ .../jellyfin/jellyfinmediaprovider.go | 293 ++++++++++++++++++ .../mediaprovider/jellyfin/jellyfinserver.go | 18 ++ backend/mediaprovider/jellyfin/searchall.go | 11 + backend/servermanager.go | 56 +++- go.mod | 2 +- go.sum | 6 +- ui/browsing/genrespage.go | 12 +- 8 files changed, 416 insertions(+), 24 deletions(-) create mode 100644 backend/mediaprovider/jellyfin/albumiterator.go create mode 100644 backend/mediaprovider/jellyfin/jellyfinserver.go create mode 100644 backend/mediaprovider/jellyfin/searchall.go diff --git a/backend/mediaprovider/jellyfin/albumiterator.go b/backend/mediaprovider/jellyfin/albumiterator.go new file mode 100644 index 0000000..80e302f --- /dev/null +++ b/backend/mediaprovider/jellyfin/albumiterator.go @@ -0,0 +1,42 @@ +package jellyfin + +import "github.com/dweymouth/supersonic/backend/mediaprovider" + +const ( + AlbumSortRecentlyAdded string = "Recently Added" + AlbumSortRecentlyPlayed string = "Recently Played" + AlbumSortFrequentlyPlayed string = "Frequently Played" + 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, + AlbumSortRecentlyPlayed, + AlbumSortFrequentlyPlayed, + AlbumSortRandom, + AlbumSortTitleAZ, + AlbumSortArtistAZ, + AlbumSortYearAscending, + AlbumSortYearDescending, + } +} + +func (s *jellyfinMediaProvider) IterateAlbums(sortOrder string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { + return nil + // TODO: unimplemented +} + +func (s *jellyfinMediaProvider) SearchAlbums(searchQuery string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator { + return nil + // TODO: unimplemented +} + +func (s *jellyfinMediaProvider) IterateTracks(searchQuery string) mediaprovider.TrackIterator { + return nil + // TODO: unimplemented +} diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go index 16a6b65..59ccc31 100644 --- a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -2,13 +2,34 @@ package jellyfin import ( "errors" + "image" + "io" + "math" + "sync" + "time" "github.com/dweymouth/go-jellyfin" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" ) +const cacheValidDurationSeconds = 60 + +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)) { @@ -22,3 +43,275 @@ func (jellyfinMediaProvider) CreatePlaylist(name string, trackIDs []string) erro func (j *jellyfinMediaProvider) DeletePlaylist(id string) error { return j.client.DeletePlaylist(id) } + +func (s *jellyfinMediaProvider) EditPlaylist(id, name, description string, public bool) error { + return errors.New("unimplemented") +} + +func (s *jellyfinMediaProvider) EditPlaylistTracks(id string, trackIDsToAdd []string, trackIndexesToRemove []int) error { + return errors.New("unimplemented") +} + +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 (s *jellyfinMediaProvider) GetAlbumInfo(albumID string) (*mediaprovider.AlbumInfo, error) { + return nil, errors.New("unimplemented") +} + +func (s *jellyfinMediaProvider) GetArtist(artistID string) (*mediaprovider.ArtistWithAlbums, error) { + return nil, errors.New("unimplemented") +} + +func (s *jellyfinMediaProvider) GetArtistInfo(artistID string) (*mediaprovider.ArtistInfo, error) { + return nil, errors.New("unimplemented") +} + +func (s *jellyfinMediaProvider) GetArtists() ([]*mediaprovider.Artist, error) { + return nil, errors.New("unimplemented") +} + +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 = "CommunityRating" + 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 = []jellyfin.NameID{{Name: genreName}} + opts.Sort.Field = "Random" + 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) { + tr, err := j.client.GetSimilarSongs(artistID, 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) { + return mediaprovider.Favorites{}, errors.New("unimplemented") +} + +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, 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), + } + fillPlaylist(pl, &playlist.Playlist) + return playlist, nil +} + +func (s *jellyfinMediaProvider) ReplacePlaylistTracks(playlistID string, trackIDs []string) error { + return errors.New("unimplemented") +} + +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) SetRating(params mediaprovider.RatingFavoriteParameters, rating int) error { + return errors.New("unimplemented") +} + +func (j *jellyfinMediaProvider) GetStreamURL(trackID string, forceRaw bool) (string, error) { + return j.client.GetStreamURL(trackID) +} + +func (j *jellyfinMediaProvider) DownloadTrack(trackID string) (io.Reader, error) { + return nil, errors.New("unimplemented") +} + +func (j *jellyfinMediaProvider) Scrobble(trackID string, submission bool) error { + return errors.New("unimplemented") +} + +func (s *jellyfinMediaProvider) RescanLibrary() error { + return errors.ErrUnsupported +} + +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) + } + + t := &mediaprovider.Track{ + ID: ch.Id, + //CoverArtID: ch.CoverArt, + ParentID: ch.AlbumID, + Name: ch.Name, + Duration: int(ch.RunTimeTicks / 1_000_000), + 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 + } + return t +} + +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.CoverArt + album.Name = a.Name + album.Duration = int(a.RunTimeTicks / 1_000_000) + album.ArtistIDs = artistIDs + album.ArtistNames = artistNames + album.Year = a.Year + //album.TrackCount = a. + album.Genres = a.Genres + album.Favorite = a.UserData.IsFavorite +} + +func toPlaylist(p *jellyfin.Playlist) *mediaprovider.Playlist { + pl := &mediaprovider.Playlist{} + fillPlaylist(p, pl) + return pl +} + +func fillPlaylist(p *jellyfin.Playlist, pl *mediaprovider.Playlist) { + pl.Name = p.Name + pl.ID = p.ID + //CoverArtID = pl.CoverArt + pl.Description = p.Overview + //.Owner = pl.Owner + //Public = pl.Public + pl.TrackCount = p.SongCount + pl.Duration = int(p.RunTimeTicks / 1_000_000) +} diff --git a/backend/mediaprovider/jellyfin/jellyfinserver.go b/backend/mediaprovider/jellyfin/jellyfinserver.go new file mode 100644 index 0000000..a09d654 --- /dev/null +++ b/backend/mediaprovider/jellyfin/jellyfinserver.go @@ -0,0 +1,18 @@ +package jellyfin + +import ( + jellyfinCli "github.com/dweymouth/go-jellyfin" + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +type JellyfinServer struct { + jellyfinCli.Client +} + +func (j *JellyfinServer) MediaProvider() mediaprovider.MediaProvider { + return newJellyfinMediaProvider(&j.Client) +} + +func (j *JellyfinServer) Ping() bool { + return false // TODO: unimplemented +} diff --git a/backend/mediaprovider/jellyfin/searchall.go b/backend/mediaprovider/jellyfin/searchall.go new file mode 100644 index 0000000..fc1f92c --- /dev/null +++ b/backend/mediaprovider/jellyfin/searchall.go @@ -0,0 +1,11 @@ +package jellyfin + +import ( + "errors" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) { + return nil, errors.New("unimplemented") +} diff --git a/backend/servermanager.go b/backend/servermanager.go index a23a458..b974b55 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" ) @@ -159,23 +162,42 @@ func (s *ServerManager) SetServerPassword(server *ServerConfig, password string) func (s *ServerManager) connect(connection ServerConnection, password string) (mediaprovider.Server, error) { var cli, altCli mediaprovider.Server - cli = &subsonicMP.SubsonicServer{ - Client: subsonic.Client{ - Client: &http.Client{Timeout: 10 * time.Second}, - BaseUrl: connection.Hostname, - User: connection.Username, - PasswordAuth: connection.LegacyAuth, - ClientName: "supersonic", - }, - } - altCli = &subsonicMP.SubsonicServer{ - Client: subsonic.Client{ - Client: &http.Client{Timeout: 10 * time.Second}, - BaseUrl: connection.AltHostname, - User: connection.Username, - PasswordAuth: connection.LegacyAuth, - ClientName: "supersonic", - }, + 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, + }, + } } pingChan := make(chan bool, 2) // false for primary hostname, true for alternate pingFunc := func(delay time.Duration, cli mediaprovider.Server, val bool) { diff --git a/go.mod b/go.mod index b452a61..07e794f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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-20231112164127-119b53593792 github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363 github.com/fsnotify/fsnotify v1.6.0 @@ -22,7 +23,6 @@ require ( github.com/alessio/shellescape v1.4.1 // indirect github.com/danieljoos/wincred v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dweymouth/go-jellyfin v0.0.0-20231112010253-d93b76137091 // indirect github.com/fredbi/uri v1.0.0 // indirect github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect github.com/fyne-io/glfw-js v0.0.0-20220120001248-ee7290d23504 // indirect diff --git a/go.sum b/go.sum index 6121a62..32e8364 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,8 @@ 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-20231112010253-d93b76137091 h1:UiyWNmeig46pkNCj7Pqc0Ta4JdxYKeXnBwuX+NOFCyM= -github.com/dweymouth/go-jellyfin v0.0.0-20231112010253-d93b76137091/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU= +github.com/dweymouth/go-jellyfin v0.0.0-20231112164127-119b53593792 h1:Be+Y0AnYaSCeCm2+TUjw5TzowttJbsZsd5a7Wn3dqOI= +github.com/dweymouth/go-jellyfin v0.0.0-20231112164127-119b53593792/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= @@ -337,7 +337,6 @@ 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= @@ -499,7 +498,6 @@ 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= 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() }, )