From b550b142d90f9a1b10de553ec9b5cb019c741f42 Mon Sep 17 00:00:00 2001 From: Aaro Koinsaari <89689072+koinsaari@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:12:38 +0300 Subject: [PATCH 1/2] feat(media): add start field to LibraryDetail for TV shows Clients need S1E1 info upfront to render a Play button without a second round-trip to the episodes endpoint. start is null for movies and always carries zeroed progress (position 0, played false). - Add start: ResumeInfo? to LibraryDetail in OpenAPI spec - Add GetFirstEpisode to Jellyfin client via shared fetchEpisodes helper - Fetch S1E1 in parallel with GetSeasons and GetNextUp in getSeriesDetail - Add Start field to media.Detail; map and emit it through mapper and handler - Add tests for GetFirstEpisode, toSeriesDetail Start, and handler response Co-Authored-By: Claude Sonnet 4.6 --- api/openapi.yaml | 1 + internal/clients/jellyfin/shows.go | 52 +++++++++++---- internal/clients/jellyfin/shows_test.go | 50 +++++++++++++++ internal/gen/api.gen.go | 85 +++++++++++++------------ internal/http/handlers_library.go | 13 ++++ internal/http/handlers_library_test.go | 40 ++++++++++++ internal/media/mapper.go | 9 ++- internal/media/mapper_test.go | 38 ++++++++++- internal/media/media.go | 1 + internal/media/service.go | 22 +++++-- internal/media/service_test.go | 32 ++++++---- 11 files changed, 267 insertions(+), 76 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 9b71ca9..23065c0 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 diff --git a/internal/clients/jellyfin/shows.go b/internal/clients/jellyfin/shows.go index 7668cad..99508be 100644 --- a/internal/clients/jellyfin/shows.go +++ b/internal/clients/jellyfin/shows.go @@ -76,25 +76,54 @@ 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("UserId", userID) + 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("UserId", userID) + 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() @@ -102,21 +131,16 @@ func (c *Client) GetEpisodes(ctx context.Context, userID, seriesID string, seaso 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) { diff --git a/internal/clients/jellyfin/shows_test.go b/internal/clients/jellyfin/shows_test.go index 29d2eaf..854f9d1 100644 --- a/internal/clients/jellyfin/shows_test.go +++ b/internal/clients/jellyfin/shows_test.go @@ -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) + } +} diff --git a/internal/gen/api.gen.go b/internal/gen/api.gen.go index eb83414..5238912 100644 --- a/internal/gen/api.gen.go +++ b/internal/gen/api.gen.go @@ -187,12 +187,13 @@ type LibraryDetail struct { Resume *ResumeInfo `json:"resume,omitempty"` // Runtime Duration in minutes - Runtime int `json:"runtime"` - Seasons []Season `json:"seasons"` - State MediaState `json:"state"` - Title string `json:"title"` - Type MediaType `json:"type"` - Year int `json:"year"` + Runtime int `json:"runtime"` + Seasons []Season `json:"seasons"` + Start *ResumeInfo `json:"start,omitempty"` + State MediaState `json:"state"` + Title string `json:"title"` + Type MediaType `json:"type"` + Year int `json:"year"` } // LibraryItem defines model for LibraryItem. @@ -1622,42 +1623,42 @@ func (sh *strictHandler) GetLibraryIdSeasonsSeasonNumberEpisodes(w http.Response // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "EVT8zISHhbNMlcn6bv3fAAAA//8=", + "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", + "/Lt3ePeMYSajEVT8zISHhbNMlcn6bv3fAAAA//8=", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/http/handlers_library.go b/internal/http/handlers_library.go index fec631a..6811173 100644 --- a/internal/http/handlers_library.go +++ b/internal/http/handlers_library.go @@ -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 } diff --git a/internal/http/handlers_library_test.go b/internal/http/handlers_library_test.go index 393a7b8..7c7c5f8 100644 --- a/internal/http/handlers_library_test.go +++ b/internal/http/handlers_library_test.go @@ -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{ diff --git a/internal/media/mapper.go b/internal/media/mapper.go index 9f39a41..e82c3bf 100644 --- a/internal/media/mapper.go +++ b/internal/media/mapper.go @@ -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} @@ -58,6 +58,12 @@ 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, @@ -65,6 +71,7 @@ func toSeriesDetail(jf jellyfin.Item, jfSeasons []jellyfin.Season, nextUp *jelly Cast: cast, Seasons: seasons, Resume: resume, + Start: start, } } diff --git a/internal/media/mapper_test.go b/internal/media/mapper_test.go index 2542b4a..5b06f8f 100644 --- a/internal/media/mapper_test.go +++ b/internal/media/mapper_test.go @@ -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") } @@ -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) + } +} diff --git a/internal/media/media.go b/internal/media/media.go index e2e5e64..1996810 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -35,6 +35,7 @@ type Detail struct { Play *PlayInfo Progress *WatchProgress Resume *ResumeInfo + Start *ResumeInfo } type PlayInfo struct { diff --git a/internal/media/service.go b/internal/media/service.go index cb01ec5..d913672 100644 --- a/internal/media/service.go +++ b/internal/media/service.go @@ -18,6 +18,7 @@ type JellyfinClient interface { GetSeasons(ctx context.Context, userID, seriesID string) ([]jellyfin.Season, error) GetEpisodes(ctx context.Context, userID, seriesID string, seasonNumber int) ([]jellyfin.Episode, error) GetNextUp(ctx context.Context, userID, seriesID string) (*jellyfin.Episode, error) + GetFirstEpisode(ctx context.Context, userID, seriesID string) (*jellyfin.Episode, error) } type Service struct { @@ -44,13 +45,15 @@ func (s *Service) GetItem(ctx context.Context, jfUserID, catalogID string) (*Det func (s *Service) getSeriesDetail(ctx context.Context, jfUserID string, item jellyfin.Item) (*Detail, error) { var ( - seasons []jellyfin.Season - seasonsErr error - nextUp *jellyfin.Episode - nextUpErr error + seasons []jellyfin.Season + seasonsErr error + nextUp *jellyfin.Episode + nextUpErr error + firstEpisode *jellyfin.Episode + firstEpErr error ) var wg sync.WaitGroup - wg.Add(2) + wg.Add(3) go func() { defer wg.Done() seasons, seasonsErr = s.jf.GetSeasons(ctx, jfUserID, item.ID) @@ -59,6 +62,10 @@ func (s *Service) getSeriesDetail(ctx context.Context, jfUserID string, item jel defer wg.Done() nextUp, nextUpErr = s.jf.GetNextUp(ctx, jfUserID, item.ID) }() + go func() { + defer wg.Done() + firstEpisode, firstEpErr = s.jf.GetFirstEpisode(ctx, jfUserID, item.ID) + }() wg.Wait() if seasonsErr != nil { @@ -67,7 +74,10 @@ func (s *Service) getSeriesDetail(ctx context.Context, jfUserID string, item jel if nextUpErr != nil { return nil, fmt.Errorf("getSeriesDetail: GetNextUp: %w", nextUpErr) } - d := toSeriesDetail(item, seasons, nextUp, s.baseURL, s.proxyBaseURL) + if firstEpErr != nil { + return nil, fmt.Errorf("getSeriesDetail: GetFirstEpisode: %w", firstEpErr) + } + d := toSeriesDetail(item, seasons, nextUp, firstEpisode, s.baseURL, s.proxyBaseURL) return &d, nil } diff --git a/internal/media/service_test.go b/internal/media/service_test.go index d7e3625..b74ae64 100644 --- a/internal/media/service_test.go +++ b/internal/media/service_test.go @@ -9,17 +9,19 @@ import ( ) type fakeJF struct { - item *jellyfin.Item - items *jellyfin.ItemsResult - err error - capturedItemID string - capturedOpts jellyfin.GetItemsOpts - getSeasons []jellyfin.Season - getSeasonsErr error - getEpisodes []jellyfin.Episode - getEpisodesErr error - getNextUp *jellyfin.Episode - getNextUpErr error + item *jellyfin.Item + items *jellyfin.ItemsResult + err error + capturedItemID string + capturedOpts jellyfin.GetItemsOpts + getSeasons []jellyfin.Season + getSeasonsErr error + getEpisodes []jellyfin.Episode + getEpisodesErr error + getNextUp *jellyfin.Episode + getNextUpErr error + getFirstEpisode *jellyfin.Episode + getFirstEpisodeErr error } func (f *fakeJF) GetItem(_ context.Context, _, itemID string) (*jellyfin.Item, error) { @@ -44,6 +46,10 @@ func (f *fakeJF) GetNextUp(_ context.Context, _, _ string) (*jellyfin.Episode, e return f.getNextUp, f.getNextUpErr } +func (f *fakeJF) GetFirstEpisode(_ context.Context, _, _ string) (*jellyfin.Episode, error) { + return f.getFirstEpisode, f.getFirstEpisodeErr +} + func newSvc(jf JellyfinClient) *Service { return NewService(jf, "https://jf.example.com", "https://api.stoganet.com") } @@ -203,6 +209,10 @@ func (f *fakeJFFunc) GetNextUp(_ context.Context, _, _ string) (*jellyfin.Episod return nil, nil } +func (f *fakeJFFunc) GetFirstEpisode(_ context.Context, _, _ string) (*jellyfin.Episode, error) { + return nil, nil +} + func okSection() *jellyfin.ItemsResult { return &jellyfin.ItemsResult{ Items: []jellyfin.Item{{ID: "jf-1", Type: jellyfin.ItemTypeMovie}}, From 3e649953e3626657085e5a0febccf76df2790966 Mon Sep 17 00:00:00 2001 From: Aaro Koinsaari <89689072+koinsaari@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:21:28 +0300 Subject: [PATCH 2/2] refactor: move UserId into fetchEpisodes, add service Start test Co-Authored-By: Claude Sonnet 4.6 --- internal/clients/jellyfin/shows.go | 3 +-- internal/media/service_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/clients/jellyfin/shows.go b/internal/clients/jellyfin/shows.go index 99508be..4cb59b7 100644 --- a/internal/clients/jellyfin/shows.go +++ b/internal/clients/jellyfin/shows.go @@ -77,7 +77,6 @@ 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) { q := url.Values{} - q.Set("UserId", userID) q.Set("SeasonNumber", fmt.Sprintf("%d", seasonNumber)) q.Set("Fields", "Overview,UserData,RunTimeTicks") items, err := c.fetchEpisodes(ctx, userID, seriesID, q) @@ -93,7 +92,6 @@ func (c *Client) GetEpisodes(ctx context.Context, userID, seriesID string, seaso func (c *Client) GetFirstEpisode(ctx context.Context, userID, seriesID string) (*Episode, error) { q := url.Values{} - q.Set("UserId", userID) q.Set("SeasonNumber", "1") q.Set("Limit", "1") q.Set("Fields", "UserData") @@ -117,6 +115,7 @@ func (c *Client) fetchEpisodes(ctx context.Context, userID, seriesID string, q u if err != nil { return nil, err } + q.Set("UserId", userID) req.URL.RawQuery = q.Encode() req.Header.Set("X-Emby-Authorization", authHeader(userID)) req.Header.Set("X-Emby-Token", c.apiKey) diff --git a/internal/media/service_test.go b/internal/media/service_test.go index b74ae64..fdec33c 100644 --- a/internal/media/service_test.go +++ b/internal/media/service_test.go @@ -141,6 +141,33 @@ func TestService_GetItem_ReturnsDetail(t *testing.T) { } } +func TestService_GetItem_Series_WithFirstEpisode_PopulatesStart(t *testing.T) { + jf := &fakeJF{ + item: &jellyfin.Item{ + ID: "series-1", + Name: "Breaking Bad", + Type: jellyfin.ItemTypeSeries, + }, + getFirstEpisode: &jellyfin.Episode{ + ID: "ep1", Name: "Pilot", IndexNumber: 1, ParentIndexNumber: 1, + }, + } + svc := newSvc(jf) + d, err := svc.GetItem(context.Background(), "jf-user-1", "jf:series-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if d.Start == nil { + t.Fatal("Start must not be nil when firstEpisode returned") + } + if d.Start.EpisodeID != "jf:ep1" { + t.Errorf("Start.EpisodeID: got %q", d.Start.EpisodeID) + } + if d.Start.Progress.PositionMS != 0 || d.Start.Progress.Played { + t.Errorf("Start.Progress must be zeroed, got %+v", d.Start.Progress) + } +} + func TestService_List_ReturnsPaginatedResult(t *testing.T) { jf := &fakeJF{items: &jellyfin.ItemsResult{ Items: []jellyfin.Item{