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 )} + {overlayPrefs && ( + + )} {hasProgress && (
; } @@ -57,6 +62,13 @@ export default function SeasonEpisodeGrid({
)} + {overlayPrefs && ( + + )} {episode.user_data?.played && (