more Jellyfin integration work

This commit is contained in:
Drew Weymouth
2023-11-14 09:05:02 -08:00
parent c8255517f6
commit 2867d13498
8 changed files with 416 additions and 24 deletions
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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")
}
+39 -17
View File
@@ -6,9 +6,12 @@ import (
"net/http"
"time"
"github.com/dweymouth/go-jellyfin"
"github.com/dweymouth/go-subsonic/subsonic"
"github.com/dweymouth/supersonic/backend/mediaprovider"
jellyfinMP "github.com/dweymouth/supersonic/backend/mediaprovider/jellyfin"
subsonicMP "github.com/dweymouth/supersonic/backend/mediaprovider/subsonic"
"github.com/dweymouth/supersonic/res"
"github.com/google/uuid"
"github.com/zalando/go-keyring"
)
@@ -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) {