diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 9a5c982..2ed696f 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -193,6 +193,7 @@ def browse( offset: int = 0, limit: int = Query(default=40, ge=1, le=100), genres: str | None = None, + genre_mode: str = "any", content_ratings: str | None = None, year_min: int | None = None, year_max: int | None = None, @@ -200,9 +201,10 @@ def browse( duration_min: int | None = None, duration_max: int | None = None, added_within: str | None = None, - director: str | None = None, - actor: str | None = None, - studio: str | None = None, + directors: str | None = None, + actors: str | None = None, + studios: str | None = None, + sort_dir: str = "desc", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -230,9 +232,10 @@ def browse( else: # all — hide only the explicitly hidden query = query.filter(or_(st.status.is_(None), st.status != "hidden")) # --- Metadata filters (movie libraries; @> containment is GIN-indexed) --- - genre_any = _csv(genres) - if genre_any: - query = query.filter(or_(*[PlexItem.genres.contains([g]) for g in genre_any])) + gsel = _csv(genres) + if gsel: + conds = [PlexItem.genres.contains([g]) for g in gsel] + query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds)) crs = _csv(content_ratings) if crs: query = query.filter(PlexItem.content_rating.in_(crs)) @@ -249,12 +252,13 @@ def browse( cutoff = _added_cutoff(added_within) if cutoff is not None: query = query.filter(PlexItem.added_at >= cutoff) - if director: - query = query.filter(PlexItem.directors.contains([director])) - if actor: - query = query.filter(PlexItem.cast_names.contains([actor])) - if studio: - query = query.filter(PlexItem.studio == studio) + for d in _csv(directors): # AND — titles this whole set directed + query = query.filter(PlexItem.directors.contains([d])) + for a in _csv(actors): # AND — titles featuring all these people + query = query.filter(PlexItem.cast_names.contains([a])) + stds = _csv(studios) + if stds: # OR — a title has one studio + query = query.filter(PlexItem.studio.in_(stds)) else: query = query.filter(PlexShow.library_id == lib.id) @@ -268,18 +272,21 @@ def browse( if tsq is not None: order = [func.ts_rank(model.search_vector, tsq).desc()] - elif sort == "title": - order = [func.lower(model.title).asc()] - elif sort == "year" and model is PlexItem: - order = [PlexItem.year.desc().nullslast()] - elif sort == "rating" and model is PlexItem: - order = [PlexItem.rating.desc().nullslast()] - elif sort == "duration" and model is PlexItem: - order = [PlexItem.duration_s.desc().nullslast()] - elif sort == "release" and model is PlexItem: - order = [PlexItem.originally_available_at.desc().nullslast()] - else: # added — newest first - order = [model.added_at.desc().nullslast()] + else: + if sort == "title": + col = func.lower(model.title) + elif sort == "year" and model is PlexItem: + col = PlexItem.year + elif sort == "rating" and model is PlexItem: + col = PlexItem.rating + elif sort == "duration" and model is PlexItem: + col = PlexItem.duration_s + elif sort == "release" and model is PlexItem: + col = PlexItem.originally_available_at + else: # added + col = model.added_at + asc = sort_dir == "asc" + order = [(col.asc() if asc else col.desc()).nullslast()] order.append(model.id.desc()) rows = query.order_by(*order).offset(offset).limit(limit).all() diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 8a74f5b..855662a 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -127,9 +127,21 @@ export default function PlexBrowse({ q, library, show, sort, filters, setFilters onBack={sub.back} onPlay={() => sub.open({ kind: "player", id: infoId })} onFilter={(patch) => { - // Clicking a metadata chip sets that filter and returns to the (now filtered) grid. - setFilters({ ...filters, ...patch }); - sub.back(); + // Clicking a metadata chip sets that filter and shows the filtered grid. Array filters + // (genres/people/studios) UNION with what's already set (so you can stack people); scalars + // replace. We push a fresh grid entry (not history.back) so browser Back returns to this + // info page instead of leaving the Plex module. + const merged = { ...filters } as unknown as Record; + for (const [k, v] of Object.entries(patch)) { + const cur = merged[k]; + if (Array.isArray(v) && Array.isArray(cur)) { + merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])])); + } else { + merged[k] = v; + } + } + setFilters(merged as unknown as PlexFilters); + sub.open({ kind: "grid" }); }} /> ); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 188425e..4adf81b 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -181,7 +181,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan {detail.studio && ( onFilter({ studio: detail.studio! }) : undefined} + onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined} > {detail.studio} @@ -232,7 +232,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan onFilter({ director: d }) : undefined} + onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined} > {d} @@ -331,7 +331,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan return onFilter ? ( + ))} + + )} {facets.genres.map((g) => (