The last Plex HTTP files do no database work of their own, so ALLOWED is empty: no route builds SQL, touches the session or a unit's table. Discover's rails move whole into plex/discover.py; a user's playlists, watch-state writes (the race-safe finish, an early checkpoint, a status, a season or show at once) and the watch-sync link go through plex/queries.py and plex/service.py. The plex package joins FENCED: it reads no other unit's tables, says whose a row is through userscope (the rollup and the watch sync's 8 inline filters moved to owned_by), and commits only in its named jobs — the library sync, TMDB enrichment, the watch sync and its background pushes. A playlist's has-item probe keeps the plain key lookup, so a NUL in it is still the 422 a test pins. Pins 178 -> 0; basedpyright baseline 118 -> 106.
522 lines
17 KiB
Python
522 lines
17 KiB
Python
"""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,
|
|
PlexLink,
|
|
PlexPerson,
|
|
PlexPlaylist,
|
|
PlexPlaylistItem,
|
|
PlexSeason,
|
|
PlexShow,
|
|
PlexState,
|
|
)
|
|
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
|
from app.userscope import owned_by, owned_row
|
|
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 season(db: Session, rating_key: str) -> PlexSeason | None:
|
|
return by_rating_key(db, PlexSeason, rating_key)
|
|
|
|
|
|
def season_episodes(db: Session, se: PlexSeason) -> list[PlexItem]:
|
|
return list(
|
|
db.execute(
|
|
select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode")
|
|
).scalars()
|
|
)
|
|
|
|
|
|
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, item_id: int) -> PlexState | None:
|
|
return (
|
|
db.execute(
|
|
select(PlexState).where(owned_by(PlexState.user_id, user), PlexState.item_id == item_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
|
|
|
|
|
|
# --- the admin page --------------------------------------------------------------------------
|
|
|
|
|
|
def sample_file_path(db: Session) -> str | None:
|
|
"""Any mirrored item's Plex-side file path — a sample to test the mount and path map against."""
|
|
return db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None)))
|
|
|
|
|
|
def watch_link(db: Session, user: User | int) -> PlexLink | None:
|
|
"""The user's Plex watch-sync link, if they have one."""
|
|
return db.execute(select(PlexLink).where(owned_by(PlexLink.user_id, user))).scalars().first()
|
|
|
|
|
|
# --- a user's playlists ----------------------------------------------------------------------
|
|
|
|
|
|
def own_playlist(
|
|
db: Session, user: User, playlist_id: int, *, lock: bool = False
|
|
) -> PlexPlaylist | None:
|
|
"""The user's playlist with this id; someone else's is the same answer as a missing one. `lock`
|
|
takes it FOR UPDATE: every add, remove and reorder does, so two of them never read the same max
|
|
position, and the list row is always locked before its items."""
|
|
return owned_row(db, PlexPlaylist, playlist_id, user, lock=lock)
|
|
|
|
|
|
def playlists_of(db: Session, user: User) -> list[PlexPlaylist]:
|
|
"""The user's playlists, newest-touched first."""
|
|
return list(
|
|
db.execute(
|
|
select(PlexPlaylist)
|
|
.where(owned_by(PlexPlaylist.user_id, user))
|
|
.order_by(PlexPlaylist.updated_at.desc())
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def playlist_count(db: Session, playlist_id: int) -> int:
|
|
return (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(
|
|
PlexPlaylistItem.playlist_id == playlist_id
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
|
|
def _playlist_rows(playlist_id: int):
|
|
return (
|
|
select(PlexPlaylistItem, PlexItem)
|
|
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
|
|
.where(PlexPlaylistItem.playlist_id == playlist_id)
|
|
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
|
)
|
|
|
|
|
|
def playlist_cover(db: Session, playlist_id: int) -> str | None:
|
|
"""The rating key of a playlist's first item — its cover."""
|
|
first = db.execute(_playlist_rows(playlist_id)).first()
|
|
return first[1].rating_key if first else None
|
|
|
|
|
|
def playlist_items(db: Session, playlist_id: int) -> list[PlexItem]:
|
|
"""A playlist's items in play order."""
|
|
return [it for _row, it in db.execute(_playlist_rows(playlist_id)).all()]
|
|
|
|
|
|
def playlist_holds(db: Session, playlist_id: int, item_ids: set[int]) -> int:
|
|
"""How many of these items the playlist holds."""
|
|
if not item_ids:
|
|
return 0
|
|
return (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(
|
|
PlexPlaylistItem.playlist_id == playlist_id,
|
|
PlexPlaylistItem.item_id.in_(item_ids),
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
|
|
def item_id(db: Session, rating_key: str) -> int | None:
|
|
"""The id of the mirrored item with this key. Unlike `item`, a NUL in the key reaches the query,
|
|
which the app answers as a 422 (`main._nul_in_text`), as for every other compared string."""
|
|
return db.scalar(select(PlexItem.id).where(PlexItem.rating_key == str(rating_key)))
|
|
|
|
|
|
def item_ids(db: Session, rating_keys: list[str]) -> set[int]:
|
|
"""The ids of the mirrored items with these keys."""
|
|
if not rating_keys:
|
|
return set()
|
|
return set(
|
|
db.execute(select(PlexItem.id).where(PlexItem.rating_key.in_(rating_keys))).scalars()
|
|
)
|
|
|
|
|
|
def items_by_keys(db: Session, rating_keys: list) -> list[PlexItem]:
|
|
"""The mirrored items with these keys, in the given order; an unknown key and a repeat drop out."""
|
|
order = [str(k) for k in rating_keys if str(k)]
|
|
if not order:
|
|
return []
|
|
by_rk = {
|
|
it.rating_key: it
|
|
for it in db.execute(select(PlexItem).where(PlexItem.rating_key.in_(order))).scalars()
|
|
}
|
|
seen: set[str] = set()
|
|
out: list[PlexItem] = []
|
|
for rk in order:
|
|
it = by_rk.get(rk)
|
|
if it is not None and rk not in seen:
|
|
seen.add(rk)
|
|
out.append(it)
|
|
return out
|
|
|
|
|
|
def shows_by_ids(db: Session, show_ids: set[int]) -> dict[int, PlexShow]:
|
|
if not show_ids:
|
|
return {}
|
|
return {
|
|
sh.id: sh for sh in db.execute(select(PlexShow).where(PlexShow.id.in_(show_ids))).scalars()
|
|
}
|