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.
271 lines
9.7 KiB
Python
271 lines
9.7 KiB
Python
"""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)
|
|
}
|