diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go
index 3a5cb5a4..2b8b543d 100644
--- a/internal/api/handlers/items.go
+++ b/internal/api/handlers/items.go
@@ -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
}
diff --git a/internal/catalog/access_filter.go b/internal/catalog/access_filter.go
index ec7b729e..fc83a268 100644
--- a/internal/catalog/access_filter.go
+++ b/internal/catalog/access_filter.go
@@ -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
}
diff --git a/internal/catalog/access_filter_test.go b/internal/catalog/access_filter_test.go
new file mode 100644
index 00000000..2225e3cc
--- /dev/null
+++ b/internal/catalog/access_filter_test.go
@@ -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)
+ }
+ }
+ })
+}
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index bd4dfa3c..06be16c6 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -1268,6 +1268,7 @@ export interface EpisodeListItem {
still_thumbhash: string;
user_data?: LeafItemUserData;
files: EpisodeFile[];
+ overlay_summary?: OverlaySummary | null;
}
export interface EpisodesResponse {
diff --git a/web/src/components/EpisodeRow.test.tsx b/web/src/components/EpisodeRow.test.tsx
index 5702e301..b1e7767e 100644
--- a/web/src/components/EpisodeRow.test.tsx
+++ b/web/src/components/EpisodeRow.test.tsx
@@ -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(
diff --git a/web/src/components/EpisodeRow.tsx b/web/src/components/EpisodeRow.tsx
index 28839a4e..d8dda8ce 100644
--- a/web/src/components/EpisodeRow.tsx
+++ b/web/src/components/EpisodeRow.tsx
@@ -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