Compare commits
3
Commits
4b6db47083
...
2cf1b688d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf1b688d9 | ||
|
|
3b0e365e26 | ||
|
|
533571f572 |
@@ -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": [
|
"./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",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"range": {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.discover import cards, queries
|
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.catalog.models import Channel, Video
|
||||||
from app.reader.models import ComfortPin, Playlist
|
from app.reader.models import ComfortPin, Playlist
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ from app.feedquery import (
|
|||||||
serialize,
|
serialize,
|
||||||
sort_keys,
|
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.catalog.models import Channel, Video
|
||||||
from app.reader.models import PlaylistItem, VideoState
|
from app.reader.models import PlaylistItem, VideoState
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.discover import cards, queries
|
from app.discover import cards, queries
|
||||||
from app.discover.queries import RAIL_LIMIT
|
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.lookups import by_rating_key
|
||||||
from app.plex.rollup import NO_TS
|
from app.plex.rollup import NO_TS
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ from app.downloads.jobdb import (
|
|||||||
)
|
)
|
||||||
from app.downloads.stated import StatedFailure
|
from app.downloads.stated import StatedFailure
|
||||||
from app.downloads.status import JobStatus
|
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.downloads.models import DownloadJob, MediaAsset
|
||||||
from app.plex import lookups as plex_lookups
|
from app.plex import lookups as plex_lookups
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ from app.downloads import credentials
|
|||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import folders, formats, mediakind, quota, storage
|
from app.downloads import folders, formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobKind, JobStatus
|
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.catalog.models import Channel, Video
|
||||||
from app.downloads.models import (
|
from app.downloads.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
|
|||||||
+1
-439
@@ -5,10 +5,8 @@ from sqlalchemy import (
|
|||||||
BigInteger,
|
BigInteger,
|
||||||
Boolean,
|
Boolean,
|
||||||
CheckConstraint,
|
CheckConstraint,
|
||||||
Computed,
|
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Float,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -20,7 +18,7 @@ from sqlalchemy import (
|
|||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
|
|
||||||
from app.db import Base
|
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_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
can_write: 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")
|
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)
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ router. Pure functions — no DB, no request state."""
|
|||||||
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from app.models import PlexItem, PlexShow, PlexState
|
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||||
|
|
||||||
|
|
||||||
class ShowState(NamedTuple):
|
class ShowState(NamedTuple):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
|
"""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.
|
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
|
Shared by the live info-page path (`plex.service.enrich_movie` / `enrich_show`) and the background
|
||||||
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
|
rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s
|
||||||
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row
|
cast handling consistent. `rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY
|
||||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
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 import people
|
||||||
from app.plex.plaintext import plain_text
|
from app.plex.plaintext import plain_text
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi import Response
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
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.cache import CACHE_DIR
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -13,7 +13,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
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 import sync as plex_sync
|
||||||
from app.plex.client import PlexClient
|
from app.plex.client import PlexClient
|
||||||
from app.plex.genres import hu_genres
|
from app.plex.genres import hu_genres
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.models import PlexPerson
|
from app.plex.models import PlexPerson
|
||||||
from app.plex import imgcache
|
from app.plex import imgcache
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,7 +10,7 @@ from sqlalchemy import and_, case, func, or_, select, type_coerce
|
|||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Session
|
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
|
from app.plex.genres import hu_genre
|
||||||
|
|
||||||
TS_CONFIG = "public.unaccent_simple"
|
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`.
|
# 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`
|
# 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.
|
# 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")
|
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import and_, case, func, or_, select
|
from sqlalchemy import and_, case, func, or_, select
|
||||||
from sqlalchemy.orm import Session, aliased
|
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
|
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
|
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session as DbSession
|
|||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads.edit import encode_thread_cap
|
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 import paths
|
||||||
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
|
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app.db import SessionLocal
|
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 import paths as plex_paths
|
||||||
from app.plex.cache import CACHE_DIR
|
from app.plex.cache import CACHE_DIR
|
||||||
from app.plex.client import PlexClient, PlexError
|
from app.plex.client import PlexClient, PlexError
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
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 import detail, overlay, people
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.genres import hu_genres
|
from app.plex.genres import hu_genres
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
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.cards import display_title
|
||||||
from app.plex.people import people_by_credits, warm_portraits
|
from app.plex.people import people_by_credits, warm_portraits
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.db import SessionLocal
|
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.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.sync import (
|
from app.plex.sync import (
|
||||||
_TYPE_EPISODE,
|
_TYPE_EPISODE,
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
"""Shared helpers, serializers and constants for the plex route package."""
|
"""Shared helpers, serializers and constants for the plex route package."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
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.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
|
from app.routes._common import not_found
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.plex.models import PlexItem
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
def item_or_404(db: Session, rating_key: str) -> "PlexItem":
|
||||||
it = by_rating_key(db, PlexItem, rating_key)
|
it = queries.item(db, rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise not_found("plex_item")
|
raise not_found("plex_item")
|
||||||
return it
|
return it
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex.models import PlexItem, PlexLink
|
||||||
PlexLink,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex import watch_sync as plex_watch
|
from app.plex import watch_sync as plex_watch
|
||||||
|
|||||||
@@ -1,110 +1,30 @@
|
|||||||
"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters."""
|
"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import (
|
from sqlalchemy.orm import Session
|
||||||
Boolean,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
and_,
|
|
||||||
cast,
|
|
||||||
func,
|
|
||||||
literal,
|
|
||||||
null,
|
|
||||||
or_,
|
|
||||||
select,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Session, aliased
|
|
||||||
|
|
||||||
from app.mailtext import lang_for_user
|
|
||||||
from app.collation import collated, sorted_words
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.mailtext import lang_for_user
|
||||||
PlexItem,
|
from app.models import User
|
||||||
PlexLibrary,
|
from app.plex import library as plex_library
|
||||||
PlexShow,
|
from app.plex import queries
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex import rollup
|
|
||||||
from app.plex.cards import display_title, episode_card
|
from app.plex.cards import display_title, episode_card
|
||||||
from app.plex.query import (
|
from app.plex.query import LibraryFilters
|
||||||
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.routes.plex._shared import _norm_lang
|
from app.routes.plex._shared import _norm_lang
|
||||||
from app.utils import to_tsquery_str
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
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")
|
@router.get("/libraries")
|
||||||
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
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
|
"""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."""
|
libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata."""
|
||||||
libs = (
|
out = [
|
||||||
db.execute(
|
{"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count}
|
||||||
select(PlexLibrary)
|
for lib, count in queries.browsable_libraries(db, lang_for_user(user))
|
||||||
.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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
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)."""
|
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
|
||||||
offset = max(0, offset)
|
offset = max(0, offset)
|
||||||
lang = _norm_lang(lang)
|
lang = _norm_lang(lang)
|
||||||
p = f.as_dict()
|
total, rows, eps = plex_library.library_page(
|
||||||
movie_lib_ids, show_lib_ids = content_libs(db)
|
db,
|
||||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
user,
|
||||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
scope=scope,
|
||||||
|
q=q,
|
||||||
ts = to_tsquery_str(q) if q else None
|
field=field,
|
||||||
tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None
|
sort=sort,
|
||||||
if field not in FIELD_COLUMNS and field != "all":
|
sort_dir=sort_dir,
|
||||||
field = "all"
|
show=show,
|
||||||
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
|
lang=lang,
|
||||||
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
|
offset=offset,
|
||||||
grid_field = field != "episode"
|
limit=limit,
|
||||||
recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None
|
f=f,
|
||||||
|
)
|
||||||
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)
|
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
total = 0
|
for r in rows:
|
||||||
if selects:
|
if r.kind == "movie":
|
||||||
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
|
items.append(
|
||||||
u = union.subquery()
|
{
|
||||||
total = db.scalar(select(func.count()).select_from(u)) or 0
|
"id": r.rk,
|
||||||
|
"type": "movie",
|
||||||
if tsq is not None:
|
"title": r.title,
|
||||||
order = [u.c.rank.desc()]
|
"year": r.year,
|
||||||
else:
|
"rating": r.rating,
|
||||||
col = sort_column(u, sort, lang)
|
"summary": r.summary,
|
||||||
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
"duration_seconds": r.duration_s,
|
||||||
order.append(u.c.rk.desc())
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all()
|
"playable": r.playable,
|
||||||
|
"transcode_hw": r.transcode_hw,
|
||||||
for r in rows:
|
"status": r.status,
|
||||||
if r.kind == "movie":
|
"position_seconds": r.position_seconds,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
.order_by(func.ts_rank(ep_vec, tsq).desc())
|
else:
|
||||||
.limit(24)
|
items.append(
|
||||||
).all()
|
{
|
||||||
for e, s, sh in eps:
|
"id": r.rk,
|
||||||
card = episode_card(e, s, lang=lang)
|
"type": "show",
|
||||||
card["show_title"] = display_title(sh, lang) if sh is not None else None
|
"title": r.title,
|
||||||
episodes.append(card)
|
"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 {
|
return {
|
||||||
"scope": scope,
|
"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
|
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
|
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.)"""
|
movie-only. (Search text is not factored in — facets track the sidebar filters.)"""
|
||||||
empty = {
|
return plex_library.facets(db, user, scope=scope, show=show, lang=lang, f=f)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,96 +1,41 @@
|
|||||||
"""Plex collections: browse mirrored collections + the admin manual-collection editor."""
|
"""Plex collections: browse mirrored collections + the admin manual-collection editor."""
|
||||||
|
|
||||||
from collections.abc import Callable
|
from datetime import datetime
|
||||||
from datetime import date, datetime
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any, NamedTuple
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.mailtext import lang_for_user
|
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 import logs
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexCollection,
|
from app.plex import queries, rollup, service
|
||||||
PlexItem,
|
|
||||||
PlexLibrary,
|
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex import rollup
|
|
||||||
from app.plex import sync as plex_sync
|
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.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.collections import (
|
||||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
DEFAULT_MEMBER_SORT,
|
||||||
from app.utils import like_escape
|
Member,
|
||||||
|
collection_card,
|
||||||
|
collection_cert,
|
||||||
|
collection_editable,
|
||||||
|
collection_source,
|
||||||
|
sort_members,
|
||||||
|
)
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
from app.routes._common import not_found
|
from app.routes._common import not_found
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.plex.models import PlexCollection, PlexState
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
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"])
|
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")
|
@router.get("/collections")
|
||||||
def 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
|
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
|
through this endpoint costs one small row each; pulling the whole list to find two titles is
|
||||||
~200 KB."""
|
~200 KB."""
|
||||||
if library is not None:
|
wanted = [k for k in keys.split(",") if k] if keys is not None else None
|
||||||
lib = (
|
rows = queries.collections_page(db, library, q, wanted, lang_for_user(user))
|
||||||
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()
|
|
||||||
)
|
|
||||||
# A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW
|
# 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
|
# 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.
|
# 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).
|
# Runtime is movie-only (shows have no single duration).
|
||||||
movie_ids, show_ids = content_libs(db)
|
art_m, art_s, runtime = queries.collection_tiles(db, [c.rating_key for c in rows])
|
||||||
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)
|
|
||||||
|
|
||||||
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)
|
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 member or ([c.rating_key] if c.thumb_key else [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"collections": [
|
"collections": [
|
||||||
{
|
{
|
||||||
**_collection_card(c),
|
**collection_card(c),
|
||||||
"art": _art(c),
|
"art": _art(c),
|
||||||
"total_runtime_s": runtime.get(c.rating_key, 0),
|
"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
|
"""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
|
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."""
|
`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)
|
movie = queries.movie(db, item_rating_key)
|
||||||
title: PlexItem | PlexShow | None = (
|
title = movie or queries.show(db, item_rating_key)
|
||||||
movie if movie is not None and movie.kind == "movie" else None
|
|
||||||
) or by_rating_key(db, PlexShow, item_rating_key)
|
|
||||||
if title is None:
|
if title is None:
|
||||||
raise not_found("item")
|
raise not_found("item")
|
||||||
member_of = set(title.collection_keys or [])
|
member_of = set(title.collection_keys or [])
|
||||||
rows = (
|
rows = queries.library_collections(db, title.library_id, lang_for_user(user))
|
||||||
db.execute(
|
|
||||||
select(PlexCollection)
|
|
||||||
.where(PlexCollection.library_id == title.library_id)
|
|
||||||
.order_by(collated(PlexCollection.title, lang_for_user(user)))
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"collections": [
|
"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
|
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}")
|
@router.get("/collections/{rating_key}")
|
||||||
def collection_detail(
|
def collection_detail(
|
||||||
rating_key: str,
|
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
|
what this page did before it had a control. Read-only here; edits go through the admin mutation
|
||||||
endpoints below."""
|
endpoints below."""
|
||||||
lang = norm_lang(lang)
|
lang = norm_lang(lang)
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = queries.collection(db, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise not_found("collection")
|
raise not_found("collection")
|
||||||
movies = (
|
movies, shows = queries.collection_members(db, rating_key, lang)
|
||||||
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()
|
|
||||||
)
|
|
||||||
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
|
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` —
|
# 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
|
# 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
|
# cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on
|
||||||
# a show collection while the control still offered it.
|
# a show collection while the control still offered it.
|
||||||
show_rows = (
|
show_rows = queries.show_rollups(db, user.id, [s.id for s in shows])
|
||||||
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 []
|
|
||||||
)
|
|
||||||
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
|
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}
|
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
|
# Same definition as the browse grid's: when it was last resumed, else when it was marked
|
||||||
# watched (coalesce(progress_updated_at, watched_at)).
|
# watched (coalesce(progress_updated_at, watched_at)).
|
||||||
return (st.progress_updated_at or st.watched_at) if st else None
|
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]
|
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]
|
certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
|
||||||
return {
|
return {
|
||||||
**_collection_card(col),
|
**collection_card(col),
|
||||||
"content_rating": _collection_cert(certs),
|
"content_rating": collection_cert(certs),
|
||||||
"year_min": min(years) if years else None,
|
"year_min": min(years) if years else None,
|
||||||
"year_max": max(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),
|
"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:
|
def _editable_collection_or_403(db: Session, rating_key: str) -> "PlexCollection":
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = queries.collection(db, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise not_found("collection")
|
raise not_found("collection")
|
||||||
if not _collection_editable(col):
|
if not collection_editable(col):
|
||||||
raise refused(403, "collection_read_only")
|
raise refused(403, "collection_read_only")
|
||||||
return col
|
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'
|
"""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
|
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."""
|
front on create + rename rather than mirror the mess."""
|
||||||
dup = (
|
dup = queries.collection_named(db, library_id, title)
|
||||||
db.execute(
|
|
||||||
select(PlexCollection).where(
|
|
||||||
PlexCollection.library_id == library_id,
|
|
||||||
func.lower(PlexCollection.title) == title.lower(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if dup is not None and dup.rating_key != exclude_rk:
|
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
|
# 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).
|
# Hungarian beside an English error dialog saying the same thing).
|
||||||
@@ -441,15 +242,13 @@ def create_collection(
|
|||||||
seed = body.item_rating_key
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
if not title or not seed:
|
||||||
raise refused(400, "collection_fields_required")
|
raise refused(400, "collection_fields_required")
|
||||||
seed_show = by_rating_key(db, PlexShow, seed)
|
seed_show = queries.show(db, seed)
|
||||||
if library:
|
if library:
|
||||||
lib = (
|
lib = queries.library_by_key(db, library)
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# The "New collection" flow only knows the seed title → derive its library.
|
# The "New collection" flow only knows the seed title → derive its library.
|
||||||
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
|
seed_item = queries.item(db, seed) or seed_show
|
||||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
lib = queries.library(db, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise not_found("library")
|
raise not_found("library")
|
||||||
_reject_duplicate_name(db, lib.id, title)
|
_reject_duplicate_name(db, lib.id, title)
|
||||||
@@ -462,28 +261,16 @@ def create_collection(
|
|||||||
wrote = True
|
wrote = True
|
||||||
new_rk = str(new.get("ratingKey") or "")
|
new_rk = str(new.get("ratingKey") or "")
|
||||||
col = plex_sync.resync_collection(db, plex, lib, new_rk)
|
col = plex_sync.resync_collection(db, plex, lib, new_rk)
|
||||||
col.editable = True
|
service.set_collection_editable(col, True)
|
||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
if wrote and new_rk:
|
if wrote and new_rk:
|
||||||
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
|
# It is on Plex now, and only the follow-up read failed: keep its row (see the service).
|
||||||
# full sync preserves `editable` but creates a missing row read-only, so the collection
|
service.keep_created_collection(db, lib, new_rk, title, seed)
|
||||||
# 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]))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
raise _write_or_pending_error(wrote, e) from e
|
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}")
|
@router.post("/collections/{rating_key}/items/{item_rating_key}")
|
||||||
@@ -494,7 +281,7 @@ def collection_add_item(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
col = _editable_collection_or_403(db, rating_key)
|
col = _editable_collection_or_403(db, rating_key)
|
||||||
lib = db.get(PlexLibrary, col.library_id)
|
lib = queries.library(db, col.library_id)
|
||||||
wrote = False
|
wrote = False
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
@@ -505,7 +292,7 @@ def collection_add_item(
|
|||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _write_or_pending_error(wrote, e) from e
|
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}")
|
@router.delete("/collections/{rating_key}/items/{item_rating_key}")
|
||||||
@@ -516,7 +303,7 @@ def collection_remove_item(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
col = _editable_collection_or_403(db, rating_key)
|
col = _editable_collection_or_403(db, rating_key)
|
||||||
lib = db.get(PlexLibrary, col.library_id)
|
lib = queries.library(db, col.library_id)
|
||||||
wrote = False
|
wrote = False
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
@@ -527,7 +314,7 @@ def collection_remove_item(
|
|||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _write_or_pending_error(wrote, e) from e
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return collection_card(col)
|
||||||
|
|
||||||
|
|
||||||
class CollectionRenameIn(BaseModel):
|
class CollectionRenameIn(BaseModel):
|
||||||
@@ -546,7 +333,7 @@ def collection_rename(
|
|||||||
if not title:
|
if not title:
|
||||||
raise refused(400, "title_required")
|
raise refused(400, "title_required")
|
||||||
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
_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
|
wrote = False
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
@@ -557,7 +344,7 @@ def collection_rename(
|
|||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _write_or_pending_error(wrote, e) from e
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return collection_card(col)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{rating_key}")
|
@router.delete("/collections/{rating_key}")
|
||||||
@@ -565,7 +352,7 @@ def collection_delete(
|
|||||||
rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
|
rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
col = _editable_collection_or_403(db, rating_key)
|
col = _editable_collection_or_403(db, rating_key)
|
||||||
lib = db.get(PlexLibrary, col.library_id)
|
lib = queries.library(db, col.library_id)
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
plex.delete_collection(rating_key)
|
plex.delete_collection(rating_key)
|
||||||
@@ -590,12 +377,12 @@ def collection_set_editable(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections
|
"""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."""
|
(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:
|
if col is None:
|
||||||
raise not_found("collection")
|
raise not_found("collection")
|
||||||
want = body.editable
|
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")
|
raise refused(400, "collection_smart")
|
||||||
col.editable = want
|
service.set_collection_editable(col, want)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _collection_card(col)
|
return collection_card(col)
|
||||||
|
|||||||
@@ -4,52 +4,34 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.collation import collated, sorted_words
|
from app.collation import sorted_words
|
||||||
from app import logs
|
from app import logs
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.discover import queries
|
from app.discover import queries as discover_queries
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexCollection,
|
|
||||||
PlexItem,
|
|
||||||
PlexLibrary,
|
|
||||||
PlexSeason,
|
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex import detail as plex_detail
|
from app.plex import detail as plex_detail
|
||||||
from app.plex import people as plex_people
|
from app.plex import queries, rollup, service, subtitles
|
||||||
from app.plex import rollup, subtitles
|
|
||||||
from app.plex.query import title_col
|
|
||||||
from app.plex import tmdb as plex_tmdb
|
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.cards import SHOW_NEW, episode_card, movie_card, show_card
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
|
||||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
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
|
from app.routes._common import not_found
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.plex.models import PlexItem, PlexSeason
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
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_TTL_S = 300
|
||||||
|
|
||||||
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
|
_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."""
|
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
|
||||||
lang = _norm_lang(lang)
|
lang = _norm_lang(lang)
|
||||||
hu = lang == "hu"
|
hu = lang == "hu"
|
||||||
sh = by_rating_key(db, PlexShow, rating_key)
|
sh = queries.show(db, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise not_found("show")
|
raise not_found("show")
|
||||||
seasons = (
|
seasons = queries.seasons(db, sh)
|
||||||
db.execute(
|
eps = queries.episodes(db, sh)
|
||||||
select(PlexSeason)
|
states = queries.states(db, user, [e.id for e in eps])
|
||||||
.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()
|
|
||||||
}
|
|
||||||
by_season: dict[int | None, list] = {}
|
by_season: dict[int | None, list] = {}
|
||||||
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
|
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
|
||||||
for e in eps:
|
for e in eps:
|
||||||
@@ -163,9 +118,7 @@ def show_detail(
|
|||||||
meta, related_raw = _show_enrichment(db, sh.rating_key)
|
meta, related_raw = _show_enrichment(db, sh.rating_key)
|
||||||
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang)
|
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang)
|
||||||
if sh.people_enriched_at is None and meta:
|
if sh.people_enriched_at is None and meta:
|
||||||
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
|
service.enrich_show(db, sh, meta) # imdb/tagline/full_cast for future views
|
||||||
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
|
|
||||||
sh.people_enriched_at = datetime.now(timezone.utc)
|
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
|
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
|
||||||
@@ -198,7 +151,7 @@ def show_detail(
|
|||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
"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,
|
"content_rating": sh.content_rating,
|
||||||
"rating": sh.rating,
|
"rating": sh.rating,
|
||||||
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
|
"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
|
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.
|
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."""
|
`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:
|
if sh is None:
|
||||||
raise not_found("show")
|
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
|
"""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)."""
|
season subpage, read from the same cached show-detail payload)."""
|
||||||
roll = rollup.rollup(cards)
|
roll = rollup.rollup(cards)
|
||||||
@@ -265,12 +218,7 @@ def _relatedshow_cards(
|
|||||||
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
|
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
|
||||||
if not rks:
|
if not rks:
|
||||||
return []
|
return []
|
||||||
shows = {
|
shows = queries.shows_by_keys(db, rks[:60], exclude)
|
||||||
s.rating_key: s
|
|
||||||
for s in db.execute(
|
|
||||||
select(PlexShow).where(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
|
|
||||||
).scalars()
|
|
||||||
}
|
|
||||||
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
|
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -285,73 +233,36 @@ def _relatedshow_cards(
|
|||||||
return out
|
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
|
"""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)."""
|
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
|
||||||
keys = it.collection_keys or []
|
keys = it.collection_keys or []
|
||||||
if not keys or it.kind != "movie":
|
if not keys or it.kind != "movie":
|
||||||
return []
|
return []
|
||||||
cols = {
|
cols = {c.rating_key: c for c in queries.collections_by_keys(db, keys)}
|
||||||
c.rating_key: c
|
|
||||||
for c in db.execute(
|
|
||||||
select(PlexCollection).where(PlexCollection.rating_key.in_(keys))
|
|
||||||
).scalars()
|
|
||||||
}
|
|
||||||
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
|
# 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))}
|
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]))
|
ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title]))
|
||||||
out = []
|
out = []
|
||||||
for col in ordered:
|
for col in ordered:
|
||||||
rk = col.rating_key
|
rk = col.rating_key
|
||||||
members = (
|
members = queries.library_movies_in(db, it.library_id, rk, lang, limit=60)
|
||||||
db.execute(
|
states = queries.states(db, user_id, [m.id for m in members])
|
||||||
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()
|
|
||||||
}
|
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"id": rk,
|
"id": rk,
|
||||||
"title": col.title,
|
"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],
|
"items": [movie_card(m, states.get(m.id), lang=lang) for m in members],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
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:
|
if it.kind != "episode" or it.show_id is None:
|
||||||
return None, None
|
return None, None
|
||||||
keys = list(
|
keys = queries.episode_keys(db, it.show_id)
|
||||||
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()
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
i = keys.index(it.rating_key)
|
i = keys.index(it.rating_key)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -398,13 +309,7 @@ def item_detail(
|
|||||||
prev/next, and the per-user resume position + watch status."""
|
prev/next, and the per-user resume position + watch status."""
|
||||||
lang = _norm_lang(lang)
|
lang = _norm_lang(lang)
|
||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
st = (
|
st = queries.state(db, user, it)
|
||||||
db.execute(
|
|
||||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/
|
# 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
|
# 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
|
# 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.
|
# 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:
|
if it.kind == "movie" and it.people_enriched_at is None and meta:
|
||||||
cast = plex_people.cast_from_meta(meta)
|
service.enrich_movie(db, it, 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)
|
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
|
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)
|
rich = plex_detail.rich_detail(meta)
|
||||||
|
|
||||||
prev_id, next_id = _episode_nav(db, it)
|
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
|
show = queries.show_by_id(db, 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
|
season = queries.season_by_id(db, it.season_id) if show and it.season_id else None
|
||||||
return {
|
return {
|
||||||
"id": it.rating_key,
|
"id": it.rating_key,
|
||||||
"kind": it.kind,
|
"kind": it.kind,
|
||||||
@@ -538,7 +439,7 @@ def item_detail(
|
|||||||
"video_bit_depth": it.video_bit_depth,
|
"video_bit_depth": it.video_bit_depth,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
"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", []),
|
"cast": rich.get("cast", []),
|
||||||
"imdb_rating": rich.get("imdb_rating"),
|
"imdb_rating": rich.get("imdb_rating"),
|
||||||
"imdb_id": rich.get("imdb_id"),
|
"imdb_id": rich.get("imdb_id"),
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app.collation import sorted_words
|
from app.collation import sorted_words
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
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 import rollup
|
||||||
from app.plex.cards import (
|
from app.plex.cards import (
|
||||||
SHOW_NEW,
|
SHOW_NEW,
|
||||||
|
|||||||
@@ -4,26 +4,18 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.collation import collated
|
from app import sysconfig
|
||||||
from app import logs, sysconfig
|
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex import queries, rollup, service
|
||||||
PlexPerson,
|
|
||||||
PlexShow,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex import rollup
|
|
||||||
from app.plex import tmdb as plex_tmdb
|
from app.plex import tmdb as plex_tmdb
|
||||||
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
|
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"])
|
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).
|
# 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)
|
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||||
photos = {
|
photos = queries.person_photos(db, wanted)
|
||||||
p.name: p.photo_thumb
|
movies = movie_lib_ids if want_movies else []
|
||||||
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(wanted))).scalars()
|
shows = show_lib_ids if want_shows else []
|
||||||
}
|
out = [
|
||||||
out = []
|
{"name": nm, "photo": photos.get(nm), "count": queries.cast_count(db, nm, movies, shows)}
|
||||||
for nm in wanted:
|
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})
|
|
||||||
return {"people": out}
|
return {"people": out}
|
||||||
|
|
||||||
|
|
||||||
@@ -144,24 +114,6 @@ def _should_schedule_enrich(person_id: int) -> bool:
|
|||||||
return True
|
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}")
|
@router.get("/person/{name}")
|
||||||
def person_detail(
|
def person_detail(
|
||||||
name: str,
|
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.
|
fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet.
|
||||||
"""
|
"""
|
||||||
lang = norm_lang(lang)
|
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.
|
# 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)
|
# 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.
|
# 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"))
|
and bool(sysconfig.get_str(db, "tmdb_api_key"))
|
||||||
)
|
)
|
||||||
if enriching and person is not None and _should_schedule_enrich(person.id):
|
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)
|
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||||
|
|
||||||
movies: list[dict] = []
|
movies: list[dict] = []
|
||||||
if movie_lib_ids:
|
if movie_lib_ids:
|
||||||
items = (
|
items = queries.filmography(db, "movie", name, movie_lib_ids, lang, _PERSON_SECTION_MAX)
|
||||||
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()
|
|
||||||
)
|
|
||||||
states = rollup.item_state_map(db, user.id, [it.id for it in items])
|
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]
|
movies = [movie_card(it, states.get(it.id), lang=lang) for it in items]
|
||||||
|
|
||||||
shows: list[dict] = []
|
shows: list[dict] = []
|
||||||
if show_lib_ids:
|
if show_lib_ids:
|
||||||
show_rows = (
|
show_rows = queries.filmography(db, "show", name, show_lib_ids, lang, _PERSON_SECTION_MAX)
|
||||||
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()
|
|
||||||
)
|
|
||||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
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]
|
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,8 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.auth import current_user, resolved_user_id
|
from app.auth import current_user, resolved_user_id
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex.models import PlexItem, PlexSeason, PlexShow, PlexState
|
||||||
PlexSeason,
|
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex import imgcache, subtitles
|
from app.plex import imgcache, subtitles
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|||||||
@@ -11,14 +11,8 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex.models import PlexItem, PlexPlaylist, PlexPlaylistItem, PlexShow, PlexState
|
||||||
PlexPlaylist,
|
|
||||||
PlexPlaylistItem,
|
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex.cards import display_title, leaf_card, norm_lang
|
from app.plex.cards import display_title, leaf_card, norm_lang
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes._common import owned_or_404, not_found
|
from app.routes._common import owned_or_404, not_found
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ every mapper — imports this module.
|
|||||||
import app.catalog.models # Catalog's tables
|
import app.catalog.models # Catalog's tables
|
||||||
import app.downloads.models # Capture's tables
|
import app.downloads.models # Capture's tables
|
||||||
import app.models # the core tables
|
import app.models # the core tables
|
||||||
|
import app.plex.models # Plex's tables
|
||||||
import app.reader.models # noqa: F401 — Reader's tables
|
import app.reader.models # noqa: F401 — Reader's tables
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.downloads import errors as dlerrors
|
|||||||
from app.downloads import formats, pipeline, quota, runner, service
|
from app.downloads import formats, pipeline, quota, runner, service
|
||||||
from app.downloads import probe as probemod
|
from app.downloads import probe as probemod
|
||||||
from app.downloads.edit import INPUT_GUARD
|
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.catalog.models import Channel, Video
|
||||||
from app.downloads.models import DownloadJob, MediaAsset
|
from app.downloads.models import DownloadJob, MediaAsset
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from app.auth import current_user
|
|||||||
from app.collation import sorted_words
|
from app.collation import sorted_words
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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.catalog.models import Channel, Subscription, Video
|
||||||
from app.reader.models import Playlist, PlaylistItem
|
from app.reader.models import Playlist, PlaylistItem
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from app.discover import hero, pins, queries, rails
|
|||||||
from app.discover.budget import BANDS, fits, remaining_seconds
|
from app.discover.budget import BANDS, fits, remaining_seconds
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import leaf_card
|
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.catalog.models import BlockedChannel, Channel, Subscription, Video
|
||||||
from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState
|
from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ contradicts the remote's own count is not believed either.
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
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.catalog.models import Channel, Video
|
||||||
from app.reader.models import Playlist, PlaylistItem
|
from app.reader.models import Playlist, PlaylistItem
|
||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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 paths as plex_paths
|
||||||
from app.plex import stream
|
from app.plex import stream
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from datetime import datetime, timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import event
|
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.catalog.models import Channel, Video
|
||||||
from app.reader.models import Playlist, PlaylistItem
|
from app.reader.models import Playlist, PlaylistItem
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
import app.routes.plex.admin as admin_routes
|
import app.routes.plex.admin as admin_routes
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.models import PlexLink
|
from app.plex.models import PlexLink
|
||||||
from app.plex.client import PlexError, PlexNotConfigured
|
from app.plex.client import PlexError, PlexNotConfigured
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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."""
|
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
|
from app.plex.cards import movie_card, show_card
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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
|
from app.plex import watch_sync as plex_watch
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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.plex.query import PLEX_SORT_KEYS
|
||||||
from app.routes.plex.browse import sort_column
|
from app.plex.library import sort_column
|
||||||
from app.routes.plex.collections import (
|
from app.plex.collections import (
|
||||||
DEFAULT_MEMBER_SORT,
|
DEFAULT_MEMBER_SORT,
|
||||||
MEMBER_SORT_KEYS,
|
MEMBER_SORT_KEYS,
|
||||||
Member,
|
Member,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import app.state as state
|
|||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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.client import PlexError
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes.plex import collections as coll_routes
|
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.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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):
|
def _seed(db):
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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 import detail
|
||||||
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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)
|
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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):
|
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 collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
|
||||||
PlexLibrary,
|
|
||||||
PlexSeason,
|
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
|
|
||||||
SCOPES = ("movie", "show", "both")
|
SCOPES = ("movie", "show", "both")
|
||||||
SHOW_STATES = ("all", "watched", "in_progress", "unwatched")
|
SHOW_STATES = ("all", "watched", "in_progress", "unwatched")
|
||||||
|
|||||||
@@ -14,13 +14,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||||
PlexLibrary,
|
|
||||||
PlexSeason,
|
|
||||||
PlexShow,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.db import SessionLocal
|
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
|
from app.routes.plex import playback
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
import app.state as app_state
|
import app.state as app_state
|
||||||
from app.main import app
|
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.plex import imgcache
|
||||||
from app.routes.plex import playback
|
from app.routes.plex import playback
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
|||||||
|
|
||||||
from sqlalchemy import text
|
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
|
# 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).
|
# SQL is valid for both tables; the assertions below check the seeded plex_shows rows).
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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):
|
def _seed(db):
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from app.db import get_db
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.plex.cards import display_summary, display_title
|
from app.plex.cards import display_summary, display_title
|
||||||
from app.models import (
|
from app.models import User
|
||||||
|
from app.plex.models import (
|
||||||
PlexCollection,
|
PlexCollection,
|
||||||
PlexItem,
|
PlexItem,
|
||||||
PlexLibrary,
|
PlexLibrary,
|
||||||
@@ -23,7 +24,6 @@ from app.models import (
|
|||||||
PlexSeason,
|
PlexSeason,
|
||||||
PlexShow,
|
PlexShow,
|
||||||
PlexState,
|
PlexState,
|
||||||
User,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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)
|
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
|
|||||||
import app.plex.sync as syncmod
|
import app.plex.sync as syncmod
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.db import engine
|
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.genres import hu_genre, hu_genres
|
||||||
from app.plex.overlay import sync_overlay
|
from app.plex.overlay import sync_overlay
|
||||||
from app.plex.sync import _apply_item, _canonical_guid
|
from app.plex.sync import _apply_item, _canonical_guid
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from datetime import datetime, timezone
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
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 imgcache
|
||||||
from app.plex import people as plex_people
|
from app.plex import people as plex_people
|
||||||
from app.plex import tmdb as plex_tmdb
|
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)
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
override_dep(get_db, db.info["request"])
|
override_dep(get_db, db.info["request"])
|
||||||
override_dep(current_user, lambda: user)
|
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"}
|
key = {"value": "key"}
|
||||||
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"])
|
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"])
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow, PlexState
|
||||||
PlexLibrary,
|
|
||||||
PlexPerson,
|
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex.query import content_libs
|
from app.plex.query import content_libs
|
||||||
|
|
||||||
NAME = "Robert De Niro"
|
NAME = "Robert De Niro"
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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
|
from app.plex import tmdb as plex_tmdb
|
||||||
|
|
||||||
PAYLOAD = {
|
PAYLOAD = {
|
||||||
@@ -190,7 +191,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
scheduled: list[int] = []
|
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")
|
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
|
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):
|
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
|
# 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).
|
# 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}
|
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
|
calls["close"] += 1
|
||||||
|
|
||||||
# Success path: person found → enrich called, session closed, no rollback.
|
# 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(
|
monkeypatch.setattr(
|
||||||
people.plex_tmdb,
|
service.plex_tmdb,
|
||||||
"enrich_person",
|
"enrich_person",
|
||||||
lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1),
|
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}
|
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
|
||||||
|
|
||||||
# Error path: enrich raises → rollback + close, no exception escapes.
|
# 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):
|
def _boom(db, p):
|
||||||
raise RuntimeError("enrich blew up")
|
raise RuntimeError("enrich blew up")
|
||||||
|
|
||||||
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom)
|
monkeypatch.setattr(service.plex_tmdb, "enrich_person", _boom)
|
||||||
people._enrich_person_bg(5) # must not raise
|
service.enrich_person_bg(5) # must not raise
|
||||||
assert calls["rollback"] == 1 and calls["close"] == 1
|
assert calls["rollback"] == 1 and calls["close"] == 1
|
||||||
|
|
||||||
# Missing person (deleted id): None-guard skips enrich, session still closed.
|
# Missing person (deleted id): None-guard skips enrich, session still closed.
|
||||||
calls.update({"rollback": 0, "close": 0, "enriched": 0})
|
calls.update({"rollback": 0, "close": 0, "enriched": 0})
|
||||||
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None))
|
monkeypatch.setattr(service, "SessionLocal", lambda: _FakeSession(None))
|
||||||
people._enrich_person_bg(9)
|
service.enrich_person_bg(9)
|
||||||
assert calls == {"rollback": 0, "close": 1, "enriched": 0}
|
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()
|
db.commit()
|
||||||
|
|
||||||
scheduled: list[int] = []
|
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")
|
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import engine
|
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.detail import rich_detail
|
||||||
from app.plex.overlay import sync_overlay
|
from app.plex.overlay import sync_overlay
|
||||||
from app.plex.plaintext import plain_text
|
from app.plex.plaintext import plain_text
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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 app.plex import sync
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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 paths as plex_paths
|
||||||
from app.plex import stream as plex_stream
|
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.
|
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")):
|
def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
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 import rollup, sync
|
||||||
from app.plex.cards import ShowState
|
from app.plex.cards import ShowState
|
||||||
|
|
||||||
@@ -490,7 +491,7 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
] # input order preserved, m2 gone
|
] # input order preserved, m2 gone
|
||||||
|
|
||||||
def test_collections_list(self, db, monkeypatch, override_dep):
|
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)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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():
|
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."""
|
"""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
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type]
|
assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type]
|
||||||
|
|||||||
@@ -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 sqlalchemy import select
|
||||||
|
|
||||||
from app.models import (
|
from app.models import User
|
||||||
PlexItem,
|
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
|
||||||
PlexLibrary,
|
|
||||||
PlexSeason,
|
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from app.plex.sync import (
|
from app.plex.sync import (
|
||||||
_TYPE_EPISODE,
|
_TYPE_EPISODE,
|
||||||
_TYPE_MOVIE,
|
_TYPE_MOVIE,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.plex import people, sync
|
from app.plex import people, sync
|
||||||
from app.plex.client import PlexError
|
from app.plex.client import PlexError
|
||||||
from app.models import PlexItem, PlexLibrary, PlexPerson
|
from app.plex.models import PlexItem, PlexLibrary, PlexPerson
|
||||||
|
|
||||||
|
|
||||||
class _FakePlex:
|
class _FakePlex:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
|
from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
|
||||||
from app.plex import tmdb
|
from app.plex import tmdb
|
||||||
|
|
||||||
# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload.
|
# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import pytest
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import sysconfig
|
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
|
from app.plex import watch_sync
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from sqlalchemy import select
|
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.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies
|
||||||
from app.routes.plex.playback import (
|
from app.routes.plex.playback import (
|
||||||
_PUSH_THROTTLE_S,
|
_PUSH_THROTTLE_S,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|||||||
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
|
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
|
||||||
SERIALIZERS = {
|
SERIALIZERS = {
|
||||||
"JSONResponse",
|
"JSONResponse",
|
||||||
"_collection_card",
|
"collection_card",
|
||||||
"_playlist_card",
|
"_playlist_card",
|
||||||
"_serialize",
|
"_serialize",
|
||||||
"_serialize_folder",
|
"_serialize_folder",
|
||||||
|
|||||||
@@ -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
|
# 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.
|
# route's count is only the unit tables it imports; it is paid by the sprint of the unit it reaches.
|
||||||
ALLOWED = {
|
ALLOWED = {
|
||||||
"plex/_shared.py": 1,
|
|
||||||
"plex/admin.py": 12,
|
"plex/admin.py": 12,
|
||||||
"plex/browse.py": 93,
|
|
||||||
"plex/collections.py": 33,
|
|
||||||
"plex/detail.py": 29,
|
|
||||||
"plex/discover.py": 44,
|
"plex/discover.py": 44,
|
||||||
"plex/people.py": 20,
|
|
||||||
"plex/playback.py": 42,
|
"plex/playback.py": 42,
|
||||||
"plex/playlists.py": 80,
|
"plex/playlists.py": 80,
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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
|
## Accepted findings
|
||||||
|
|||||||
Reference in New Issue
Block a user