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:
2026-10-19 01:20:14 +02:00
parent e82f87acb0
commit dbb4065e7b
24 changed files with 1489 additions and 915 deletions
+270
View File
@@ -0,0 +1,270 @@
"""Reader's reads: a user's labels, saved views, watch state and playlists, for the HTTP layer and
for the other units that ask about them.
Every read of a user's row goes through `userscope`, so "someone else's row" is the same answer as
a missing one. Reader reads Catalog's videos along its declared edge (a playlist's cover and
length, whether a video exists); another unit asks the questions at the end of this module rather
than joining Reader's tables itself.
"""
from sqlalchemy import ColumnElement, and_, func, or_, select
from sqlalchemy.orm import InstrumentedAttribute, Session, aliased
from app.feedquery import feed_columns
from app.models import Channel, User, Video
from app.reader.models import ChannelTag, Playlist, PlaylistItem, SavedView, Tag, VideoState
from app.userscope import owned_by, owned_row
# --- catalog, read along the edge ------------------------------------------------------------
def video(db: Session, video_id: str) -> Video | None:
return db.get(Video, video_id)
# --- tags ------------------------------------------------------------------------------------
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]:
"""The tags this user sees (the shared system ones and their own), each with how many channel
links it has, by category then name."""
return [
(tag, count)
for tag, count in db.execute(
select(Tag, func.count(ChannelTag.id))
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
.where(or_(Tag.user_id.is_(None), owned_by(Tag.user_id, user)))
.group_by(Tag.id)
.order_by(Tag.category, Tag.name)
).tuples()
]
def own_tag(db: Session, user: User, tag_id: int) -> Tag | None:
"""One of the user's own tags. A system tag (no owner) is nobody's to edit, so it reads as
missing too."""
return owned_row(db, Tag, tag_id, user)
# --- saved views -----------------------------------------------------------------------------
def views(db: Session, user: User, source: str) -> list[SavedView]:
return list(
db.execute(
select(SavedView)
.where(owned_by(SavedView.user_id, user), SavedView.source == source)
.order_by(SavedView.position, SavedView.id)
).scalars()
)
def all_views(db: Session, user: User) -> list[SavedView]:
return list(db.execute(select(SavedView).where(owned_by(SavedView.user_id, user))).scalars())
def own_view(db: Session, user: User, view_id: int) -> SavedView | None:
return owned_row(db, SavedView, view_id, user)
# --- watch state -----------------------------------------------------------------------------
def video_state(db: Session, user: User, video_id: str) -> VideoState | None:
return db.execute(
select(VideoState).where(
owned_by(VideoState.user_id, user), VideoState.video_id == video_id
)
).scalar_one_or_none()
# --- playlists -------------------------------------------------------------------------------
def own_playlist(
db: Session, user: User, playlist_id: int, *, lock: bool = False
) -> Playlist | None:
return owned_row(db, Playlist, playlist_id, user, lock=lock)
def playlists_for(db: Session, user: User) -> list[Playlist]:
"""The user's playlists in their rail order."""
return list(
db.execute(
select(Playlist)
.where(owned_by(Playlist.user_id, user))
.order_by(Playlist.position, Playlist.created_at)
).scalars()
)
def watch_later(db: Session, user: User) -> Playlist | None:
return db.scalar(
select(Playlist).where(owned_by(Playlist.user_id, user), Playlist.kind == "watch_later")
)
def item_counts(db: Session, ids: list[int]) -> dict[int, int]:
return dict(
db.execute(
select(PlaylistItem.playlist_id, func.count())
.where(PlaylistItem.playlist_id.in_(ids))
.group_by(PlaylistItem.playlist_id)
)
.tuples()
.all()
)
def durations(db: Session, ids: list[int]) -> dict[int, int]:
"""Each playlist's running time: the sum of its videos' lengths."""
return {
pid: int(total or 0)
for pid, total in db.execute(
select(PlaylistItem.playlist_id, func.coalesce(func.sum(Video.duration_seconds), 0))
.join(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id.in_(ids))
.group_by(PlaylistItem.playlist_id)
).tuples()
}
def containing(db: Session, ids: list[int], video_id: str) -> set[int]:
"""Which of these playlists already hold the video (the add-to-playlist popover's ticks)."""
return set(
db.execute(
select(PlaylistItem.playlist_id).where(
PlaylistItem.playlist_id.in_(ids), PlaylistItem.video_id == video_id
)
).scalars()
)
def covers(db: Session, ids: list[int]) -> dict[int, str | None]:
"""Each playlist's cover: its first item's thumbnail, for every list in ONE window query rather
than a lookup per playlist."""
rn = (
func.row_number()
.over(partition_by=PlaylistItem.playlist_id, order_by=PlaylistItem.position)
.label("rn")
)
first_items = (
select(PlaylistItem.playlist_id.label("pid"), Video.thumbnail_url.label("thumb"), rn)
.join(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id.in_(ids))
.subquery()
)
return dict(
db.execute(select(first_items.c.pid, first_items.c.thumb).where(first_items.c.rn == 1))
.tuples()
.all()
)
def playlist_totals(db: Session, playlist_id: int) -> tuple[int, int, str | None]:
"""One playlist's item count, running time and cover. An outer join, so an empty list still
answers (0, 0, None)."""
count, total = db.execute(
select(func.count(PlaylistItem.id), func.coalesce(func.sum(Video.duration_seconds), 0))
.select_from(PlaylistItem)
.outerjoin(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id == playlist_id)
).one()
cover = db.scalar(
select(Video.thumbnail_url)
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
.where(PlaylistItem.playlist_id == playlist_id)
.order_by(PlaylistItem.position)
.limit(1)
)
return count or 0, total or 0, cover
def playlist_rows(db: Session, user: User, playlist_id: int) -> list:
"""A playlist's videos as feed rows (the same columns the feed serializes), in list order."""
state = aliased(VideoState)
return list(
db.execute(
select(*feed_columns(user, state))
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, owned_by(state.user_id, user)))
.where(PlaylistItem.playlist_id == playlist_id)
# `id` breaks a position tie the same way on every read (the renumbering add does too).
.order_by(PlaylistItem.position, PlaylistItem.id)
).all()
)
def items(db: Session, playlist_id: int) -> list[PlaylistItem]:
"""A playlist's item rows, in list order."""
return list(
db.execute(
select(PlaylistItem)
.where(PlaylistItem.playlist_id == playlist_id)
.order_by(PlaylistItem.position, PlaylistItem.id)
).scalars()
)
def item(db: Session, playlist_id: int, video_id: str) -> PlaylistItem | None:
return db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == playlist_id, PlaylistItem.video_id == video_id
)
)
# --- for the other units ---------------------------------------------------------------------
def kept_by_a_reader(video_id: InstrumentedAttribute[str]) -> ColumnElement[bool]:
"""A video someone holds: watched, in progress or hidden (a `VideoState`), or in one of their
playlists. The catalog's cleanup jobs keep such a video; as a clause, so their DELETE stays
one statement."""
return or_(
select(VideoState.id).where(VideoState.video_id == video_id).exists(),
select(PlaylistItem.id).where(PlaylistItem.video_id == video_id).exists(),
)
def playlisted_by(db: Session, video_ids: list[str]) -> list[tuple[int, str]]:
"""(user id, video id) for every user who has one of these videos in a playlist."""
return list(
db.execute(
select(Playlist.user_id, PlaylistItem.video_id)
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id)
.where(PlaylistItem.video_id.in_(video_ids))
).tuples()
)
def has_auto_tags(channel_id: InstrumentedAttribute[str]) -> ColumnElement[bool]:
"""A channel the auto-tagger has already labelled, as a clause for its "only the missing" scan."""
return (
select(ChannelTag.id)
.where(
ChannelTag.channel_id == channel_id,
ChannelTag.source == "auto",
ChannelTag.user_id.is_(None),
)
.exists()
)
def view_tag_ids(filters: object) -> list[int]:
"""The tag ids a stored view's filters name; anything else in that slot names none."""
tags = filters.get("tags") if isinstance(filters, dict) else None
if not isinstance(tags, list):
return []
return [t for t in tags if type(t) is int] # `type`, not isinstance: True is not a tag id
def tags_in_feed_views(db: Session) -> set[int]:
"""Every tag id any user's saved feed view filters on. Read defensively (`view_tag_ids`): a view
stored before its route checked the shape must not raise for everyone's job."""
return {
t
for (filters,) in db.execute(select(SavedView.filters).where(SavedView.source == "feed"))
for t in view_tag_ids(filters)
}
+466
View File
@@ -0,0 +1,466 @@
"""Reader's writes: a user's labels and saved views, where they are in a video, their playlists'
items and order — and the system tags the catalog's auto-tagger hands over.
Every function here flushes and leaves the commit to its caller (the handler, or the job that owns
its session). A write that must not double up keeps its guard where it is written: an upsert on the
unique key, a row lock taken before a presence check. The comfort shelf's writes are Home's
(`discover.pins`); a playlist's YouTube sync is `sync.playlists`.
"""
from datetime import datetime, timezone
from sqlalchemy import exists, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.models import User
from app.reader import queries
from app.reader.models import ChannelTag, Playlist, PlaylistItem, SavedView, Tag, VideoState
from app.sync.playlists import lock_playlists, recompute_dirty
from app.userscope import owned_by
TAG_CATEGORIES = ("language", "topic", "other")
VIEW_NAME_MAX = 80
# --- tags ------------------------------------------------------------------------------------
def create_tag(db: Session, user: User, name: str, category: str, color: str | None) -> Tag:
"""A tag of the user's own. An unknown category falls back to "other" rather than refusing. A
name the user already has raises `IntegrityError` at the flush."""
tag = Tag(
user_id=user.id,
name=name,
color=color,
category=category if category in TAG_CATEGORIES else "other",
)
db.add(tag)
db.flush()
return tag
def update_tag(tag: Tag, *, sent: set[str], name: str | None, color: str | None) -> None:
"""Change the fields the caller sent: `color` sent as None clears it."""
if "name" in sent and name is not None:
tag.name = name
if "color" in sent:
tag.color = color
def delete_tag(db: Session, tag: Tag) -> None:
db.delete(tag) # its channel links cascade
db.flush()
def ensure_system_tag(db: Session, name: str, category: str, color: str | None) -> Tag:
"""The shared system tag of this name, made the first time a channel earns it."""
tag = db.execute(
select(Tag).where(Tag.user_id.is_(None), Tag.name == name)
).scalar_one_or_none()
if tag is None:
tag = Tag(user_id=None, name=name, category=category, color=color)
db.add(tag)
db.flush()
return tag
def set_auto_tags(db: Session, channel_id: str, desired: dict[int, float | None]) -> None:
"""Make a channel's automatic tag links exactly `desired` (tag id → confidence). Only the
auto-tagger's own links move; a user's links on the channel are never touched."""
existing = {
ct.tag_id: ct
for ct in db.execute(
select(ChannelTag).where(
ChannelTag.channel_id == channel_id,
ChannelTag.user_id.is_(None),
ChannelTag.source == "auto",
)
).scalars()
}
for tag_id, ct in existing.items():
if tag_id not in desired:
db.delete(ct)
for tag_id, conf in desired.items():
ct = existing.get(tag_id)
if ct is None:
db.add(
ChannelTag(
channel_id=channel_id,
tag_id=tag_id,
user_id=None,
source="auto",
confidence=conf,
)
)
else:
ct.confidence = conf
db.flush()
def prune_orphan_system_tags(db: Session) -> int:
"""Delete the system tags no channel links to any more (after a mapping change) — but not one a
saved feed view filters on. Views hold tag IDS, and a deleted tag comes back under a new id when
a channel earns it again, so the view's filter would point at nothing; the feed drops an unknown
id, and the view showed the whole feed under its old name. The kept ids are filtered here rather
than in SQL: a `NOT IN` over every view's ids could pass the driver's bind-parameter limit."""
in_views = queries.tags_in_feed_views(db)
orphans = [
tag
for tag in db.execute(
select(Tag).where(Tag.user_id.is_(None), ~exists().where(ChannelTag.tag_id == Tag.id))
).scalars()
if tag.id not in in_views
]
for tag in orphans:
db.delete(tag)
db.flush()
return len(orphans)
# --- saved views -----------------------------------------------------------------------------
def create_view(db: Session, user: User, source: str, name: str, filters: dict) -> SavedView:
"""A view appended after the user's others of the same module (each module numbers its own
0..N). A name the user already has there raises `IntegrityError` at the flush."""
maxpos = db.execute(
select(func.coalesce(func.max(SavedView.position), -1)).where(
owned_by(SavedView.user_id, user), SavedView.source == source
)
).scalar_one()
view = SavedView(
user_id=user.id,
source=source,
name=name[:VIEW_NAME_MAX],
filters=filters,
position=maxpos + 1,
)
db.add(view)
db.flush()
return view
def update_view(
db: Session,
view: SavedView,
*,
sent: set[str],
name: str | None = None,
filters: dict | None = None,
is_default: bool | None = None,
) -> None:
"""Change the fields the caller sent. Making a view the default clears the flag on the user's
other views of the same module first: at most one default per (user, source)."""
if "name" in sent and name is not None:
view.name = name[:VIEW_NAME_MAX]
if "filters" in sent and filters is not None:
view.filters = filters
if "is_default" in sent:
make_default = bool(is_default)
if make_default:
db.execute(
update(SavedView)
.where(
owned_by(SavedView.user_id, view.user_id),
SavedView.source == view.source,
SavedView.id != view.id,
)
.values(is_default=False)
)
view.is_default = make_default
db.flush()
def delete_view(db: Session, view: SavedView) -> None:
db.delete(view)
db.flush()
def reorder_views(db: Session, user: User, ids: list[int]) -> None:
"""Number the listed views 0..n in the listed order; ids that are not the user's are skipped."""
by_id = {v.id: v for v in queries.all_views(db, user)}
pos = 0
for vid in ids:
v = by_id.get(vid)
if v is not None:
v.position = pos
pos += 1
db.flush()
# --- watch state -----------------------------------------------------------------------------
def unmark(db: Session, user: User, video_id: str) -> None:
"""Back to "new". A row that still holds a resume point keeps it (un-marking "watched" restores
the in-progress state, not where the user left off); otherwise the row goes. The player fires
this beside a checkpoint that may delete the same row: the caller's commit then raises
`StaleDataError`, which is that lost race and nothing worse."""
row = queries.video_state(db, user, video_id)
if row is None:
return
if row.position_seconds:
row.status = "new"
row.watched_at = None
else:
db.delete(row)
db.flush()
def mark(db: Session, user: User, video_id: str, status: str) -> None:
""" "watched" or "hidden", as one atomic upsert on (user, video). The player marks "watched" at
the end of playback at the instant a checkpoint may delete the in-progress row; a SELECT then
UPDATE raised `StaleDataError` on that race, and an INSERT … ON CONFLICT is immune to it.
"hidden" keeps any earlier `watched_at`."""
now = datetime.now(timezone.utc)
set_: dict = {"status": status, "updated_at": now}
if status == "watched":
set_["watched_at"] = now
db.execute(
pg_insert(VideoState)
.values(
user_id=user.id,
video_id=video_id,
status=status,
watched_at=now if status == "watched" else None,
)
.on_conflict_do_update(constraint="uq_user_video", set_=set_)
)
def clear_state(db: Session, user: User, video_id: str) -> None:
"""The video back to pristine for this user: status, resume point and `watched_at` all go.
Playlist membership lives elsewhere and stays."""
row = queries.video_state(db, user, video_id)
if row is not None:
db.delete(row)
db.flush()
def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here."""
row = queries.video_state(db, user, video_id)
now = datetime.now(timezone.utc)
if not position:
if row is None:
return
if row.status == "new":
db.delete(row)
else:
row.position_seconds = 0
row.progress_updated_at = now
db.flush()
return
if row is None:
row = VideoState(user_id=user.id, video_id=video_id)
db.add(row)
row.position_seconds = position
row.progress_updated_at = now
db.flush()
# --- playlists -------------------------------------------------------------------------------
class MoveRefused(Exception):
"""A move between playlists that cannot happen: `reason` is the code the UI translates."""
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
def touch(pl: Playlist) -> None:
"""Bump the playlist's `updated_at` for an edit that only writes `playlist_items`.
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
reorder / move write the item rows instead — so without this the rail's "recently changed"
sort would only ever see renames. The assignment is what makes the column mean "last time
this list changed", which is what the rail promises the reader.
The DATABASE's clock, like the mixin's own `onupdate`: one sort key written from two clocks
(this process's and Postgres's) could order a rename and an item edit the wrong way round."""
pl.updated_at = func.now()
def _next_playlist_position(db: Session, user_id: int) -> int:
"""Append position for a new playlist in this user's rail."""
return (
db.execute(
select(func.coalesce(func.max(Playlist.position), -1)).where(
owned_by(Playlist.user_id, user_id)
)
).scalar_one()
+ 1
)
def next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
position, and the list's order between them is then arbitrary."""
lock_playlists(db, playlist_id)
return (
db.execute(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
PlaylistItem.playlist_id == playlist_id
)
).scalar_one()
+ 1
)
def create_playlist(db: Session, user: User, name: str) -> Playlist:
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
db.add(pl)
db.flush()
return pl
def watch_later(db: Session, user: User) -> Playlist:
"""The user's built-in Watch later playlist, created on first use."""
pl = queries.watch_later(db, user)
if pl is None:
pl = Playlist(
user_id=user.id,
name="Watch later",
kind="watch_later",
position=_next_playlist_position(db, user.id),
)
db.add(pl)
db.flush() # the id, for the item the caller adds next
return pl
def add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
insert hit uq_playlist_video as a 500."""
lock_playlists(db, pl.id)
if queries.item(db, pl.id, video_id) is not None:
return False
db.add(
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_item_position(db, pl.id))
)
if mark_dirty:
recompute_dirty(db, pl)
touch(pl)
db.flush()
return True
def remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
lock_playlists(db, pl.id)
found = queries.item(db, pl.id, video_id)
if found is None:
return False
db.delete(found)
if mark_dirty:
recompute_dirty(db, pl)
touch(pl)
db.flush()
return True
def place_item(db: Session, pl: Playlist, video_id: str, position: int) -> None:
"""Put a video already in the list at `position` (clamped to the end), renumbering the whole
list: positions can have gaps after removals, so "index N" is only meaningful over the
ordered rows."""
rows = queries.items(db, pl.id)
new = next(r for r in rows if r.video_id == video_id)
rows.remove(new)
rows.insert(min(position, len(rows)), new)
for pos, row in enumerate(rows):
row.position = pos
db.flush()
def add_to_playlist(db: Session, pl: Playlist, video_id: str, position: int | None) -> None:
"""Add a video to one of the user's lists, at `position` when given (the list page's "Undo" of
a removal puts the row back where it was in ONE request), else at the end. A video already in
the list stays where it is."""
added = add_item(db, pl, video_id, mark_dirty=False)
if added and position is not None:
place_item(db, pl, video_id, position)
recompute_dirty(db, pl)
db.flush()
def rename_playlist(db: Session, pl: Playlist, name: str) -> None:
"""A new name moves `dirty`; the caller holds the playlist's lock (the scheduled mirror refresh
reads `dirty` under it)."""
if name != pl.name:
pl.name = name
recompute_dirty(db, pl)
db.flush()
def delete_playlist(db: Session, pl: Playlist) -> None:
"""The playlist first, as every item write: the cascade deletes the items after the list row
is locked."""
lock_playlists(db, pl.id)
db.delete(pl) # items cascade
db.flush()
def move_item(db: Session, src: Playlist, dst: Playlist, video_id: str) -> None:
"""Move one video from `src` into `dst`, in the caller's one transaction.
The client-side shape — add to the target, then remove from the source — leaves the video in
BOTH lists whenever the second call fails. Idempotent: a video already in the target is simply
dropped from the source (moving into a list that has it is a removal, not a duplicate). Raises
`MoveRefused` for the same list twice, or a video a concurrent edit already took out."""
if src.id == dst.id:
raise MoveRefused("same_playlist")
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id)
row = queries.item(db, src.id, video_id)
if row is None:
raise MoveRefused("not_in_playlist")
dst_changed = False
if queries.item(db, dst.id, video_id) is None:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. The
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
row.playlist_id = dst.id
row.position = next_item_position(db, dst.id)
dst_changed = True
if not dst_changed:
db.delete(row)
# The source always changed; the target only when the row landed in it. Touching a target
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
recompute_dirty(db, src)
touch(src)
if dst_changed:
recompute_dirty(db, dst)
touch(dst)
db.flush()
def reorder_items(db: Session, pl: Playlist, order: list[str]) -> int:
"""Number the listed videos 0..n in the listed order. Items the list left out (added in another
tab meanwhile) keep their relative order after them, so no two items share a position. The
caller holds the playlist's lock. Returns how many items the list has."""
by_video = {it.video_id: it for it in queries.items(db, pl.id)}
pos = 0
seen: set[str] = set()
for vid in order:
it = by_video.get(vid)
if it is not None:
it.position = pos
pos += 1
seen.add(vid)
for it in sorted(
(it for vid, it in by_video.items() if vid not in seen), key=lambda i: i.position
):
it.position = pos
pos += 1
recompute_dirty(db, pl)
touch(pl)
db.flush()
return pos