refactor(reader): the HTTP layer reads through queries and writes through the service
Feed, playlists, saved views, tags and the Home pins do no database work in their routes any more: reads go through reader/queries (and feedquery for a feed page, its counts and facets), writes through reader/service and discover/pins, which flush; the handlers commit. Reader is fenced below its HTTP layer too: its owner filters go through userscope, and the catalog's jobs ask Reader instead of joining its tables (the auto-tagger's system tags, the cleanup jobs' keep rule, the maintenance notice). A playlist sync's catalog ingest moves to sync/videos.
This commit is contained in:
@@ -27,6 +27,7 @@ from app.reader.models import ComfortPin, Playlist
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import SHOW_NEW, leaf_card, show_card
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.userscope import owned_by, owned_row
|
||||
|
||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||
PIN_COLUMNS: dict[str, str] = {
|
||||
@@ -93,7 +94,7 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
|
||||
# old slots — and one global lock order means two reorders cannot deadlock.
|
||||
locked = db.execute(
|
||||
select(ComfortPin)
|
||||
.where(ComfortPin.user_id == user.id)
|
||||
.where(owned_by(ComfortPin.user_id, user.id))
|
||||
.order_by(ComfortPin.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
@@ -118,7 +119,7 @@ def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(ComfortPin)
|
||||
.where(ComfortPin.user_id == user.id)
|
||||
.where(owned_by(ComfortPin.user_id, user.id))
|
||||
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
|
||||
)
|
||||
.scalars()
|
||||
@@ -133,17 +134,44 @@ def next_position(db: Session, user: User) -> int:
|
||||
deletes the row, and every FK cascade silently removes a pin when the thing it points at goes
|
||||
away — so past the first hole a count under-counts and would drop the new pin into the MIDDLE of
|
||||
the shelf, or onto a position another pin already holds (nothing in the schema forbids that).
|
||||
Same idiom as `routes/playlists._next_playlist_position`."""
|
||||
Same idiom as a new playlist's place in the rail (`reader.service`)."""
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.coalesce(func.max(ComfortPin.position), -1)).where(
|
||||
ComfortPin.user_id == user.id
|
||||
owned_by(ComfortPin.user_id, user.id)
|
||||
)
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
|
||||
|
||||
def own_pin(db: Session, user: User, pin_id: int) -> ComfortPin | None:
|
||||
return owned_row(db, ComfortPin, pin_id, user)
|
||||
|
||||
|
||||
def pin_of(db: Session, user: User, column: str, value: object) -> ComfortPin | None:
|
||||
"""The user's pin of this thing (`column`/`value` from `resolve_ref`), if it is on the shelf."""
|
||||
return db.execute(
|
||||
select(ComfortPin).where(
|
||||
owned_by(ComfortPin.user_id, user), getattr(ComfortPin, column) == value
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def add(db: Session, user: User, column: str, value: object) -> ComfortPin:
|
||||
"""A new pin at the end of the shelf. A second pin of the same thing (another tab, at the same
|
||||
moment) raises `IntegrityError` at the flush: the unique constraint is the guard."""
|
||||
pin = ComfortPin(user_id=user.id, position=next_position(db, user), **{column: value})
|
||||
db.add(pin)
|
||||
db.flush()
|
||||
return pin
|
||||
|
||||
|
||||
def remove(db: Session, pin: ComfortPin) -> None:
|
||||
db.delete(pin)
|
||||
db.flush()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Batch:
|
||||
"""What the shelf resolves for every pinned channel and playlist in one go."""
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.reader.models import PlaylistItem, VideoState
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
||||
from app.plex.query import apply_show_state_filter, content_libs
|
||||
from app.userscope import owned_by
|
||||
|
||||
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
||||
# (`routes/plex/discover.py` imports it): the two surfaces should feel like the same app, and a rail
|
||||
@@ -201,7 +202,7 @@ def _unseen_movie_conds(uid: int):
|
||||
.select_from(PlexState)
|
||||
.where(
|
||||
PlexState.item_id == PlexItem.id,
|
||||
PlexState.user_id == uid,
|
||||
owned_by(PlexState.user_id, uid),
|
||||
or_(
|
||||
PlexState.status.in_(("watched", "hidden")),
|
||||
# Started counts as "not a surprise" too — see the note in `yt_surprise`.
|
||||
@@ -229,7 +230,9 @@ def plex_in_progress(
|
||||
if movie_libs:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.join(
|
||||
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_libs),
|
||||
@@ -323,7 +326,9 @@ def next_episode(db: Session, uid: int, show_id: int, *, lang: str) -> dict | No
|
||||
)
|
||||
base = (
|
||||
select(PlexItem, PlexState)
|
||||
.outerjoin(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.outerjoin(
|
||||
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||
)
|
||||
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user