Files
supersonic/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go
T

452 lines
12 KiB
Go

package jellyfin
import (
"errors"
"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
type JellyfinServer struct {
jellyfin.Client
}
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, removeInfo []mediaprovider.IDAndIndex) error {
ids := sharedutil.MapSlice(removeInfo, func(x mediaprovider.IDAndIndex) string { return x.ID })
return j.client.RemoveSongsFromPlaylist(playlistID, ids)
}
func (j *jellyfinMediaProvider) ReplacePlaylistTracks(playlistID string, trackIDs []string) error {
trs, err := j.client.GetPlaylistSongs(playlistID)
if err != nil {
return err
}
idsToDelete := sharedutil.MapSlice(trs, func(t *jellyfin.Song) string { return t.Id })
if err = j.client.RemoveSongsFromPlaylist(playlistID, idsToDelete); 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) Scrobble(trackID string, submission bool) error {
return errors.New("unimplemented")
}
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 / 10_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 / 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 / 10_000_000)
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 / 1_000_000)
// Jellyfin does not have public playlists
pl.Owner = j.client.LoggedInUser()
pl.Public = false
}