feat(catalog): include per-episode overlay summaries on season episode listings (#364)

* feat(catalog): include per-episode overlay summaries on season episode listings

Episode cards already render user-configurable overlay badges everywhere
except the series/season detail pages: the season-episodes endpoint never
included overlay_summary, so SeasonEpisodeGrid and EpisodeRow had nothing
to render.

Server: add overlay_summary (omitempty) to episodeResponse and populate it
in buildEpisodeResponses via overlays.BuildSummary over the episode's
already-loaded media files, access-filtered with FilterMediaFilesByAccess
to match the browse/sections paths. No extra queries; the files were
already batch-fetched for the files[] payload. Additive-only API change.

Web: extend EpisodeListItem with overlay_summary, add
overlayDataFromEpisodeListItem (shared extract helper), and render
CardOverlays (variant="wide") on the episode stills in SeasonEpisodeGrid
and EpisodeRow using useOverlayPrefs, matching the ContinueWatchingCard
pattern. The hook already honors the admin kill switch and per-user prefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(catalog): apply library restrictions in FilterMediaFilesByAccess without a quality ceiling

FilterMediaFilesByAccess short-circuited whenever MaxPlaybackQuality was
empty, skipping the AllowedLibraryIDs/DisabledLibraryIDs checks that
FileAllowedByAccess enforces. For viewers with library restrictions but
no quality ceiling, callers (episode listings, browse/section overlay
summaries, item detail versions) received files from restricted
libraries. Short-circuit only when no access criteria are set at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-07-16 10:37:34 -04:00
committed by GitHub
co-authored by rxwatcher Claude Opus 4.8
parent 035ef457a4
commit f377be9d6e
9 changed files with 106 additions and 7 deletions
+4 -1
View File
@@ -344,6 +344,7 @@ type episodeResponse struct {
StillThumbhash string `json:"still_thumbhash,omitempty"`
UserData *catalog.SeasonUserData `json:"user_data,omitempty"`
Files []episodeFileResponse `json:"files,omitempty"`
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
}
type episodeImageFallback struct {
@@ -959,8 +960,10 @@ func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models
}
for i := range resp {
resp[i].StillURL = stillURLs[stillPaths[i]].URL
resp[i].Files = episodeFileResponses(filesByEpisode[resp[i].ContentID], filter)
files := catalog.FilterMediaFilesByAccess(filesByEpisode[resp[i].ContentID], filter)
resp[i].Files = episodeFileResponses(files, filter)
resp[i].UserData = userData[resp[i].ContentID]
resp[i].OverlaySummary = overlays.BuildSummary(files)
}
return resp
}
+8 -3
View File
@@ -137,10 +137,15 @@ func intInSlice(value int, values []int) bool {
return false
}
// FilterMediaFilesByAccess drops file versions that exceed the viewer's
// effective quality ceiling.
// FilterMediaFilesByAccess drops file versions the viewer cannot access:
// files in libraries outside their allowed set, in libraries they disabled,
// or above their effective quality ceiling — the same predicate as
// FileAllowedByAccess.
func FilterMediaFilesByAccess(files []*models.MediaFile, filter AccessFilter) []*models.MediaFile {
if len(files) == 0 || strings.TrimSpace(filter.MaxPlaybackQuality) == "" {
unrestricted := filter.AllowedLibraryIDs == nil &&
len(filter.DisabledLibraryIDs) == 0 &&
strings.TrimSpace(filter.MaxPlaybackQuality) == ""
if len(files) == 0 || unrestricted {
return files
}
+51
View File
@@ -0,0 +1,51 @@
package catalog
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestFilterMediaFilesByAccess(t *testing.T) {
allowed := &models.MediaFile{ID: 1, MediaFolderID: 1, Resolution: "1080p"}
otherLibrary := &models.MediaFile{ID: 2, MediaFolderID: 2, Resolution: "2160p"}
files := []*models.MediaFile{allowed, otherLibrary}
t.Run("no restrictions returns all files", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{})
if len(got) != 2 {
t.Fatalf("expected 2 files, got %d", len(got))
}
})
t.Run("allowed library ids filter without quality ceiling", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{AllowedLibraryIDs: []int{1}})
if len(got) != 1 || got[0].ID != allowed.ID {
t.Fatalf("expected only file %d, got %v", allowed.ID, got)
}
})
t.Run("disabled library ids filter without quality ceiling", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{DisabledLibraryIDs: []int{2}})
if len(got) != 1 || got[0].ID != allowed.ID {
t.Fatalf("expected only file %d, got %v", allowed.ID, got)
}
})
t.Run("quality ceiling filters", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{MaxPlaybackQuality: "1080p"})
if len(got) != 1 || got[0].ID != allowed.ID {
t.Fatalf("expected only file %d, got %v", allowed.ID, got)
}
})
t.Run("matches FileAllowedByAccess predicate", func(t *testing.T) {
filter := AccessFilter{AllowedLibraryIDs: []int{1}, MaxPlaybackQuality: "1080p"}
got := FilterMediaFilesByAccess(files, filter)
for _, f := range got {
if !FileAllowedByAccess(f, filter) {
t.Fatalf("file %d returned despite failing FileAllowedByAccess", f.ID)
}
}
})
}
+1
View File
@@ -1268,6 +1268,7 @@ export interface EpisodeListItem {
still_thumbhash: string;
user_data?: LeafItemUserData;
files: EpisodeFile[];
overlay_summary?: OverlaySummary | null;
}
export interface EpisodesResponse {
+9 -1
View File
@@ -1,8 +1,16 @@
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import EpisodeRow from "./EpisodeRow";
vi.mock("@/components/overlays/CardOverlays", () => ({
default: () => null,
}));
vi.mock("@/hooks/useOverlayPrefs", () => ({
useOverlayPrefs: () => ({ prefs: null }),
}));
describe("EpisodeRow", () => {
it("renders progress from inline episode user_data", () => {
const markup = renderToStaticMarkup(
+11
View File
@@ -1,6 +1,9 @@
import ViewTransitionLink from "@/components/ViewTransitionLink";
import { Play, Star } from "lucide-react";
import type { EpisodeListItem } from "@/api/types";
import CardOverlays from "@/components/overlays/CardOverlays";
import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
import { overlayDataFromEpisodeListItem } from "@/lib/overlays";
interface EpisodeRowProps {
episode: EpisodeListItem;
@@ -10,6 +13,7 @@ interface EpisodeRowProps {
}
export default function EpisodeRow({ episode, rating, watched, progress }: EpisodeRowProps) {
const { prefs: overlayPrefs } = useOverlayPrefs();
const watchedState = watched ?? episode.user_data?.played ?? false;
const derivedProgress =
progress ??
@@ -52,6 +56,13 @@ export default function EpisodeRow({ episode, rating, watched, progress }: Episo
<Play className="size-6" />
</div>
)}
{overlayPrefs && (
<CardOverlays
data={overlayDataFromEpisodeListItem(episode)}
prefs={overlayPrefs}
variant="wide"
/>
)}
{hasProgress && (
<div className="bg-background/40 absolute inset-x-0 bottom-0 h-[3px]">
<div
+5 -1
View File
@@ -1,4 +1,4 @@
import type { BrowseItem, OverlaySummary, SectionItem } from "@/api/types";
import type { BrowseItem, EpisodeListItem, OverlaySummary, SectionItem } from "@/api/types";
import type { OverlayData } from "./types";
// BrowseItem and SectionItem share the fields the overlay system consumes;
@@ -68,3 +68,7 @@ export function overlayDataFromBrowseItem(item: BrowseItem): OverlayData {
export function overlayDataFromSectionItem(item: SectionItem): OverlayData {
return extract(item);
}
export function overlayDataFromEpisodeListItem(item: EpisodeListItem): OverlayData {
return extract(item);
}
+5 -1
View File
@@ -28,5 +28,9 @@ export {
export { OVERLAY_PRESETS, PRESET_IDS, getPreset, ACCENT_PALETTE } from "./presets";
export { POSITION_OPTIONS, CATEGORY_GROUPS, CATEGORY_META } from "./ui-constants";
export { OverlayIcon } from "./icons";
export { overlayDataFromBrowseItem, overlayDataFromSectionItem } from "./extractors";
export {
overlayDataFromBrowseItem,
overlayDataFromEpisodeListItem,
overlayDataFromSectionItem,
} from "./extractors";
export { SAMPLE_MOVIE_DATA, SAMPLE_SHOW_DATA } from "./sample-data";
@@ -2,6 +2,9 @@ import { Link } from "react-router";
import { Check, Play } from "lucide-react";
import type { EpisodeListItem } from "@/api/types";
import MediaItemMenu from "@/components/MediaItemMenu";
import CardOverlays from "@/components/overlays/CardOverlays";
import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
import { overlayDataFromEpisodeListItem } from "@/lib/overlays";
import { EpisodeGridSkeleton } from "./SectionSkeletons";
import type { EpisodeNavigationState } from "../itemDetailLayout";
@@ -16,6 +19,8 @@ export default function SeasonEpisodeGrid({
isLoading,
episodeLinkState,
}: SeasonEpisodeGridProps) {
const { prefs: overlayPrefs } = useOverlayPrefs();
if (isLoading) {
return <EpisodeGridSkeleton />;
}
@@ -57,6 +62,13 @@ export default function SeasonEpisodeGrid({
<Play size={32} className="text-muted-foreground/30" />
</div>
)}
{overlayPrefs && (
<CardOverlays
data={overlayDataFromEpisodeListItem(episode)}
prefs={overlayPrefs}
variant="wide"
/>
)}
{episode.user_data?.played && (
<div className="watched-badge">
<Check className="size-4" />