feat(sections): show episode context in cards

This commit is contained in:
Quick
2026-05-27 15:48:00 -04:00
parent dc4c20bb0f
commit 71ca2012c5
13 changed files with 485 additions and 86 deletions
+1 -1
View File
@@ -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)
}
}
+48 -1
View File
@@ -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 {
+109 -4
View File
@@ -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)
}
}
+57 -22
View File
@@ -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)
+18 -17
View File
@@ -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{},
+99 -10
View File
@@ -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)
}
+45
View File
@@ -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")
}
}
+15 -15
View File
@@ -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)
}
+1 -1
View File
@@ -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&#x27;re Lost in the Darkness");
});
});
+11 -14
View File
@@ -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 (
<div className="media-card group/card">
@@ -176,6 +168,11 @@ export default function ItemCard({
</div>
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">{displayTitle}</div>
{episodeLabels?.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
<SortMeta item={item} sortField={sortField} />
</div>
@@ -21,6 +21,35 @@ vi.mock("@/hooks/useOverlayPrefs", () => ({
}));
describe("SectionItemCard", () => {
it("renders episode cards with series title, episode title, and episode code", () => {
const markup = renderToStaticMarkup(
<SectionItemCard
item={{
content_id: "episode-1",
type: "episode",
title: "Dumbston Checks In",
series_title: "American Dad!",
season_number: 22,
episode_number: 10,
year: 2025,
genres: ["Animation"],
status: "matched",
rating_imdb: 7.1,
overview: "Overview",
poster_url: "",
poster_thumbhash: "",
backdrop_url: "",
backdrop_thumbhash: "",
logo_url: "",
}}
/>,
);
expect(markup).toContain("American Dad!");
expect(markup).toContain("Dumbston Checks In");
expect(markup).toContain("S22E10");
});
it("renders premiere metadata when an upcoming event is present", () => {
const markup = renderToStaticMarkup(
<SectionItemCard
+16 -1
View File
@@ -5,6 +5,7 @@ import CardOverlays from "@/components/overlays/CardOverlays";
import { decodeThumbhash } from "@/lib/thumbhash";
import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
import { overlayDataFromSectionItem } from "@/lib/overlays";
import { buildEpisodeCardLabels } from "@/lib/episodeCardLabels";
import {
formatUpcomingDate,
formatUpcomingSubtitle,
@@ -28,6 +29,7 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
const subtitle = upcomingEvent ? formatUpcomingSubtitle(upcomingEvent) : "";
const airDateLabel = upcomingEvent ? formatUpcomingDate(upcomingEvent.air_date) : "";
const airTimeLabel = upcomingEvent ? formatUpcomingTime(upcomingEvent.air_time) : null;
const episodeLabels = !upcomingEvent ? buildEpisodeCardLabels(item) : null;
return (
<div className="media-card group/card">
@@ -91,7 +93,9 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
/>
</div>
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">{item.title}</div>
<div className="truncate text-[14px] font-semibold tracking-tight">
{episodeLabels ? episodeLabels.seriesTitle : item.title}
</div>
{upcomingEvent ? (
<>
{subtitle && (
@@ -104,6 +108,17 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
{airTimeLabel && <span className="text-muted-foreground">{airTimeLabel}</span>}
</div>
</>
) : episodeLabels ? (
<>
{episodeLabels.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
{episodeLabels.episodeCode}
</div>
</>
) : (
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
{item.year ? `${item.year}` : ""} {item.type === "series" ? "Series" : ""}
+36
View File
@@ -0,0 +1,36 @@
interface EpisodeCardLabelItem {
type?: string;
title?: string;
series_title?: string;
season_number?: number | null;
episode_number?: number | null;
}
export interface EpisodeCardLabels {
seriesTitle: string;
episodeTitle?: string;
episodeCode: string;
}
function formatEpisodeCode(seasonNumber: number, episodeNumber: number): string {
return `S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}`;
}
export function buildEpisodeCardLabels(item: EpisodeCardLabelItem): EpisodeCardLabels | null {
if (
item.type !== "episode" ||
item.season_number == null ||
item.episode_number == null ||
!item.title
) {
return null;
}
const hasSeriesTitle = Boolean(item.series_title && item.series_title !== item.title);
return {
seriesTitle: hasSeriesTitle ? item.series_title! : item.title,
episodeTitle: hasSeriesTitle ? item.title : undefined,
episodeCode: formatEpisodeCode(item.season_number, item.episode_number),
};
}