"""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 import CursorResult, delete, func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert 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.models import User from app.plex import queries, rollup from app.plex.models import ( PlexCollection, PlexItem, PlexLibrary, PlexLink, PlexPerson, PlexPlaylist, PlexPlaylistItem, PlexShow, PlexState, ) from app.userscope import owned_by 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() def set_watch_link(db: Session, user: User, enabled: bool) -> PlexLink: """Turn the user's two-way watch sync on or off, creating their link on first use. The link uses the server admin token (`uses_admin`), so it needs no Plex login of its own.""" link = queries.watch_link(db, user) if link is None: link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)) db.add(link) link.sync_enabled = enabled db.flush() return link # --- a user's playlists ---------------------------------------------------------------------- def create_playlist(db: Session, user: User, title: str, seed: PlexItem | None) -> PlexPlaylist: """A new playlist of the user's, holding `seed` when one is given.""" pl = PlexPlaylist(user_id=user.id, title=title) db.add(pl) db.flush() if seed is not None: db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=seed.id, position=0)) db.flush() return pl def rename_playlist(pl: PlexPlaylist, title: str) -> None: pl.title = title def delete_playlist(db: Session, pl: PlexPlaylist) -> None: db.delete(pl) db.flush() def _touch(pl: PlexPlaylist) -> None: pl.updated_at = datetime.now(timezone.utc) def _last_position(db: Session, playlist_id: int) -> int: return db.execute( select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( PlexPlaylistItem.playlist_id == playlist_id ) ).scalar_one() def add_item(db: Session, pl: PlexPlaylist, it: PlexItem) -> None: """Append `it` to the (locked) playlist; an item already in it stays where it is.""" exists = db.execute( select(PlexPlaylistItem.id).where( PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id == it.id ) ).first() if exists is None: pos = _last_position(db, pl.id) + 1 db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=pos)) _touch(pl) db.flush() def remove_item(db: Session, pl: PlexPlaylist, it: PlexItem) -> None: db.execute( delete(PlexPlaylistItem).where( PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id == it.id ) ) _touch(pl) db.flush() def add_items(db: Session, pl: PlexPlaylist, items: list[PlexItem]) -> int: """Append many items in their given order (a whole season or show); those already in the list are skipped. Says how many were added.""" existing = set( db.execute( select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pl.id) ).scalars() ) pos = _last_position(db, pl.id) added = 0 for it in items: if it.id in existing: continue pos += 1 db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=pos)) existing.add(it.id) added += 1 if added: _touch(pl) db.flush() return added def insert_items_at(db: Session, pl: PlexPlaylist, placed: list[tuple[int, PlexItem]]) -> int: """Insert new rows at their indices (ascending, so each index is where the row stood when the rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item already in the list does not move: the add stays idempotent.""" rows = list( db.execute( select(PlexPlaylistItem) .where(PlexPlaylistItem.playlist_id == pl.id) .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) ).scalars() ) present = {r.item_id for r in rows} added = 0 for at, it in sorted(placed, key=lambda p: p[0]): if it.id in present: continue row = PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0) db.add(row) rows.insert(min(at, len(rows)), row) present.add(it.id) added += 1 for i, r in enumerate(rows): r.position = i if added: _touch(pl) db.flush() return added def remove_items(db: Session, pl: PlexPlaylist, item_ids: list[int]) -> int: """Remove these items from the playlist; says how many were there.""" if not item_ids: return 0 result = db.execute( delete(PlexPlaylistItem).where( PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id.in_(item_ids) ), execution_options={"synchronize_session": False}, ) removed = result.rowcount if isinstance(result, CursorResult) else 0 if removed: _touch(pl) db.flush() return removed def reorder_playlist(db: Session, pl: PlexPlaylist, rating_keys: list[str]) -> None: """Set the play order from an explicit list of item keys (0-based positions).""" id_by_rk = { it.rating_key: it.id for it in db.execute( select(PlexItem).where(PlexItem.rating_key.in_(rating_keys or [""])) ).scalars() } rows = { r.item_id: r for r in db.execute( select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pl.id) ).scalars() } pos = 0 seen: set[int] = set() for rk in rating_keys: row = rows.get(id_by_rk.get(rk, -1)) if row is not None and row.item_id not in seen: row.position = pos pos += 1 seen.add(row.item_id) # Rows the payload omitted (added in another tab meanwhile) keep their relative order after # the given ones, so no two rows share a position (the YouTube reorder does the same). for row in sorted( (r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id) ): row.position = pos pos += 1 _touch(pl) db.flush() # --- a user's watch state -------------------------------------------------------------------- def mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool: """Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end checkpoints arrive together routinely (the 10 s tick and the debounced `seeked` flush), and a read-then-write let both see "not watched" and both scrobble. The transition is decided by the write itself: an insert that did not conflict, or an update conditional on the old status — under READ COMMITTED the second writer re-reads the committed row and matches nothing.""" values = { "status": "watched", "watched_at": now, "position_seconds": 0, "progress_updated_at": now, "synced_to_plex": False, } inserted = db.execute( pg_insert(PlexState) .values(user_id=user_id, item_id=item_id, **values) .on_conflict_do_nothing(constraint="uq_plex_user_item") .returning(PlexState.id) ).first() if inserted is not None: return True mine = owned_by(PlexState.user_id, user_id) & (PlexState.item_id == item_id) result = db.execute(update(PlexState).where(mine, PlexState.status != "watched").values(values)) if isinstance(result, CursorResult) and result.rowcount: return True # Already watched: a re-finish still restamps the row, as it always did — just no scrobble. db.execute(update(PlexState).where(mine).values(values)) return False def state_row( db: Session, user: User | int, item_id: int, st: PlexState | None = None, *, synced: bool = False, ) -> PlexState: """The user's state row on this item, made if the read before found none. Made by the insert itself (ON CONFLICT DO NOTHING) and then read: the first checkpoint of an item and the `final` flush beside it routinely both read "none", and a plain add let the second one fail on `uq_plex_user_item` — a 500, and the push it was carrying lost. Every first row goes through here: a checkpoint, a status, a season marked at once, a pull from Plex. `synced` is what a row made now says about Plex; a row another writer made first keeps its own.""" if st is not None: return st db.execute( pg_insert(PlexState) .values( user_id=user if isinstance(user, int) else user.id, item_id=item_id, synced_to_plex=synced, ) .on_conflict_do_nothing(constraint="uq_plex_user_item") ) made = queries.state(db, user, item_id) assert made is not None # inserted now, or committed by the request that won return made def clear_progress(db: Session, st: PlexState | None, now: datetime) -> None: """A checkpoint too early to keep: a state that held nothing else goes, any other forgets its position.""" if st is not None and st.status == "new": db.delete(st) elif st is not None: st.position_seconds = 0 st.progress_updated_at = now db.flush() def checkpoint( db: Session, user: User, item_id: int, st: PlexState | None, position: int, now: datetime, *, dirty: bool, ) -> PlexState: """Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms.""" st = state_row(db, user, item_id, st) st.position_seconds = position st.progress_updated_at = now if dirty: st.synced_to_plex = False db.flush() return st def forget_state(db: Session, st: PlexState) -> None: db.delete(st) db.flush() def set_status( db: Session, user: User, item_id: int, st: PlexState | None, status: str ) -> PlexState: """Set a watched or hidden status; watched also clears the resume point.""" st = state_row(db, user, item_id, st) st.status = status if status == "watched": st.watched_at = datetime.now(timezone.utc) st.position_seconds = 0 st.synced_to_plex = False db.flush() return st def bulk_state( db: Session, user_id: int, eps: list[PlexItem], watched: bool ) -> list[tuple[int, str, str]]: """Mark every episode in `eps` watched (or unwatched) for a user and say what changed, as the (item id, rating key, action) pushes a linked Plex account needs. Unwatching keeps the user's private "hidden" marks.""" now = datetime.now(timezone.utc) existing = rollup.item_state_map(db, user_id, [e.id for e in eps]) to_push: list[tuple[int, str, str]] = [] for e in eps: st = existing.get(e.id) prev = st.status if st is not None else "new" if watched: st = state_row(db, user_id, e.id, st) prev = st.status # a first checkpoint beside this may have made it meanwhile st.status = "watched" st.watched_at = now st.position_seconds = 0 st.synced_to_plex = False if prev != "watched": to_push.append((e.id, e.rating_key, "watched")) else: # → new: drop any state (but keep the user's private "hidden" marks) if st is not None and prev != "hidden": db.delete(st) if prev != "new": to_push.append((e.id, e.rating_key, "unwatched")) db.flush() return to_push