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:
co-authored by
rxwatcher
Claude Opus 4.8
parent
035ef457a4
commit
f377be9d6e
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1268,6 +1268,7 @@ export interface EpisodeListItem {
|
||||
still_thumbhash: string;
|
||||
user_data?: LeafItemUserData;
|
||||
files: EpisodeFile[];
|
||||
overlay_summary?: OverlaySummary | null;
|
||||
}
|
||||
|
||||
export interface EpisodesResponse {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user