feat(sections): show episode context in cards
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : ""}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user