diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 7d5c7acb..fa3a4784 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -549,7 +549,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil { slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr) } - if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name); seedErr != nil { + if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil { slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr) } } diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index 206eedc6..5ae1cb9e 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -26,6 +26,7 @@ type SectionHandler struct { repo *sections.Repository fetcher *sections.Fetcher previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection + episodeFetcher sectionEpisodeFetcher FolderRepo *catalog.FolderRepository EpisodeRepo *catalog.EpisodeRepository StoreProvider userstore.UserStoreProvider @@ -37,7 +38,11 @@ type SectionHandler struct { // NewSectionHandler creates a new SectionHandler. func NewSectionHandler(repo *sections.Repository, fetcher *sections.Fetcher) *SectionHandler { - return &SectionHandler{repo: repo, fetcher: fetcher, previewFetcher: fetcher} + return &SectionHandler{repo: repo, fetcher: fetcher, previewFetcher: fetcher, episodeFetcher: fetcher} +} + +type sectionEpisodeFetcher interface { + FetchEpisodesByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) } func (h *SectionHandler) defaultHomeSections(ctx context.Context) ([]*sections.PageSection, error) { @@ -1169,6 +1174,7 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect } userStates := h.listSectionItemUserStates(r, allItems) imageURLs := h.resolveSectionItemImageURLs(r.Context(), withItems) + episodeMeta := h.listSectionEpisodeItemMeta(r.Context(), withItems, requestAccessFilter(r)) for _, s := range withItems { items := make([]sectionItemResponse, 0, len(s.Items)) for _, item := range s.Items { @@ -1178,6 +1184,11 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect meta = &value } } + if meta == nil { + if value, ok := episodeMeta[item.ContentID]; ok { + meta = &value + } + } imageKey := sectionItemImageKey{sectionID: s.ID, contentID: item.ContentID} items = append(items, h.toSectionItemResponse(s.SectionType, item, meta, overlaySummaries[item.ContentID], userStates[item.ContentID], imageURLs[imageKey])) } @@ -1196,6 +1207,42 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect return resp } +func (h *SectionHandler) listSectionEpisodeItemMeta(ctx context.Context, withItems []sections.SectionWithItems, filter catalog.AccessFilter) map[string]sections.SectionItemMeta { + if h == nil || h.episodeFetcher == nil { + return map[string]sections.SectionItemMeta{} + } + + ids := make([]string, 0) + seen := make(map[string]struct{}) + for _, section := range withItems { + for _, item := range section.Items { + if item == nil || item.Type != "episode" || strings.TrimSpace(item.ContentID) == "" { + continue + } + if section.ItemMeta != nil { + if _, ok := section.ItemMeta[item.ContentID]; ok { + continue + } + } + if _, ok := seen[item.ContentID]; ok { + continue + } + seen[item.ContentID] = struct{}{} + ids = append(ids, item.ContentID) + } + } + if len(ids) == 0 { + return map[string]sections.SectionItemMeta{} + } + + _, meta, err := h.episodeFetcher.FetchEpisodesByContentIDs(ctx, ids, filter) + if err != nil { + slog.Warn("loading section episode metadata", "error", err) + return map[string]sections.SectionItemMeta{} + } + return meta +} + func (h *SectionHandler) resolveSectionItemImageURLs(ctx context.Context, withItems []sections.SectionWithItems) map[sectionItemImageKey]sectionItemImageURLs { result := make(map[sectionItemImageKey]sectionItemImageURLs) if h.DetailSvc == nil { diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go index f7b06e07..a46f5d2b 100644 --- a/internal/api/handlers/sections_test.go +++ b/internal/api/handlers/sections_test.go @@ -17,6 +17,16 @@ import ( "github.com/Silo-Server/silo-server/internal/userstore" ) +type stubSectionEpisodeFetcher struct { + calls int + meta map[string]sections.SectionItemMeta +} + +func (s *stubSectionEpisodeFetcher) FetchEpisodesByContentIDs(_ context.Context, _ []string, _ catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) { + s.calls++ + return nil, s.meta, nil +} + func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { tests := []struct { name string @@ -65,6 +75,101 @@ func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { } } +func TestBuildSectionsResponseEnrichesEpisodeMetadata(t *testing.T) { + seasonNumber := 1 + episodeNumber := 1 + seriesID := "series-1" + fetcher := &stubSectionEpisodeFetcher{ + meta: map[string]sections.SectionItemMeta{ + "episode-1": { + SeriesID: &seriesID, + SeriesTitle: "American Dad!", + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + }, + }, + } + h := &SectionHandler{episodeFetcher: fetcher} + withItems := []sections.SectionWithItems{ + { + ResolvedSection: sections.ResolvedSection{ID: "released", SectionType: sections.SectionCustomFilter, Title: "Released"}, + Items: []*models.MediaItem{{ + ContentID: "episode-1", + Type: "episode", + Title: "Dumbston Checks In", + Status: "matched", + }}, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/sections", nil) + resp := h.buildSectionsResponse(req, withItems) + + if fetcher.calls != 1 { + t.Fatalf("episode metadata fetch calls = %d, want 1", fetcher.calls) + } + item := resp.Sections[0].Items[0] + if item.SeriesTitle != "American Dad!" { + t.Fatalf("series title = %q, want %q", item.SeriesTitle, "American Dad!") + } + if item.SeasonNumber == nil || *item.SeasonNumber != 1 { + t.Fatalf("season number = %v, want 1", item.SeasonNumber) + } + if item.EpisodeNumber == nil || *item.EpisodeNumber != 1 { + t.Fatalf("episode number = %v, want 1", item.EpisodeNumber) + } +} + +func TestBuildSectionsResponseKeepsExistingEpisodeMeta(t *testing.T) { + seasonNumber := 2 + episodeNumber := 6 + seriesID := "series-existing" + fetcher := &stubSectionEpisodeFetcher{ + meta: map[string]sections.SectionItemMeta{ + "episode-1": { + SeriesTitle: "Fetched Series", + }, + }, + } + h := &SectionHandler{episodeFetcher: fetcher} + withItems := []sections.SectionWithItems{ + { + ResolvedSection: sections.ResolvedSection{ID: "next", SectionType: sections.SectionNextUp, Title: "Next"}, + Items: []*models.MediaItem{{ + ContentID: "episode-1", + Type: "episode", + Title: "Episode 6", + Status: "matched", + }}, + ItemMeta: map[string]sections.SectionItemMeta{ + "episode-1": { + SeriesID: &seriesID, + SeriesTitle: "Only Child", + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + }, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/sections", nil) + resp := h.buildSectionsResponse(req, withItems) + + if fetcher.calls != 0 { + t.Fatalf("episode metadata fetch calls = %d, want 0", fetcher.calls) + } + item := resp.Sections[0].Items[0] + if item.SeriesTitle != "Only Child" { + t.Fatalf("series title = %q, want %q", item.SeriesTitle, "Only Child") + } + if item.SeasonNumber == nil || *item.SeasonNumber != 2 { + t.Fatalf("season number = %v, want 2", item.SeasonNumber) + } + if item.EpisodeNumber == nil || *item.EpisodeNumber != 6 { + t.Fatalf("episode number = %v, want 6", item.EpisodeNumber) + } +} + type countingSectionImageResolver struct { batchCalls int singleCalls int @@ -199,8 +304,8 @@ func TestDropEmptySeasonalSectionsHandlesNilAndEmpty(t *testing.T) { func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) { got := libraryDefaultSections(&models.MediaFolder{Type: "series"}, 12) - if len(got) != 7 { - t.Fatalf("expected 7 series default sections, got %d", len(got)) + if len(got) != 6 { + t.Fatalf("expected 6 series default sections, got %d", len(got)) } if got[1].Title != "Recently Added TV" { t.Fatalf("section 1 title = %q, want %q", got[1].Title, "Recently Added TV") @@ -208,8 +313,8 @@ func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) { if got[2].Title != "Recently Released Episodes" { t.Fatalf("section 2 title = %q, want %q", got[2].Title, "Recently Released Episodes") } - if got[5].SectionType != sections.SectionRecommendedForYou { - t.Fatalf("section 5 type = %q, want %q", got[5].SectionType, sections.SectionRecommendedForYou) + if got[4].SectionType != sections.SectionRecommendedForYou { + t.Fatalf("section 4 type = %q, want %q", got[4].SectionType, sections.SectionRecommendedForYou) } } diff --git a/internal/sections/defaults.go b/internal/sections/defaults.go index cbd9a64b..f201bb3a 100644 --- a/internal/sections/defaults.go +++ b/internal/sections/defaults.go @@ -21,16 +21,17 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { if library == nil { continue } - for _, sectionType := range []SectionType{SectionRecentlyAdded, SectionRecentlyReleased} { + for _, section := range generatedHomeLibraryRecentDefaults(library.ID, library.Name, library.Type) { + section.Position = position result = append(result, &PageSection{ - ID: fmt.Sprintf("default-home-%s-library-%d", sectionType, library.ID), - Scope: "home", - Position: position, - SectionType: sectionType, - Title: GeneratedHomeLibraryRecentTitle(sectionType, library.Name), - ItemLimit: 20, - Config: GeneratedHomeLibraryRecentConfig(library.ID), - Enabled: true, + ID: generatedHomeLibraryRecentID(section, library.ID), + Scope: section.Scope, + Position: section.Position, + SectionType: section.SectionType, + Title: section.Title, + ItemLimit: section.ItemLimit, + Config: section.Config, + Enabled: section.Enabled, }) position++ } @@ -62,6 +63,50 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { return result } +func generatedHomeLibraryRecentID(section *PageSection, libraryID int) string { + kind := generatedHomeLibraryRecentKindForSection(section) + if kind == generatedHomeLibraryRecentKindReleasedEpisodes { + return fmt.Sprintf("default-home-%s-library-%d", kind, libraryID) + } + return fmt.Sprintf("default-home-%s-library-%d", section.SectionType, libraryID) +} + +func generatedHomeLibraryRecentDefaults(libraryID int, libraryName, libraryType string) []*PageSection { + sections := []*PageSection{ + { + Scope: "home", + SectionType: SectionRecentlyAdded, + Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyAdded, libraryName), + ItemLimit: 20, + Config: GeneratedHomeLibraryRecentConfig(libraryID), + Enabled: true, + }, + } + + switch libraryType { + case "series": + sections = append(sections, &PageSection{ + Scope: "home", + SectionType: SectionCustomFilter, + Title: fmt.Sprintf("Recently Released Episodes in %s", libraryName), + ItemLimit: 20, + Config: GeneratedHomeLibraryRecentEpisodesConfig(libraryID), + Enabled: true, + }) + default: + sections = append(sections, &PageSection{ + Scope: "home", + SectionType: SectionRecentlyReleased, + Title: GeneratedHomeLibraryRecentTitle(SectionRecentlyReleased, libraryName), + ItemLimit: 20, + Config: GeneratedHomeLibraryRecentConfig(libraryID), + Enabled: true, + }) + } + + return sections +} + func defaultQueryConfig(def catalog.QueryDefinition) json.RawMessage { config, err := json.Marshal(def.Normalize()) if err != nil { @@ -97,15 +142,6 @@ func defaultRecentEpisodesConfig() json.RawMessage { }) } -func defaultRecentShowsConfig() json.RawMessage { - return defaultQueryConfig(catalog.QueryDefinition{ - MediaScope: "series", - Match: "all", - Groups: []catalog.QueryGroup{}, - Sort: catalog.QuerySort{Field: "last_air_date", Order: "desc"}, - }) -} - // DefaultLibrarySectionsForType returns the canonical default sections for a // library type. These are used when seeding new libraries and restoring // library defaults. @@ -127,10 +163,9 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: emptyCfg, Enabled: true}, {ID: "default-recently-added-tv", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added TV", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true}, {ID: "default-recently-released-episodes", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionCustomFilter, Title: "Recently Released Episodes", ItemLimit: 20, Config: defaultRecentEpisodesConfig(), Enabled: true}, - {ID: "default-recently-released-tv-shows", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Recently Released TV Shows", ItemLimit: 20, Config: defaultRecentShowsConfig(), Enabled: true}, - {ID: "default-top-rated-tv", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionCustomFilter, Title: "Top Rated TV", ItemLimit: 20, Config: defaultTopRatedConfig("series"), Enabled: true}, - {ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 5, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true}, - {ID: "default-random-tv", Scope: "library", LibraryID: libraryID, Position: 6, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true}, + {ID: "default-top-rated-tv", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Top Rated TV", ItemLimit: 20, Config: defaultTopRatedConfig("series"), Enabled: true}, + {ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-random-tv", Scope: "library", LibraryID: libraryID, Position: 5, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true}, } default: return DefaultLibrarySections(libraryID) diff --git a/internal/sections/defaults_test.go b/internal/sections/defaults_test.go index 4212c2cc..fab39c17 100644 --- a/internal/sections/defaults_test.go +++ b/internal/sections/defaults_test.go @@ -21,8 +21,8 @@ func TestDefaultHomeSectionsWithoutLibraries(t *testing.T) { func TestDefaultHomeSectionsWithLibraries(t *testing.T) { libraries := []*models.MediaFolder{ - {ID: 7, Name: "Movies", SortOrder: 1}, - {ID: 9, Name: "Shows", SortOrder: 2}, + {ID: 7, Name: "Movies", Type: "movies", SortOrder: 1}, + {ID: 9, Name: "Shows", Type: "series", SortOrder: 2}, } got := DefaultHomeSections(libraries) @@ -43,7 +43,7 @@ func TestDefaultHomeSectionsWithLibraries(t *testing.T) { {index: 1, id: "default-home-recently_added-library-7", sectionType: SectionRecentlyAdded, title: "Recently Added in Movies", position: 1, libraryID: 7}, {index: 2, id: "default-home-recently_released-library-7", sectionType: SectionRecentlyReleased, title: "Recently Released in Movies", position: 2, libraryID: 7}, {index: 3, id: "default-home-recently_added-library-9", sectionType: SectionRecentlyAdded, title: "Recently Added in Shows", position: 3, libraryID: 9}, - {index: 4, id: "default-home-recently_released-library-9", sectionType: SectionRecentlyReleased, title: "Recently Released in Shows", position: 4, libraryID: 9}, + {index: 4, id: "default-home-recently_released_episodes-library-9", sectionType: SectionCustomFilter, title: "Recently Released Episodes in Shows", position: 4, libraryID: 9}, } for _, tt := range tests { @@ -71,6 +71,14 @@ func TestDefaultHomeSectionsWithLibraries(t *testing.T) { t.Fatalf("section %d config library id = %d, want %d", tt.index, libraryID, tt.libraryID) } } + + assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{ + LibraryIDs: []int{9}, + MediaScope: "episode", + Match: "all", + Groups: []catalog.QueryGroup{}, + Sort: catalog.QuerySort{Field: "release_date", Order: "desc"}, + }) } func TestDefaultLibrarySectionsForTypeMovies(t *testing.T) { @@ -140,8 +148,8 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) { libraryID := 17 got := DefaultLibrarySectionsForType(&libraryID, "series") - if len(got) != 7 { - t.Fatalf("expected 7 series default sections, got %d", len(got)) + if len(got) != 6 { + t.Fatalf("expected 6 series default sections, got %d", len(got)) } tests := []struct { @@ -154,10 +162,9 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) { {index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0}, {index: 1, id: "default-recently-added-tv", sectionType: SectionRecentlyAdded, title: "Recently Added TV", position: 1}, {index: 2, id: "default-recently-released-episodes", sectionType: SectionCustomFilter, title: "Recently Released Episodes", position: 2}, - {index: 3, id: "default-recently-released-tv-shows", sectionType: SectionCustomFilter, title: "Recently Released TV Shows", position: 3}, - {index: 4, id: "default-top-rated-tv", sectionType: SectionCustomFilter, title: "Top Rated TV", position: 4}, - {index: 5, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 5}, - {index: 6, id: "default-random-tv", sectionType: SectionRandom, title: "Random Picks", position: 6}, + {index: 3, id: "default-top-rated-tv", sectionType: SectionCustomFilter, title: "Top Rated TV", position: 3}, + {index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4}, + {index: 5, id: "default-random-tv", sectionType: SectionRandom, title: "Random Picks", position: 5}, } for _, tt := range tests { @@ -192,19 +199,13 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) { Sort: catalog.QuerySort{Field: "release_date", Order: "desc"}, }) assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{ - MediaScope: "series", - Match: "all", - Groups: []catalog.QueryGroup{}, - Sort: catalog.QuerySort{Field: "last_air_date", Order: "desc"}, - }) - assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{ MediaScope: "series", Match: "all", Groups: []catalog.QueryGroup{}, Sort: catalog.QuerySort{Field: "rating_imdb", Order: "desc"}, }) - assertEmptyJSON(t, got[5].Config) - assertQueryDefinition(t, got[6].Config, catalog.QueryDefinition{ + assertEmptyJSON(t, got[4].Config) + assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{ MediaScope: "series", Match: "all", Groups: []catalog.QueryGroup{}, diff --git a/internal/sections/generated.go b/internal/sections/generated.go index 0ee8c5d3..4eff2e79 100644 --- a/internal/sections/generated.go +++ b/internal/sections/generated.go @@ -4,13 +4,23 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" ) const GeneratedHomeLibraryRecentSource = "home_library_recent" +const ( + generatedHomeLibraryRecentKindAdded = "recently_added" + generatedHomeLibraryRecentKindReleased = "recently_released" + generatedHomeLibraryRecentKindReleasedEpisodes = "recently_released_episodes" +) + type generatedHomeLibraryRecentConfig struct { - FilterLibraryID *int `json:"filter_library_id"` - GeneratedSource string `json:"generated_source"` + FilterLibraryID *int `json:"filter_library_id"` + GeneratedLibraryID *int `json:"generated_library_id"` + GeneratedKind string `json:"generated_kind"` + GeneratedSource string `json:"generated_source"` } func GeneratedHomeLibraryRecentConfig(libraryID int) json.RawMessage { @@ -24,6 +34,30 @@ func GeneratedHomeLibraryRecentConfig(libraryID int) json.RawMessage { return config } +func GeneratedHomeLibraryRecentEpisodesConfig(libraryID int) json.RawMessage { + config, err := json.Marshal(struct { + catalog.QueryDefinition + GeneratedLibraryID int `json:"generated_library_id"` + GeneratedKind string `json:"generated_kind"` + GeneratedSource string `json:"generated_source"` + }{ + QueryDefinition: catalog.QueryDefinition{ + LibraryIDs: []int{libraryID}, + MediaScope: "episode", + Match: "all", + Groups: []catalog.QueryGroup{}, + Sort: catalog.QuerySort{Field: "release_date", Order: "desc"}, + }.Normalize(), + GeneratedLibraryID: libraryID, + GeneratedKind: generatedHomeLibraryRecentKindReleasedEpisodes, + GeneratedSource: GeneratedHomeLibraryRecentSource, + }) + if err != nil { + return json.RawMessage(`{}`) + } + return config +} + func GeneratedHomeLibraryRecentTitle(sectionType SectionType, libraryName string) string { switch sectionType { case SectionRecentlyAdded: @@ -35,28 +69,74 @@ func GeneratedHomeLibraryRecentTitle(sectionType SectionType, libraryName string } } +func generatedHomeLibraryRecentTitle(kind string, sectionType SectionType, libraryName string) string { + switch kind { + case generatedHomeLibraryRecentKindReleasedEpisodes: + return fmt.Sprintf("Recently Released Episodes in %s", libraryName) + case generatedHomeLibraryRecentKindAdded: + return fmt.Sprintf("Recently Added in %s", libraryName) + case generatedHomeLibraryRecentKindReleased: + return fmt.Sprintf("Recently Released in %s", libraryName) + default: + return GeneratedHomeLibraryRecentTitle(sectionType, libraryName) + } +} + +func generatedHomeLibraryRecentKindForSection(s *PageSection) string { + if s == nil { + return "" + } + _, kind, _ := parseGeneratedHomeLibraryRecentConfig(s.Config) + if kind != "" { + return kind + } + switch s.SectionType { + case SectionRecentlyAdded: + return generatedHomeLibraryRecentKindAdded + case SectionRecentlyReleased: + return generatedHomeLibraryRecentKindReleased + default: + return "" + } +} + func ParseGeneratedHomeLibraryRecentConfig(config json.RawMessage) (int, bool) { + id, _, ok := parseGeneratedHomeLibraryRecentConfig(config) + return id, ok +} + +func parseGeneratedHomeLibraryRecentConfig(config json.RawMessage) (int, string, bool) { var cfg generatedHomeLibraryRecentConfig if len(config) == 0 { - return 0, false + return 0, "", false } if err := json.Unmarshal(config, &cfg); err != nil { - return 0, false + return 0, "", false } - if cfg.GeneratedSource != GeneratedHomeLibraryRecentSource || cfg.FilterLibraryID == nil || *cfg.FilterLibraryID <= 0 { - return 0, false + if cfg.GeneratedSource != GeneratedHomeLibraryRecentSource { + return 0, "", false } - return *cfg.FilterLibraryID, true + libraryID := cfg.GeneratedLibraryID + if libraryID == nil { + libraryID = cfg.FilterLibraryID + } + if libraryID == nil || *libraryID <= 0 { + return 0, "", false + } + return *libraryID, cfg.GeneratedKind, true } func IsGeneratedHomeLibraryRecentSection(s *PageSection, libraryID int) bool { if s == nil || s.Scope != "home" || s.LibraryID != nil { return false } - if s.SectionType != SectionRecentlyAdded && s.SectionType != SectionRecentlyReleased { + id, _, ok := parseGeneratedHomeLibraryRecentConfig(s.Config) + if !ok { + return false + } + if s.SectionType != SectionRecentlyAdded && s.SectionType != SectionRecentlyReleased && s.SectionType != SectionCustomFilter { return false } - id, ok := ParseGeneratedHomeLibraryRecentConfig(s.Config) return ok && id == libraryID } @@ -64,6 +144,15 @@ func ShouldSyncGeneratedHomeLibraryRecentTitle(s *PageSection, oldLibraryName st if s == nil { return false } - expected := GeneratedHomeLibraryRecentTitle(s.SectionType, oldLibraryName) + kind := generatedHomeLibraryRecentKindForSection(s) + expected := generatedHomeLibraryRecentTitle(kind, s.SectionType, oldLibraryName) return strings.TrimSpace(s.Title) == expected } + +func GeneratedHomeLibraryRecentSyncedTitle(s *PageSection, libraryName string) string { + if s == nil { + return "" + } + kind := generatedHomeLibraryRecentKindForSection(s) + return generatedHomeLibraryRecentTitle(kind, s.SectionType, libraryName) +} diff --git a/internal/sections/generated_test.go b/internal/sections/generated_test.go index 692dae9a..f0011853 100644 --- a/internal/sections/generated_test.go +++ b/internal/sections/generated_test.go @@ -14,6 +14,30 @@ func TestParseGeneratedHomeLibraryRecentConfig(t *testing.T) { } } +func TestParseGeneratedHomeLibraryRecentEpisodesConfig(t *testing.T) { + libraryID, ok := ParseGeneratedHomeLibraryRecentConfig(GeneratedHomeLibraryRecentEpisodesConfig(42)) + if !ok { + t.Fatalf("expected config to parse") + } + if libraryID != 42 { + t.Fatalf("library id = %d, want 42", libraryID) + } + + def, err := ParseQueryDefinition(GeneratedHomeLibraryRecentEpisodesConfig(42)) + if err != nil { + t.Fatalf("ParseQueryDefinition() error = %v", err) + } + if def.MediaScope != "episode" { + t.Fatalf("media_scope = %q, want episode", def.MediaScope) + } + if def.Sort.Field != "release_date" || def.Sort.Order != "desc" { + t.Fatalf("sort = %#v, want release_date desc", def.Sort) + } + if len(def.LibraryIDs) != 1 || def.LibraryIDs[0] != 42 { + t.Fatalf("library_ids = %v, want [42]", def.LibraryIDs) + } +} + func TestShouldSyncGeneratedHomeLibraryRecentTitle(t *testing.T) { section := &PageSection{ Scope: "home", @@ -30,3 +54,24 @@ func TestShouldSyncGeneratedHomeLibraryRecentTitle(t *testing.T) { t.Fatalf("did not expect custom title to sync") } } + +func TestShouldSyncGeneratedHomeLibraryRecentEpisodesTitle(t *testing.T) { + section := &PageSection{ + Scope: "home", + SectionType: SectionCustomFilter, + Title: "Recently Released Episodes in Shows", + Config: GeneratedHomeLibraryRecentEpisodesConfig(3), + } + if !ShouldSyncGeneratedHomeLibraryRecentTitle(section, "Shows") { + t.Fatalf("expected generated episode title to sync") + } + + if got := GeneratedHomeLibraryRecentSyncedTitle(section, "TV"); got != "Recently Released Episodes in TV" { + t.Fatalf("synced title = %q, want %q", got, "Recently Released Episodes in TV") + } + + section.Title = "Staff Picks" + if ShouldSyncGeneratedHomeLibraryRecentTitle(section, "Shows") { + t.Fatalf("did not expect custom title to sync") + } +} diff --git a/internal/sections/repo.go b/internal/sections/repo.go index 57464d6b..7fb4ca19 100644 --- a/internal/sections/repo.go +++ b/internal/sections/repo.go @@ -383,15 +383,17 @@ func (r *Repository) nextHomePosition(ctx context.Context) (int, error) { return maxPosition + 1, nil } -func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int, libraryName string) ([]*PageSection, error) { +func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int, libraryName, libraryType string) ([]*PageSection, error) { existing, err := r.listGeneratedHomeLibraryRecentSections(ctx, libraryID) if err != nil { return nil, fmt.Errorf("listing generated home sections: %w", err) } - existingByType := make(map[SectionType]*PageSection, len(existing)) + existingByKind := make(map[string]*PageSection, len(existing)) for _, section := range existing { - existingByType[section.SectionType] = section + if kind := generatedHomeLibraryRecentKindForSection(section); kind != "" { + existingByKind[kind] = section + } } position, err := r.nextHomePosition(ctx) @@ -400,19 +402,17 @@ func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Contex } created := make([]*PageSection, 0, 2) - for _, sectionType := range []SectionType{SectionRecentlyAdded, SectionRecentlyReleased} { - if _, ok := existingByType[sectionType]; ok { + for _, section := range generatedHomeLibraryRecentDefaults(libraryID, libraryName, libraryType) { + kind := generatedHomeLibraryRecentKindForSection(section) + if kind == generatedHomeLibraryRecentKindReleasedEpisodes { + if _, ok := existingByKind[generatedHomeLibraryRecentKindReleased]; ok { + continue + } + } + if _, ok := existingByKind[kind]; ok { continue } - section := &PageSection{ - Scope: "home", - Position: position, - SectionType: sectionType, - Title: GeneratedHomeLibraryRecentTitle(sectionType, libraryName), - ItemLimit: 20, - Config: GeneratedHomeLibraryRecentConfig(libraryID), - Enabled: true, - } + section.Position = position createdSection, createErr := r.Create(ctx, section) if createErr != nil { return nil, fmt.Errorf("creating generated home section %q: %w", section.Title, createErr) @@ -434,7 +434,7 @@ func (r *Repository) SyncGeneratedHomeLibraryRecentTitles(ctx context.Context, l if !ShouldSyncGeneratedHomeLibraryRecentTitle(section, oldLibraryName) { continue } - section.Title = GeneratedHomeLibraryRecentTitle(section.SectionType, newLibraryName) + section.Title = GeneratedHomeLibraryRecentSyncedTitle(section, newLibraryName) if err := r.Update(ctx, section); err != nil { return fmt.Errorf("updating generated home section %s: %w", section.ID, err) } diff --git a/web/src/components/ItemCard.test.tsx b/web/src/components/ItemCard.test.tsx index 639a01b5..c52e5dfc 100644 --- a/web/src/components/ItemCard.test.tsx +++ b/web/src/components/ItemCard.test.tsx @@ -75,7 +75,7 @@ describe("ItemCard SortMeta", () => { }); expect(markup).toContain("The Last of Us"); - expect(markup).toContain("S1 E1"); + expect(markup).toContain("S01E01"); expect(markup).toContain("When You're Lost in the Darkness"); }); }); diff --git a/web/src/components/ItemCard.tsx b/web/src/components/ItemCard.tsx index 5c4de0e9..4fc6cba5 100644 --- a/web/src/components/ItemCard.tsx +++ b/web/src/components/ItemCard.tsx @@ -7,20 +7,12 @@ import { timeAgo } from "@/lib/timeAgo"; import MediaItemMenu from "@/components/MediaItemMenu"; import CardOverlays from "@/components/overlays/CardOverlays"; import { overlayDataFromBrowseItem, type CardOverlayPrefs } from "@/lib/overlays"; +import { buildEpisodeCardLabels } from "@/lib/episodeCardLabels"; function SortMeta({ item, sortField }: { item: BrowseItem; sortField?: string }) { - if ( - item.type === "episode" && - item.series_title && - item.season_number != null && - item.episode_number != null - ) { - return ( - <> - S{item.season_number} E{item.episode_number} - {item.title ? ` • ${item.title}` : ""} - > - ); + const episodeLabels = buildEpisodeCardLabels(item); + if (episodeLabels) { + return <>{episodeLabels.episodeCode}>; } const defaultLabel = [item.year || "", item.type === "series" ? "Series" : ""] @@ -90,8 +82,8 @@ export default function ItemCard({ const [loaded, setLoaded] = useState(false); const thumbhashUrl = item.poster_thumbhash ? decodeThumbhash(item.poster_thumbhash) : ""; const itemHref = `/item/${item.content_id}${libraryId ? `?libraryId=${libraryId}` : ""}`; - const displayTitle = - item.type === "episode" && item.series_title ? item.series_title : item.title; + const episodeLabels = buildEpisodeCardLabels(item); + const displayTitle = episodeLabels ? episodeLabels.seriesTitle : item.title; return (