- A channel someone follows: the videos its exploration brings in (the list's dialog, the channel page's explore) come in as the catalog's own, not flagged via_explore, which subscribing would never clear and which kept their watched marks waiting for good. - The explore cleanup keeps the row of a channel a Takeout history shows watched (it still reclaims the videos), so a looked-at channel no longer drops off the list. - The Shorts job takes up only users whose list still has lengths to ask or channels to name, by each user's own follows, blocks and dismissals; a naming that failed is tried again, and the list says it is not complete. - The length lookups have a daily share of their own: a large history no longer leaves the export's Watch later file waiting a day. - Only a channel of the user's own history can be put out of the list. - The dialog's videos have a query root of their own: the list's invalidations no longer re-ask (and re-explore) under an open dialog.
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), enriched — marked via_explore while
|
||
nobody follows it. 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}
|