Build and publish / Test and build (darwin) (push) Successful in 1m43s
Build and publish / Test and build (linux) (push) Successful in 2m30s
Build and publish / Test and build (windows) (push) Successful in 3m5s
Build and publish / Build and publish Docker image (push) Successful in 1m46s
Implements the three roadmap feature sections:
- Browse: retrieve_item_children, retrieve_season_list, retrieve_episode_list;
search_for_item gains item_types (e.g. "Series") so shows are findable
without a selected library. Items now expose item_type, series_name, and
per-user played state/resume position.
- retrieve_next_episode: next_unplayed (Emby NextUp, resume counts) or latest
(most recent present episode; virtual placeholders skipped).
- retrieve_now_playing + set_subtitle/set_audio_track: stream listing and
track switching via SetSubtitleStreamIndex/SetAudioStreamIndex GeneralCommands,
with a PlayNow restart-at-position fallback for sessions that cannot switch
mid-play (DLNA/Chromecast bridges).
Conformance manifest grows to 34 call sites with three new documented spec
gaps (StartPositionTicks on PlayRequest, missing /Shows/{Id}/Episodes response
schema, undeclared Fields/EnableUserData on /Users/{UserId}/Items/{Id}).
152 lines
4.6 KiB
Go
152 lines
4.6 KiB
Go
package emby
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// browseFields requests the extra item fields used by the browse/episode tools.
|
|
const browseFields = itemExtraFields + ",SeriesName,SeasonName"
|
|
|
|
// GetChildren lists the direct children of an item: seasons of a series,
|
|
// episodes of a season, tracks of an album, or folder contents. Unlike
|
|
// GetItems it does not restrict MediaTypes, so container items (Series,
|
|
// Season, Folder) are returned too.
|
|
func (c *Client) GetChildren(ctx context.Context, userID, parentID string) ([]MediaItem, error) {
|
|
if parentID == "" {
|
|
return nil, fmt.Errorf("the 'item_id' parameter cannot be empty")
|
|
}
|
|
q := url.Values{
|
|
"Recursive": {"false"},
|
|
"ParentId": {parentID},
|
|
"Fields": {browseFields},
|
|
"SortBy": {"SortName"},
|
|
}
|
|
var res QueryResult[BaseItemDto]
|
|
if err := c.Get(ctx, "/Users/"+userID+"/Items", q, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]MediaItem, 0, len(res.Items))
|
|
for _, it := range res.Items {
|
|
items = append(items, toMediaItem(&it, true))
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetSeasons lists the seasons of a series.
|
|
func (c *Client) GetSeasons(ctx context.Context, userID, seriesID string) ([]MediaItem, error) {
|
|
if seriesID == "" {
|
|
return nil, fmt.Errorf("the 'series_id' parameter cannot be empty")
|
|
}
|
|
q := url.Values{
|
|
"UserId": {userID},
|
|
"Fields": {browseFields},
|
|
}
|
|
var res QueryResult[BaseItemDto]
|
|
if err := c.Get(ctx, "/Shows/"+seriesID+"/Seasons", q, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]MediaItem, 0, len(res.Items))
|
|
for _, it := range res.Items {
|
|
items = append(items, toMediaItem(&it, true))
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetEpisodes lists the episodes of a series, optionally restricted to one
|
|
// season. Episodes are sorted by season/episode number and include per-user
|
|
// watch state.
|
|
func (c *Client) GetEpisodes(ctx context.Context, userID, seriesID, seasonID string) ([]MediaItem, error) {
|
|
if seriesID == "" {
|
|
return nil, fmt.Errorf("the 'series_id' parameter cannot be empty")
|
|
}
|
|
q := url.Values{
|
|
"UserId": {userID},
|
|
"Fields": {browseFields},
|
|
"SortBy": {"ParentIndexNumber,IndexNumber"},
|
|
"EnableUserData": {"true"},
|
|
}
|
|
if seasonID != "" {
|
|
q.Set("SeasonId", seasonID)
|
|
}
|
|
var res QueryResult[BaseItemDto]
|
|
if err := c.Get(ctx, "/Shows/"+seriesID+"/Episodes", q, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]MediaItem, 0, len(res.Items))
|
|
for _, it := range res.Items {
|
|
items = append(items, toMediaItem(&it, true))
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetNextUp returns the user's next-up episode for a series: the first episode
|
|
// that is not fully played, including partially-watched resume candidates.
|
|
// Returns nil when the series has no next-up episode (e.g. fully watched).
|
|
func (c *Client) GetNextUp(ctx context.Context, userID, seriesID string) (*MediaItem, error) {
|
|
q := url.Values{
|
|
"UserId": {userID},
|
|
"SeriesId": {seriesID},
|
|
"Fields": {browseFields},
|
|
"EnableUserData": {"true"},
|
|
"Limit": {"1"},
|
|
}
|
|
var res QueryResult[BaseItemDto]
|
|
if err := c.Get(ctx, "/Shows/NextUp", q, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(res.Items) == 0 {
|
|
return nil, nil
|
|
}
|
|
mi := toMediaItem(&res.Items[0], true)
|
|
return &mi, nil
|
|
}
|
|
|
|
// GetLatestEpisode returns the most recently premiered episode of a series
|
|
// that is actually present (virtual/missing placeholders are skipped).
|
|
// Returns nil when the series has no episodes.
|
|
func (c *Client) GetLatestEpisode(ctx context.Context, userID, seriesID string) (*MediaItem, error) {
|
|
q := url.Values{
|
|
"UserId": {userID},
|
|
"Fields": {browseFields},
|
|
"SortBy": {"PremiereDate"},
|
|
"SortOrder": {"Descending"},
|
|
"Limit": {"10"},
|
|
"EnableUserData": {"true"},
|
|
}
|
|
var res QueryResult[BaseItemDto]
|
|
if err := c.Get(ctx, "/Shows/"+seriesID+"/Episodes", q, &res); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range res.Items {
|
|
if strings.EqualFold(res.Items[i].LocationType, "Virtual") {
|
|
continue
|
|
}
|
|
mi := toMediaItem(&res.Items[i], true)
|
|
return &mi, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// FindSeries resolves a series by name across all libraries. An exact
|
|
// case-insensitive name match wins; otherwise all candidates are returned so
|
|
// the caller can disambiguate.
|
|
func (c *Client) FindSeries(ctx context.Context, userID, name string) (exact *MediaItem, candidates []MediaItem, err error) {
|
|
items, err := c.GetItems(ctx, userID, "", ItemQuery{
|
|
SearchTerm: name,
|
|
IncludeItemTypes: "Series",
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for i := range items {
|
|
if strings.EqualFold(items[i].Title, name) {
|
|
mi := items[i]
|
|
return &mi, nil, nil
|
|
}
|
|
}
|
|
return nil, items, nil
|
|
}
|