feat(plex): TV-show metadata sync + series filters (Phase 1 of series view)
Give TV shows the same filterable metadata as movies so the TV grid can be filtered/sorted, not just library+sort. Backend: migration 0052 adds rating/content_rating/studio/originally_available_at/genres/directors/cast_names/ people_text to plex_shows (+ GIN/indexes, people_text folded into search_vector); _sync_shows populates them cheaply from the show section listing (no per-item calls). /browse show-branch gains the movie filter set (minus duration) plus an aggregate per-user watch-state (a show rolls up its episodes: all watched=watched, any progress=in_progress, none=new) with year/rating/release sorts; /facets returns show facets. Frontend: PlexSidebar renders the metadata filters + watch-state for TV libraries (duration stays movie-only); show cards show a watched/in-progress badge. i18n plex.inProgress (en/hu/de). Needs a Plex re-sync to populate the new columns.
This commit is contained in:
parent
f0bab315ad
commit
569d31235d
9 changed files with 266 additions and 52 deletions
91
backend/alembic/versions/0052_plex_show_meta.py
Normal file
91
backend/alembic/versions/0052_plex_show_meta.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Plex show-level filterable/orderable metadata
|
||||||
|
|
||||||
|
Revision ID: 0052_plex_show_meta
|
||||||
|
Revises: 0051_plex_link
|
||||||
|
Create Date: 2026-07-10
|
||||||
|
|
||||||
|
Gives TV shows the same filterable metadata movies already have (0045/0046), mirrored cheaply from
|
||||||
|
the Plex show section listing (no per-item calls): rating (audienceRating ~ IMDb), content rating,
|
||||||
|
studio/network, first-air date, and GIN-indexed genre / director / cast arrays + a `people_text`
|
||||||
|
blob folded into the generated `search_vector` (weight B). This lets the TV grid be filtered and
|
||||||
|
sorted like the movie grid. A generated column's expression can't be altered in place, so the
|
||||||
|
search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0052_plex_show_meta"
|
||||||
|
down_revision: Union[str, None] = "0051_plex_link"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_CFG = "public.unaccent_simple"
|
||||||
|
_NEW_VECTOR = (
|
||||||
|
f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
|
||||||
|
f"setweight(to_tsvector('{_CFG}', coalesce(people_text, '')), 'B') || "
|
||||||
|
f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
|
||||||
|
)
|
||||||
|
_OLD_VECTOR = (
|
||||||
|
f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
|
||||||
|
f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_vector(expr: str) -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_plex_shows_search_vector")
|
||||||
|
op.execute("ALTER TABLE plex_shows DROP COLUMN search_vector")
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE plex_shows ADD COLUMN search_vector tsvector "
|
||||||
|
f"GENERATED ALWAYS AS ({expr}) STORED"
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_plex_shows_search_vector ON plex_shows USING gin (search_vector)")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("plex_shows", sa.Column("rating", sa.Float(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("content_rating", sa.String(length=16), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("studio", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("originally_available_at", sa.Date(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("genres", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("directors", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("cast_names", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("people_text", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"])
|
||||||
|
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"])
|
||||||
|
op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"])
|
||||||
|
op.create_index("ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"])
|
||||||
|
op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin")
|
||||||
|
op.create_index("ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin")
|
||||||
|
op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin")
|
||||||
|
# NB: ix_plex_shows_collections_gin already exists (created with collections in 0047).
|
||||||
|
|
||||||
|
_rebuild_vector(_NEW_VECTOR)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_rebuild_vector(_OLD_VECTOR)
|
||||||
|
for ix in (
|
||||||
|
"ix_plex_shows_cast_gin",
|
||||||
|
"ix_plex_shows_directors_gin",
|
||||||
|
"ix_plex_shows_genres_gin",
|
||||||
|
"ix_plex_shows_originally_available_at",
|
||||||
|
"ix_plex_shows_studio",
|
||||||
|
"ix_plex_shows_content_rating",
|
||||||
|
"ix_plex_shows_rating",
|
||||||
|
):
|
||||||
|
op.drop_index(ix, table_name="plex_shows")
|
||||||
|
for col in (
|
||||||
|
"people_text",
|
||||||
|
"cast_names",
|
||||||
|
"directors",
|
||||||
|
"genres",
|
||||||
|
"originally_available_at",
|
||||||
|
"studio",
|
||||||
|
"content_rating",
|
||||||
|
"rating",
|
||||||
|
):
|
||||||
|
op.drop_column("plex_shows", col)
|
||||||
|
|
@ -956,9 +956,17 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
|
||||||
|
|
||||||
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page."""
|
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus
|
||||||
|
the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV
|
||||||
|
grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing."""
|
||||||
|
|
||||||
__tablename__ = "plex_shows"
|
__tablename__ = "plex_shows"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
|
||||||
|
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
|
@ -973,10 +981,20 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
||||||
collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in
|
collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in
|
||||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
||||||
|
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
||||||
|
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||||
|
studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio
|
||||||
|
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date
|
||||||
|
genres: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
directors: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
cast_names: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
|
||||||
search_vector: Mapped[object | None] = mapped_column(
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
TSVECTOR,
|
TSVECTOR,
|
||||||
Computed(
|
Computed(
|
||||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||||
persisted=True,
|
persisted=True,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,16 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||||
sh.art_key = meta.get("art")
|
sh.art_key = meta.get("art")
|
||||||
sh.child_count = meta.get("childCount")
|
sh.child_count = meta.get("childCount")
|
||||||
sh.added_at = _epoch(meta.get("addedAt"))
|
sh.added_at = _epoch(meta.get("addedAt"))
|
||||||
|
# Filterable / orderable metadata — same cheap section-listing tags as movies.
|
||||||
|
sh.rating = _rating(meta)
|
||||||
|
sh.content_rating = (meta.get("contentRating") or None)
|
||||||
|
sh.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
|
||||||
|
sh.originally_available_at = _date(meta.get("originallyAvailableAt"))
|
||||||
|
sh.genres = _tags(meta, "Genre")
|
||||||
|
sh.directors = _tags(meta, "Director")
|
||||||
|
sh.cast_names = _tags(meta, "Role", limit=20)
|
||||||
|
people = list(dict.fromkeys((sh.directors or []) + (sh.cast_names or [])))
|
||||||
|
sh.people_text = " ".join(people) or None
|
||||||
stats["shows"] += 1
|
stats["shows"] += 1
|
||||||
db.flush()
|
db.flush()
|
||||||
show_id = {rk: sh.id for rk, sh in shows.items()}
|
show_id = {rk: sh.id for rk, sh in shows.items()}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from urllib.parse import quote
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy import and_, func, or_, text
|
from sqlalchemy import and_, case, func, or_, text
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
|
@ -228,7 +228,7 @@ def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _show_card(sh: PlexShow) -> dict:
|
def _show_card(sh: PlexShow, status: str = "new") -> dict:
|
||||||
return {
|
return {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
"type": "show",
|
"type": "show",
|
||||||
|
|
@ -236,9 +236,51 @@ def _show_card(sh: PlexShow) -> dict:
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _show_status(total: int, watched: int, inprog: int) -> str:
|
||||||
|
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
|
||||||
|
if total and watched >= total:
|
||||||
|
return "watched"
|
||||||
|
if (watched or 0) > 0 or (inprog or 0) > 0:
|
||||||
|
return "in_progress"
|
||||||
|
return "new"
|
||||||
|
|
||||||
|
|
||||||
|
def _show_agg_subq(db: Session, user_id: int):
|
||||||
|
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
|
||||||
|
grid by aggregate watch-state and (b) compute each card's badge."""
|
||||||
|
ps = aliased(PlexState)
|
||||||
|
return (
|
||||||
|
db.query(
|
||||||
|
PlexItem.show_id.label("show_id"),
|
||||||
|
func.count(PlexItem.id).label("total"),
|
||||||
|
func.count(case((ps.status == "watched", 1))).label("watched"),
|
||||||
|
func.count(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).label("inprog"),
|
||||||
|
)
|
||||||
|
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||||
|
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
||||||
|
.group_by(PlexItem.show_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _show_state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||||
|
if not show_ids:
|
||||||
|
return {}
|
||||||
|
rows = _show_agg_subq(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
|
||||||
|
return {r.show_id: _show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": e.rating_key,
|
"id": e.rating_key,
|
||||||
|
|
@ -358,6 +400,43 @@ def browse(
|
||||||
query = query.filter(PlexItem.studio.in_(stds))
|
query = query.filter(PlexItem.studio.in_(stds))
|
||||||
else:
|
else:
|
||||||
query = query.filter(PlexShow.library_id == lib.id)
|
query = query.filter(PlexShow.library_id == lib.id)
|
||||||
|
# Aggregate per-user watch-state across the show's episodes (a show isn't one row of state).
|
||||||
|
if show in ("watched", "in_progress", "unwatched"):
|
||||||
|
agg = _show_agg_subq(db, user.id).subquery()
|
||||||
|
query = query.outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||||
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
|
inp = func.coalesce(agg.c.inprog, 0)
|
||||||
|
if show == "watched":
|
||||||
|
query = query.filter(tot > 0, wat >= tot)
|
||||||
|
elif show == "in_progress":
|
||||||
|
query = query.filter(or_(wat > 0, inp > 0), wat < tot)
|
||||||
|
else: # unwatched — no episode started
|
||||||
|
query = query.filter(wat == 0, inp == 0)
|
||||||
|
# --- Metadata filters (same as movies, minus duration which shows don't have) ---
|
||||||
|
gsel = _csv(genres)
|
||||||
|
if gsel:
|
||||||
|
conds = [PlexShow.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(PlexShow.content_rating.in_(crs))
|
||||||
|
if year_min is not None:
|
||||||
|
query = query.filter(PlexShow.year >= year_min)
|
||||||
|
if year_max is not None:
|
||||||
|
query = query.filter(PlexShow.year <= year_max)
|
||||||
|
if rating_min is not None:
|
||||||
|
query = query.filter(PlexShow.rating >= rating_min)
|
||||||
|
cutoff = _added_cutoff(added_within)
|
||||||
|
if cutoff is not None:
|
||||||
|
query = query.filter(PlexShow.added_at >= cutoff)
|
||||||
|
for d in _csv(directors): # AND
|
||||||
|
query = query.filter(PlexShow.directors.contains([d]))
|
||||||
|
for a in _csv(actors): # AND
|
||||||
|
query = query.filter(PlexShow.cast_names.contains([a]))
|
||||||
|
stds = _csv(studios)
|
||||||
|
if stds: # OR
|
||||||
|
query = query.filter(PlexShow.studio.in_(stds))
|
||||||
|
|
||||||
# Collection filter applies to both movies and shows (both carry collection_keys).
|
# Collection filter applies to both movies and shows (both carry collection_keys).
|
||||||
if collection:
|
if collection:
|
||||||
|
|
@ -376,14 +455,14 @@ def browse(
|
||||||
else:
|
else:
|
||||||
if sort == "title":
|
if sort == "title":
|
||||||
col = func.lower(model.title)
|
col = func.lower(model.title)
|
||||||
elif sort == "year" and model is PlexItem:
|
elif sort == "year": # both movies and shows have year
|
||||||
col = PlexItem.year
|
col = model.year
|
||||||
elif sort == "rating" and model is PlexItem:
|
elif sort == "rating": # both have rating (audienceRating)
|
||||||
col = PlexItem.rating
|
col = model.rating
|
||||||
elif sort == "duration" and model is PlexItem:
|
elif sort == "duration" and model is PlexItem:
|
||||||
col = PlexItem.duration_s
|
col = PlexItem.duration_s
|
||||||
elif sort == "release" and model is PlexItem:
|
elif sort == "release": # both have originally_available_at
|
||||||
col = PlexItem.originally_available_at
|
col = model.originally_available_at
|
||||||
else: # added
|
else: # added
|
||||||
col = model.added_at
|
col = model.added_at
|
||||||
asc = sort_dir == "asc"
|
asc = sort_dir == "asc"
|
||||||
|
|
@ -402,7 +481,8 @@ def browse(
|
||||||
}
|
}
|
||||||
items = [_movie_card(r, states.get(r.id)) for r in rows]
|
items = [_movie_card(r, states.get(r.id)) for r in rows]
|
||||||
else:
|
else:
|
||||||
items = [_show_card(r) for r in rows]
|
statuses = _show_state_map(db, user.id, [r.id for r in rows])
|
||||||
|
items = [_show_card(r, statuses.get(r.id, "new")) for r in rows]
|
||||||
|
|
||||||
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
||||||
|
|
||||||
|
|
@ -413,8 +493,9 @@ def facets(
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Available filter values for a movie library — genres + content ratings (with counts) and the
|
"""Available filter values for a movie OR show library — genres + content ratings (with counts)
|
||||||
year/rating/duration bounds — so the sidebar only offers what the library actually contains."""
|
and the year/rating[/duration] bounds — so the sidebar only offers what the library contains.
|
||||||
|
Shows carry the same filterable metadata as movies now (0052), minus duration."""
|
||||||
empty = {
|
empty = {
|
||||||
"genres": [],
|
"genres": [],
|
||||||
"content_ratings": [],
|
"content_ratings": [],
|
||||||
|
|
@ -425,9 +506,14 @@ def facets(
|
||||||
"duration_max": None,
|
"duration_max": None,
|
||||||
}
|
}
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
||||||
if lib is None or lib.kind != "movie":
|
if lib is None:
|
||||||
return empty
|
return empty
|
||||||
|
if lib.kind == "movie":
|
||||||
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
|
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
|
||||||
|
dur_sel = "min(duration_s), max(duration_s)"
|
||||||
|
else: # show library — same columns on plex_shows, no per-row duration
|
||||||
|
base = "FROM plex_shows WHERE library_id = :lib"
|
||||||
|
dur_sel = "NULL, NULL"
|
||||||
genres = db.execute(
|
genres = db.execute(
|
||||||
text(
|
text(
|
||||||
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
|
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
|
||||||
|
|
@ -440,7 +526,7 @@ def facets(
|
||||||
{"lib": lib.id},
|
{"lib": lib.id},
|
||||||
).all()
|
).all()
|
||||||
b = db.execute(
|
b = db.execute(
|
||||||
text(f"SELECT min(year), max(year), max(rating), min(duration_s), max(duration_s) {base}"),
|
text(f"SELECT min(year), max(year), max(rating), {dur_sel} {base}"),
|
||||||
{"lib": lib.id},
|
{"lib": lib.id},
|
||||||
).first()
|
).first()
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,12 @@ function PlexPosterCard({
|
||||||
{t("plex.seasons", { count: card.season_count })}
|
{t("plex.seasons", { count: card.season_count })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||||
|
{card.type === "show" && card.status === "in_progress" && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 text-[10px] bg-accent/80 text-accent-fg px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||||
|
{t("plex.inProgress")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{card.playable && (
|
{card.playable && (
|
||||||
<span
|
<span
|
||||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ type Props = {
|
||||||
|
|
||||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||||
const SHOW_SORTS = ["added", "title"] as const;
|
// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime).
|
||||||
|
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
||||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||||
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
||||||
|
|
@ -70,7 +71,7 @@ export default function PlexSidebar({
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: ["plex-facets", library],
|
queryKey: ["plex-facets", library],
|
||||||
queryFn: () => api.plexFacets(library),
|
queryFn: () => api.plexFacets(library),
|
||||||
enabled: !!library && isMovieLib,
|
enabled: !!library, // facets now come for movie AND show libraries (0052)
|
||||||
});
|
});
|
||||||
const facets = facetsQ.data;
|
const facets = facetsQ.data;
|
||||||
|
|
||||||
|
|
@ -80,7 +81,7 @@ export default function PlexSidebar({
|
||||||
const collectionsQ = useQuery({
|
const collectionsQ = useQuery({
|
||||||
queryKey: ["plex-collections", library, collSearchDeb],
|
queryKey: ["plex-collections", library, collSearchDeb],
|
||||||
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
|
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
|
||||||
enabled: !!library && isMovieLib && !filters.collection,
|
enabled: !!library && !filters.collection, // collections apply to shows too
|
||||||
});
|
});
|
||||||
const collections = collectionsQ.data?.collections ?? [];
|
const collections = collectionsQ.data?.collections ?? [];
|
||||||
|
|
||||||
|
|
@ -89,9 +90,8 @@ export default function PlexSidebar({
|
||||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
||||||
}, [libs, library, setLibrary]);
|
}, [libs, library, setLibrary]);
|
||||||
|
|
||||||
const fCount = isMovieLib ? plexFilterCount(filters) : 0;
|
const fCount = plexFilterCount(filters); // shows carry the same filters now (0052)
|
||||||
const activeCount =
|
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||||
fCount + (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
|
||||||
const anyActive = activeCount > 0;
|
const anyActive = activeCount > 0;
|
||||||
|
|
||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
|
|
@ -205,8 +205,7 @@ export default function PlexSidebar({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Watch state (movie libraries only) */}
|
{/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
|
||||||
{isMovieLib && (
|
|
||||||
<Section label={t("plex.filter.show")}>
|
<Section label={t("plex.filter.show")}>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
{SHOW_OPTS.map((s) => (
|
{SHOW_OPTS.map((s) => (
|
||||||
|
|
@ -216,7 +215,6 @@ export default function PlexSidebar({
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sort + direction */}
|
{/* Sort + direction */}
|
||||||
<Section label={t("plex.filter.sort")}>
|
<Section label={t("plex.filter.sort")}>
|
||||||
|
|
@ -236,8 +234,8 @@ export default function PlexSidebar({
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Metadata filters (movie libraries only) */}
|
{/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */}
|
||||||
{isMovieLib && (
|
{(
|
||||||
<>
|
<>
|
||||||
{/* Active people / studios — set by clicking the info page (stackable). */}
|
{/* Active people / studios — set by clicking the info page (stackable). */}
|
||||||
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
||||||
|
|
@ -370,7 +368,8 @@ export default function PlexSidebar({
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Duration buckets */}
|
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
||||||
|
{isMovieLib && (
|
||||||
<Section label={t("plex.filter.duration")}>
|
<Section label={t("plex.filter.duration")}>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
<Chip
|
<Chip
|
||||||
|
|
@ -390,6 +389,7 @@ export default function PlexSidebar({
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Added to Plex */}
|
{/* Added to Plex */}
|
||||||
<Section label={t("plex.filter.added")}>
|
<Section label={t("plex.filter.added")}>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
||||||
"loadMore": "Mehr laden",
|
"loadMore": "Mehr laden",
|
||||||
"watched": "Angesehen",
|
"watched": "Angesehen",
|
||||||
|
"inProgress": "Läuft",
|
||||||
"play": "Abspielen",
|
"play": "Abspielen",
|
||||||
"resume": "Fortsetzen",
|
"resume": "Fortsetzen",
|
||||||
"openShow": "Serie öffnen",
|
"openShow": "Serie öffnen",
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"watched": "Watched",
|
"watched": "Watched",
|
||||||
|
"inProgress": "In progress",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
"openShow": "Open show",
|
"openShow": "Open show",
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
||||||
"loadMore": "Több betöltése",
|
"loadMore": "Több betöltése",
|
||||||
"watched": "Megnézve",
|
"watched": "Megnézve",
|
||||||
|
"inProgress": "Folyamatban",
|
||||||
"play": "Lejátszás",
|
"play": "Lejátszás",
|
||||||
"resume": "Folytatás",
|
"resume": "Folytatás",
|
||||||
"openShow": "Sorozat megnyitása",
|
"openShow": "Sorozat megnyitása",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue