Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ components:
play: { $ref: '#/components/schemas/PlayInfo', nullable: true }
progress: { $ref: '#/components/schemas/WatchProgress', nullable: true }
resume: { $ref: '#/components/schemas/ResumeInfo', nullable: true }
start: { $ref: '#/components/schemas/ResumeInfo', nullable: true }

LibraryListResponse:
type: object
Expand Down
49 changes: 36 additions & 13 deletions internal/clients/jellyfin/shows.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,47 +76,70 @@ func (c *Client) GetSeasons(ctx context.Context, userID, seriesID string) ([]Sea
}

func (c *Client) GetEpisodes(ctx context.Context, userID, seriesID string, seasonNumber int) ([]Episode, error) {
raw, err := url.JoinPath(c.baseURL, "Shows", seriesID, "Episodes")
q := url.Values{}
q.Set("SeasonNumber", fmt.Sprintf("%d", seasonNumber))
q.Set("Fields", "Overview,UserData,RunTimeTicks")
items, err := c.fetchEpisodes(ctx, userID, seriesID, q)
if err != nil {
return nil, fmt.Errorf("jellyfin GetEpisodes: %w", err)
}
episodes := make([]Episode, len(items))
for i, e := range items {
episodes[i] = e.toEpisode()
}
return episodes, nil
}

func (c *Client) GetFirstEpisode(ctx context.Context, userID, seriesID string) (*Episode, error) {
q := url.Values{}
q.Set("SeasonNumber", "1")
q.Set("Limit", "1")
q.Set("Fields", "UserData")
items, err := c.fetchEpisodes(ctx, userID, seriesID, q)
if err != nil {
return nil, fmt.Errorf("jellyfin GetFirstEpisode: %w", err)
}
if len(items) == 0 {
return nil, nil
}
ep := items[0].toEpisode()
return &ep, nil
}

func (c *Client) fetchEpisodes(ctx context.Context, userID, seriesID string, q url.Values) ([]jfEpisodeResponse, error) {
raw, err := url.JoinPath(c.baseURL, "Shows", seriesID, "Episodes")
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Set("UserId", userID)
q.Set("SeasonNumber", fmt.Sprintf("%d", seasonNumber))
q.Set("Fields", "Overview,UserData,RunTimeTicks")
req.URL.RawQuery = q.Encode()
req.Header.Set("X-Emby-Authorization", authHeader(userID))
req.Header.Set("X-Emby-Token", c.apiKey)

resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("jellyfin GetEpisodes: %w", err)
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return nil, ErrItemNotFound
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("jellyfin GetEpisodes: unexpected status %d", resp.StatusCode)
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}

var body struct {
Items []jfEpisodeResponse `json:"Items"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("jellyfin GetEpisodes: decode: %w", err)
return nil, fmt.Errorf("decode: %w", err)
}

episodes := make([]Episode, len(body.Items))
for i, e := range body.Items {
episodes[i] = e.toEpisode()
}
return episodes, nil
return body.Items, nil
}

func (c *Client) GetNextUp(ctx context.Context, userID, seriesID string) (*Episode, error) {
Expand Down
50 changes: 50 additions & 0 deletions internal/clients/jellyfin/shows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,53 @@ func TestGetNextUp_NothingToResume_ReturnsNil(t *testing.T) {
t.Errorf("expected nil, got %+v", ep)
}
}

func TestGetFirstEpisode_ReturnsEpisode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("SeasonNumber") != "1" {
t.Errorf("SeasonNumber: got %q, want 1", r.URL.Query().Get("SeasonNumber"))
}
if r.URL.Query().Get("Limit") != "1" {
t.Errorf("Limit: got %q, want 1", r.URL.Query().Get("Limit"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{
"Id": "ep1", "Name": "Pilot", "IndexNumber": 1,
"ParentIndexNumber": 1,
"ImageTags": map[string]string{"Primary": "tag"},
"UserData": map[string]any{"PlaybackPositionTicks": int64(0), "Played": false},
},
},
})
}))
defer srv.Close()

c := &Client{baseURL: srv.URL, hc: srv.Client()}
ep, err := c.GetFirstEpisode(context.Background(), "uid", "series1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep == nil {
t.Fatal("expected episode, got nil")
}
if ep.ID != "ep1" || ep.IndexNumber != 1 || ep.Name != "Pilot" {
t.Errorf("episode mismatch: %+v", ep)
}
}

func TestGetFirstEpisode_EmptyResult_ReturnsNil(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"Items": []any{}})
}))
defer srv.Close()

c := &Client{baseURL: srv.URL, hc: srv.Client()}
ep, err := c.GetFirstEpisode(context.Background(), "uid", "series1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ep != nil {
t.Errorf("expected nil, got %+v", ep)
}
}
85 changes: 43 additions & 42 deletions internal/gen/api.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions internal/http/handlers_library.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ func toGenDetail(d *media.Detail) gen.LibraryDetail {
detail.Resume.Thumbnail = &d.Resume.Thumbnail
}
}
if d.Start != nil {
detail.Start = &gen.ResumeInfo{
SeasonNumber: d.Start.SeasonNumber,
EpisodeNumber: d.Start.EpisodeNumber,
EpisodeId: d.Start.EpisodeID,
Title: d.Start.Title,
Play: gen.PlayInfo{StreamUrl: d.Start.Play.StreamURL},
Progress: gen.WatchProgress{PositionMs: d.Start.Progress.PositionMS, Played: d.Start.Progress.Played},
}
if d.Start.Thumbnail != "" {
detail.Start.Thumbnail = &d.Start.Thumbnail
}
}

return detail
}
Expand Down
40 changes: 40 additions & 0 deletions internal/http/handlers_library_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,46 @@ func TestGetLibraryId_SeriesDetail_HasSeasonsAndResume(t *testing.T) {
}
}

func TestGetLibraryId_SeriesDetail_HasStart(t *testing.T) {
fc := &fakeLibrary{detail: &media.Detail{
Item: media.Item{
ID: "tmdb:tv:1396", Title: "Breaking Bad",
Year: 2008, Type: media.TypeTV, State: media.StatePlayable,
},
Genres: []string{"Drama"},
Seasons: []media.Season{},
Start: &media.ResumeInfo{
SeasonNumber: 1, EpisodeNumber: 1, EpisodeID: "jf:ep1",
Title: "Pilot",
Play: media.PlayInfo{StreamURL: "https://api.stoganet.com/stream/ep1"},
Progress: media.WatchProgress{PositionMS: 0, Played: false},
},
}}

h := newLibraryServer(t, authedFakeAuth(), fc)
w := authedGet(t, h, "/library/tmdb:tv:1396")

if w.Code != http.StatusOK {
t.Fatalf("status: got %d. body: %s", w.Code, w.Body.String())
}
var resp gen.LibraryDetail
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Start == nil {
t.Fatal("Start must not be nil")
}
if resp.Start.EpisodeId != "jf:ep1" {
t.Errorf("Start.EpisodeId: got %q", resp.Start.EpisodeId)
}
if resp.Start.Play.StreamUrl != "https://api.stoganet.com/stream/ep1" {
t.Errorf("Start.Play.StreamUrl: got %q", resp.Start.Play.StreamUrl)
}
if resp.Start.Progress.PositionMs != 0 || resp.Start.Progress.Played {
t.Errorf("Start.Progress must be zero, got %+v", resp.Start.Progress)
}
}

func TestGetLibraryId_MovieDetail_HasPlayAndProgress(t *testing.T) {
fc := &fakeLibrary{detail: &media.Detail{
Item: media.Item{
Expand Down
9 changes: 8 additions & 1 deletion internal/media/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func toDetail(jf jellyfin.Item, jellyfinBaseURL, proxyBaseURL string) Detail {
}
}

func toSeriesDetail(jf jellyfin.Item, jfSeasons []jellyfin.Season, nextUp *jellyfin.Episode, jellyfinBaseURL, proxyBaseURL string) Detail {
func toSeriesDetail(jf jellyfin.Item, jfSeasons []jellyfin.Season, nextUp *jellyfin.Episode, firstEpisode *jellyfin.Episode, jellyfinBaseURL, proxyBaseURL string) Detail {
cast := make([]CastMember, len(jf.People))
for i, p := range jf.People {
cast[i] = CastMember{Name: p.Name, Role: p.Role}
Expand All @@ -58,13 +58,20 @@ func toSeriesDetail(jf jellyfin.Item, jfSeasons []jellyfin.Season, nextUp *jelly
r := toResumeInfo(*nextUp, jellyfinBaseURL, proxyBaseURL)
resume = &r
}
var start *ResumeInfo
if firstEpisode != nil {
s := toResumeInfo(*firstEpisode, jellyfinBaseURL, proxyBaseURL)
s.Progress = WatchProgress{}
start = &s
}
return Detail{
Item: toItem(jf, jellyfinBaseURL),
Genres: jf.Genres,
Runtime: 0,
Cast: cast,
Seasons: seasons,
Resume: resume,
Start: start,
}
}

Expand Down
38 changes: 36 additions & 2 deletions internal/media/mapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ func TestToSeriesDetail_HasSeasonsAndResume(t *testing.T) {
ID: "ep3", Name: "Pilot", IndexNumber: 1, ParentIndexNumber: 1,
UserData: jellyfin.UserData{PlaybackPositionTicks: 4_120_000_000, Played: false},
}
d := toSeriesDetail(jf, seasons, nextUp, "http://jf.example.com", "https://api.stoganet.com")
firstEp := &jellyfin.Episode{ID: "ep1", Name: "Pilot", IndexNumber: 1, ParentIndexNumber: 1}
d := toSeriesDetail(jf, seasons, nextUp, firstEp, "http://jf.example.com", "https://api.stoganet.com")
if d.Play != nil {
t.Error("series must not have Play")
}
Expand All @@ -272,8 +273,41 @@ func TestToSeriesDetail_HasSeasonsAndResume(t *testing.T) {

func TestToSeriesDetail_NoNextUp_NilResume(t *testing.T) {
jf := jellyfin.Item{ID: "tv1", Name: "Breaking Bad", Type: jellyfin.ItemTypeSeries}
d := toSeriesDetail(jf, nil, nil, "http://jf.example.com", "https://api.stoganet.com")
d := toSeriesDetail(jf, nil, nil, nil, "http://jf.example.com", "https://api.stoganet.com")
if d.Resume != nil {
t.Errorf("Resume should be nil for unwatched series, got %+v", d.Resume)
}
}

func TestToSeriesDetail_Start_PopulatedFromFirstEpisode(t *testing.T) {
jf := jellyfin.Item{ID: "tv1", Name: "Breaking Bad", Type: jellyfin.ItemTypeSeries}
firstEp := &jellyfin.Episode{
ID: "ep1", Name: "Pilot", IndexNumber: 1, ParentIndexNumber: 1,
PrimaryImageTag: "tag1",
UserData: jellyfin.UserData{PlaybackPositionTicks: 9_000_000_000, Played: true},
}
d := toSeriesDetail(jf, nil, nil, firstEp, "http://jf.example.com", "https://api.stoganet.com")
if d.Start == nil {
t.Fatal("Start must not be nil when firstEpisode is provided")
}
if d.Start.EpisodeID != "jf:ep1" {
t.Errorf("Start.EpisodeID: got %q", d.Start.EpisodeID)
}
if d.Start.Play.StreamURL != "https://api.stoganet.com/stream/ep1" {
t.Errorf("Start.Play.StreamURL: got %q", d.Start.Play.StreamURL)
}
if d.Start.Progress.PositionMS != 0 || d.Start.Progress.Played {
t.Errorf("Start.Progress must be zeroed, got %+v", d.Start.Progress)
}
if d.Start.Thumbnail != "http://jf.example.com/Items/ep1/Images/Primary" {
t.Errorf("Start.Thumbnail: got %q", d.Start.Thumbnail)
}
}

func TestToSeriesDetail_NoFirstEpisode_NilStart(t *testing.T) {
jf := jellyfin.Item{ID: "tv1", Name: "Breaking Bad", Type: jellyfin.ItemTypeSeries}
d := toSeriesDetail(jf, nil, nil, nil, "http://jf.example.com", "https://api.stoganet.com")
if d.Start != nil {
t.Errorf("Start should be nil when no firstEpisode, got %+v", d.Start)
}
}
Loading