refactor(catalog): the HTTP layer reads through queries and writes through the service
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.
This commit is contained in:
@@ -10,9 +10,10 @@ 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.collation import collated
|
||||
from app.feedquery import feed_columns, serialize
|
||||
from app.models import User
|
||||
from app.catalog.models import Channel, Video
|
||||
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
|
||||
|
||||
@@ -269,3 +270,92 @@ def tags_in_feed_views(db: Session) -> set[int]:
|
||||
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()
|
||||
|
||||
@@ -464,3 +464,17 @@ def reorder_items(db: Session, pl: Playlist, order: list[str]) -> int:
|
||||
touch(pl)
|
||||
db.flush()
|
||||
return pos
|
||||
|
||||
|
||||
def attach_user_tag(db: Session, user: User, channel_id: str, tag_id: int) -> None:
|
||||
"""The user's own label on a channel (idempotent). The caller has checked the tag is theirs."""
|
||||
if queries.user_tag_link(db, user, channel_id, tag_id) is None:
|
||||
db.add(ChannelTag(channel_id=channel_id, tag_id=tag_id, user_id=user.id, source="user"))
|
||||
db.flush()
|
||||
|
||||
|
||||
def detach_user_tag(db: Session, user: User, channel_id: str, tag_id: int) -> None:
|
||||
row = queries.user_tag_link(db, user, channel_id, tag_id)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
|
||||
Reference in New Issue
Block a user