Skip to content

Commit 9f87373

Browse files
committed
feat(person): add filters to Known For section (issue #91)
1 parent 7985ea4 commit 9f87373

4 files changed

Lines changed: 132 additions & 17 deletions

File tree

‎backend/routers/media.py‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1420,6 +1420,10 @@ async def recently_added(
14201420
async def get_person_details(
14211421
person_id: int,
14221422
page: int = Query(1, ge=1),
1423+
collection: str | None = Query(None), # "in" | "out" | None (no filter)
1424+
genre: list[str] = Query(default=[]), # OR'd together — any selected genre matches
1425+
year: list[int] = Query(default=[]), # OR'd together — any selected year matches
1426+
min_rating: str | None = Query(None), # "9".."5" (N+ stars) or "lt5" (under 5 stars)
14231427
db: AsyncSession = Depends(get_db),
14241428
current_user: User = Depends(get_current_user),
14251429
):
@@ -1448,6 +1452,8 @@ async def get_person_details(
14481452
"character": c.get("character"),
14491453
"popularity": popularity,
14501454
"adult": c.get("adult", False),
1455+
"genre_ids": c.get("genre_ids", []),
1456+
"vote_average": c.get("vote_average") or 0,
14511457
"_score": popularity * max(role_weight, 0.05),
14521458
})
14531459
_crew_dept_weight = {"Directing": 1.0, "Writing": 0.9, "Production": 0.7, "Creator": 1.0}
@@ -1464,6 +1470,8 @@ async def get_person_details(
14641470
"character": c.get("job"),
14651471
"popularity": popularity,
14661472
"adult": c.get("adult", False),
1473+
"genre_ids": c.get("genre_ids", []),
1474+
"vote_average": c.get("vote_average") or 0,
14671475
"_score": popularity * role_weight,
14681476
})
14691477
# Deduplicate by tmdb_id — a person may appear in multiple episodes of the
@@ -1481,10 +1489,48 @@ async def get_person_details(
14811489
deduped.sort(key=lambda x: x["_score"], reverse=True)
14821490
for credit in deduped:
14831491
del credit["_score"]
1492+
1493+
# Cheap in-memory filters first (no DB work) to shrink the list before
1494+
# the collection cross-reference below, which does need a DB round-trip.
1495+
# Selections within a filter are OR'd (any selected genre/year matches);
1496+
# the different filters are AND'd together.
1497+
if genre:
1498+
def _matches_any_genre(credit: dict) -> bool:
1499+
genre_map = MOVIE_GENRE_IDS if credit["type"] == "movie" else TV_GENRE_IDS
1500+
target_ids = {genre_map[g] for g in genre if g in genre_map}
1501+
return bool(target_ids & set(credit.get("genre_ids", [])))
1502+
deduped = [c for c in deduped if _matches_any_genre(c)]
1503+
if year:
1504+
year_strs = {str(y) for y in year}
1505+
deduped = [c for c in deduped if (c.get("release_date") or "")[:4] in year_strs]
1506+
if min_rating == "lt5":
1507+
deduped = [c for c in deduped if c.get("vote_average", 0) < 5]
1508+
elif min_rating:
1509+
try:
1510+
threshold = float(min_rating)
1511+
except ValueError:
1512+
threshold = None
1513+
if threshold is not None:
1514+
deduped = [c for c in deduped if c.get("vote_average", 0) >= threshold]
1515+
1516+
for credit in deduped:
1517+
credit.pop("genre_ids", None)
1518+
credit.pop("vote_average", None)
1519+
1520+
if collection in ("in", "out"):
1521+
# Filter before paginating so total_credits/page counts reflect the
1522+
# filtered set, not the full filmography (enrich_with_state is a
1523+
# handful of batched queries regardless of list size, same as any
1524+
# other listing endpoint here).
1525+
await enrich_with_state(db, current_user.id, deduped)
1526+
wants_in_library = collection == "in"
1527+
deduped = [c for c in deduped if bool(c.get("in_library")) == wants_in_library]
1528+
14841529
total_credits = len(deduped)
14851530
start = (page - 1) * _PERSON_PAGE_SIZE
14861531
top_credits = deduped[start:start + _PERSON_PAGE_SIZE]
1487-
await enrich_with_state(db, current_user.id, top_credits)
1532+
if collection not in ("in", "out"):
1533+
await enrich_with_state(db, current_user.id, top_credits)
14881534

14891535
# Which of the user's lists contain this person?
14901536
user_list_ids_q = await db.execute(select(UserList.id).where(UserList.user_id == current_user.id))
@@ -1514,6 +1560,7 @@ async def get_person_details(
15141560
"total_credits": total_credits,
15151561
"page": page,
15161562
"page_size": _PERSON_PAGE_SIZE,
1563+
"collection": collection,
15171564
"in_lists": person_in_lists,
15181565
}
15191566
except Exception as e:

‎frontend/src/lib/api.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export interface PersonDetail {
216216
page: number;
217217
page_size: number;
218218
in_lists: number[];
219+
collection: "in" | "out" | null;
219220
}
220221

221222
export interface WatchEvent {
@@ -1068,8 +1069,19 @@ export const api = {
10681069
getRecommendations: (type: string, tmdbId: number, token?: string) =>
10691070
get<{ results: MediaItem[] }>(`/media/${type}/${tmdbId}/recommendations`, undefined, token),
10701071

1071-
getPerson: (personId: number, page: number = 1, token?: string) =>
1072-
get<PersonDetail>(`/media/person/${personId}`, { page }, token),
1072+
getPerson: (
1073+
personId: number,
1074+
page: number = 1,
1075+
token?: string,
1076+
filters?: { collection?: "in" | "out" | ""; genre?: string[]; year?: number[]; minRating?: string },
1077+
) =>
1078+
get<PersonDetail>(`/media/person/${personId}`, {
1079+
page,
1080+
collection: filters?.collection || undefined,
1081+
genre: filters?.genre?.length ? filters.genre : undefined,
1082+
year: filters?.year?.length ? filters.year : undefined,
1083+
min_rating: filters?.minRating || undefined,
1084+
}, token),
10731085

10741086
getCollection: (collectionId: number, token?: string) =>
10751087
get<CollectionDetail>(`/media/collection/${collectionId}`, undefined, token),

‎frontend/src/pages/partials/person-credits.astro‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@ export const partial = true;
77
const token = Astro.cookies.get("token")?.value;
88
const personId = parseInt(Astro.url.searchParams.get("person_id") || "0");
99
const page = parseInt(Astro.url.searchParams.get("page") || "1");
10+
const collectionParam = Astro.url.searchParams.get("collection");
11+
const collection: "in" | "out" | "" = collectionParam === "in" || collectionParam === "out" ? collectionParam : "";
12+
const genre = Astro.url.searchParams.getAll("genre");
13+
const year = Astro.url.searchParams.getAll("year").map(Number).filter((y) => !Number.isNaN(y));
14+
const minRating = Astro.url.searchParams.get("min_rating") ?? "";
1015
1116
if (!token || !personId) {
1217
return new Response(null, { status: 400 });
1318
}
1419
1520
let person: Awaited<ReturnType<typeof api.media.getPerson>>;
1621
try {
17-
person = await api.media.getPerson(personId, page, token);
22+
person = await api.media.getPerson(personId, page, token, { collection, genre, year, minRating });
1823
} catch {
1924
return new Response(null, { status: 500 });
2025
}

‎frontend/src/pages/person/[id].astro‎

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,36 @@ import Base from "../../layouts/Base.astro";
33
import MediaCard from "../../components/MediaCard.astro";
44
import ActionBar from "../../components/ActionBar.astro";
55
import CommentsSection from "../../components/CommentsSection.astro";
6+
import FilterPanel from "../../components/FilterPanel.astro";
67
import { api, type MediaItem } from "../../lib/api";
78
export const prerender = false;
89
910
const { id } = Astro.params;
1011
const { token } = Astro.locals;
11-
const person = await api.media.getPerson(Number(id), 1, token);
12+
const collectionParam = Astro.url.searchParams.get("collection");
13+
const collection: "in" | "out" | "" = collectionParam === "in" || collectionParam === "out" ? collectionParam : "";
14+
const genre = Astro.url.searchParams.getAll("genre");
15+
const year = Astro.url.searchParams.getAll("year").map(Number).filter((y) => !Number.isNaN(y));
16+
const minRating = Astro.url.searchParams.get("min_rating") ?? "";
17+
const person = await api.media.getPerson(Number(id), 1, token, { collection, genre, year, minRating });
1218
1319
const credits = person.credits || [];
1420
const totalPages = Math.ceil(person.total_credits / person.page_size);
21+
22+
const MOVIE_GENRES = [
23+
"Action", "Adventure", "Animation", "Comedy", "Crime", "Documentary",
24+
"Drama", "Family", "Fantasy", "History", "Horror", "Music", "Mystery",
25+
"Romance", "Science Fiction", "Thriller", "War", "Western",
26+
];
27+
const TV_GENRES = [
28+
"Action & Adventure", "Animation", "Comedy", "Crime", "Documentary",
29+
"Drama", "Family", "Kids", "Mystery", "News", "Reality",
30+
"Sci-Fi & Fantasy", "Soap", "Talk", "War & Politics", "Western",
31+
];
32+
const CREDIT_GENRES = Array.from(new Set([...MOVIE_GENRES, ...TV_GENRES])).sort();
33+
34+
const currentYear = new Date().getFullYear();
35+
const years = Array.from({ length: currentYear - 1949 }, (_, i) => currentYear - i);
1536
---
1637

1738
<Base title={person.name}>
@@ -93,17 +114,42 @@ const totalPages = Math.ceil(person.total_credits / person.page_size);
93114
)}
94115

95116
<div>
96-
<h2 class="section-headline mb-6">Known For</h2>
97-
<div id="credits-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
98-
{credits.map((item) => (
99-
<div class="flex flex-col gap-1">
100-
<MediaCard item={item as unknown as MediaItem} />
101-
{item.character && (
102-
<p class="text-xs text-zinc-500 truncate px-1">as {item.character}</p>
103-
)}
104-
</div>
105-
))}
117+
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
118+
<h2 class="section-headline">Known For</h2>
119+
<FilterPanel
120+
id="person-credits-filters"
121+
basePath={`/person/${id}`}
122+
sections={[
123+
{ key: "collection", label: "Collection", options: [{ value: "in", label: "In My Collection" }, { value: "out", label: "Not In My Collection" }], selected: collection ? [collection] : [], multi: false },
124+
{ key: "genre", label: "Genre", options: CREDIT_GENRES.map((g) => ({ value: g, label: g })), selected: genre },
125+
{ key: "year", label: "Year", options: years.map((y) => ({ value: String(y), label: String(y) })), selected: year.map(String) },
126+
{
127+
key: "min_rating",
128+
label: "Rating",
129+
options: [
130+
...[9, 8, 7, 6, 5].map((r) => ({ value: String(r), label: `${r}+ Stars` })),
131+
{ value: "lt5", label: "Less than 5 Stars" },
132+
],
133+
selected: minRating ? [minRating] : [],
134+
multi: false,
135+
},
136+
]}
137+
/>
106138
</div>
139+
{credits.length === 0 && (collection || genre.length > 0 || year.length > 0 || minRating) ? (
140+
<p class="text-zinc-500 text-sm">No matching credits found.</p>
141+
) : (
142+
<div id="credits-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
143+
{credits.map((item) => (
144+
<div class="flex flex-col gap-1">
145+
<MediaCard item={item as unknown as MediaItem} />
146+
{item.character && (
147+
<p class="text-xs text-zinc-500 truncate px-1">as {item.character}</p>
148+
)}
149+
</div>
150+
))}
151+
</div>
152+
)}
107153
{totalPages > 1 && (
108154
<div id="credits-pagination" class="flex items-center justify-center gap-3 mt-8">
109155
<button id="credits-prev" disabled class="px-4 py-2 text-sm rounded-lg bg-zinc-800 text-zinc-400 border border-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer hover:bg-zinc-700 transition-colors">← Prev</button>
@@ -164,7 +210,7 @@ const totalPages = Math.ceil(person.total_credits / person.page_size);
164210
}
165211
</script>
166212

167-
<script define:vars={{ personId: person.tmdb_id, totalCredits: person.total_credits, pageSize: person.page_size }}>
213+
<script define:vars={{ personId: person.tmdb_id, totalCredits: person.total_credits, pageSize: person.page_size, collection, genre, year, minRating }}>
168214
const totalPages = Math.ceil(totalCredits / pageSize);
169215
let currentPage = 1;
170216
let loading = false;
@@ -188,7 +234,12 @@ const totalPages = Math.ceil(person.total_credits / person.page_size);
188234
grid.style.pointerEvents = 'none';
189235

190236
try {
191-
const res = await fetch('/partials/person-credits?person_id=' + personId + '&page=' + page);
237+
const params = new URLSearchParams({ person_id: String(personId), page: String(page) });
238+
if (collection) params.set('collection', collection);
239+
genre.forEach((g) => params.append('genre', g));
240+
year.forEach((y) => params.append('year', String(y)));
241+
if (minRating) params.set('min_rating', minRating);
242+
const res = await fetch('/partials/person-credits?' + params.toString());
192243
if (!res.ok) return;
193244
const html = await res.text();
194245
const tmp = document.createElement('div');

0 commit comments

Comments
 (0)