Channels, search and sync do no database work in their routes any more: reads go through catalog/queries, writes through catalog/service, and a live search's ingest is catalog/search. What a catalog route needs from Reader (a playlist's channels, a user's tag links, the feed projection of results) it asks reader/queries and reader/service. The scheduler and the runs page read channel titles and the work queue through catalog/queries. Catalog is fenced below its HTTP layer: its owner filters go through userscope, it reads no other unit's tables, and its ingest jobs commit only where they are named.
362 lines
13 KiB
Python
362 lines
13 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.collation import collated
|
|
from app.feedquery import feed_columns, serialize
|
|
from app.models import User
|
|
from app.catalog.models import Channel, Subscription, 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)
|
|
}
|
|
|
|
|
|
def held_by(db: Session, user: User, video_ids: list[str]) -> set[str]:
|
|
"""Which of these videos the user already holds: watched, in progress or hidden, or in one of
|
|
their playlists (a live search sinks them below the new finds)."""
|
|
if not video_ids:
|
|
return set()
|
|
has_state = (
|
|
select(VideoState.id)
|
|
.where(VideoState.video_id == Video.id, owned_by(VideoState.user_id, user))
|
|
.exists()
|
|
)
|
|
in_playlist = (
|
|
select(PlaylistItem.id)
|
|
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
|
.where(PlaylistItem.video_id == Video.id, owned_by(Playlist.user_id, user))
|
|
.exists()
|
|
)
|
|
return set(
|
|
db.scalars(select(Video.id).where(Video.id.in_(video_ids), or_(has_state, in_playlist)))
|
|
)
|
|
|
|
|
|
def feed_rows_by_ids(db: Session, user: User, video_ids: list[str]) -> list[dict]:
|
|
"""These videos serialized with the feed's projection (their channel, this user's watch state,
|
|
saved), in the order given."""
|
|
if not video_ids:
|
|
return []
|
|
state = aliased(VideoState)
|
|
rows = db.execute(
|
|
select(*feed_columns(user, state))
|
|
.join(Channel, Channel.id == Video.channel_id)
|
|
.outerjoin(state, and_(state.video_id == Video.id, owned_by(state.user_id, user)))
|
|
.where(Video.id.in_(video_ids))
|
|
).all()
|
|
by_id = {r.id: serialize(r) for r in rows}
|
|
return [by_id[i] for i in video_ids if i in by_id]
|
|
|
|
|
|
def playlist_channels(db: Session, user: User, exclude: str | None, lang: str) -> list:
|
|
"""The channels of the videos in the user's playlists that they don't subscribe to (`exclude`:
|
|
their own channel), each with how many of those videos it has and across how many playlists;
|
|
most present first, then by title in the reader's alphabet."""
|
|
subscribed = select(Subscription.channel_id).where(owned_by(Subscription.user_id, user))
|
|
q = (
|
|
select(
|
|
Channel,
|
|
func.count(func.distinct(PlaylistItem.video_id)),
|
|
func.count(func.distinct(Playlist.id)),
|
|
)
|
|
.select_from(PlaylistItem)
|
|
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
|
.join(Video, Video.id == PlaylistItem.video_id)
|
|
.join(Channel, Channel.id == Video.channel_id)
|
|
.where(owned_by(Playlist.user_id, user), Channel.id.not_in(subscribed))
|
|
)
|
|
if exclude:
|
|
q = q.where(Channel.id != exclude)
|
|
return list(
|
|
db.execute(
|
|
q.group_by(Channel.id).order_by(
|
|
func.count(func.distinct(PlaylistItem.video_id)).desc(),
|
|
collated(Channel.title, lang),
|
|
)
|
|
).all()
|
|
)
|
|
|
|
|
|
def user_tag_links(db: Session, user: User, channel_id: str | None = None) -> dict[str, list[int]]:
|
|
"""The user's own tag links by channel (one channel's, when given)."""
|
|
stmt = select(ChannelTag.channel_id, ChannelTag.tag_id).where(
|
|
owned_by(ChannelTag.user_id, user)
|
|
)
|
|
if channel_id is not None:
|
|
stmt = stmt.where(ChannelTag.channel_id == channel_id)
|
|
out: dict[str, list[int]] = {}
|
|
for cid, tag_id in db.execute(stmt).tuples():
|
|
out.setdefault(cid, []).append(tag_id)
|
|
return out
|
|
|
|
|
|
def user_tag_link(db: Session, user: User, channel_id: str, tag_id: int) -> ChannelTag | None:
|
|
return db.execute(
|
|
select(ChannelTag).where(
|
|
ChannelTag.channel_id == channel_id,
|
|
ChannelTag.tag_id == tag_id,
|
|
owned_by(ChannelTag.user_id, user),
|
|
)
|
|
).scalar_one_or_none()
|