"""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 User from app.catalog.models import Channel, 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) }