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:
npeter83 2026-07-10 21:43:09 +02:00
parent f0bab315ad
commit 569d31235d
9 changed files with 266 additions and 52 deletions

View 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)

View file

@ -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,
), ),

View file

@ -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()}

View file

@ -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 {

View file

@ -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"}`}

View file

@ -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")}>

View file

@ -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",

View file

@ -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",

View file

@ -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",