add features
This commit is contained in:
@@ -196,6 +196,15 @@ type Server interface {
|
||||
MediaProvider() MediaProvider
|
||||
}
|
||||
|
||||
// PodcastProvider is implemented by Subsonic servers with the standard podcast API.
|
||||
// Podcast IDs are opaque and must never be converted to numeric IDs.
|
||||
type PodcastProvider interface {
|
||||
GetPodcastChannels() ([]*PodcastChannel, error)
|
||||
GetPodcastChannel(id string) (*PodcastChannel, error)
|
||||
GetNewestPodcastEpisodes(count int) ([]*PodcastEpisode, error)
|
||||
GetPodcastEpisode(id string) (*PodcastEpisode, error)
|
||||
}
|
||||
|
||||
type MediaProvider interface {
|
||||
SetPrefetchCoverCallback(cb func(coverArtID string))
|
||||
|
||||
|
||||
@@ -221,8 +221,44 @@ type MediaItemType int
|
||||
const (
|
||||
MediaItemTypeTrack MediaItemType = iota
|
||||
MediaItemTypeRadioStation
|
||||
MediaItemTypePodcastEpisode
|
||||
)
|
||||
|
||||
type PodcastChannel struct {
|
||||
ID, URL, Title, Description, CoverArtID, OriginalImageURL, Status, ErrorMessage string
|
||||
Episodes []*PodcastEpisode
|
||||
}
|
||||
|
||||
type PodcastEpisode struct {
|
||||
ID, StreamID, ChannelID, Title, Description, Status, CoverArtID, OriginalImageURL, ChannelTitle string
|
||||
PublishDate time.Time
|
||||
Duration time.Duration
|
||||
Size int64
|
||||
BitRate int
|
||||
ContentType string
|
||||
}
|
||||
|
||||
func (p *PodcastEpisode) Playable() bool {
|
||||
return p != nil && p.Status == "completed" && p.StreamID != ""
|
||||
}
|
||||
|
||||
func (p *PodcastEpisode) Metadata() MediaItemMetadata {
|
||||
if p == nil {
|
||||
return MediaItemMetadata{}
|
||||
}
|
||||
return MediaItemMetadata{Type: MediaItemTypePodcastEpisode, MIMEType: p.ContentType,
|
||||
ID: p.ID, Name: p.Title, Artists: []string{p.ChannelTitle}, Album: p.ChannelTitle,
|
||||
CoverArtID: p.CoverArtID, Duration: p.Duration, Size: p.Size, BitRate: p.BitRate}
|
||||
}
|
||||
|
||||
func (p *PodcastEpisode) Copy() MediaItem {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
c := *p
|
||||
return &c
|
||||
}
|
||||
|
||||
type MediaItemMetadata struct {
|
||||
Type MediaItemType
|
||||
MIMEType string
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
)
|
||||
|
||||
type podcastChannelDTO struct {
|
||||
ID string `xml:"id,attr" json:"id"`
|
||||
URL string `xml:"url,attr" json:"url"`
|
||||
Title string `xml:"title,attr" json:"title"`
|
||||
Description string `xml:"description,attr" json:"description"`
|
||||
CoverArt string `xml:"coverArt,attr" json:"coverArt"`
|
||||
OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl"`
|
||||
Status string `xml:"status,attr" json:"status"`
|
||||
ErrorMessage string `xml:"errorMessage,attr" json:"errorMessage"`
|
||||
Episodes []podcastEpisodeDTO `xml:"episode" json:"episode"`
|
||||
}
|
||||
|
||||
type podcastEpisodeDTO struct {
|
||||
ID string `xml:"id,attr" json:"id"`
|
||||
StreamID string `xml:"streamId,attr" json:"streamId"`
|
||||
ChannelID string `xml:"channelId,attr" json:"channelId"`
|
||||
Title string `xml:"title,attr" json:"title"`
|
||||
Description string `xml:"description,attr" json:"description"`
|
||||
Status string `xml:"status,attr" json:"status"`
|
||||
CoverArt string `xml:"coverArt,attr" json:"coverArt"`
|
||||
PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"`
|
||||
Duration int `xml:"duration,attr" json:"duration"`
|
||||
Size int64 `xml:"size,attr" json:"size"`
|
||||
BitRate int `xml:"bitRate,attr" json:"bitRate"`
|
||||
ContentType string `xml:"contentType,attr" json:"contentType"`
|
||||
}
|
||||
|
||||
type podcastAPIResponse struct {
|
||||
XMLName xml.Name `xml:"subsonic-response" json:"-"`
|
||||
Status string `xml:"status,attr" json:"status"`
|
||||
Error *struct {
|
||||
Code int `xml:"code,attr" json:"code"`
|
||||
Message string `xml:"message,attr" json:"message"`
|
||||
} `xml:"error" json:"error"`
|
||||
Podcasts struct {
|
||||
Channels []podcastChannelDTO `xml:"channel" json:"channel"`
|
||||
} `xml:"podcasts" json:"podcasts"`
|
||||
Newest struct {
|
||||
Episodes []podcastEpisodeDTO `xml:"episode" json:"episode"`
|
||||
} `xml:"newestPodcasts" json:"newestPodcasts"`
|
||||
Episode *podcastEpisodeDTO `xml:"podcastEpisode" json:"podcastEpisode"`
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) podcastRequest(endpoint string, params url.Values) (*podcastAPIResponse, error) {
|
||||
endpoint += ".view"
|
||||
resp, err := s.client.Request(http.MethodGet, endpoint, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("podcast API: HTTP %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out podcastAPIResponse
|
||||
if s.client.UseJSON {
|
||||
var wrapper struct {
|
||||
Response podcastAPIResponse `json:"subsonic-response"`
|
||||
}
|
||||
if err = json.Unmarshal(body, &wrapper); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = wrapper.Response
|
||||
} else if err = xml.Unmarshal(body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Status != "ok" {
|
||||
if out.Error != nil {
|
||||
return nil, fmt.Errorf("podcast API error %d: %s", out.Error.Code, out.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("podcast API returned status %q", out.Status)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func episodeFromDTO(e podcastEpisodeDTO, ch *mediaprovider.PodcastChannel) *mediaprovider.PodcastEpisode {
|
||||
p := &mediaprovider.PodcastEpisode{ID: e.ID, StreamID: e.StreamID, ChannelID: e.ChannelID, Title: e.Title,
|
||||
Description: e.Description, Status: e.Status, CoverArtID: e.CoverArt, PublishDate: e.PublishDate,
|
||||
Duration: time.Duration(e.Duration) * time.Second, Size: e.Size, BitRate: e.BitRate, ContentType: e.ContentType}
|
||||
if ch != nil {
|
||||
p.ChannelTitle = ch.Title
|
||||
if p.ChannelID == "" {
|
||||
p.ChannelID = ch.ID
|
||||
}
|
||||
if p.CoverArtID == "" {
|
||||
p.CoverArtID = ch.CoverArtID
|
||||
}
|
||||
p.OriginalImageURL = ch.OriginalImageURL
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func channelFromDTO(c podcastChannelDTO) *mediaprovider.PodcastChannel {
|
||||
ch := &mediaprovider.PodcastChannel{ID: c.ID, URL: c.URL, Title: c.Title, Description: c.Description,
|
||||
CoverArtID: c.CoverArt, OriginalImageURL: c.OriginalImageURL, Status: c.Status, ErrorMessage: c.ErrorMessage}
|
||||
for _, e := range c.Episodes {
|
||||
ch.Episodes = append(ch.Episodes, episodeFromDTO(e, ch))
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetPodcastChannels() ([]*mediaprovider.PodcastChannel, error) {
|
||||
r, err := s.podcastRequest("getPodcasts", url.Values{"includeEpisodes": {"false"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*mediaprovider.PodcastChannel, 0, len(r.Podcasts.Channels))
|
||||
for _, c := range r.Podcasts.Channels {
|
||||
result = append(result, channelFromDTO(c))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *subsonicMediaProvider) GetPodcastChannel(id string) (*mediaprovider.PodcastChannel, error) {
|
||||
r, err := s.podcastRequest("getPodcasts", url.Values{"id": {id}, "includeEpisodes": {"true"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(r.Podcasts.Channels) == 0 {
|
||||
return nil, errors.New("podcast channel not found")
|
||||
}
|
||||
return channelFromDTO(r.Podcasts.Channels[0]), nil
|
||||
}
|
||||
func (s *subsonicMediaProvider) GetNewestPodcastEpisodes(count int) ([]*mediaprovider.PodcastEpisode, error) {
|
||||
r, err := s.podcastRequest("getNewestPodcasts", url.Values{"count": {strconv.Itoa(count)}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*mediaprovider.PodcastEpisode, 0, len(r.Newest.Episodes))
|
||||
for _, e := range r.Newest.Episodes {
|
||||
result = append(result, episodeFromDTO(e, nil))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *subsonicMediaProvider) GetPodcastEpisode(id string) (*mediaprovider.PodcastEpisode, error) {
|
||||
r, err := s.podcastRequest("getPodcastEpisode", url.Values{"id": {id}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Episode == nil {
|
||||
return nil, errors.New("podcast episode not found")
|
||||
}
|
||||
return episodeFromDTO(*r.Episode, nil), nil
|
||||
}
|
||||
Reference in New Issue
Block a user