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.
588 lines
25 KiB
Python
588 lines
25 KiB
Python
"""Channel manager: the user's subscriptions with their personal overrides (priority,
|
||
hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes
|
||
(subscribe / unsubscribe) that require the optional write scope."""
|
||
|
||
from datetime import datetime, timezone
|
||
from functools import partial
|
||
from typing import TYPE_CHECKING
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.collation import sorted_words
|
||
from app.mailtext import lang_for_user
|
||
from app import logs, quota, scheduler
|
||
from app.auth import admin_user, current_user, require_write_scope
|
||
from app.db import get_db
|
||
from app.models import User
|
||
from app.catalog import queries, service
|
||
from app.reader import queries as reader_queries
|
||
from app.reader import service as reader_service
|
||
from app.routes._common import not_found, youtube_refusal
|
||
from app.sync.explore import explore_ingest_page
|
||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||
from app.sync.subscriptions import apply_channel_details, details_stale
|
||
from app.utils import iso
|
||
from app.youtube.client import YouTubeClient, YouTubeError
|
||
from app.refusal import refused
|
||
|
||
if TYPE_CHECKING:
|
||
from app.catalog.models import Channel, Subscription
|
||
|
||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||
|
||
log = logs.get_logger(__name__)
|
||
|
||
|
||
@router.get("")
|
||
def list_channels(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||
rows = queries.subscriptions(db, user, lang_for_user(user))
|
||
|
||
# Per-channel aggregates over the shared catalog (channel_aggregates), computed on read rather than
|
||
# denormalised: an Index Only Scan over a ~20 MB index, where it used to read every video's heap page
|
||
# (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory).
|
||
# (The Normal/Shorts/Live breakdown moved to the channel-detail page, so it is no longer
|
||
# computed here — the manager table dropped its N/S/L column.)
|
||
channel_ids = [c.id for _, c in rows]
|
||
agg: dict[str, dict] = {}
|
||
for cid, n, last_up, total_dur in queries.aggregates(db, channel_ids):
|
||
agg[cid] = {
|
||
"stored": n,
|
||
"last_video_at": iso(last_up),
|
||
"total_duration_seconds": int(total_dur or 0),
|
||
}
|
||
|
||
# Channels already in the *global* deep-backfill queue — i.e. at least one user (any
|
||
# user) has requested full history and it isn't done yet. Their whole back-catalogue is
|
||
# coming for everyone (video data is shared), so the UI shouldn't offer "get full
|
||
# history" to a user who simply hasn't personally opted in.
|
||
deep_queued = queries.deep_queued(db, channel_ids)
|
||
|
||
# The user's personal tag links, grouped by channel.
|
||
tags_by_channel = reader_queries.user_tag_links(db, user)
|
||
|
||
return [
|
||
{
|
||
**_channel_summary(ch),
|
||
"stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
|
||
"last_video_at": (agg.get(ch.id) or {}).get("last_video_at"),
|
||
"total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0),
|
||
"priority": sub.priority,
|
||
"hidden": sub.hidden,
|
||
"deep_requested": sub.deep_requested,
|
||
"deep_in_queue": ch.id in deep_queued and not ch.backfill_done,
|
||
"tag_ids": tags_by_channel.get(ch.id, []),
|
||
"details_synced": ch.details_synced_at is not None,
|
||
"recent_synced": ch.recent_synced_at is not None,
|
||
"backfill_done": ch.backfill_done,
|
||
}
|
||
for sub, ch in rows
|
||
]
|
||
|
||
|
||
# Cap how many stub channels we enrich per discovery call, to bound first-load latency and
|
||
# quota (channels.list = 1 unit / 50). Anything beyond is enriched on subsequent loads.
|
||
DISCOVERY_ENRICH_CAP = 200
|
||
|
||
|
||
def _discovery_rows(db: Session, user: User) -> list:
|
||
"""The user's playlist channels they don't subscribe to (and that aren't their own),
|
||
each with how many playlist videos come from it and across how many playlists."""
|
||
return reader_queries.playlist_channels(db, user, user.yt_channel_id, lang_for_user(user))
|
||
|
||
|
||
@router.get("/discovery")
|
||
def discover_channels(
|
||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||
) -> list[dict]:
|
||
"""Channels that appear in the user's playlists but that they don't subscribe to (their
|
||
own channel excluded). The base list is a local join over the shared catalog. We also
|
||
enrich each channel's metadata up front (title/thumbnail/subscriber count) so the user
|
||
can judge a channel BEFORE subscribing — but we do NOT pull its videos. Most-present
|
||
channels come first; subscribing is a separate action."""
|
||
# Resolve & cache the user's own channel id so it can be excluded — you don't subscribe
|
||
# to yourself. Lazy + cached; needs OAuth, so token-less users just skip it.
|
||
if user.yt_channel_id is None and user.token is not None:
|
||
try:
|
||
with (
|
||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||
YouTubeClient(db, user) as yt,
|
||
):
|
||
own = yt.get_my_channel_id()
|
||
if own:
|
||
service.remember_own_channel(user, own)
|
||
db.commit()
|
||
except YouTubeError as exc:
|
||
# No traceback anywhere in this file's YouTube handlers: for an upstream answer
|
||
# `_upstream_failure` already logged the structured `youtube.api.error`, and the
|
||
# message names the call, its status and its reason (or, with no answer at all, the
|
||
# transport failure). The stack under it is this `except` and the client call.
|
||
log.warning(
|
||
"discovery: own-channel resolve failed: %s",
|
||
exc,
|
||
extra=logs.event("channels.discovery.own_channel_failed", reason=exc.reason),
|
||
)
|
||
|
||
rows = _discovery_rows(db, user)
|
||
|
||
# Enrich stub channels' metadata (uses the API key — no write scope needed). The Channel
|
||
# objects are identity-mapped, so apply_channel_details mutates the very rows we already hold
|
||
# (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can
|
||
# change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB.
|
||
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
|
||
if need:
|
||
try:
|
||
with (
|
||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||
YouTubeClient(db, user) as yt,
|
||
):
|
||
apply_channel_details(db, yt.get_channels(need), need)
|
||
db.commit()
|
||
# Match _discovery_rows' ORDER BY exactly: video-count DESC, then title in the reader's
|
||
# alphabet (NULLs LAST, as Postgres orders them — coercing NULL→"" would wrongly float
|
||
# untitled channels up).
|
||
titles = [r[0].title for r in rows if r[0].title is not None]
|
||
alpha = {w: i for i, w in enumerate(sorted_words(db, titles, lang_for_user(user)))}
|
||
rows = sorted(
|
||
rows,
|
||
key=lambda r: (-int(r[1]), r[0].title is None, alpha.get(r[0].title or "", 0)),
|
||
)
|
||
except YouTubeError as exc:
|
||
log.warning(
|
||
"discovery: channel enrich failed: %s",
|
||
exc,
|
||
extra=logs.event("channels.discovery.enrich_failed", reason=exc.reason),
|
||
)
|
||
db.rollback()
|
||
|
||
return [
|
||
{
|
||
**_channel_summary(ch),
|
||
"playlist_video_count": int(vid_count),
|
||
"playlist_count": int(pl_count),
|
||
"details_synced": ch.details_synced_at is not None,
|
||
}
|
||
for ch, vid_count, pl_count in rows
|
||
]
|
||
|
||
|
||
def _channel_summary(ch: "Channel") -> dict:
|
||
"""The channel fields common to the list and discovery projections."""
|
||
return {
|
||
"id": ch.id,
|
||
"title": ch.title,
|
||
"handle": ch.handle,
|
||
"thumbnail_url": ch.thumbnail_url,
|
||
"subscriber_count": ch.subscriber_count,
|
||
"video_count": ch.video_count,
|
||
"description": ch.description,
|
||
}
|
||
|
||
|
||
def _channel_detail_dict(
|
||
channel: "Channel",
|
||
*,
|
||
subscribed: bool,
|
||
explored: bool,
|
||
blocked: bool,
|
||
stored: int,
|
||
count_normal: int,
|
||
count_short: int,
|
||
count_live: int,
|
||
deep_requested: bool,
|
||
priority: int,
|
||
hidden: bool,
|
||
tag_ids: list[int],
|
||
) -> dict:
|
||
return {
|
||
"id": channel.id,
|
||
"title": channel.title,
|
||
"handle": channel.handle,
|
||
"description": channel.description,
|
||
"thumbnail_url": channel.thumbnail_url,
|
||
"banner_url": channel.banner_url,
|
||
"subscriber_count": channel.subscriber_count,
|
||
"video_count": channel.video_count,
|
||
"total_view_count": channel.total_view_count,
|
||
"published_at": iso(channel.published_at),
|
||
"external_links": channel.external_links or [],
|
||
"country": channel.country,
|
||
"default_language": channel.default_language,
|
||
"topic_categories": channel.topic_categories or [],
|
||
"keywords": channel.keywords,
|
||
"subscribed": subscribed,
|
||
"explored": explored,
|
||
"blocked": blocked,
|
||
"stored_videos": stored,
|
||
# Per-type breakdown (Normal / Shorts / Live) — moved off the manager table onto the
|
||
# channel page. Same aggregation the manager list uses (count_normal = stored − shorts − live).
|
||
"count_normal": count_normal,
|
||
"count_short": count_short,
|
||
"count_live": count_live,
|
||
# Deep-backfill state, so the channel page can offer "get full history" when the catalog
|
||
# holds only the recent-backfill/RSS subset (video_count is YouTube's raw total).
|
||
"deep_requested": deep_requested,
|
||
# This user's per-subscription feed priority (0 when not subscribed) so the channel page can
|
||
# offer the same priority stepper as the manager. Editable only while subscribed.
|
||
"priority": priority,
|
||
# This user's per-subscription "hide from feed" flag (False when not subscribed), so the
|
||
# channel page can offer the same Unhide toggle the manager has. Editable only while subscribed.
|
||
"hidden": hidden,
|
||
"backfill_done": channel.backfill_done,
|
||
"from_explore": channel.from_explore,
|
||
"details_synced": channel.details_synced_at is not None,
|
||
# This user's personal tag links for the channel (same shape as the manager list), so the
|
||
# channel page can offer the same tag editor next to Subscribe.
|
||
"tag_ids": tag_ids,
|
||
}
|
||
|
||
|
||
@router.get("/{channel_id}")
|
||
def channel_detail(
|
||
channel_id: str,
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
||
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
|
||
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
|
||
needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
|
||
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
|
||
cached after)."""
|
||
channel = _channel_or_404(db, channel_id)
|
||
# No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
|
||
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
|
||
stale = details_stale(channel, datetime.now(timezone.utc))
|
||
if stale:
|
||
try:
|
||
with (
|
||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||
YouTubeClient(db, user) as yt,
|
||
):
|
||
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||
db.commit()
|
||
channel = _channel_or_404(db, channel_id)
|
||
except YouTubeError as exc:
|
||
log.warning(
|
||
"channel detail enrich failed: %s",
|
||
exc,
|
||
extra=logs.event(
|
||
"channels.detail.enrich_failed", channel_id=channel_id, reason=exc.reason
|
||
),
|
||
)
|
||
db.rollback()
|
||
sub = queries.subscription(db, user, channel_id)
|
||
subscribed = sub is not None
|
||
explored = queries.exploration(db, user, channel_id) is not None
|
||
blocked = queries.is_blocked(db, user, channel_id)
|
||
# Stored total + the Normal/Shorts/Live split in one grouped pass (same filters as the manager
|
||
# list), so the channel page can show the per-type breakdown that moved off the table.
|
||
stored, count_short, count_live = queries.video_counts(db, channel_id)
|
||
count_normal = max(0, stored - count_short - count_live)
|
||
tag_ids = reader_queries.user_tag_links(db, user, channel_id).get(channel_id, [])
|
||
return _channel_detail_dict(
|
||
channel,
|
||
subscribed=subscribed,
|
||
explored=explored,
|
||
blocked=blocked,
|
||
stored=int(stored),
|
||
count_normal=count_normal,
|
||
count_short=count_short,
|
||
count_live=count_live,
|
||
deep_requested=bool(sub.deep_requested) if sub else False,
|
||
priority=int(sub.priority) if sub else 0,
|
||
hidden=bool(sub.hidden) if sub else False,
|
||
tag_ids=tag_ids,
|
||
)
|
||
|
||
|
||
@router.post("/{channel_id}/explore")
|
||
def explore_channel(
|
||
channel_id: str,
|
||
page_token: str | None = None,
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user,
|
||
grace-clocked row that gates this user's access to the channel's ephemeral videos and
|
||
drives the later cleanup), flags the channel ephemeral unless someone organically follows
|
||
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
|
||
Returns the next page token so the page can "load more". Guarded against the shared
|
||
backfill quota reserve."""
|
||
channel = _channel_or_404(db, channel_id)
|
||
if queries.is_blocked(db, user, channel_id):
|
||
raise refused(403, "channel_blocked")
|
||
if quota.backfill_reserve_reached():
|
||
raise refused(429, "quota_reserve")
|
||
|
||
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
||
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
|
||
service.explore(db, user, channel)
|
||
db.commit()
|
||
|
||
try:
|
||
with (
|
||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE),
|
||
YouTubeClient(db, user) as yt,
|
||
):
|
||
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
||
# Stale, not "a field is missing": a terminated channel never gets an uploads playlist
|
||
# or a creation date, and asking again on every explore would spend quota on a miss.
|
||
if details_stale(channel, datetime.now(timezone.utc)):
|
||
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||
db.commit()
|
||
channel = _channel_or_404(db, channel_id)
|
||
result = explore_ingest_page(db, yt, channel, page_token)
|
||
except YouTubeError as exc:
|
||
raise youtube_refusal(exc) from exc
|
||
return {
|
||
"ok": True,
|
||
"channel_id": channel_id,
|
||
"ingested": result["ingested"],
|
||
"next_page_token": result["next_page_token"],
|
||
}
|
||
|
||
|
||
def _channel_or_404(db: Session, channel_id: str) -> "Channel":
|
||
channel = queries.channel(db, channel_id)
|
||
if channel is None:
|
||
raise not_found("channel")
|
||
return channel
|
||
|
||
|
||
def _user_subscription(db: Session, user: User, channel_id: str) -> "Subscription":
|
||
sub = queries.subscription(db, user, channel_id)
|
||
if sub is None:
|
||
raise not_found("subscription")
|
||
return sub
|
||
|
||
|
||
class ChannelUpdateIn(BaseModel):
|
||
# PATCH semantics: a field left unset (None) is untouched. Every field is optional so the
|
||
# UI can send just the one control it changed. int/bool coercion + rejection of garbage
|
||
# (a non-numeric priority, a non-bool flag) is now Pydantic's job → 422, not a 500.
|
||
priority: int | None = None
|
||
hidden: bool | None = None
|
||
deep_requested: bool | None = None
|
||
|
||
|
||
@router.patch("/{channel_id}")
|
||
def update_channel(
|
||
channel_id: str,
|
||
body: ChannelUpdateIn,
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
sub = _user_subscription(db, user, channel_id)
|
||
channel = queries.channel(db, channel_id)
|
||
deep_turned_on = service.update_subscription(
|
||
db,
|
||
sub,
|
||
priority=body.priority,
|
||
hidden=body.hidden,
|
||
deep_requested=body.deep_requested,
|
||
)
|
||
db.commit()
|
||
|
||
# Opting a channel into full history should give an immediate feed: if we haven't even
|
||
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
|
||
# the scheduler.
|
||
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||
run_recent_backfill(db, [channel], max_channels=1)
|
||
|
||
# …and kick the FULL deep backfill for this one channel immediately, as a recorded run of its own,
|
||
# so the whole catalogue lands in seconds instead of over the scheduler's paced 10-minute ticks.
|
||
# The request returns right away; the channel page's live poll shows the count climb. The
|
||
# scheduler still finishes a very large channel's tail on its normal runs (deep_requested stays set).
|
||
# A channel whose history is all in gets no run: it would only say "nothing to do", one kept row
|
||
# per flip of the flag.
|
||
if deep_turned_on and not (channel is not None and channel.backfill_done):
|
||
scheduler.run_ad_hoc(
|
||
scheduler.CHANNEL_BACKFILL,
|
||
partial(backfill_channel_now, channel_id, user.id),
|
||
actor_id=user.id,
|
||
target=channel_id,
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"id": channel_id,
|
||
"priority": sub.priority,
|
||
"hidden": sub.hidden,
|
||
"deep_requested": sub.deep_requested,
|
||
"recent_synced": channel.recent_synced_at is not None if channel else None,
|
||
}
|
||
|
||
|
||
@router.post("/{channel_id}/reset-backfill")
|
||
def reset_backfill(
|
||
channel_id: str,
|
||
user: User = Depends(admin_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""Admin-only reset: re-fetch this channel from scratch regardless of its current sync
|
||
state (a "reset" trigger). Clears the channel's backfill markers, opts it back into deep
|
||
backfill, and re-runs its recent pull immediately; the deep scheduler re-pages the full
|
||
back-catalog on its next run. Idempotent — videos upsert by id, so nothing duplicates."""
|
||
channel = _channel_or_404(db, channel_id)
|
||
service.reset_backfill(db, channel)
|
||
db.commit()
|
||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||
run_recent_backfill(db, [channel], max_channels=1)
|
||
return {"ok": True, "id": channel_id, "reset": True}
|
||
|
||
|
||
@router.post("/{channel_id}/subscribe")
|
||
def subscribe(
|
||
channel_id: str,
|
||
user: User = Depends(require_write_scope),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""Subscribe to a channel discovered from the user's playlists. Gated behind the
|
||
optional write scope (YouTube subscriptions.insert) via require_write_scope. Creates the
|
||
local subscription with YouTube's returned resource id and enriches the channel's metadata
|
||
if it's still a stub, so the new row renders properly in the manager. It does NOT pull the
|
||
channel's videos — the scheduler picks those up on its next run, like any subscribed channel."""
|
||
channel = _channel_or_404(db, channel_id)
|
||
if queries.subscription(db, user, channel_id) is not None:
|
||
raise refused(409, "already_subscribed")
|
||
with (
|
||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
||
YouTubeClient(db, user) as yt,
|
||
):
|
||
try:
|
||
yt_sub_id = yt.insert_subscription(channel_id)
|
||
except YouTubeError as exc:
|
||
# Already following on YouTube but not recorded here (a desync — e.g. a stale import
|
||
# dropped the local row, so it resurfaced in discovery). That's not a failure: the end
|
||
# state the user wanted already holds, so record it locally and move on. The next
|
||
# subscription resync fills in the resource id. Any other YouTube error is a real
|
||
# problem — surface it with a clear message (422, not a 502 that the client would
|
||
# mistake for a transient "connection lost").
|
||
# On the reason code, never on the prose: the error no longer carries the body (it is
|
||
# the request quoted back), and a message YouTube rewords would silently turn "already
|
||
# following" back into a 422 the user cannot act on. `reason` is comma-joined when the
|
||
# body listed several.
|
||
if "subscriptionDuplicate" in exc.reason.split(","):
|
||
yt_sub_id = ""
|
||
else:
|
||
raise youtube_refusal(exc) from exc
|
||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
||
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
||
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
||
# must not block recording the subscription the user asked for.
|
||
if channel.details_synced_at is None:
|
||
try:
|
||
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||
except YouTubeError as exc:
|
||
log.warning(
|
||
"subscribe: stub enrich failed: %s",
|
||
exc,
|
||
extra=logs.event(
|
||
"channels.subscribe.enrich_failed",
|
||
channel_id=channel_id,
|
||
reason=exc.reason,
|
||
),
|
||
)
|
||
service.record_subscription(db, user, channel, yt_sub_id)
|
||
db.commit()
|
||
return {"ok": True, "subscribed": channel_id}
|
||
|
||
|
||
@router.delete("/{channel_id}/subscription")
|
||
def unsubscribe(
|
||
channel_id: str,
|
||
user: User = Depends(require_write_scope),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""Unsubscribe from this channel on YouTube and drop the local subscription. Gated
|
||
behind the optional write scope (require_write_scope); read-only users get a 403 and
|
||
should Hide instead."""
|
||
sub = _user_subscription(db, user, channel_id)
|
||
if not sub.yt_subscription_id:
|
||
raise refused(400, "no_subscription_id")
|
||
try:
|
||
with (
|
||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
||
YouTubeClient(db, user) as yt,
|
||
):
|
||
yt.delete_subscription(sub.yt_subscription_id)
|
||
except YouTubeError as exc:
|
||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||
# a transient "connection lost" gateway blip.
|
||
raise youtube_refusal(exc) from exc
|
||
service.drop_subscription(db, sub)
|
||
db.commit()
|
||
return {"ok": True, "unsubscribed": channel_id}
|
||
|
||
|
||
@router.get("/blocked/list")
|
||
def list_blocked(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||
"""The user's blocked channels (videos from these are excluded from their search/feed/explore)."""
|
||
rows = queries.blocked_channels(db, user, lang_for_user(user))
|
||
return [
|
||
{"id": c.id, "title": c.title, "handle": c.handle, "thumbnail_url": c.thumbnail_url}
|
||
for c in rows
|
||
]
|
||
|
||
|
||
@router.post("/{channel_id}/block")
|
||
def block_channel(
|
||
channel_id: str,
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""Block a channel for this user: its videos won't appear in their live search (and won't be
|
||
ingested for them) and are hidden from their feed / explore. Idempotent. The now-hidden
|
||
un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course."""
|
||
_channel_or_404(db, channel_id)
|
||
service.block(db, user, channel_id)
|
||
db.commit()
|
||
return {"ok": True, "blocked": channel_id}
|
||
|
||
|
||
@router.delete("/{channel_id}/block")
|
||
def unblock_channel(
|
||
channel_id: str,
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
service.unblock(db, user, channel_id)
|
||
db.commit()
|
||
return {"ok": True, "unblocked": channel_id}
|
||
|
||
|
||
class AttachTagIn(BaseModel):
|
||
tag_id: int
|
||
|
||
|
||
@router.post("/{channel_id}/tags")
|
||
def attach_tag(
|
||
channel_id: str,
|
||
body: AttachTagIn,
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
_user_subscription(db, user, channel_id)
|
||
# Only the user's own tags may be attached as personal links (a 404 if the tag is missing OR
|
||
# belongs to someone else, without leaking which).
|
||
if reader_queries.own_tag(db, user, body.tag_id) is None:
|
||
raise not_found("tag")
|
||
reader_service.attach_user_tag(db, user, channel_id, body.tag_id)
|
||
db.commit()
|
||
return {"ok": True, "channel_id": channel_id, "tag_id": body.tag_id}
|
||
|
||
|
||
@router.delete("/{channel_id}/tags/{tag_id}")
|
||
def detach_tag(
|
||
channel_id: str,
|
||
tag_id: int,
|
||
user: User = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
reader_service.detach_user_tag(db, user, channel_id, tag_id)
|
||
db.commit()
|
||
return {"ok": True, "channel_id": channel_id, "tag_id": tag_id}
|