"""Live YouTube search, the catalog's side: what a page of results leaves in the shared catalog. A result becomes a catalog row so it renders with the normal feed cards and the in-app player and gains per-user state. The ingest runs in steps with a YouTube call between each, and each step's rows are committed before the next call: an enrich that fails rolls back to the stubs, which stay (the FK targets for what follows, backfilled later). Results are flagged (`videos.via_search`, `channels.from_search`) so the Library can hide this search-discovered noise; Shorts and live/upcoming videos are never kept. The HTTP layer (`routes/search.py`) runs the paging, the caps and the refusals. """ from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from sqlalchemy import delete, func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.engine import CursorResult from sqlalchemy.orm import Session from app import logs, quota, sysconfig from app.catalog import queries from app.catalog.models import LIVE_OR_UPCOMING, Channel, SearchFind, Subscription, Video from app.models import User from app.reader import queries as reader_queries from app.sync.subscriptions import apply_channel_details from app.sync.videos import apply_video_details, insert_stubs, parse_dt from app.userscope import owned_by from app.utils import like_escape from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.shorts import make_client, probe_is_short log = logs.get_logger(__name__) def classify_shorts(db: Session, videos: Sequence[Video]) -> None: """Confirm/deny Shorts for the freshly-ingested videos so none slip into the feed. Mirrors the scheduler's run_shorts_classification, but scoped to just these rows and run synchronously (the search is an explicit, awaited action). Non-candidates (too long, no duration, or live) are finalised without a probe; the rest hit youtube.com/shorts (no quota). Undetermined probes are left for the scheduler to retry.""" probe_max = sysconfig.get_int(db, "shorts_probe_max_seconds") to_probe: list[Video] = [] for v in videos: if v.shorts_probed: continue if v.enriched_at is None: # Enrichment failed (proxy blip) or YouTube omitted the row — duration is unknown, # so DON'T finalise shorts_probed off a null duration; leave it for the scheduler # (which only classifies enriched rows) to retry. Otherwise a real Short would leak. continue if v.live_status != "none" or v.duration_seconds is None or v.duration_seconds > probe_max: v.is_short = False v.shorts_probed = True else: to_probe.append(v) if to_probe: with make_client() as client: def work(v: Video): return v, probe_is_short(client, v.id) with ThreadPoolExecutor(max_workers=16) as pool: for v, result in pool.map(work, to_probe): if result is None: continue v.is_short = result v.shorts_probed = True db.commit() def ingest_candidates( db: Session, yt: YouTubeClient, user: User, candidates: list[dict] ) -> list[str]: """Materialise one page of search candidates into the catalog (channel stubs + enrich, video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE video ids — non-Short, non-live — in the input relevance order. A videos.list that fails raises its YouTubeError: with nothing enriched, nothing could be kept.""" ids = [it["id"] for it in candidates] if not ids: return [] # 1) Channels are the FK target — create stubs for unknown ones (flagged from_search), # then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure. channel_ids = list({it["channel_id"] for it in candidates}) existing = set(db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all()) new_channel_ids = [c for c in channel_ids if c not in existing] if new_channel_ids: titles = {it["channel_id"]: it["channel_title"] for it in candidates} for cid in new_channel_ids: db.add(Channel(id=cid, title=titles.get(cid), from_search=True)) db.commit() try: with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER): details = yt.get_channels(new_channel_ids) apply_channel_details(db, details, new_channel_ids) db.commit() except YouTubeError: # The stubs remain and the details can be backfilled later — but until they are, those # channels sit in the results with no thumbnail and no subscriber count, looking like # channels that have neither. log.warning( "the channels found by a search could not be enriched", extra=logs.event("search.channel_enrich_failed", channels=len(new_channel_ids)), ) db.rollback() # details can be backfilled later; the stubs remain # 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO # NOTHING), so an organically-synced video stays organic. # published_at is NOT NULL (migration 0068); the scrape source exposes no publish date # (only a relative "3 years ago"), so fall back to now() to satisfy the constraint — the # enrich in step 3 overwrites it with the video's real snippet date. now = datetime.now(timezone.utc) insert_stubs( db, [ { "id": it["id"], "channel_id": it["channel_id"], "title": it["title"], "published_at": parse_dt(it["published_at"]) or now, "thumbnail_url": it["thumbnail_url"], "via_search": True, } for it in candidates ], ) # 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live. videos = db.scalars(select(Video).where(Video.id.in_(ids))).all() try: with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH): detail_by_id = {it["id"]: it for it in yt.get_videos(ids)} except YouTubeError: # An unenriched video cannot be judged live or Short, so none of this page could be kept: # swallowed, the failure answered as a page with nothing on it — "no results" for a search # that never ran (the shared budget spent, no API key). The stubs stay (`insert_stubs` # committed them), as the channels' do above; the caller answers the failure (its refusal # says which). log.warning( "the videos found by a search could not be enriched", extra=logs.event("search.video_enrich_failed", candidates=len(ids)), ) raise enriched_at = datetime.now( timezone.utc ) # when THIS enrich ran (distinct from the stub placeholder) for v in videos: item = detail_by_id.get(v.id) if item is None: continue apply_video_details(v, item) v.enriched_at = enriched_at db.commit() # 4) Confirm/deny Shorts (no quota) so none enter via search. classify_shorts(db, videos) keep = { v.id for v in videos if v.enriched_at is not None and not v.is_short and v.live_status not in LIVE_OR_UPCOMING } return [vid for vid in ids if vid in keep] def _fold_term(db: Session, video_ids: list[str], term: str) -> None: """Append `term` to each video's space-joined `search_terms`, unless it is already one of them. "Already one of them" is a whole-term match, as a literal: a substring test skipped `cat` on a video already found for `category`, and an unescaped one read the `_` in `my_channel` as a wildcard — either way the term was never folded in, and local search lost that relevance.""" padded = func.concat(" ", func.coalesce(Video.search_terms, ""), " ") db.execute( update(Video) .where(Video.id.in_(video_ids)) .where(padded.notilike(f"% {like_escape(term)} %")) .values( search_terms=func.trim( func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term) ) ) ) def new_first(db: Session, user: User, ids: list[str]) -> list[str]: """Reorder results so genuinely-new discoveries come first and things the user already has (a subscribed channel, or a video they've watched / in-progress / saved / playlisted) sink to the back — keeping the relevance order within each group.""" if not ids: return ids have = set( db.scalars( select(Video.id).where( Video.id.in_(ids), Video.channel_id.in_(queries.subscribed_channels(user)) ) ) ) | reader_queries.held_by(db, user, ids) return [i for i in ids if i not in have] + [i for i in ids if i in have] def record_finds(db: Session, user: User, video_ids: list[str], term: str) -> None: """These results as THIS user's search finds (the Mine feed, the Source filter), and the term folded into each one's `search_terms` so local full-text search inherits YouTube's relevance. Idempotent.""" db.execute( pg_insert(SearchFind) .values([{"user_id": user.id, "video_id": vid} for vid in video_ids]) .on_conflict_do_nothing(index_elements=["user_id", "video_id"]) ) _fold_term(db, video_ids, term) def clear(db: Session, user: User, video_ids: list[str]) -> int: """Discard these results "as if never added": drop this user's finds of them, then delete the ones nobody kept — a `via_search` video no reader holds (`reader.queries.kept_by_a_reader`), whose channel nobody subscribes to, and that no other user still has as a find. Returns how many videos went. Only the ones this user found: a video in the catalog with no find at all (a related list's, which records none) is in someone's list right now, and an id any user could name would let them delete it from under that list.""" mine = list( db.scalars( delete(SearchFind) .where(owned_by(SearchFind.user_id, user), SearchFind.video_id.in_(video_ids)) .returning(SearchFind.video_id) ) ) if not mine: return 0 subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists() other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists() result = db.execute( delete(Video).where( Video.id.in_(mine), Video.via_search.is_(True), ~reader_queries.kept_by_a_reader(Video.id), ~subscribed, ~other_find, ) ) return result.rowcount if isinstance(result, CursorResult) else 0