refactor(plex): extract card serializers + query builder from browse.py
Second half of the 2026-08-17 review's "Plex query/cards extraction" — slim the 1841-line browse.py god-module ahead of S6 (which reworks the library query's column selection for the HU overlay). - app/plex/cards.py: the pure grid/leaf card serializers (_movie_card, _show_card, _episode_card, _leaf_card). - app/plex/query.py: the library query-shaping layer — field-scope tsvector rechecks (_TS_CONFIG, _FIELD_COLUMNS, _field_recheck) and the shared sidebar metadata filters (_csv, _added_cutoff, _meta_filter_conds, _apply_meta_filters, LibraryFilters, _like_escape). This is the seam S6 will touch. browse.py imports both and keeps its route handlers; the entangled collection/playlist card cluster (_collection_source/_collection_editable) stays put deliberately. All helpers were browse-internal, so no external importers change. Pure code motion — DB-lane suite 287 passed / 2 skipped; ruff clean. browse.py 1841 -> 1667 lines.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
"""Plex list/leaf card serializers: the small `model -> dict` projections the library grid,
|
||||||
|
playlists, and detail views render. Extracted from routes/plex/browse.py so the row-shaping
|
||||||
|
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
||||||
|
router. Pure functions — no DB, no request state."""
|
||||||
|
|
||||||
|
from app.models import PlexItem, PlexShow, PlexState
|
||||||
|
|
||||||
|
|
||||||
|
def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": it.rating_key,
|
||||||
|
"type": "movie",
|
||||||
|
"title": it.title,
|
||||||
|
"year": it.year,
|
||||||
|
"duration_seconds": it.duration_s,
|
||||||
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
|
"playable": it.playable,
|
||||||
|
"status": st.status if st else "new",
|
||||||
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _show_card(sh: PlexShow, status: str = "new") -> dict:
|
||||||
|
return {
|
||||||
|
"id": sh.rating_key,
|
||||||
|
"type": "show",
|
||||||
|
"title": sh.title,
|
||||||
|
"year": sh.year,
|
||||||
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
|
"season_count": sh.child_count,
|
||||||
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": e.rating_key,
|
||||||
|
"type": "episode",
|
||||||
|
"title": e.title,
|
||||||
|
"summary": e.summary,
|
||||||
|
"season_number": e.season_number,
|
||||||
|
"episode_number": e.episode_number,
|
||||||
|
"duration_seconds": e.duration_s,
|
||||||
|
"thumb": f"/api/plex/image/{e.rating_key}",
|
||||||
|
"playable": e.playable,
|
||||||
|
"status": st.status if st else "new",
|
||||||
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _leaf_card(
|
||||||
|
it: PlexItem,
|
||||||
|
st: PlexState | None,
|
||||||
|
show_title: str | None = None,
|
||||||
|
show_rk: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
|
||||||
|
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season."""
|
||||||
|
if it.kind == "episode":
|
||||||
|
card = _episode_card(it, st)
|
||||||
|
card["show_title"] = show_title
|
||||||
|
card["show_id"] = show_rk
|
||||||
|
return card
|
||||||
|
return _movie_card(it, st)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Plex library query-shaping: the full-text field-scope rechecks and the sidebar metadata
|
||||||
|
filters that /library and /facets share. Extracted from routes/plex/browse.py so the query
|
||||||
|
builder has one home (and so the HU-overlay column selection lands here, not in the router).
|
||||||
|
Pure query fragments — every function returns SQLAlchemy expressions or a params dict."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_
|
||||||
|
|
||||||
|
_TS_CONFIG = "public.unaccent_simple"
|
||||||
|
|
||||||
|
# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE
|
||||||
|
# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to
|
||||||
|
# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column
|
||||||
|
# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions
|
||||||
|
# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)`
|
||||||
|
# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled
|
||||||
|
# separately in the query body (it targets episode rows, not the movie/show grid).
|
||||||
|
_FIELD_COLUMNS = ("title", "people", "summary", "episode")
|
||||||
|
|
||||||
|
|
||||||
|
def _field_recheck(model, field: str, tsq):
|
||||||
|
"""A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None."""
|
||||||
|
if field == "title":
|
||||||
|
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, ""))
|
||||||
|
elif field == "people":
|
||||||
|
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, ""))
|
||||||
|
elif field == "summary":
|
||||||
|
expr = func.to_tsvector(
|
||||||
|
_TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return expr.op("@@")(tsq)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv(v: str | None) -> list[str]:
|
||||||
|
return [s.strip() for s in v.split(",") if s.strip()] if v else []
|
||||||
|
|
||||||
|
|
||||||
|
_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365}
|
||||||
|
|
||||||
|
|
||||||
|
def _added_cutoff(within: str | None) -> datetime | None:
|
||||||
|
days = _ADDED_WINDOWS.get(within or "")
|
||||||
|
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_filter_conds(model, p: dict) -> list:
|
||||||
|
"""The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for
|
||||||
|
`p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a
|
||||||
|
list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets."""
|
||||||
|
conds: list = []
|
||||||
|
gsel = _csv(p.get("genres"))
|
||||||
|
if gsel:
|
||||||
|
gc = [model.genres.contains([g]) for g in gsel]
|
||||||
|
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
|
||||||
|
crs = _csv(p.get("content_ratings"))
|
||||||
|
if crs:
|
||||||
|
conds.append(model.content_rating.in_(crs))
|
||||||
|
if p.get("year_min") is not None:
|
||||||
|
conds.append(model.year >= p["year_min"])
|
||||||
|
if p.get("year_max") is not None:
|
||||||
|
conds.append(model.year <= p["year_max"])
|
||||||
|
if p.get("rating_min") is not None:
|
||||||
|
conds.append(model.rating >= p["rating_min"])
|
||||||
|
cutoff = _added_cutoff(p.get("added_within"))
|
||||||
|
if cutoff is not None:
|
||||||
|
conds.append(model.added_at >= cutoff)
|
||||||
|
for d in _csv(p.get("directors")): # AND
|
||||||
|
conds.append(model.directors.contains([d]))
|
||||||
|
alist = _csv(p.get("actors"))
|
||||||
|
if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode
|
||||||
|
ac = [model.cast_names.contains([a]) for a in alist]
|
||||||
|
conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac))
|
||||||
|
stds = _csv(p.get("studios"))
|
||||||
|
if stds: # OR
|
||||||
|
conds.append(model.studio.in_(stds))
|
||||||
|
if p.get("collection"):
|
||||||
|
conds.append(model.collection_keys.contains([p["collection"]]))
|
||||||
|
return conds
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_meta_filters(q, model, p: dict):
|
||||||
|
conds = _meta_filter_conds(model, p)
|
||||||
|
return q.where(*conds) if conds else q
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LibraryFilters:
|
||||||
|
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
||||||
|
``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included
|
||||||
|
for /facets' wsq but ignored by `_meta_filter_conds` (both callers apply duration separately)."""
|
||||||
|
|
||||||
|
genres: str | None = None
|
||||||
|
genre_mode: str = "any"
|
||||||
|
content_ratings: str | None = None
|
||||||
|
year_min: int | None = None
|
||||||
|
year_max: int | None = None
|
||||||
|
rating_min: float | None = None
|
||||||
|
duration_min: int | None = None
|
||||||
|
duration_max: int | None = None
|
||||||
|
added_within: str | None = None
|
||||||
|
directors: str | None = None
|
||||||
|
actors: str | None = None
|
||||||
|
actor_mode: str = (
|
||||||
|
"any" # how multiple actors combine: any (OR, default) | all (AND)
|
||||||
|
)
|
||||||
|
studios: str | None = None
|
||||||
|
collection: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"genres": self.genres,
|
||||||
|
"genre_mode": self.genre_mode,
|
||||||
|
"content_ratings": self.content_ratings,
|
||||||
|
"year_min": self.year_min,
|
||||||
|
"year_max": self.year_max,
|
||||||
|
"rating_min": self.rating_min,
|
||||||
|
"duration_min": self.duration_min,
|
||||||
|
"duration_max": self.duration_max,
|
||||||
|
"added_within": self.added_within,
|
||||||
|
"directors": self.directors,
|
||||||
|
"actors": self.actors,
|
||||||
|
"actor_mode": self.actor_mode,
|
||||||
|
"studios": self.studios,
|
||||||
|
"collection": self.collection,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _like_escape(s: str) -> str:
|
||||||
|
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from datetime import datetime, timezone
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -43,40 +42,25 @@ from app.plex import detail as plex_detail
|
|||||||
from app.plex import people as plex_people
|
from app.plex import people as plex_people
|
||||||
from app.plex import rollup, subtitles
|
from app.plex import rollup, subtitles
|
||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
|
from app.feedquery import to_tsquery_str
|
||||||
|
from app.plex.cards import _episode_card, _leaf_card, _movie_card, _show_card
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.feedquery import to_tsquery_str
|
from app.plex.query import (
|
||||||
|
_FIELD_COLUMNS,
|
||||||
|
_TS_CONFIG,
|
||||||
|
LibraryFilters,
|
||||||
|
_apply_meta_filters,
|
||||||
|
_csv,
|
||||||
|
_field_recheck,
|
||||||
|
_like_escape,
|
||||||
|
_meta_filter_conds,
|
||||||
|
)
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.routes.plex._shared import item_or_404
|
from app.routes.plex._shared import item_or_404
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
_TS_CONFIG = "public.unaccent_simple"
|
|
||||||
|
|
||||||
# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE
|
|
||||||
# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to
|
|
||||||
# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column
|
|
||||||
# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions
|
|
||||||
# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)`
|
|
||||||
# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled
|
|
||||||
# separately in the query body (it targets episode rows, not the movie/show grid).
|
|
||||||
_FIELD_COLUMNS = ("title", "people", "summary", "episode")
|
|
||||||
|
|
||||||
|
|
||||||
def _field_recheck(model, field: str, tsq):
|
|
||||||
"""A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None."""
|
|
||||||
if field == "title":
|
|
||||||
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, ""))
|
|
||||||
elif field == "people":
|
|
||||||
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, ""))
|
|
||||||
elif field == "summary":
|
|
||||||
expr = func.to_tsvector(
|
|
||||||
_TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
return expr.op("@@")(tsq)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/libraries")
|
@router.get("/libraries")
|
||||||
def list_libraries(
|
def list_libraries(
|
||||||
@@ -112,33 +96,6 @@ def list_libraries(
|
|||||||
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
||||||
|
|
||||||
|
|
||||||
def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
|
||||||
return {
|
|
||||||
"id": it.rating_key,
|
|
||||||
"type": "movie",
|
|
||||||
"title": it.title,
|
|
||||||
"year": it.year,
|
|
||||||
"duration_seconds": it.duration_s,
|
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
|
||||||
"playable": it.playable,
|
|
||||||
"status": st.status if st else "new",
|
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _show_card(sh: PlexShow, status: str = "new") -> dict:
|
|
||||||
return {
|
|
||||||
"id": sh.rating_key,
|
|
||||||
"type": "show",
|
|
||||||
"title": sh.title,
|
|
||||||
"year": sh.year,
|
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
|
||||||
"season_count": sh.child_count,
|
|
||||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
|
||||||
"status": status,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _lib_key(db: Session, library_id: int | None) -> str | None:
|
def _lib_key(db: Session, library_id: int | None) -> str | None:
|
||||||
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
||||||
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
||||||
@@ -148,132 +105,6 @@ def _lib_key(db: Session, library_id: int | None) -> str | None:
|
|||||||
return lib.plex_key if lib else None
|
return lib.plex_key if lib else None
|
||||||
|
|
||||||
|
|
||||||
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
|
||||||
return {
|
|
||||||
"id": e.rating_key,
|
|
||||||
"type": "episode",
|
|
||||||
"title": e.title,
|
|
||||||
"summary": e.summary,
|
|
||||||
"season_number": e.season_number,
|
|
||||||
"episode_number": e.episode_number,
|
|
||||||
"duration_seconds": e.duration_s,
|
|
||||||
"thumb": f"/api/plex/image/{e.rating_key}",
|
|
||||||
"playable": e.playable,
|
|
||||||
"status": st.status if st else "new",
|
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _leaf_card(
|
|
||||||
it: PlexItem,
|
|
||||||
st: PlexState | None,
|
|
||||||
show_title: str | None = None,
|
|
||||||
show_rk: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
|
|
||||||
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season."""
|
|
||||||
if it.kind == "episode":
|
|
||||||
card = _episode_card(it, st)
|
|
||||||
card["show_title"] = show_title
|
|
||||||
card["show_id"] = show_rk
|
|
||||||
return card
|
|
||||||
return _movie_card(it, st)
|
|
||||||
|
|
||||||
|
|
||||||
def _csv(v: str | None) -> list[str]:
|
|
||||||
return [s.strip() for s in v.split(",") if s.strip()] if v else []
|
|
||||||
|
|
||||||
|
|
||||||
_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365}
|
|
||||||
|
|
||||||
|
|
||||||
def _added_cutoff(within: str | None) -> datetime | None:
|
|
||||||
days = _ADDED_WINDOWS.get(within or "")
|
|
||||||
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
|
||||||
|
|
||||||
|
|
||||||
def _meta_filter_conds(model, p: dict) -> list:
|
|
||||||
"""The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for
|
|
||||||
`p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a
|
|
||||||
list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets."""
|
|
||||||
conds: list = []
|
|
||||||
gsel = _csv(p.get("genres"))
|
|
||||||
if gsel:
|
|
||||||
gc = [model.genres.contains([g]) for g in gsel]
|
|
||||||
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
|
|
||||||
crs = _csv(p.get("content_ratings"))
|
|
||||||
if crs:
|
|
||||||
conds.append(model.content_rating.in_(crs))
|
|
||||||
if p.get("year_min") is not None:
|
|
||||||
conds.append(model.year >= p["year_min"])
|
|
||||||
if p.get("year_max") is not None:
|
|
||||||
conds.append(model.year <= p["year_max"])
|
|
||||||
if p.get("rating_min") is not None:
|
|
||||||
conds.append(model.rating >= p["rating_min"])
|
|
||||||
cutoff = _added_cutoff(p.get("added_within"))
|
|
||||||
if cutoff is not None:
|
|
||||||
conds.append(model.added_at >= cutoff)
|
|
||||||
for d in _csv(p.get("directors")): # AND
|
|
||||||
conds.append(model.directors.contains([d]))
|
|
||||||
alist = _csv(p.get("actors"))
|
|
||||||
if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode
|
|
||||||
ac = [model.cast_names.contains([a]) for a in alist]
|
|
||||||
conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac))
|
|
||||||
stds = _csv(p.get("studios"))
|
|
||||||
if stds: # OR
|
|
||||||
conds.append(model.studio.in_(stds))
|
|
||||||
if p.get("collection"):
|
|
||||||
conds.append(model.collection_keys.contains([p["collection"]]))
|
|
||||||
return conds
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_meta_filters(q, model, p: dict):
|
|
||||||
conds = _meta_filter_conds(model, p)
|
|
||||||
return q.where(*conds) if conds else q
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LibraryFilters:
|
|
||||||
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
|
||||||
``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included
|
|
||||||
for /facets' wsq but ignored by `_meta_filter_conds` (both callers apply duration separately)."""
|
|
||||||
|
|
||||||
genres: str | None = None
|
|
||||||
genre_mode: str = "any"
|
|
||||||
content_ratings: str | None = None
|
|
||||||
year_min: int | None = None
|
|
||||||
year_max: int | None = None
|
|
||||||
rating_min: float | None = None
|
|
||||||
duration_min: int | None = None
|
|
||||||
duration_max: int | None = None
|
|
||||||
added_within: str | None = None
|
|
||||||
directors: str | None = None
|
|
||||||
actors: str | None = None
|
|
||||||
actor_mode: str = (
|
|
||||||
"any" # how multiple actors combine: any (OR, default) | all (AND)
|
|
||||||
)
|
|
||||||
studios: str | None = None
|
|
||||||
collection: str | None = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"genres": self.genres,
|
|
||||||
"genre_mode": self.genre_mode,
|
|
||||||
"content_ratings": self.content_ratings,
|
|
||||||
"year_min": self.year_min,
|
|
||||||
"year_max": self.year_max,
|
|
||||||
"rating_min": self.rating_min,
|
|
||||||
"duration_min": self.duration_min,
|
|
||||||
"duration_max": self.duration_max,
|
|
||||||
"added_within": self.added_within,
|
|
||||||
"directors": self.directors,
|
|
||||||
"actors": self.actors,
|
|
||||||
"actor_mode": self.actor_mode,
|
|
||||||
"studios": self.studios,
|
|
||||||
"collection": self.collection,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library")
|
@router.get("/library")
|
||||||
def unified_library(
|
def unified_library(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
@@ -702,10 +533,6 @@ def facets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _like_escape(s: str) -> str:
|
|
||||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
||||||
|
|
||||||
|
|
||||||
def _collection_editable(c: PlexCollection) -> bool:
|
def _collection_editable(c: PlexCollection) -> bool:
|
||||||
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
||||||
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
||||||
|
|||||||
Reference in New Issue
Block a user