Compare commits

..
3 Commits
Author SHA1 Message Date
peter 2cf1b688d9 test(plex): a collection's editable flag and the mirror's reads, committed through HTTP
The editable endpoint had no route test: dropping its commit left every test green.
2026-10-19 22:11:57 +02:00
peter 3b0e365e26 refactor(plex): the browse, detail, people and collection pages read through queries and write through the service
The HTTP files browse, detail, people, collections and _shared do no database work of their own
now. New plex/queries.py (the rows a page shows, a user's watch state through userscope),
plex/service.py (the info pages' enrichment, a collection's editable flag, the row kept after a
failed follow-up read, the person bio's background fetch; none commits), plex/library.py (the
unified browse feed, its search and facets) and plex/collections.py (a collection's source,
editability, card, certificate and member order). Every watch-state join in the moved code names
its owner through owned_by.

Pins 354 -> 178 in 4 files; basedpyright baseline 132 -> 118.
2026-10-19 22:11:20 +02:00
peter 533571f572 refactor(plex): the unit's tables move into plex/models
PlexLibrary, PlexShow, PlexSeason, PlexItem, PlexPerson, PlexCollection, PlexState, PlexLink,
PlexPlaylist and PlexPlaylistItem leave app/models.py for app/plex/models.py; app.tables registers
them. Every import is re-pointed; no schema change.
2026-10-19 21:42:07 +02:00
77 changed files with 2140 additions and 1693 deletions
-114
View File
@@ -650,121 +650,7 @@
}
}
],
"./app/routes/plex/browse.py": [
{
"code": "reportArgumentType",
"range": {
"startColumn": 26,
"endColumn": 61,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 26,
"endColumn": 61,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 11,
"endColumn": 13,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 25,
"endColumn": 27,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 11,
"endColumn": 13,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 25,
"endColumn": 27,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 15,
"endColumn": 17,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 28,
"endColumn": 30,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 15,
"endColumn": 17,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 28,
"endColumn": 30,
"lineCount": 1
}
}
],
"./app/routes/plex/collections.py": [
{
"code": "reportAssignmentType",
"range": {
"startColumn": 11,
"endColumn": 39,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 76,
"endColumn": 80,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 75,
"endColumn": 79,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 80,
"endColumn": 84,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
+2 -1
View File
@@ -22,7 +22,8 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.discover import cards, queries
from app.models import PlexItem, PlexShow, User
from app.models import User
from app.plex.models import PlexItem, PlexShow
from app.catalog.models import Channel, Video
from app.reader.models import ComfortPin, Playlist
from app.plex import rollup
+2 -1
View File
@@ -29,7 +29,8 @@ from app.feedquery import (
serialize,
sort_keys,
)
from app.models import PlexItem, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexShow, PlexState
from app.catalog.models import Channel, Video
from app.reader.models import PlaylistItem, VideoState
from app.plex import rollup
+2 -1
View File
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session
from app import sysconfig
from app.discover import cards, queries
from app.discover.queries import RAIL_LIMIT
from app.models import PlexShow, User
from app.models import User
from app.plex.models import PlexShow
from app.plex.lookups import by_rating_key
from app.plex.rollup import NO_TS
+2 -1
View File
@@ -28,7 +28,8 @@ from app.downloads.jobdb import (
)
from app.downloads.stated import StatedFailure
from app.downloads.status import JobStatus
from app.models import DISPLAY_TEXT_MAX, PlexItem
from app.models import DISPLAY_TEXT_MAX
from app.plex.models import PlexItem
from app.downloads.models import DownloadJob, MediaAsset
from app.plex import lookups as plex_lookups
from app.plex import paths as plex_paths
+2 -1
View File
@@ -24,7 +24,8 @@ from app.downloads import credentials
from app.downloads import edit as editmod
from app.downloads import folders, formats, mediakind, quota, storage
from app.downloads.status import HOLDING, JobKind, JobStatus
from app.models import DISPLAY_TEXT_MAX, PlexItem, PlexShow, User
from app.models import DISPLAY_TEXT_MAX, User
from app.plex.models import PlexItem, PlexShow
from app.catalog.models import Channel, Video
from app.downloads.models import (
DownloadJob,
+1 -439
View File
@@ -5,10 +5,8 @@ from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Computed,
Date,
DateTime,
Float,
ForeignKey,
Index,
Integer,
@@ -20,7 +18,7 @@ from sqlalchemy import (
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base
@@ -514,439 +512,3 @@ class ApiTokenGrant(Base):
can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# --- Plex integration (optional) -------------------------------------------------------------
# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is
# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states
# so it works for users without a Plex account. Mirrors the app's "shared catalog, private
# per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as
# `videos` (migrations 0031/0032).
class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
"""A mirrored Plex library section. Only `enabled` sections are exposed in the feed.
`plex_key` is the section id on the Plex server."""
__tablename__ = "plex_libraries"
id: Mapped[int] = mapped_column(primary_key=True)
plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
title: Mapped[str] = mapped_column(String(255))
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
overlay_of: Mapped[int | None] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True
)
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
"""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"
__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"),
Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"),
# ix_plex_shows_collections_gin already created with collections (migration 0047).
)
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
library_id: Mapped[int] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
)
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
# twin during sync. Indexed for the guid->row overlay match.
guid: Mapped[str | None] = mapped_column(String(64), index=True)
title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text)
year: Mapped[int | None] = mapped_column(Integer)
thumb_key: Mapped[str | None] = mapped_column(
String(512)
) # Plex thumb path (proxied, not stored)
art_key: Mapped[str | None] = mapped_column(String(512))
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
collection_keys: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # Plex collections this show is in
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
# none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python
# None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract
# elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items.
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
people_text: Mapped[str | None] = mapped_column(
Text
) # cast+directors, folded into search_vector
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive;
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched,
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"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')",
persisted=True,
),
)
search_vector_hu: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
persisted=True,
),
)
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
"""A season under a show — used to order episodes for prev/next stepping."""
__tablename__ = "plex_seasons"
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
show_id: Mapped[int] = mapped_column(
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
)
title: Mapped[str | None] = mapped_column(String(255))
season_number: Mapped[int | None] = mapped_column(Integer, index=True)
thumb_key: Mapped[str | None] = mapped_column(String(512))
class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
"""A playable LEAF — a movie or a single episode. This is what the feed lists and the player
plays. Movies carry `library_id` and no parents; episodes carry show/season refs +
season/episode numbers. `file_path` is the Plex-side absolute path of the physical media,
mapped through `plex_path_map` to a local mount at playback time. `playable` is the
ffprobe-derived browser-compatibility class (direct | remux | transcode)."""
__tablename__ = "plex_items"
__table_args__ = (
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"),
)
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
library_id: Mapped[int] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
)
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
# sync; indexed for the guid->row match. Episodes carry it too but it's unused for them.
guid: Mapped[str | None] = mapped_column(String(64), index=True)
show_id: Mapped[int | None] = mapped_column(
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
)
season_id: Mapped[int | None] = mapped_column(
ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True
)
season_number: Mapped[int | None] = mapped_column(Integer)
episode_number: Mapped[int | None] = mapped_column(Integer)
title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text)
year: Mapped[int | None] = mapped_column(Integer)
duration_s: Mapped[int | None] = mapped_column(Integer)
thumb_key: Mapped[str | None] = mapped_column(String(512))
art_key: Mapped[str | None] = mapped_column(String(512))
cast: Mapped[list | None] = mapped_column(JSON) # [role names]
# Physical media (from the Plex Part).
file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path
part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch)
container: Mapped[str | None] = mapped_column(String(16))
codec_video: Mapped[str | None] = mapped_column(String(32))
codec_audio: Mapped[str | None] = mapped_column(String(32))
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
playable: Mapped[str | None] = mapped_column(String(12), index=True)
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
# filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
rating: Mapped[float | None] = mapped_column(Float, index=True)
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
studio: Mapped[str | None] = mapped_column(String(255), index=True)
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
# finds titles by a person's name (and ranks them above summary-only mentions).
people_text: Mapped[str | None] = mapped_column(Text)
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB backdrop file_paths [str] (crossfade pool)
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"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')",
persisted=True,
),
)
search_vector_hu: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
persisted=True,
),
)
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio
(the headshot FILE lives in the image cache, filled by the people_enrich job)."""
__tablename__ = "plex_people"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
# TMDB person id, linked by name from a title's TMDB credits (the /person
# biography fetch builds on it). NULL until a TMDB-enriched title names this person.
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
# --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job,
# or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
biography: Mapped[str | None] = mapped_column(Text)
# TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and
# keywords have no Hungarian on TMDB, so they stay in the instance language.
biography_hu: Mapped[str | None] = mapped_column(Text)
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
deathday: Mapped[str | None] = mapped_column(String(10))
place_of_birth: Mapped[str | None] = mapped_column(Text)
known_for_department: Mapped[str | None] = mapped_column(String(64))
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
known_for: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{tmdb_id, media_type, title}]
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
`editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
__tablename__ = "plex_collections"
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
library_id: Mapped[int] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
)
title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text)
thumb_key: Mapped[str | None] = mapped_column(String(512))
child_count: Mapped[int | None] = mapped_column(Integer)
smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class PlexState(Base, UpdatedAtMixin):
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
`synced_to_plex` marks whether this state was pushed back to a linked Plex account."""
__tablename__ = "plex_states"
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
item_id: Mapped[int] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
)
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
position_seconds: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
class PlexLink(Base, UpdatedAtMixin):
"""Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user).
For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`,
`plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes
the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to
filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link
their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the
sync loop then iterates link rows uniformly.
`initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the
high-water mark for the incremental Plex→Siftlode pass."""
__tablename__ = "plex_link"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
)
uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted)
plex_account_id: Mapped[int | None] = mapped_column(Integer)
plex_username: Mapped[str | None] = mapped_column(String(255))
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
initial_import_done: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later
phase (`plex_rating_key` set once pushed back to the owner's Plex account)."""
__tablename__ = "plex_playlists"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(512))
plex_rating_key: Mapped[str | None] = mapped_column(String(32))
class PlexPlaylistItem(Base):
"""One ordered entry in a playlist. `position` (0-based) orders playback."""
__tablename__ = "plex_playlist_items"
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
item_id: Mapped[int] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
+1 -1
View File
@@ -5,7 +5,7 @@ router. Pure functions — no DB, no request state."""
from typing import NamedTuple
from app.models import PlexItem, PlexShow, PlexState
from app.plex.models import PlexItem, PlexShow, PlexState
class ShowState(NamedTuple):
+122
View File
@@ -0,0 +1,122 @@
"""What a collection is to the app: where it comes from, whether it may be edited, its card, its
effective content rating, and how its members order — shared by the collection pages, the info
page's collection strips and the admin editor."""
from collections.abc import Callable
from datetime import date, datetime
from typing import Any, NamedTuple
from app.plex.models import PlexCollection
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it).
_CERT_RANK = {
"G": 1,
"TV-Y": 1,
"TV-Y7": 1,
"TV-G": 1,
"PG": 2,
"TV-PG": 2,
"PG-13": 3,
"TV-14": 3,
"R": 4,
"TV-MA": 4,
"NC-17": 5,
}
def collection_cert(certs: list[str | None]) -> str | None:
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
return max(ranked, key=lambda x: x[1])[0] if ranked else None
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
def collection_source(col: PlexCollection) -> str:
first = (col.title or "").strip().split(" ", 1)[0].lower()
if first in _STRIP_PROVIDERS:
return first
if col.smart:
return "smart"
return "collection"
def collection_editable(c: PlexCollection) -> bool:
"""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."""
return bool(c.editable) and not c.smart and collection_source(c) == "collection"
def collection_card(c: PlexCollection) -> dict:
return {
"id": c.rating_key,
"title": c.title,
"summary": c.summary,
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
"child_count": c.child_count,
"smart": c.smart,
"source": collection_source(c),
"editable": c.editable,
"can_edit": collection_editable(
c
), # whether the edit controls should be offered (admin UI)
}
class Member(NamedTuple):
"""One collection member flattened to everything the Sort control can order by. The page's two
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
ordering is a pure list sort over these, which is also what makes it unit-testable."""
kind: str # "movie" | "show"
obj: Any # PlexItem | PlexShow, carried through to the card serializer
title: str # lower-cased; never NULL (the column is not nullable)
year: int | None
rating: float | None
added: datetime | None
release: date | None
duration: int | None # movies only — a show has no single runtime
last_watched: datetime | None
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
# that silently does nothing here.
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
"added": lambda m: m.added,
"lastwatched": lambda m: m.last_watched,
"release": lambda m: m.release,
"year": lambda m: m.year,
"rating": lambda m: m.rating,
"duration": lambda m: m.duration,
"title": lambda m: m.title,
}
# What the page ordered by before it had a control, and still its default: a film series wants to be
# read in release order, not by whenever Plex happened to ingest it.
DEFAULT_MEMBER_SORT = "year"
def sort_members(
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
) -> list[Member]:
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
compare as Python strings, which puts every accented initial after "z"."""
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
if key is MEMBER_SORT_KEYS["title"]:
key = by_title
ordered = sorted(members, key=by_title) # stable secondary, applied first
have = [m for m in ordered if key(m) is not None]
missing = [m for m in ordered if key(m) is None]
have.sort(key=key, reverse=sort_dir != "asc")
return have + missing
+5 -4
View File
@@ -1,10 +1,11 @@
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
the detail endpoints serve from the DB row.
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
Shared by the live info-page path (`plex.service.enrich_movie` / `enrich_show`) and the background
rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s
cast handling consistent. `rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY
fields onto a movie/show row (genres/directors/studio/content_rating already land from the cheap
listing at `_apply_facet_fields`)."""
from app.plex import people
from app.plex.plaintext import plain_text
+1 -1
View File
@@ -15,7 +15,7 @@ from fastapi import Response
from sqlalchemy.orm import Session
from app import logs
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.cache import CACHE_DIR
from app.plex.lookups import by_rating_key
+473
View File
@@ -0,0 +1,473 @@
"""The Plex browse grid's reads: the unified movies + shows feed, its search, and its facets.
A user's watch state is joined through `userscope`; the feed and its facets share every filter
(`plex.query`), so a facet count is the size of what the grid would show.
"""
from sqlalchemy import Boolean, Integer, String, and_, cast, func, literal, null, or_, select
from sqlalchemy.orm import Session, aliased
from app.collation import collated, sorted_words
from app.models import User
from app.plex import rollup
from app.plex.cards import norm_lang
from app.plex.models import PlexItem, PlexShow, PlexState
from app.plex.query import (
FIELD_COLUMNS,
PLAYBACK_CLASSES,
TS_CONFIG,
LibraryFilters,
apply_meta_filters,
apply_movie_state_filter,
apply_show_state_filter,
content_libs,
field_recheck,
genres_col,
meta_filter_conds,
movie_status_case,
playable_conds,
playback_class_expr,
show_status_case,
split_csv,
summary_col,
title_col,
vector_col,
vector_match,
)
from app.userscope import owned_by
from app.utils import to_tsquery_str
def sort_column(u, sort: str, lang: str = "en"):
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
in the reader's alphabet (`collation`)."""
return {
"added": u.c.added_at,
"title": collated(u.c.title, lang),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
"lastwatched": u.c.last_watched,
}.get(sort, u.c.added_at)
def library_page(
db: Session,
user: User,
*,
scope: str,
q: str | None,
field: str,
sort: str,
sort_dir: str,
show: str,
lang: str,
offset: int,
limit: int,
f: LibraryFilters,
) -> tuple[int, list, list]:
"""One page of the unified cross-library browse: (total, rows, episodes). `rows` mixes movies
and shows (`kind` says which), scoped to movie|show|both, with the shared filters/search/watch
state (a show's state is the aggregate of its episodes). On a search that also matches episodes
(and shows are in scope), `episodes` holds the matching ones as (item, state, show) — the
grouped 'Episodes' section. `lang` is already normalised."""
p = f.as_dict()
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
ts = to_tsquery_str(q) if q else None
tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None
if field not in FIELD_COLUMNS and field != "all":
field = "all"
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
grid_field = field != "episode"
recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None
selects = []
if want_movies and grid_field:
st = aliased(PlexState)
m_status = movie_status_case(st)
mq = (
select(
PlexItem.rating_key.label("rk"),
literal("movie").label("kind"),
title_col(PlexItem, lang).label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
func.left(summary_col(PlexItem, lang), 300).label("summary"),
PlexItem.added_at.label("added_at"),
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
PlexItem.transcode_hw.label("transcode_hw"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
literal(False).label("resumable"),
# "last watched" sort key: when the movie was last resumed, else when it was marked
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
(
func.ts_rank(vector_col(PlexItem, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
)
.outerjoin(st, and_(st.item_id == PlexItem.id, owned_by(st.user_id, user)))
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
)
mq = apply_movie_state_filter(mq, st, show)
mq = apply_meta_filters(mq, PlexItem, p, lang)
if f.duration_min is not None:
mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
# Playback-class filter (movie-only — a show is an aggregate with no single class).
mq = mq.where(*playable_conds(split_csv(f.playable)))
if tsq is not None:
mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None and (narrow := recheck(PlexItem, field, tsq, lang)) is not None:
mq = mq.where(narrow)
selects.append(mq)
if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
s_status = show_status_case(tot, wat, inp)
sq = (
select(
PlexShow.rating_key.label("rk"),
literal("show").label("kind"),
title_col(PlexShow, lang).label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
func.left(summary_col(PlexShow, lang), 300).label("summary"),
PlexShow.added_at.label("added_at"),
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
cast(null(), Boolean).label("transcode_hw"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
(inp > 0).label("resumable"),
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
(
func.ts_rank(vector_col(PlexShow, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
)
.outerjoin(agg, agg.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_lib_ids))
)
sq = apply_show_state_filter(sq, tot, wat, inp, show)
sq = apply_meta_filters(sq, PlexShow, p, lang)
if tsq is not None:
sq = sq.where(vector_match(PlexShow, lang, tsq))
if recheck is not None and (narrow := recheck(PlexShow, field, tsq, lang)) is not None:
sq = sq.where(narrow)
selects.append(sq)
total = 0
rows: list = []
if selects:
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
u = union.subquery()
total = db.scalar(select(func.count()).select_from(u)) or 0
if tsq is not None:
order = [u.c.rank.desc()]
else:
col = sort_column(u, sort, lang)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())
rows = list(db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all())
# Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour)
# and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes
# target the movie/show grid, so they suppress episodes.
eps: list = []
if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState)
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
# still by its English one, like `vector_match` does for the grid.
if field == "episode":
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
else:
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
hit = ep_vec.op("@@")(tsq)
if lang == "hu":
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
ep_vec = ep_vec_hu
eps = list(
db.execute(
select(PlexItem, ep_st, PlexShow)
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, owned_by(ep_st.user_id, user)))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.where(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids),
hit,
)
.order_by(func.ts_rank(ep_vec, tsq).desc())
.limit(24)
).all()
)
return total, rows, eps
def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: LibraryFilters) -> dict:
"""Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
filters (faceted search): each group's values/bounds are computed with all the OTHER active
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
movie-only. (Search text is not factored in — facets track the sidebar filters.) `lang` is
normalised here, after the empty answer."""
empty = {
"genres": [],
"content_ratings": [],
"studios": [],
"playable": [],
"year_min": None,
"year_max": None,
"rating_max": None,
"duration_min": None,
"duration_max": None,
}
all_movie_ids, all_show_ids = content_libs(db)
movie_ids = all_movie_ids if scope in ("movie", "both") else []
show_ids = all_show_ids if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
lang = norm_lang(lang)
p = f.as_dict()
uid = user.id
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
# The same filter on the same rows, so the counts do not change
# (test_plex_facets_grid_parity).
shows_in_scope: list[int] | None = None
if show_ids and show in ("watched", "in_progress", "unwatched"):
agg = rollup.agg_subquery(db, uid).subquery()
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
in_scope = apply_show_state_filter(
in_scope,
func.coalesce(agg.c.total, 0),
func.coalesce(agg.c.watched, 0),
func.coalesce(agg.c.inprog, 0),
show,
)
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
def wsq(query, model, ids, pp, kind_movie, with_dur):
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
facet aggregation query, so the facets reflect exactly the visible result set."""
query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
if kind_movie:
query = query.where(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
query = query.where(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
query = query.where(model.duration_s <= pp["duration_max"])
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
st = aliased(PlexState)
query = query.outerjoin(st, and_(st.item_id == model.id, owned_by(st.user_id, uid)))
query = apply_movie_state_filter(query, st, show)
elif shows_in_scope is not None:
query = query.where(model.id.in_(shows_in_scope))
return query
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
def per_table(model, ids, kind_movie: bool) -> None:
if not ids:
return
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
# every matching title carries them, so they stay togglable.)
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
gcol = genres_col(model, lang)
expanded = (
wsq(
select(func.jsonb_array_elements_text(gcol).label("g")),
model,
ids,
genre_pp,
kind_movie,
True,
)
.where(func.jsonb_typeof(gcol) == "array")
.subquery()
)
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
genre_counts[g] = genre_counts.get(g, 0) + c
# Content ratings — exclude its own.
crq = (
wsq(
select(model.content_rating, func.count()),
model,
ids,
{**p, "content_ratings": None},
kind_movie,
True,
)
.where(model.content_rating.isnot(None))
.group_by(model.content_rating)
)
for cr, c in db.execute(crq).all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
stq = (
wsq(
select(model.studio, func.count()),
model,
ids,
{**p, "studios": None},
kind_movie,
True,
)
.where(model.studio.isnot(None))
.group_by(model.studio)
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
if kind_movie:
pcls = playback_class_expr()
pcq = (
wsq(
select(pcls.label("cls"), func.count()),
model,
ids,
{**p, "playable": None},
kind_movie,
True,
)
.where(pcls.isnot(None))
.group_by(pcls)
)
for cls, c in db.execute(pcq).all():
playable_counts[cls] = playable_counts.get(cls, 0) + c
# Year bounds — exclude the year filter.
by = db.execute(
wsq(
select(func.min(model.year), func.max(model.year)),
model,
ids,
{**p, "year_min": None, "year_max": None},
kind_movie,
True,
)
).one()
if by[0] is not None:
years.append(by[0])
if by[1] is not None:
years.append(by[1])
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
# rating filter and the UI badge use (IMDb preferred, audience fallback).
br = db.scalar(
wsq(
select(func.max(func.coalesce(model.imdb_rating, model.rating))),
model,
ids,
{**p, "rating_min": None},
kind_movie,
True,
)
)
if br is not None:
ratings.append(float(br))
# Duration bounds (movies) — exclude the duration filter.
if kind_movie:
bd = db.execute(
wsq(
select(func.min(model.duration_s), func.max(model.duration_s)),
model,
ids,
p,
kind_movie,
False,
)
).one()
if bd[0] is not None:
durs.append(bd[0])
if bd[1] is not None:
durs.append(bd[1])
per_table(PlexItem, movie_ids, True)
per_table(PlexShow, show_ids, False)
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in split_csv(f.genres):
genre_counts.setdefault(g, 0)
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0)
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
return {
"genres": [
{"value": g, "count": c}
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
],
"content_ratings": [
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
],
"studios": [
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
],
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
"playable": [
{"value": c, "count": playable_counts[c]}
for c in PLAYBACK_CLASSES
if c in playable_counts
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
"duration_min": min(durs) if durs else None,
"duration_max": max(durs) if durs else None,
}
+463
View File
@@ -0,0 +1,463 @@
"""Plex's tables: a self-contained catalog mirrored from a local Plex server — libraries, shows,
seasons, items, people, collections, the server's playlists — and each user's private part of it:
watch state, the account link, their own playlists. Only this unit writes them; another unit reads
them along a declared edge, or asks this unit's service."""
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
Computed,
Date,
DateTime,
Float,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
from app.models import TimestampMixin, UpdatedAtMixin
# --- Plex integration (optional) -------------------------------------------------------------
# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is
# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states
# so it works for users without a Plex account. Mirrors the app's "shared catalog, private
# per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as
# `videos` (migrations 0031/0032).
class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
"""A mirrored Plex library section. Only `enabled` sections are exposed in the feed.
`plex_key` is the section id on the Plex server."""
__tablename__ = "plex_libraries"
id: Mapped[int] = mapped_column(primary_key=True)
plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
title: Mapped[str] = mapped_column(String(255))
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
overlay_of: Mapped[int | None] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True
)
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
"""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"
__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"),
Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"),
# ix_plex_shows_collections_gin already created with collections (migration 0047).
)
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
library_id: Mapped[int] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
)
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
# twin during sync. Indexed for the guid->row overlay match.
guid: Mapped[str | None] = mapped_column(String(64), index=True)
title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text)
year: Mapped[int | None] = mapped_column(Integer)
thumb_key: Mapped[str | None] = mapped_column(
String(512)
) # Plex thumb path (proxied, not stored)
art_key: Mapped[str | None] = mapped_column(String(512))
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
collection_keys: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # Plex collections this show is in
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
# none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python
# None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract
# elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items.
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
people_text: Mapped[str | None] = mapped_column(
Text
) # cast+directors, folded into search_vector
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive;
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched,
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"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')",
persisted=True,
),
)
search_vector_hu: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
persisted=True,
),
)
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
"""A season under a show — used to order episodes for prev/next stepping."""
__tablename__ = "plex_seasons"
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
show_id: Mapped[int] = mapped_column(
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
)
title: Mapped[str | None] = mapped_column(String(255))
season_number: Mapped[int | None] = mapped_column(Integer, index=True)
thumb_key: Mapped[str | None] = mapped_column(String(512))
class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
"""A playable LEAF — a movie or a single episode. This is what the feed lists and the player
plays. Movies carry `library_id` and no parents; episodes carry show/season refs +
season/episode numbers. `file_path` is the Plex-side absolute path of the physical media,
mapped through `plex_path_map` to a local mount at playback time. `playable` is the
ffprobe-derived browser-compatibility class (direct | remux | transcode)."""
__tablename__ = "plex_items"
__table_args__ = (
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"),
)
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
library_id: Mapped[int] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
)
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
# sync; indexed for the guid->row match. Episodes carry it too but it's unused for them.
guid: Mapped[str | None] = mapped_column(String(64), index=True)
show_id: Mapped[int | None] = mapped_column(
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
)
season_id: Mapped[int | None] = mapped_column(
ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True
)
season_number: Mapped[int | None] = mapped_column(Integer)
episode_number: Mapped[int | None] = mapped_column(Integer)
title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text)
year: Mapped[int | None] = mapped_column(Integer)
duration_s: Mapped[int | None] = mapped_column(Integer)
thumb_key: Mapped[str | None] = mapped_column(String(512))
art_key: Mapped[str | None] = mapped_column(String(512))
cast: Mapped[list | None] = mapped_column(JSON) # [role names]
# Physical media (from the Plex Part).
file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path
part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch)
container: Mapped[str | None] = mapped_column(String(16))
codec_video: Mapped[str | None] = mapped_column(String(32))
codec_audio: Mapped[str | None] = mapped_column(String(32))
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
playable: Mapped[str | None] = mapped_column(String(12), index=True)
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
# filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
rating: Mapped[float | None] = mapped_column(Float, index=True)
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
studio: Mapped[str | None] = mapped_column(String(255), index=True)
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
# finds titles by a person's name (and ranks them above summary-only mentions).
people_text: Mapped[str | None] = mapped_column(Text)
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB backdrop file_paths [str] (crossfade pool)
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"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')",
persisted=True,
),
)
search_vector_hu: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
persisted=True,
),
)
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio
(the headshot FILE lives in the image cache, filled by the people_enrich job)."""
__tablename__ = "plex_people"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
# TMDB person id, linked by name from a title's TMDB credits (the /person
# biography fetch builds on it). NULL until a TMDB-enriched title names this person.
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
# --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job,
# or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
biography: Mapped[str | None] = mapped_column(Text)
# TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and
# keywords have no Hungarian on TMDB, so they stay in the instance language.
biography_hu: Mapped[str | None] = mapped_column(Text)
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
deathday: Mapped[str | None] = mapped_column(String(10))
place_of_birth: Mapped[str | None] = mapped_column(Text)
known_for_department: Mapped[str | None] = mapped_column(String(64))
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
known_for: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{tmdb_id, media_type, title}]
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
`editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
__tablename__ = "plex_collections"
id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
library_id: Mapped[int] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
)
title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text)
thumb_key: Mapped[str | None] = mapped_column(String(512))
child_count: Mapped[int | None] = mapped_column(Integer)
smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class PlexState(Base, UpdatedAtMixin):
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
`synced_to_plex` marks whether this state was pushed back to a linked Plex account."""
__tablename__ = "plex_states"
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
item_id: Mapped[int] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
)
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
position_seconds: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
class PlexLink(Base, UpdatedAtMixin):
"""Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user).
For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`,
`plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes
the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to
filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link
their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the
sync loop then iterates link rows uniformly.
`initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the
high-water mark for the incremental Plex→Siftlode pass."""
__tablename__ = "plex_link"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
)
uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted)
plex_account_id: Mapped[int | None] = mapped_column(Integer)
plex_username: Mapped[str | None] = mapped_column(String(255))
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
initial_import_done: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later
phase (`plex_rating_key` set once pushed back to the owner's Plex account)."""
__tablename__ = "plex_playlists"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(512))
plex_rating_key: Mapped[str | None] = mapped_column(String(32))
class PlexPlaylistItem(Base):
"""One ordered entry in a playlist. `position` (0-based) orders playback."""
__tablename__ = "plex_playlist_items"
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
item_id: Mapped[int] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
+1 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex.models import PlexItem, PlexLibrary, PlexShow
from app.plex import sync as plex_sync
from app.plex.client import PlexClient
from app.plex.genres import hu_genres
+1 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.models import PlexPerson
from app.plex.models import PlexPerson
from app.plex import imgcache
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
+382
View File
@@ -0,0 +1,382 @@
"""Plex's reads for the HTTP layer: the mirrored rows a page shows, and a user's watch state on them.
A user's own rows (`PlexState`) are read through `userscope`, so "someone else's row" is the same
answer as a missing one. A rating key with a NUL in it names no row (`lookups.by_rating_key`).
"""
from typing import Literal
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.collation import collated
from app.models import User
from app.plex import rollup
from app.plex.lookups import by_rating_key
from app.plex.models import (
PlexCollection,
PlexItem,
PlexLibrary,
PlexPerson,
PlexSeason,
PlexShow,
PlexState,
)
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
from app.userscope import owned_by
from app.utils import like_escape
# --- one row ---------------------------------------------------------------------------------
def item(db: Session, rating_key: str) -> PlexItem | None:
return by_rating_key(db, PlexItem, rating_key)
def movie(db: Session, rating_key: str) -> PlexItem | None:
"""The movie with this key — an episode's key is not one."""
it = item(db, rating_key)
return it if it is not None and it.kind == "movie" else None
def show(db: Session, rating_key: str) -> PlexShow | None:
return by_rating_key(db, PlexShow, rating_key)
def collection(db: Session, rating_key: str) -> PlexCollection | None:
return by_rating_key(db, PlexCollection, rating_key)
def show_by_id(db: Session, show_id: int) -> PlexShow | None:
return db.get(PlexShow, show_id)
def season_by_id(db: Session, season_id: int) -> PlexSeason | None:
return db.get(PlexSeason, season_id)
def library(db: Session, library_id: int) -> PlexLibrary | None:
return db.get(PlexLibrary, library_id)
def library_by_key(db: Session, plex_key: str) -> PlexLibrary | None:
return (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(plex_key)))
.scalars()
.first()
)
def library_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
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
def person(db: Session, name: str) -> PlexPerson | None:
return db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
# --- a user's watch state --------------------------------------------------------------------
def state(db: Session, user: User | int, it: PlexItem) -> PlexState | None:
return (
db.execute(
select(PlexState).where(owned_by(PlexState.user_id, user), PlexState.item_id == it.id)
)
.scalars()
.first()
)
def states(db: Session, user: User | int, item_ids: list[int]) -> dict[int, PlexState]:
"""The user's state on each of these items that has one, by item id."""
if not item_ids:
return {}
return {
s.item_id: s
for s in db.execute(
select(PlexState).where(
owned_by(PlexState.user_id, user), PlexState.item_id.in_(item_ids)
)
).scalars()
}
# --- a show ----------------------------------------------------------------------------------
def seasons(db: Session, sh: PlexShow) -> list[PlexSeason]:
return list(
db.execute(
select(PlexSeason)
.where(PlexSeason.show_id == sh.id)
.order_by(PlexSeason.season_number.asc().nullsfirst())
).scalars()
)
def episodes(db: Session, sh: PlexShow) -> list[PlexItem]:
"""A show's episodes in season/episode order."""
return list(
db.execute(
select(PlexItem)
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
def episode_keys(db: Session, show_id: int) -> list[str]:
"""The rating keys of a show's episodes, in the order `episodes` gives."""
return list(
db.execute(
select(PlexItem.rating_key)
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
def shows_by_keys(db: Session, rating_keys: list[str], exclude: int) -> dict[str, PlexShow]:
"""The mirrored shows among these keys, but `exclude`, by key."""
return {
s.rating_key: s
for s in db.execute(
select(PlexShow).where(PlexShow.rating_key.in_(rating_keys), PlexShow.id != exclude)
).scalars()
}
# --- libraries -------------------------------------------------------------------------------
def browsable_libraries(db: Session, lang: str) -> list[tuple[PlexLibrary, int]]:
"""The mirrored libraries a user can browse, each with its title count (movies, or shows). An
HU overlay library is left out: it is never browsed, it only feeds its EN twin's Hungarian
metadata."""
libs = db.execute(
select(PlexLibrary)
.where(PlexLibrary.overlay_of.is_(None))
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang))
).scalars()
out = []
for lib in libs:
if lib.kind == "movie":
count = db.scalar(
select(func.count(PlexItem.id)).where(
PlexItem.library_id == lib.id, PlexItem.kind == "movie"
)
)
else:
count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id))
out.append((lib, count or 0))
return out
# --- people ----------------------------------------------------------------------------------
def person_photos(db: Session, names: list[str]) -> dict[str, str | None]:
return {
p.name: p.photo_thumb
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars()
}
def cast_count(db: Session, name: str, movie_lib_ids: list[int], show_lib_ids: list[int]) -> int:
"""How many movies (in `movie_lib_ids`) and shows (in `show_lib_ids`) name this person in their
cast. An empty id list leaves its side out."""
count = 0
if movie_lib_ids:
count += (
db.scalar(
select(func.count(PlexItem.id)).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
PlexItem.cast_names.contains([name]),
)
)
or 0
)
if show_lib_ids:
count += (
db.scalar(
select(func.count(PlexShow.id)).where(
PlexShow.library_id.in_(show_lib_ids),
PlexShow.cast_names.contains([name]),
)
)
or 0
)
return count
def filmography(
db: Session,
kind: Literal["movie", "show"],
name: str,
lib_ids: list[int],
lang: str,
limit: int,
) -> list:
"""The movies or shows in these libraries whose cast or directors name this person, newest
first."""
model: type[PlexItem] | type[PlexShow] = PlexItem if kind == "movie" else PlexShow
query = select(model).where(
model.library_id.in_(lib_ids),
or_(model.cast_names.contains([name]), model.directors.contains([name])),
)
if model is PlexItem:
query = query.where(PlexItem.kind == "movie")
return list(
db.execute(
query.order_by(
model.year.desc().nullslast(), collated(title_col(model, lang), lang)
).limit(limit)
).scalars()
)
# --- collections -----------------------------------------------------------------------------
def collections_page(
db: Session, library: str | None, q: str | None, keys: list[str] | None, lang: str
) -> list[PlexCollection]:
"""Collections, most-populated first: one library's (by its plex key), else every enabled
library's; name-filtered by `q`; narrowed to `keys` when given (an empty list asks for none)."""
if library is not None:
lib = library_by_key(db, library)
if lib is None:
return []
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
else:
enabled_ids = list(
db.execute(
select(PlexLibrary.id).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
).scalars()
)
if not enabled_ids:
return []
query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids))
term = (q or "").strip()
if term:
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
if keys is not None:
# An explicit but empty `keys=` asks for nothing, which is not the same as no `keys` (every
# collection): answered here without a query. The empty IN () would also match nothing.
if not keys:
return []
query = query.where(PlexCollection.rating_key.in_(keys))
return list(
db.execute(
query.order_by(
PlexCollection.child_count.desc().nullslast(), collated(PlexCollection.title, lang)
)
).scalars()
)
def library_collections(db: Session, library_id: int, lang: str) -> list[PlexCollection]:
return list(
db.execute(
select(PlexCollection)
.where(PlexCollection.library_id == library_id)
.order_by(collated(PlexCollection.title, lang))
).scalars()
)
def collections_by_keys(db: Session, keys: list[str]) -> list[PlexCollection]:
return list(
db.execute(select(PlexCollection).where(PlexCollection.rating_key.in_(keys))).scalars()
)
def collection_named(db: Session, library_id: int, title: str) -> PlexCollection | None:
"""A collection of this library with this name, case aside."""
return (
db.execute(
select(PlexCollection).where(
PlexCollection.library_id == library_id,
func.lower(PlexCollection.title) == title.lower(),
)
)
.scalars()
.first()
)
def collection_members(
db: Session, rating_key: str, lang: str
) -> tuple[list[PlexItem], list[PlexShow]]:
"""A collection's movies and shows, each by year then title."""
movies = db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
).scalars()
shows = db.execute(
select(PlexShow)
.where(PlexShow.collection_keys.contains([rating_key]))
.order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang))
).scalars()
return list(movies), list(shows)
def library_movies_in(
db: Session, library_id: int, rating_key: str, lang: str, limit: int
) -> list[PlexItem]:
"""The movies of one library in this collection, by year then title."""
return list(
db.execute(
select(PlexItem)
.where(
PlexItem.library_id == library_id,
PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rating_key]),
)
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
.limit(limit)
).scalars()
)
def show_rollups(db: Session, user_id: int, show_ids: list[int]) -> list:
"""The user's episode rollup (total, watched, in progress, last resumed) of each of these shows
that has episodes."""
if not show_ids:
return []
return list(
db.execute(
rollup.agg_subquery(db, user_id, include_resumed=True).where(
PlexItem.show_id.in_(show_ids)
)
).all()
)
def collection_tiles(
db: Session, keys: list[str]
) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, int]]:
"""What each collection's tile shows: the top two movie members' keys, the top two show members'
keys (a collection lives in one library, so exactly one side has any), and its movies' total
runtime (a show has no single duration)."""
movie_ids, show_ids = content_libs(db)
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
return art_m, art_s, runtime
+2 -2
View File
@@ -10,7 +10,7 @@ from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session
from app.models import PlexItem, PlexLibrary
from app.plex.models import PlexItem, PlexLibrary
from app.plex.genres import hu_genre
TS_CONFIG = "public.unaccent_simple"
@@ -236,7 +236,7 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
# Public — it is the shared source of truth imported by plex.library (facet order + zero-fill).
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
+1 -1
View File
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.models import PlexItem, PlexState
from app.plex.models import PlexItem, PlexState
from app.plex.cards import ShowState
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
+81
View File
@@ -0,0 +1,81 @@
"""Plex's writes for the HTTP layer, one function per use case. None of them commits: the handler
does, so a page that fails to save an enrichment still renders, and a Plex write and its local
mirror land together.
The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's targeted re-sync
after a write-back, its local cleanup after a delete.
"""
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app import logs
from app.db import SessionLocal
from app.plex import detail as plex_detail
from app.plex import people as plex_people
from app.plex import tmdb as plex_tmdb
from app.plex.lookups import by_rating_key
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexPerson, PlexShow
log = logs.get_logger(__name__)
def enrich_show(db: Session, sh: PlexShow, meta: dict) -> None:
"""Fill a not-yet-enriched show from its live metadata: the full cast (filterable), IMDb and
tagline, stamped so later views serve from the row."""
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
plex_detail.apply_rich_detail(sh, meta)
sh.people_enriched_at = datetime.now(timezone.utc)
def enrich_movie(db: Session, it: PlexItem, meta: dict) -> None:
"""The same for a movie. The listing sync stored only its top ~3 cast; the live metadata
carries all of them."""
cast = plex_people.cast_from_meta(meta)
if cast:
plex_people.enrich_row_people(db, it, cast)
plex_detail.apply_rich_detail(it, meta)
it.people_enriched_at = datetime.now(timezone.utc)
def set_collection_editable(col: PlexCollection, editable: bool) -> None:
col.editable = editable
def keep_created_collection(
db: Session, lib: PlexLibrary, rating_key: str, title: str, seed: str
) -> None:
"""A collection Plex made, whose follow-up read failed: its row is kept, editable. The full sync
preserves `editable` but creates a missing row read-only, so the collection would drop out of
every "add to" list; and the duplicate-name check reads these rows, so a retry of the same name
would make a second one on Plex. Plex made it with exactly the seed in it, so the seed's row says
so too — otherwise the title showed unticked in the collection it was just added to, and the
collection empty, until that sync."""
db.add(
PlexCollection(
rating_key=rating_key, library_id=lib.id, title=title, editable=True, child_count=1
)
)
Model = PlexItem if lib.kind == "movie" else PlexShow
if row := by_rating_key(db, Model, seed):
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rating_key]))
db.flush()
def enrich_person_bg(person_id: int) -> None:
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
next open, and the bio lands from the row by then."""
db = SessionLocal()
try:
person = db.get(PlexPerson, person_id)
if person is not None:
plex_tmdb.enrich_person(db, person)
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
db.rollback()
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
finally:
db.close()
+1 -1
View File
@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session as DbSession
from app import logs, sysconfig
from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.models import PlexItem
from app.plex.models import PlexItem
from app.plex import paths
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
+1 -1
View File
@@ -47,7 +47,7 @@ from sqlalchemy.orm import Session
from app import logs
from app.db import SessionLocal
from app.models import PlexItem
from app.plex.models import PlexItem
from app.plex import paths as plex_paths
from app.plex.cache import CACHE_DIR
from app.plex.client import PlexClient, PlexError
+1 -1
View File
@@ -21,7 +21,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex import detail, overlay, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.genres import hu_genres
+1 -1
View File
@@ -22,7 +22,7 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.config import settings
from app.models import PlexItem, PlexPerson, PlexShow
from app.plex.models import PlexItem, PlexPerson, PlexShow
from app.plex.cards import display_title
from app.plex.people import people_by_credits, warm_portraits
+1 -1
View File
@@ -29,7 +29,7 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.db import SessionLocal
from app.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.sync import (
_TYPE_EPISODE,
+8 -4
View File
@@ -1,18 +1,22 @@
"""Shared helpers, serializers and constants for the plex route package."""
from typing import TYPE_CHECKING
from sqlalchemy.orm import Session
from app import logs
from app.models import PlexItem
from app.plex import queries
from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name
from app.plex.lookups import by_rating_key
from app.routes._common import not_found
if TYPE_CHECKING:
from app.plex.models import PlexItem
log = logs.get_logger(__name__)
def item_or_404(db: Session, rating_key: str) -> PlexItem:
it = by_rating_key(db, PlexItem, rating_key)
def item_or_404(db: Session, rating_key: str) -> "PlexItem":
it = queries.item(db, rating_key)
if it is None:
raise not_found("plex_item")
return it
+2 -5
View File
@@ -10,11 +10,8 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.auth import admin_user, current_user
from app.db import get_db
from app.models import (
PlexItem,
PlexLink,
User,
)
from app.models import User
from app.plex.models import PlexItem, PlexLink
from app.plex import paths as plex_paths
from app.plex import sync as plex_sync
from app.plex import watch_sync as plex_watch
+63 -509
View File
@@ -1,110 +1,30 @@
"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import (
Boolean,
Integer,
String,
and_,
cast,
func,
literal,
null,
or_,
select,
)
from sqlalchemy.orm import Session, aliased
from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
from app.collation import collated, sorted_words
from app import sysconfig
from app.auth import current_user
from app.db import get_db
from app.models import (
PlexItem,
PlexLibrary,
PlexShow,
PlexState,
User,
)
from app.plex import rollup
from app.mailtext import lang_for_user
from app.models import User
from app.plex import library as plex_library
from app.plex import queries
from app.plex.cards import display_title, episode_card
from app.plex.query import (
FIELD_COLUMNS,
PLAYBACK_CLASSES,
TS_CONFIG,
LibraryFilters,
apply_meta_filters,
apply_movie_state_filter,
apply_show_state_filter,
content_libs,
field_recheck,
genres_col,
meta_filter_conds,
movie_status_case,
playable_conds,
playback_class_expr,
show_status_case,
split_csv,
summary_col,
title_col,
vector_col,
vector_match,
)
from app.plex.query import LibraryFilters
from app.routes.plex._shared import _norm_lang
from app.utils import to_tsquery_str
router = APIRouter(prefix="/api/plex", tags=["plex"])
def sort_column(u, sort: str, lang: str = "en"):
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
in the reader's alphabet (`collation`)."""
return {
"added": u.c.added_at,
"title": collated(u.c.title, lang),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
"lastwatched": u.c.last_watched,
}.get(sort, u.c.added_at)
@router.get("/libraries")
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay
libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata."""
libs = (
db.execute(
select(PlexLibrary)
.where(PlexLibrary.overlay_of.is_(None))
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang_for_user(user)))
)
.scalars()
.all()
)
out = []
for lib in libs:
if lib.kind == "movie":
count = db.scalar(
select(func.count(PlexItem.id)).where(
PlexItem.library_id == lib.id, PlexItem.kind == "movie"
)
)
else:
count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id))
out.append(
{
"key": lib.plex_key,
"title": lib.title,
"kind": lib.kind,
"count": count or 0,
}
)
out = [
{"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count}
for lib, count in queries.browsable_libraries(db, lang_for_user(user))
]
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
@@ -129,193 +49,60 @@ def unified_library(
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
offset = max(0, offset)
lang = _norm_lang(lang)
p = f.as_dict()
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
ts = to_tsquery_str(q) if q else None
tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None
if field not in FIELD_COLUMNS and field != "all":
field = "all"
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
grid_field = field != "episode"
recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None
selects = []
if want_movies and grid_field:
st = aliased(PlexState)
m_status = movie_status_case(st)
mq = (
select(
PlexItem.rating_key.label("rk"),
literal("movie").label("kind"),
title_col(PlexItem, lang).label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
func.left(summary_col(PlexItem, lang), 300).label("summary"),
PlexItem.added_at.label("added_at"),
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
PlexItem.transcode_hw.label("transcode_hw"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
literal(False).label("resumable"),
# "last watched" sort key: when the movie was last resumed, else when it was marked
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
(
func.ts_rank(vector_col(PlexItem, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
)
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
)
mq = apply_movie_state_filter(mq, st, show)
mq = apply_meta_filters(mq, PlexItem, p, lang)
if f.duration_min is not None:
mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
# Playback-class filter (movie-only — a show is an aggregate with no single class).
mq = mq.where(*playable_conds(split_csv(f.playable)))
if tsq is not None:
mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None:
mq = mq.where(recheck(PlexItem, field, tsq, lang))
selects.append(mq)
if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
s_status = show_status_case(tot, wat, inp)
sq = (
select(
PlexShow.rating_key.label("rk"),
literal("show").label("kind"),
title_col(PlexShow, lang).label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
func.left(summary_col(PlexShow, lang), 300).label("summary"),
PlexShow.added_at.label("added_at"),
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
cast(null(), Boolean).label("transcode_hw"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
(inp > 0).label("resumable"),
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
(
func.ts_rank(vector_col(PlexShow, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
)
.outerjoin(agg, agg.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_lib_ids))
)
sq = apply_show_state_filter(sq, tot, wat, inp, show)
sq = apply_meta_filters(sq, PlexShow, p, lang)
if tsq is not None:
sq = sq.where(vector_match(PlexShow, lang, tsq))
if recheck is not None:
sq = sq.where(recheck(PlexShow, field, tsq, lang))
selects.append(sq)
total, rows, eps = plex_library.library_page(
db,
user,
scope=scope,
q=q,
field=field,
sort=sort,
sort_dir=sort_dir,
show=show,
lang=lang,
offset=offset,
limit=limit,
f=f,
)
items = []
total = 0
if selects:
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
u = union.subquery()
total = db.scalar(select(func.count()).select_from(u)) or 0
if tsq is not None:
order = [u.c.rank.desc()]
else:
col = sort_column(u, sort, lang)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())
rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all()
for r in rows:
if r.kind == "movie":
items.append(
{
"id": r.rk,
"type": "movie",
"title": r.title,
"year": r.year,
"rating": r.rating,
"summary": r.summary,
"duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable,
"transcode_hw": r.transcode_hw,
"status": r.status,
"position_seconds": r.position_seconds,
}
)
else:
items.append(
{
"id": r.rk,
"type": "show",
"title": r.title,
"year": r.year,
"rating": r.rating,
"summary": r.summary,
"thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count,
"status": r.status,
"resumable": r.resumable,
}
)
# Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour)
# and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes
# target the movie/show grid, so they suppress episodes.
episodes = []
if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState)
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
# still by its English one, like `vector_match` does for the grid.
if field == "episode":
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
else:
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
hit = ep_vec.op("@@")(tsq)
if lang == "hu":
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
ep_vec = ep_vec_hu
eps = db.execute(
select(PlexItem, ep_st, PlexShow)
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.where(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids),
hit,
for r in rows:
if r.kind == "movie":
items.append(
{
"id": r.rk,
"type": "movie",
"title": r.title,
"year": r.year,
"rating": r.rating,
"summary": r.summary,
"duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable,
"transcode_hw": r.transcode_hw,
"status": r.status,
"position_seconds": r.position_seconds,
}
)
.order_by(func.ts_rank(ep_vec, tsq).desc())
.limit(24)
).all()
for e, s, sh in eps:
card = episode_card(e, s, lang=lang)
card["show_title"] = display_title(sh, lang) if sh is not None else None
episodes.append(card)
else:
items.append(
{
"id": r.rk,
"type": "show",
"title": r.title,
"year": r.year,
"rating": r.rating,
"summary": r.summary,
"thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count,
"status": r.status,
"resumable": r.resumable,
}
)
episodes = []
for e, s, sh in eps:
card = episode_card(e, s, lang=lang)
card["show_title"] = display_title(sh, lang) if sh is not None else None
episodes.append(card)
return {
"scope": scope,
@@ -341,237 +128,4 @@ def facets(
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
movie-only. (Search text is not factored in — facets track the sidebar filters.)"""
empty = {
"genres": [],
"content_ratings": [],
"studios": [],
"playable": [],
"year_min": None,
"year_max": None,
"rating_max": None,
"duration_min": None,
"duration_max": None,
}
all_movie_ids, all_show_ids = content_libs(db)
movie_ids = all_movie_ids if scope in ("movie", "both") else []
show_ids = all_show_ids if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
lang = _norm_lang(lang)
p = f.as_dict()
uid = user.id
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
# The same filter on the same rows, so the counts do not change
# (test_plex_facets_grid_parity).
shows_in_scope: list[int] | None = None
if show_ids and show in ("watched", "in_progress", "unwatched"):
agg = rollup.agg_subquery(db, uid).subquery()
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
in_scope = apply_show_state_filter(
in_scope,
func.coalesce(agg.c.total, 0),
func.coalesce(agg.c.watched, 0),
func.coalesce(agg.c.inprog, 0),
show,
)
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
def wsq(query, model, ids, pp, kind_movie, with_dur):
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
facet aggregation query, so the facets reflect exactly the visible result set."""
query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
if kind_movie:
query = query.where(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
query = query.where(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
query = query.where(model.duration_s <= pp["duration_max"])
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
st = aliased(PlexState)
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
query = apply_movie_state_filter(query, st, show)
elif shows_in_scope is not None:
query = query.where(model.id.in_(shows_in_scope))
return query
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
def per_table(model, ids, kind_movie: bool) -> None:
if not ids:
return
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
# every matching title carries them, so they stay togglable.)
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
gcol = genres_col(model, lang)
expanded = (
wsq(
select(func.jsonb_array_elements_text(gcol).label("g")),
model,
ids,
genre_pp,
kind_movie,
True,
)
.where(func.jsonb_typeof(gcol) == "array")
.subquery()
)
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
genre_counts[g] = genre_counts.get(g, 0) + c
# Content ratings — exclude its own.
crq = (
wsq(
select(model.content_rating, func.count()),
model,
ids,
{**p, "content_ratings": None},
kind_movie,
True,
)
.where(model.content_rating.isnot(None))
.group_by(model.content_rating)
)
for cr, c in db.execute(crq).all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
stq = (
wsq(
select(model.studio, func.count()),
model,
ids,
{**p, "studios": None},
kind_movie,
True,
)
.where(model.studio.isnot(None))
.group_by(model.studio)
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
if kind_movie:
pcls = playback_class_expr()
pcq = (
wsq(
select(pcls.label("cls"), func.count()),
model,
ids,
{**p, "playable": None},
kind_movie,
True,
)
.where(pcls.isnot(None))
.group_by(pcls)
)
for cls, c in db.execute(pcq).all():
playable_counts[cls] = playable_counts.get(cls, 0) + c
# Year bounds — exclude the year filter.
by = db.execute(
wsq(
select(func.min(model.year), func.max(model.year)),
model,
ids,
{**p, "year_min": None, "year_max": None},
kind_movie,
True,
)
).first()
if by[0] is not None:
years.append(by[0])
if by[1] is not None:
years.append(by[1])
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
# rating filter and the UI badge use (IMDb preferred, audience fallback).
br = db.scalar(
wsq(
select(func.max(func.coalesce(model.imdb_rating, model.rating))),
model,
ids,
{**p, "rating_min": None},
kind_movie,
True,
)
)
if br is not None:
ratings.append(float(br))
# Duration bounds (movies) — exclude the duration filter.
if kind_movie:
bd = db.execute(
wsq(
select(func.min(model.duration_s), func.max(model.duration_s)),
model,
ids,
p,
kind_movie,
False,
)
).first()
if bd[0] is not None:
durs.append(bd[0])
if bd[1] is not None:
durs.append(bd[1])
per_table(PlexItem, movie_ids, True)
per_table(PlexShow, show_ids, False)
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in split_csv(f.genres):
genre_counts.setdefault(g, 0)
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0)
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
return {
"genres": [
{"value": g, "count": c}
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
],
"content_ratings": [
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
],
"studios": [
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
],
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
"playable": [
{"value": c, "count": playable_counts[c]}
for c in PLAYBACK_CLASSES
if c in playable_counts
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
"duration_min": min(durs) if durs else None,
"duration_max": max(durs) if durs else None,
}
return plex_library.facets(db, user, scope=scope, show=show, lang=lang, f=f)
+56 -269
View File
@@ -1,96 +1,41 @@
"""Plex collections: browse mirrored collections + the admin manual-collection editor."""
from collections.abc import Callable
from datetime import date, datetime
from typing import Any, NamedTuple
from datetime import datetime
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
from app.collation import collated, sorted_words
from app.collation import sorted_words
from app import logs
from app.auth import admin_user, current_user
from app.db import get_db
from app.models import (
PlexCollection,
PlexItem,
PlexLibrary,
PlexShow,
PlexState,
User,
)
from app.plex import rollup
from app.models import User
from app.plex import queries, rollup, service
from app.plex import sync as plex_sync
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
from app.utils import like_escape
from app.plex.collections import (
DEFAULT_MEMBER_SORT,
Member,
collection_card,
collection_cert,
collection_editable,
collection_source,
sort_members,
)
from app.refusal import refused
from app.routes._common import not_found
if TYPE_CHECKING:
from app.plex.models import PlexCollection, PlexState
log = logs.get_logger(__name__)
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it).
_CERT_RANK = {
"G": 1,
"TV-Y": 1,
"TV-Y7": 1,
"TV-G": 1,
"PG": 2,
"TV-PG": 2,
"PG-13": 3,
"TV-14": 3,
"R": 4,
"TV-MA": 4,
"NC-17": 5,
}
def _collection_cert(certs: list[str | None]) -> str | None:
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
return max(ranked, key=lambda x: x[1])[0] if ranked else None
router = APIRouter(prefix="/api/plex", tags=["plex"])
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
def _collection_source(col: PlexCollection) -> str:
first = (col.title or "").strip().split(" ", 1)[0].lower()
if first in _STRIP_PROVIDERS:
return first
if col.smart:
return "smart"
return "collection"
def _collection_editable(c: PlexCollection) -> bool:
"""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."""
return bool(c.editable) and not c.smart and _collection_source(c) == "collection"
def _collection_card(c: PlexCollection) -> dict:
return {
"id": c.rating_key,
"title": c.title,
"summary": c.summary,
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
"child_count": c.child_count,
"smart": c.smart,
"source": _collection_source(c),
"editable": c.editable,
"can_edit": _collection_editable(
c
), # whether the edit controls should be offered (admin UI)
}
@router.get("/collections")
def collections(
@@ -110,64 +55,22 @@ def collections(
sidebar token and the applied-filter chip had nothing to render but the raw id. Resolving them
through this endpoint costs one small row each; pulling the whole list to find two titles is
~200 KB."""
if library is not None:
lib = (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(library)))
.scalars()
.first()
)
if lib is None:
return {"collections": []}
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
else:
enabled_ids = list(
db.execute(
select(PlexLibrary.id).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
).scalars()
)
if not enabled_ids:
return {"collections": []}
query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids))
term = (q or "").strip()
if term:
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
if keys is not None:
wanted = [k for k in (keys or "").split(",") if k]
# An explicit but empty `keys=` asks for nothing — answer nothing, rather than falling through
# to "every collection" (which is what an unguarded empty IN () would have to become).
if not wanted:
return {"collections": []}
query = query.where(PlexCollection.rating_key.in_(wanted))
rows = (
db.execute(
query.order_by(
PlexCollection.child_count.desc().nullslast(),
collated(PlexCollection.title, lang_for_user(user)),
)
)
.scalars()
.all()
)
wanted = [k for k in keys.split(",") if k] if keys is not None else None
rows = queries.collections_page(db, library, q, wanted, lang_for_user(user))
# A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW
# members (a collection lives in one library, so exactly one side has art) — a show collection would
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
# Runtime is movie-only (shows have no single duration).
movie_ids, show_ids = content_libs(db)
keys = [c.rating_key for c in rows]
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
art_m, art_s, runtime = queries.collection_tiles(db, [c.rating_key for c in rows])
def _art(c: PlexCollection) -> list[str]:
def _art(c: "PlexCollection") -> list[str]:
member = art_m.get(c.rating_key) or art_s.get(c.rating_key)
return member or ([c.rating_key] if c.thumb_key else [])
return {
"collections": [
{
**_collection_card(c),
**collection_card(c),
"art": _art(c),
"total_runtime_s": runtime.get(c.rating_key, 0),
}
@@ -185,89 +88,21 @@ def collections_for_item(
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's
library (a collection lives in one library, and only a manual one can take a title — the same
`can_edit` the editor offers), each saying whether the title is in it already, by name."""
movie = by_rating_key(db, PlexItem, item_rating_key)
title: PlexItem | PlexShow | None = (
movie if movie is not None and movie.kind == "movie" else None
) or by_rating_key(db, PlexShow, item_rating_key)
movie = queries.movie(db, item_rating_key)
title = movie or queries.show(db, item_rating_key)
if title is None:
raise not_found("item")
member_of = set(title.collection_keys or [])
rows = (
db.execute(
select(PlexCollection)
.where(PlexCollection.library_id == title.library_id)
.order_by(collated(PlexCollection.title, lang_for_user(user)))
)
.scalars()
.all()
)
rows = queries.library_collections(db, title.library_id, lang_for_user(user))
return {
"collections": [
{**_collection_card(c), "contains": c.rating_key in member_of}
{**collection_card(c), "contains": c.rating_key in member_of}
for c in rows
if _collection_editable(c)
if collection_editable(c)
]
}
class Member(NamedTuple):
"""One collection member flattened to everything the Sort control can order by. The page's two
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
ordering is a pure list sort over these, which is also what makes it unit-testable."""
kind: str # "movie" | "show"
obj: Any # PlexItem | PlexShow, carried through to the card serializer
title: str # lower-cased; never NULL (the column is not nullable)
year: int | None
rating: float | None
added: datetime | None
release: date | None
duration: int | None # movies only — a show has no single runtime
last_watched: datetime | None
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
# that silently does nothing here.
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
"added": lambda m: m.added,
"lastwatched": lambda m: m.last_watched,
"release": lambda m: m.release,
"year": lambda m: m.year,
"rating": lambda m: m.rating,
"duration": lambda m: m.duration,
"title": lambda m: m.title,
}
# What the page ordered by before it had a control, and still its default: a film series wants to be
# read in release order, not by whenever Plex happened to ingest it.
DEFAULT_MEMBER_SORT = "year"
def sort_members(
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
) -> list[Member]:
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
compare as Python strings, which puts every accented initial after "z"."""
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
if key is MEMBER_SORT_KEYS["title"]:
key = by_title
ordered = sorted(members, key=by_title) # stable secondary, applied first
have = [m for m in ordered if key(m) is not None]
missing = [m for m in ordered if key(m) is None]
have.sort(key=key, reverse=sort_dir != "asc")
return have + missing
@router.get("/collections/{rating_key}")
def collection_detail(
rating_key: str,
@@ -283,45 +118,20 @@ def collection_detail(
what this page did before it had a control. Read-only here; edits go through the admin mutation
endpoints below."""
lang = norm_lang(lang)
col = by_rating_key(db, PlexCollection, rating_key)
col = queries.collection(db, rating_key)
if col is None:
raise not_found("collection")
movies = (
db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
)
.scalars()
.all()
)
shows = (
db.execute(
select(PlexShow)
.where(PlexShow.collection_keys.contains([rating_key]))
.order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang))
)
.scalars()
.all()
)
movies, shows = queries.collection_members(db, rating_key, lang)
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
# ONE rollup pass over the show members, for both the aggregate badge status and `resumed_at` —
# the browse grid's "last watched" for a show (the newest resume/watched across its episodes). The
# cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on
# a show collection while the control still offered it.
show_rows = (
db.execute(
rollup.agg_subquery(db, user.id, include_resumed=True).where(
PlexItem.show_id.in_([s.id for s in shows])
)
).all()
if shows
else []
)
show_rows = queries.show_rollups(db, user.id, [s.id for s in shows])
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
sresumed = {r.show_id: r.resumed_at for r in show_rows}
def _movie_last_watched(st: PlexState | None) -> datetime | None:
def _movie_last_watched(st: "PlexState | None") -> datetime | None:
# Same definition as the browse grid's: when it was last resumed, else when it was marked
# watched (coalesce(progress_updated_at, watched_at)).
return (st.progress_updated_at or st.watched_at) if st else None
@@ -365,8 +175,8 @@ def collection_detail(
years = [m.year for m in members if m.year is not None]
certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
return {
**_collection_card(col),
"content_rating": _collection_cert(certs),
**collection_card(col),
"content_rating": collection_cert(certs),
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"total_runtime_s": sum(m.duration_s or 0 for m in movies),
@@ -374,11 +184,11 @@ def collection_detail(
}
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
col = by_rating_key(db, PlexCollection, rating_key)
def _editable_collection_or_403(db: Session, rating_key: str) -> "PlexCollection":
col = queries.collection(db, rating_key)
if col is None:
raise not_found("collection")
if not _collection_editable(col):
if not collection_editable(col):
raise refused(403, "collection_read_only")
return col
@@ -389,16 +199,7 @@ def _reject_duplicate_name(
"""No two collections in a section may share a name: Plex CONFLATES same-named collections'
membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up
front on create + rename rather than mirror the mess."""
dup = (
db.execute(
select(PlexCollection).where(
PlexCollection.library_id == library_id,
func.lower(PlexCollection.title) == title.lower(),
)
)
.scalars()
.first()
)
dup = queries.collection_named(db, library_id, title)
if dup is not None and dup.rating_key != exclude_rk:
# Structured, so the bilingual UI says it in the reader's language (it was toasted in
# Hungarian beside an English error dialog saying the same thing).
@@ -441,15 +242,13 @@ def create_collection(
seed = body.item_rating_key
if not title or not seed:
raise refused(400, "collection_fields_required")
seed_show = by_rating_key(db, PlexShow, seed)
seed_show = queries.show(db, seed)
if library:
lib = (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
)
lib = queries.library_by_key(db, library)
else:
# The "New collection" flow only knows the seed title → derive its library.
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
seed_item = queries.item(db, seed) or seed_show
lib = queries.library(db, seed_item.library_id) if seed_item else None
if lib is None:
raise not_found("library")
_reject_duplicate_name(db, lib.id, title)
@@ -462,28 +261,16 @@ def create_collection(
wrote = True
new_rk = str(new.get("ratingKey") or "")
col = plex_sync.resync_collection(db, plex, lib, new_rk)
col.editable = True
service.set_collection_editable(col, True)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
if wrote and new_rk:
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
# full sync preserves `editable` but creates a missing row read-only, so the collection
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
# the collection it was just added to, and the collection empty, until that sync.
db.add(
PlexCollection(
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
)
)
Model = PlexItem if lib.kind == "movie" else PlexShow
if row := by_rating_key(db, Model, seed):
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
# It is on Plex now, and only the follow-up read failed: keep its row (see the service).
service.keep_created_collection(db, lib, new_rk, title, seed)
db.commit()
raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
return collection_card(col)
@router.post("/collections/{rating_key}/items/{item_rating_key}")
@@ -494,7 +281,7 @@ def collection_add_item(
db: Session = Depends(get_db),
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
lib = queries.library(db, col.library_id)
wrote = False
try:
with PlexClient(db) as plex:
@@ -505,7 +292,7 @@ def collection_add_item(
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
return collection_card(col)
@router.delete("/collections/{rating_key}/items/{item_rating_key}")
@@ -516,7 +303,7 @@ def collection_remove_item(
db: Session = Depends(get_db),
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
lib = queries.library(db, col.library_id)
wrote = False
try:
with PlexClient(db) as plex:
@@ -527,7 +314,7 @@ def collection_remove_item(
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
return collection_card(col)
class CollectionRenameIn(BaseModel):
@@ -546,7 +333,7 @@ def collection_rename(
if not title:
raise refused(400, "title_required")
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
lib = db.get(PlexLibrary, col.library_id)
lib = queries.library(db, col.library_id)
wrote = False
try:
with PlexClient(db) as plex:
@@ -557,7 +344,7 @@ def collection_rename(
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
return collection_card(col)
@router.delete("/collections/{rating_key}")
@@ -565,7 +352,7 @@ def collection_delete(
rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
lib = queries.library(db, col.library_id)
try:
with PlexClient(db) as plex:
plex.delete_collection(rating_key)
@@ -590,12 +377,12 @@ def collection_set_editable(
) -> dict:
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
col = by_rating_key(db, PlexCollection, rating_key)
col = queries.collection(db, rating_key)
if col is None:
raise not_found("collection")
want = body.editable
if want and (col.smart or _collection_source(col) != "collection"):
if want and (col.smart or collection_source(col) != "collection"):
raise refused(400, "collection_smart")
col.editable = want
service.set_collection_editable(col, want)
db.commit()
return _collection_card(col)
return collection_card(col)
+31 -130
View File
@@ -4,52 +4,34 @@ import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.collation import collated, sorted_words
from app.collation import sorted_words
from app import logs
from app.auth import current_user
from app.db import get_db
from app.discover import queries
from app.models import (
PlexCollection,
PlexItem,
PlexLibrary,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.discover import queries as discover_queries
from app.models import User
from app.plex import detail as plex_detail
from app.plex import people as plex_people
from app.plex import rollup, subtitles
from app.plex.query import title_col
from app.plex import queries, rollup, service, subtitles
from app.plex import tmdb as plex_tmdb
from app.plex.collections import collection_source
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404
from app.routes.plex.collections import _collection_source
from app.routes._common import not_found
if TYPE_CHECKING:
from app.plex.models import PlexItem, PlexSeason
log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/plex", tags=["plex"])
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
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
_SHOW_ENRICH_TTL_S = 300
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
@@ -113,39 +95,12 @@ def show_detail(
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
lang = _norm_lang(lang)
hu = lang == "hu"
sh = by_rating_key(db, PlexShow, rating_key)
sh = queries.show(db, rating_key)
if sh is None:
raise not_found("show")
seasons = (
db.execute(
select(PlexSeason)
.where(PlexSeason.show_id == sh.id)
.order_by(PlexSeason.season_number.asc().nullsfirst())
)
.scalars()
.all()
)
eps = (
db.execute(
select(PlexItem)
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
)
.scalars()
.all()
)
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user.id,
PlexState.item_id.in_([e.id for e in eps] or [0]),
)
).scalars()
}
seasons = queries.seasons(db, sh)
eps = queries.episodes(db, sh)
states = queries.states(db, user, [e.id for e in eps])
by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps:
@@ -163,9 +118,7 @@ def show_detail(
meta, related_raw = _show_enrichment(db, sh.rating_key)
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang)
if sh.people_enriched_at is None and meta:
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
sh.people_enriched_at = datetime.now(timezone.utc)
service.enrich_show(db, sh, meta) # imdb/tagline/full_cast for future views
try:
db.commit()
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
@@ -198,7 +151,7 @@ def show_detail(
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
"library": _lib_key(db, sh.library_id),
"library": queries.library_key(db, sh.library_id),
"content_rating": sh.content_rating,
"rating": sh.rating,
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
@@ -234,13 +187,13 @@ def show_next(
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
`next` is null for a show with no episodes."""
sh = by_rating_key(db, PlexShow, rating_key)
sh = queries.show(db, rating_key)
if sh is None:
raise not_found("show")
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
return {"next": discover_queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
def _season_block(se: "PlexSeason", cards: list[dict]) -> dict:
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
season subpage, read from the same cached show-detail payload)."""
roll = rollup.rollup(cards)
@@ -265,12 +218,7 @@ def _relatedshow_cards(
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
if not rks:
return []
shows = {
s.rating_key: s
for s in db.execute(
select(PlexShow).where(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
).scalars()
}
shows = queries.shows_by_keys(db, rks[:60], exclude)
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
out: list[dict] = []
seen: set[str] = set()
@@ -285,73 +233,36 @@ def _relatedshow_cards(
return out
def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> list[dict]:
def _collection_strips(db: Session, it: "PlexItem", user_id: int, lang: str) -> list[dict]:
"""The collections this movie belongs to, each with its member titles (playable cards) — the
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
keys = it.collection_keys or []
if not keys or it.kind != "movie":
return []
cols = {
c.rating_key: c
for c in db.execute(
select(PlexCollection).where(PlexCollection.rating_key.in_(keys))
).scalars()
}
cols = {c.rating_key: c for c in queries.collections_by_keys(db, keys)}
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))}
ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title]))
out = []
for col in ordered:
rk = col.rating_key
members = (
db.execute(
select(PlexItem)
.where(
PlexItem.library_id == it.library_id,
PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rk]),
)
.order_by(
PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)
)
.limit(60)
)
.scalars()
.all()
)
ids = [m.id for m in members]
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
)
).scalars()
}
members = queries.library_movies_in(db, it.library_id, rk, lang, limit=60)
states = queries.states(db, user_id, [m.id for m in members])
out.append(
{
"id": rk,
"title": col.title,
"source": _collection_source(col),
"source": collection_source(col),
"items": [movie_card(m, states.get(m.id), lang=lang) for m in members],
}
)
return out
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
def _episode_nav(db: Session, it: "PlexItem") -> tuple[str | None, str | None]:
if it.kind != "episode" or it.show_id is None:
return None, None
keys = list(
db.execute(
select(PlexItem.rating_key)
.where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
keys = queries.episode_keys(db, it.show_id)
try:
i = keys.index(it.rating_key)
except ValueError:
@@ -398,13 +309,7 @@ def item_detail(
prev/next, and the per-user resume position + watch status."""
lang = _norm_lang(lang)
it = item_or_404(db, rating_key)
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
)
.scalars()
.first()
)
st = queries.state(db, user, it)
# Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/
# tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only
@@ -485,11 +390,7 @@ def item_detail(
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
# and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show.
if it.kind == "movie" and it.people_enriched_at is None and meta:
cast = plex_people.cast_from_meta(meta)
if cast:
plex_people.enrich_row_people(db, it, cast)
plex_detail.apply_rich_detail(it, meta)
it.people_enriched_at = datetime.now(timezone.utc)
service.enrich_movie(db, it, meta)
try:
db.commit()
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
@@ -522,8 +423,8 @@ def item_detail(
rich = plex_detail.rich_detail(meta)
prev_id, next_id = _episode_nav(db, it)
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
show = queries.show_by_id(db, it.show_id) if it.kind == "episode" and it.show_id else None
season = queries.season_by_id(db, it.season_id) if show and it.season_id else None
return {
"id": it.rating_key,
"kind": it.kind,
@@ -538,7 +439,7 @@ def item_detail(
"video_bit_depth": it.video_bit_depth,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id),
"library": queries.library_key(db, it.library_id),
"cast": rich.get("cast", []),
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),
+2 -1
View File
@@ -21,7 +21,8 @@ from sqlalchemy.orm import Session
from app.collation import sorted_words
from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexShow, PlexState
from app.plex import rollup
from app.plex.cards import (
SHOW_NEW,
+16 -99
View File
@@ -4,26 +4,18 @@ import threading
import time
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.collation import collated
from app import logs, sysconfig
from app import sysconfig
from app.auth import current_user
from app.db import SessionLocal, get_db
from app.models import (
PlexItem,
PlexPerson,
PlexShow,
User,
)
from app.plex import rollup
from app.db import get_db
from app.models import User
from app.plex import queries, rollup, service
from app.plex import tmdb as plex_tmdb
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
from app.plex.query import content_libs, split_csv, title_col
from app.plex.query import content_libs, split_csv
router = APIRouter(prefix="/api/plex", tags=["plex"])
log = logs.get_logger(__name__)
# How many "Known for" titles the person page shows (the stored pool is deeper; see plex.tmdb).
@@ -80,35 +72,13 @@ def people_cards(
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
photos = {
p.name: p.photo_thumb
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(wanted))).scalars()
}
out = []
for nm in wanted:
count = 0
if want_movies:
count += (
db.scalar(
select(func.count(PlexItem.id)).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
PlexItem.cast_names.contains([nm]),
)
)
or 0
)
if want_shows:
count += (
db.scalar(
select(func.count(PlexShow.id)).where(
PlexShow.library_id.in_(show_lib_ids),
PlexShow.cast_names.contains([nm]),
)
)
or 0
)
out.append({"name": nm, "photo": photos.get(nm), "count": count})
photos = queries.person_photos(db, wanted)
movies = movie_lib_ids if want_movies else []
shows = show_lib_ids if want_shows else []
out = [
{"name": nm, "photo": photos.get(nm), "count": queries.cast_count(db, nm, movies, shows)}
for nm in wanted
]
return {"people": out}
@@ -144,24 +114,6 @@ def _should_schedule_enrich(person_id: int) -> bool:
return True
def _enrich_person_bg(person_id: int) -> None:
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
next open, and the bio lands from the row by then."""
db = SessionLocal()
try:
person = db.get(PlexPerson, person_id)
if person is not None:
plex_tmdb.enrich_person(db, person)
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
db.rollback()
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
finally:
db.close()
@router.get("/person/{name}")
def person_detail(
name: str,
@@ -183,7 +135,7 @@ def person_detail(
fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet.
"""
lang = norm_lang(lang)
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
person = queries.person(db, name)
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
# can't pile background tasks onto the shared threadpool.
@@ -197,53 +149,18 @@ def person_detail(
and bool(sysconfig.get_str(db, "tmdb_api_key"))
)
if enriching and person is not None and _should_schedule_enrich(person.id):
background_tasks.add_task(_enrich_person_bg, person.id)
background_tasks.add_task(service.enrich_person_bg, person.id)
movie_lib_ids, show_lib_ids = content_libs(db)
movies: list[dict] = []
if movie_lib_ids:
items = (
db.execute(
select(PlexItem)
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
or_(
PlexItem.cast_names.contains([name]),
PlexItem.directors.contains([name]),
),
)
.order_by(
PlexItem.year.desc().nullslast(), collated(title_col(PlexItem, lang), lang)
)
.limit(_PERSON_SECTION_MAX)
)
.scalars()
.all()
)
items = queries.filmography(db, "movie", name, movie_lib_ids, lang, _PERSON_SECTION_MAX)
states = rollup.item_state_map(db, user.id, [it.id for it in items])
movies = [movie_card(it, states.get(it.id), lang=lang) for it in items]
shows: list[dict] = []
if show_lib_ids:
show_rows = (
db.execute(
select(PlexShow)
.where(
PlexShow.library_id.in_(show_lib_ids),
or_(
PlexShow.cast_names.contains([name]),
PlexShow.directors.contains([name]),
),
)
.order_by(
PlexShow.year.desc().nullslast(), collated(title_col(PlexShow, lang), lang)
)
.limit(_PERSON_SECTION_MAX)
)
.scalars()
.all()
)
show_rows = queries.filmography(db, "show", name, show_lib_ids, lang, _PERSON_SECTION_MAX)
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
+2 -7
View File
@@ -22,13 +22,8 @@ from sqlalchemy.orm import Session
from app.auth import current_user, resolved_user_id
from app.db import SessionLocal, get_db
from app.models import (
PlexItem,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.models import User
from app.plex.models import PlexItem, PlexSeason, PlexShow, PlexState
from app.plex import imgcache, subtitles
from app.plex import paths as plex_paths
from app.plex.lookups import by_rating_key
+2 -8
View File
@@ -11,14 +11,8 @@ from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.models import (
PlexItem,
PlexPlaylist,
PlexPlaylistItem,
PlexShow,
PlexState,
User,
)
from app.models import User
from app.plex.models import PlexItem, PlexPlaylist, PlexPlaylistItem, PlexShow, PlexState
from app.plex.cards import display_title, leaf_card, norm_lang
from app.plex.lookups import by_rating_key
from app.routes._common import owned_or_404, not_found
+1
View File
@@ -8,4 +8,5 @@ every mapper — imports this module.
import app.catalog.models # Catalog's tables
import app.downloads.models # Capture's tables
import app.models # the core tables
import app.plex.models # Plex's tables
import app.reader.models # noqa: F401 — Reader's tables
+1 -1
View File
@@ -18,7 +18,7 @@ from app.downloads import errors as dlerrors
from app.downloads import formats, pipeline, quota, runner, service
from app.downloads import probe as probemod
from app.downloads.edit import INPUT_GUARD
from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex.models import PlexItem, PlexLibrary, PlexShow
from app.catalog.models import Channel, Video
from app.downloads.models import DownloadJob, MediaAsset
from app.plex import paths as plex_paths
+2 -1
View File
@@ -9,7 +9,8 @@ from app.auth import current_user
from app.collation import sorted_words
from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, User
from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary
from app.catalog.models import Channel, Subscription, Video
from app.reader.models import Playlist, PlaylistItem
from datetime import datetime, timezone
+2 -1
View File
@@ -17,7 +17,8 @@ from app.discover import hero, pins, queries, rails
from app.discover.budget import BANDS, fits, remaining_seconds
from app.plex import rollup
from app.plex.cards import leaf_card
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
from app.catalog.models import BlockedChannel, Channel, Subscription, Video
from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState
+1 -1
View File
@@ -12,7 +12,7 @@ contradicts the remote's own count is not believed either.
import pytest
from sqlalchemy import select
from app.models import PlexCollection, PlexItem, PlexLibrary
from app.plex.models import PlexCollection, PlexItem, PlexLibrary
from app.catalog.models import Channel, Video
from app.reader.models import Playlist, PlaylistItem
from app.plex import sync as plex_sync
+2 -1
View File
@@ -28,7 +28,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary
from app.plex import paths as plex_paths
from app.plex import stream
+1 -1
View File
@@ -19,7 +19,7 @@ from datetime import datetime, timezone
import pytest
from sqlalchemy import event
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
from app.catalog.models import Channel, Video
from app.reader.models import Playlist, PlaylistItem
+1 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
import app.routes.plex.admin as admin_routes
from app import sysconfig
from app.models import PlexLink
from app.plex.models import PlexLink
from app.plex.client import PlexError, PlexNotConfigured
+1 -1
View File
@@ -5,7 +5,7 @@ and the crossfading hero), but ONLY when the item actually has an `art_key` —
resolve to a blank image on every card that tried to use it, so it must be null and let the client fall
back to the poster."""
from app.models import PlexItem, PlexShow
from app.plex.models import PlexItem, PlexShow
from app.plex.cards import movie_card, show_card
+2 -1
View File
@@ -22,7 +22,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.plex import watch_sync as plex_watch
+4 -3
View File
@@ -17,10 +17,11 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
from app.plex.query import PLEX_SORT_KEYS
from app.routes.plex.browse import sort_column
from app.routes.plex.collections import (
from app.plex.library import sort_column
from app.plex.collections import (
DEFAULT_MEMBER_SORT,
MEMBER_SORT_KEYS,
Member,
@@ -13,7 +13,8 @@ import app.state as state
from app.auth import admin_user, current_user
from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
from app.plex.client import PlexError
from app.plex.lookups import by_rating_key
from app.routes.plex import collections as coll_routes
@@ -9,7 +9,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
def _seed(db):
+2 -1
View File
@@ -19,7 +19,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex import detail
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
+2 -1
View File
@@ -13,7 +13,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
+2 -1
View File
@@ -13,7 +13,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow
def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
@@ -15,14 +15,8 @@ the merge is provably behaviour-equivalent (and a drift between grid and facets
from collections import Counter
from datetime import datetime, timezone
from app.models import (
PlexItem,
PlexLibrary,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
SCOPES = ("movie", "show", "both")
SHOW_STATES = ("all", "watched", "in_progress", "unwatched")
@@ -14,13 +14,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import (
PlexItem,
PlexLibrary,
PlexSeason,
PlexShow,
User,
)
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow
from fastapi.testclient import TestClient
+1 -1
View File
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.db import SessionLocal
from app.models import PlexItem, PlexLibrary, PlexState
from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.routes.plex import playback
+1 -1
View File
@@ -9,7 +9,7 @@ from fastapi.testclient import TestClient
import app.state as app_state
from app.main import app
from app.models import PlexItem, PlexLibrary
from app.plex.models import PlexItem, PlexLibrary
from app.plex import imgcache
from app.routes.plex import playback
+1 -1
View File
@@ -7,7 +7,7 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
from sqlalchemy import text
from app.models import PlexLibrary, PlexShow
from app.plex.models import PlexLibrary, PlexShow
# Mirror of migration 0060's statements (plex_items runs against an empty table here — proving the
# SQL is valid for both tables; the assertions below check the seeded plex_shows rows).
+2 -1
View File
@@ -9,7 +9,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary
def _seed(db):
+2 -2
View File
@@ -14,7 +14,8 @@ from app.db import get_db
from app.main import app
from app import sysconfig
from app.plex.cards import display_summary, display_title
from app.models import (
from app.models import User
from app.plex.models import (
PlexCollection,
PlexItem,
PlexLibrary,
@@ -23,7 +24,6 @@ from app.models import (
PlexSeason,
PlexShow,
PlexState,
User,
)
+2 -1
View File
@@ -12,7 +12,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
+1 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
import app.plex.sync as syncmod
from app import sysconfig
from app.db import engine
from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex.models import PlexItem, PlexLibrary, PlexShow
from app.plex.genres import hu_genre, hu_genres
from app.plex.overlay import sync_overlay
from app.plex.sync import _apply_item, _canonical_guid
+3 -2
View File
@@ -9,7 +9,8 @@ from datetime import datetime, timezone
import httpx
import pytest
from app.models import PlexItem, PlexLibrary, PlexPerson, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexPerson
from app.plex import imgcache
from app.plex import people as plex_people
from app.plex import tmdb as plex_tmdb
@@ -361,7 +362,7 @@ def test_the_person_page_says_a_bio_is_on_its_way(db, monkeypatch, override_dep)
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: None)
monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: None)
key = {"value": "key"}
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"])
+2 -8
View File
@@ -12,14 +12,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import (
PlexItem,
PlexLibrary,
PlexPerson,
PlexShow,
PlexState,
User,
)
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow, PlexState
from app.plex.query import content_libs
NAME = "Robert De Niro"
+12 -11
View File
@@ -10,7 +10,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexPerson, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexPerson
from app.plex import tmdb as plex_tmdb
PAYLOAD = {
@@ -190,7 +191,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri
db.commit()
scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: scheduled.append(pid))
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: "key")
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
@@ -203,7 +204,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri
def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
# The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling
# back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed).
from app.routes.plex import people
from app.plex import service
calls = {"rollback": 0, "close": 0, "enriched": 0}
@@ -221,13 +222,13 @@ def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
calls["close"] += 1
# Success path: person found → enrich called, session closed, no rollback.
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object()))
monkeypatch.setattr(service, "SessionLocal", lambda: _FakeSession(object()))
monkeypatch.setattr(
people.plex_tmdb,
service.plex_tmdb,
"enrich_person",
lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1),
)
people._enrich_person_bg(5)
service.enrich_person_bg(5)
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
# Error path: enrich raises → rollback + close, no exception escapes.
@@ -236,14 +237,14 @@ def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
def _boom(db, p):
raise RuntimeError("enrich blew up")
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom)
people._enrich_person_bg(5) # must not raise
monkeypatch.setattr(service.plex_tmdb, "enrich_person", _boom)
service.enrich_person_bg(5) # must not raise
assert calls["rollback"] == 1 and calls["close"] == 1
# Missing person (deleted id): None-guard skips enrich, session still closed.
calls.update({"rollback": 0, "close": 0, "enriched": 0})
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None))
people._enrich_person_bg(9)
monkeypatch.setattr(service, "SessionLocal", lambda: _FakeSession(None))
service.enrich_person_bg(9)
assert calls == {"rollback": 0, "close": 1, "enriched": 0}
@@ -280,7 +281,7 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch, override
db.commit()
scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: scheduled.append(pid))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P")
assert resp.status_code == 200, resp.text
+1 -1
View File
@@ -12,7 +12,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import engine
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
from app.plex.detail import rich_detail
from app.plex.overlay import sync_overlay
from app.plex.plaintext import plain_text
+2 -1
View File
@@ -14,7 +14,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary
from app.plex import sync
from fastapi.testclient import TestClient
+2 -1
View File
@@ -19,7 +19,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary
from app.plex import paths as plex_paths
from app.plex import stream as plex_stream
@@ -6,7 +6,7 @@ rows, since positions have gaps after a removal. One request: an append plus a s
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
"""
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
+3 -2
View File
@@ -16,7 +16,8 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex import rollup, sync
from app.plex.cards import ShowState
@@ -490,7 +491,7 @@ class TestPlaylistAndCollectionEndpoints:
] # input order preserved, m2 gone
def test_collections_list(self, db, monkeypatch, override_dep):
from app.models import PlexCollection
from app.plex.models import PlexCollection
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
+66
View File
@@ -0,0 +1,66 @@
"""The Plex pages' local reads and the one local-only write, through HTTP.
The service tests (`test_plex_service.py`) hold what each read and write does; these hold that the
route calls it, commits — the `db` fixture fails a test whose handler left a write uncommitted —
and what a client sees back. None of these reach Plex. Needs the DB.
"""
from app.plex.models import PlexCollection, PlexItem, PlexLibrary
def _seed(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M"),
PlexCollection(rating_key="c1", library_id=lib.id, title="Mine"),
PlexCollection(rating_key="c2", library_id=lib.id, title="IMDb Top 250"),
]
)
db.commit()
def test_an_admin_marks_a_collection_editable_and_it_stays(db, make_user, as_user):
_seed(db)
admin = as_user(make_user(role="admin"))
r = admin.post("/api/plex/collections/c1/editable", json={"editable": True})
assert r.status_code == 200, r.text
assert (r.json()["editable"], r.json()["can_edit"]) == (True, True)
db.expire_all()
listed = admin.get("/api/plex/collections").json()["collections"]
assert {c["id"]: c["editable"] for c in listed} == {"c1": True, "c2": False}
# An auto-list is never editable.
r = admin.post("/api/plex/collections/c2/editable", json={"editable": True})
assert r.status_code == 400
assert (
admin.post("/api/plex/collections/c9/editable", json={"editable": True}).status_code == 404
)
def test_the_library_list_and_a_movie_page_read_from_the_mirror(
db, make_user, as_user, monkeypatch
):
from app.routes.plex import detail
_seed(db)
client = as_user(make_user())
libs = client.get("/api/plex/libraries").json()["libraries"]
assert [(lib["key"], lib["count"]) for lib in libs] == [("1", 1)]
class _NoPlex:
def __init__(self, db):
from app.plex.client import PlexNotConfigured
raise PlexNotConfigured()
monkeypatch.setattr(detail, "PlexClient", _NoPlex)
body = client.get("/api/plex/item/m1").json()
assert (body["id"], body["library"], body["status"], body["collections"]) == (
"m1",
"1",
"new",
[],
)
assert client.get("/api/plex/item/nope").json()["detail"]["reason"] == "plex_item"
+264
View File
@@ -0,0 +1,264 @@
"""Plex's service and queries below the HTTP layer, one use case each.
The routes are thin callers now, and their own tests stay the contract a client sees. These hold
what each read and write does on its own: that a user's watch state is theirs alone (another
user's state is the same answer as none), that a write leaves the commit to its caller, and the rule
each one carries (an explicit empty key list asks for nothing, a kept collection is editable and
holds its seed). DB-backed (real Postgres).
"""
from datetime import datetime, timezone
from sqlalchemy import select
from app.plex import library, queries, service
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
from app.plex.query import LibraryFilters
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
def _lib(db, key="1", kind="movie", **kw):
lib = PlexLibrary(plex_key=key, title=kw.pop("title", f"L{key}"), kind=kind, enabled=True, **kw)
db.add(lib)
db.commit()
return lib
def _movie(db, lib, rk, **kw):
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=kw.pop("title", rk), **kw)
db.add(it)
db.commit()
return it
def _state(db, user, it, **kw):
st = PlexState(user_id=user.id, item_id=it.id, **kw)
db.add(st)
db.commit()
return st
def _uncommitted(db):
return bool(db.new or db.dirty or db.deleted) or db.in_transaction()
def _page(db, user, **kw):
args = dict(
scope="both",
q=None,
field="all",
sort="title",
sort_dir="asc",
show="all",
lang="en",
offset=0,
limit=40,
f=LibraryFilters(),
)
args.update(kw)
return library.library_page(db, user, **args)
# --- whose a watch state is ----------------------------------------------------------------
def test_a_users_watch_state_is_theirs_alone(db, make_user):
owner, stranger = make_user(), make_user()
lib = _lib(db)
it = _movie(db, lib, "m1")
_state(db, owner, it, status="watched")
assert queries.state(db, owner, it).status == "watched"
assert queries.state(db, stranger, it) is None
assert queries.states(db, stranger, [it.id]) == {}
assert set(queries.states(db, owner, [it.id])) == {it.id}
assert queries.states(db, owner, []) == {}
def test_the_grid_shows_the_callers_state_and_hides_only_what_they_hid(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db)
seen = _movie(db, lib, "seen")
hid = _movie(db, lib, "hid")
_state(db, other, seen, status="watched")
_state(db, other, hid, status="hidden")
_state(db, me, seen, status="new", position_seconds=90)
total, rows, _ = _page(db, me)
assert total == 2
assert {r.rk: (r.status, r.position_seconds) for r in rows}["seen"][1] == 90
assert [r.rk for r in _page(db, me, show="watched")[1]] == []
assert [r.rk for r in _page(db, other, show="watched")[1]] == ["seen"]
assert [r.rk for r in _page(db, other)[1]] == ["seen"] # their hidden one is gone, for them
def test_the_facets_count_what_the_caller_would_see(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db)
_movie(db, lib, "a", genres=["Drama"], year=2001)
hid = _movie(db, lib, "b", genres=["Horror"], year=1990)
_state(db, other, hid, status="hidden")
mine = library.facets(db, me, scope="both", show="all", lang="en", f=LibraryFilters())
theirs = library.facets(db, other, scope="both", show="all", lang="en", f=LibraryFilters())
assert [g["value"] for g in mine["genres"]] == ["Drama", "Horror"]
assert [g["value"] for g in theirs["genres"]] == ["Drama"]
assert (theirs["year_min"], mine["year_min"]) == (2001, 1990)
def test_an_episode_search_joins_the_callers_state(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db, "2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
db.add(sh)
db.commit()
ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=sh.id)
db.add(ep)
db.commit()
_state(db, other, ep, status="watched")
_, _, eps = _page(db, me, q="pilot")
assert [(e.rating_key, s, show.rating_key) for e, s, show in eps] == [("e1", None, "s1")]
def test_a_shows_rollup_is_the_callers(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db, "2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
db.add(sh)
db.commit()
ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="E", show_id=sh.id)
db.add(ep)
db.commit()
_state(db, other, ep, status="watched", watched_at=NOW)
(row,) = queries.show_rollups(db, me.id, [sh.id])
assert (row.total, row.watched, row.resumed_at) == (1, 0, None)
assert queries.show_rollups(db, me.id, []) == []
# --- libraries, people, a show ---------------------------------------------------------------
def test_the_browsable_libraries_leave_an_overlay_out_and_count_their_titles(db):
en = _lib(db, "1", title="Movies")
_lib(db, "9", title="Filmek", overlay_of=en.id)
_movie(db, en, "m1")
_movie(db, en, "m2")
db.add(PlexItem(rating_key="x", library_id=en.id, kind="episode", title="not a movie"))
db.commit()
assert [(lib.plex_key, n) for lib, n in queries.browsable_libraries(db, "en")] == [("1", 2)]
def test_a_filmography_is_cast_or_directors_and_movies_only(db):
lib = _lib(db)
_movie(db, lib, "acted", cast_names=["Ann"], year=2000)
_movie(db, lib, "directed", directors=["Ann"], year=2010)
_movie(db, lib, "other", cast_names=["Bob"])
db.add(
PlexItem(rating_key="ep", library_id=lib.id, kind="episode", title="E", cast_names=["Ann"])
)
db.commit()
rows = queries.filmography(db, "movie", "Ann", [lib.id], "en", limit=10)
assert [r.rating_key for r in rows] == ["directed", "acted"]
assert queries.filmography(db, "show", "Ann", [lib.id], "en", limit=10) == []
assert queries.cast_count(db, "Ann", [lib.id], []) == 1 # the director credit is not cast
assert queries.cast_count(db, "Ann", [], []) == 0
def test_a_shows_episodes_and_their_keys_are_in_season_order(db):
lib = _lib(db, "2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
db.add(sh)
db.commit()
for rk, season, ep in (("b", 2, 1), ("a", 1, 2), ("c", 1, 1)):
db.add(
PlexItem(
rating_key=rk,
library_id=lib.id,
kind="episode",
title=rk,
show_id=sh.id,
season_number=season,
episode_number=ep,
)
)
db.commit()
assert [e.rating_key for e in queries.episodes(db, sh)] == ["c", "a", "b"]
assert queries.episode_keys(db, sh.id) == ["c", "a", "b"]
assert queries.show(db, "s1\x00") is None # a NUL names no row
# --- collections -----------------------------------------------------------------------------
def test_a_collection_list_narrows_by_library_name_and_keys(db):
lib = _lib(db)
off = _lib(db, "3", title="Off")
off.enabled = False
db.add_all(
[
PlexCollection(rating_key="c1", library_id=lib.id, title="Bond 100%", child_count=5),
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=9),
PlexCollection(rating_key="c3", library_id=off.id, title="Hidden"),
]
)
db.commit()
keys = lambda rows: [c.rating_key for c in rows] # noqa: E731
assert keys(queries.collections_page(db, None, None, None, "en")) == ["c2", "c1"]
assert keys(queries.collections_page(db, "3", None, None, "en")) == ["c3"]
assert keys(queries.collections_page(db, "404", None, None, "en")) == []
assert keys(queries.collections_page(db, None, "0%", None, "en")) == ["c1"] # % is literal
assert keys(queries.collections_page(db, None, "%", None, "en")) == ["c1"]
assert keys(queries.collections_page(db, None, None, ["c1"], "en")) == ["c1"]
assert keys(queries.collections_page(db, None, None, [], "en")) == [] # asks for none
assert queries.collection_named(db, lib.id, "PIXAR").rating_key == "c2"
assert queries.collection_named(db, off.id, "pixar") is None
def test_a_strip_lists_one_librarys_members_of_a_collection(db):
lib, other = _lib(db), _lib(db, "4")
_movie(db, lib, "in", collection_keys=["c1"], year=2002)
_movie(db, lib, "early", collection_keys=["c1"], year=1999)
_movie(db, other, "elsewhere", collection_keys=["c1"])
_movie(db, lib, "out", collection_keys=["c2"])
rows = queries.library_movies_in(db, lib.id, "c1", "en", limit=10)
assert [r.rating_key for r in rows] == ["early", "in"]
movies, shows = queries.collection_members(db, "c1", "en")
assert ([m.rating_key for m in movies], shows) == (["early", "in", "elsewhere"], [])
# --- writes ----------------------------------------------------------------------------------
def test_a_movie_enrichment_is_stamped_and_left_to_the_caller_to_commit(db):
lib = _lib(db)
it = _movie(db, lib, "m1")
meta = {"Role": [{"tag": "Ann", "role": "Lead"}], "tagline": "Big."}
service.enrich_movie(db, it, meta)
assert it.people_enriched_at is not None
assert "Ann" in (it.cast_names or [])
assert _uncommitted(db)
db.rollback()
db.expire_all()
assert db.get(PlexItem, it.id).people_enriched_at is None
def test_a_collection_kept_after_a_failed_read_is_editable_and_holds_its_seed(db):
lib = _lib(db)
seed = _movie(db, lib, "seed", collection_keys=["old"])
service.keep_created_collection(db, lib, "new", "Mine", "seed")
service.keep_created_collection(db, lib, "new2", "Mine 2", "seed")
db.commit()
db.expire_all()
col = queries.collection(db, "new")
assert (col.editable, col.child_count, col.title) == (True, 1, "Mine")
assert db.get(PlexItem, seed.id).collection_keys == ["old", "new", "new2"]
def test_marking_a_collection_editable_is_left_to_the_caller(db):
lib = _lib(db)
db.add(PlexCollection(rating_key="c1", library_id=lib.id, title="C"))
db.commit()
col = queries.collection(db, "c1")
service.set_collection_editable(col, True)
assert _uncommitted(db)
db.commit()
db.expire_all()
assert db.scalar(select(PlexCollection.editable)) is True
+1 -1
View File
@@ -1088,7 +1088,7 @@ def test_a_nul_in_the_rating_key_is_not_a_500(client, monkeypatch):
def test_a_nul_rating_key_names_no_row():
"""Postgres refuses NUL in text, so looking it up was a DataError (a 500) on every Plex route."""
from app.models import PlexItem
from app.plex.models import PlexItem
from app.plex.lookups import by_rating_key
assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type]
+2 -8
View File
@@ -8,14 +8,8 @@ with a stub Plex (the same seam the overlay test uses), so no live server is nee
from sqlalchemy import select
from app.models import (
PlexItem,
PlexLibrary,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex.sync import (
_TYPE_EPISODE,
_TYPE_MOVIE,
+1 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import select
from app.plex import people, sync
from app.plex.client import PlexError
from app.models import PlexItem, PlexLibrary, PlexPerson
from app.plex.models import PlexItem, PlexLibrary, PlexPerson
class _FakePlex:
+1 -1
View File
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
import httpx
import pytest
from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
from app.plex import tmdb
# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload.
+2 -1
View File
@@ -7,7 +7,8 @@ import pytest
from sqlalchemy import select
from app import sysconfig
from app.models import PlexItem, PlexLibrary, PlexLink, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex import watch_sync
+2 -1
View File
@@ -13,7 +13,8 @@ from types import SimpleNamespace
from sqlalchemy import select
from app.models import PlexItem, PlexLibrary, PlexState, User
from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies
from app.routes.plex.playback import (
_PUSH_THROTTLE_S,
+1 -1
View File
@@ -40,7 +40,7 @@ FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef)
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
SERIALIZERS = {
"JSONResponse",
"_collection_card",
"collection_card",
"_playlist_card",
"_serialize",
"_serialize_folder",
-5
View File
@@ -110,13 +110,8 @@ UNIT_ROUTES = {
# What each HTTP file still does, by file: the debt a unit's sprint pays off. Exact counts. A core
# route's count is only the unit tables it imports; it is paid by the sprint of the unit it reaches.
ALLOWED = {
"plex/_shared.py": 1,
"plex/admin.py": 12,
"plex/browse.py": 93,
"plex/collections.py": 33,
"plex/detail.py": 29,
"plex/discover.py": 44,
"plex/people.py": 20,
"plex/playback.py": 42,
"plex/playlists.py": 80,
}
+1 -1
View File
@@ -113,7 +113,7 @@ project checks needs a row.
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
| unit fence | the database work the HTTP layer still does, pinned | `backend/tests/test_unit_fences.py`: a unit's route file may not build SQL, call the session's data methods, set a model's attributes or import a unit's tables; a core route may not import a unit's tables. Switched on unit by unit: `ALLOWED` pins each file's count exactly, so a rise fails and a fall fails until the pin is lowered — the list may only shrink. End state: empty, and the pin mechanism goes. A unit in `FENCED` is held below its HTTP layer too: its code reads another unit's tables only along `READS`, writes them never, filters owners through `userscope`, and commits only where `UNIT_COMMITS` names a job's own session. | 826 in 23 files (E42 S0) → 667 in 19 (S1: Capture 0, fenced) → 525 in 14 (S2: Reader 0, fenced) → 354 in 9 (S3: Catalog 0, fenced; core routes 0) |
| unit fence | the database work the HTTP layer still does, pinned | `backend/tests/test_unit_fences.py`: a unit's route file may not build SQL, call the session's data methods, set a model's attributes or import a unit's tables; a core route may not import a unit's tables. Switched on unit by unit: `ALLOWED` pins each file's count exactly, so a rise fails and a fall fails until the pin is lowered — the list may only shrink. End state: empty, and the pin mechanism goes. A unit in `FENCED` is held below its HTTP layer too: its code reads another unit's tables only along `READS`, writes them never, filters owners through `userscope`, and commits only where `UNIT_COMMITS` names a job's own session. | 826 in 23 files (E42 S0) → 667 in 19 (S1: Capture 0, fenced) → 525 in 14 (S2: Reader 0, fenced) → 354 in 9 (S3: Catalog 0, fenced; core routes 0) → 178 in 4 (S4a: Plex browse, detail, people, collections 0) |
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
## Accepted findings