"""Service-layer helpers for the `sources` table. Single source of truth for "queue a URL" — both the `second-brain add` CLI and the web POST /sources/add route call into here, so they can't drift on validation, dedupe, or default semantics. """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Callable, Optional from urllib.parse import parse_qs, urlparse from sqlalchemy.orm import Session from second_brain.config import DOMAINS from second_brain.models import Source, SourceStatus, SourceType, utcnow logger = logging.getLogger(__name__) _VIDEO_HOSTS = { "youtube.com", "www.youtube.com", "youtu.be", "vimeo.com", "www.vimeo.com", } def is_article_url(url: str) -> bool: """Match the CLI's `_is_article` heuristic: anything not on a known video host is treated as an article.""" parsed = urlparse(url) return parsed.netloc not in _VIDEO_HOSTS def validate_url(url: str) -> str: """Normalize and validate the input URL. Raises ValueError on bad shape.""" if url is None: raise ValueError("url is required") url = url.strip() if not url: raise ValueError("url is required") parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise ValueError("url must use http or https") if not parsed.netloc: raise ValueError("url is missing a host") return url @dataclass class AddResult: """Outcome of `add_source`. `added=False` means the URL was already queued.""" source: Source added: bool def add_source( sess: Session, *, url: str, domain: str = "development", focus: Optional[str] = None, title: Optional[str] = None, ) -> AddResult: """Queue a single source URL. Mirrors the CLI add path exactly: - normalize + validate URL - reject unknown domains - if the URL is already queued, return the existing row with added=False - otherwise insert a PENDING row and return it with added=True Does NOT open or commit a session — caller owns the transaction boundary (matches the rest of the codebase). For the CLI, that's `db.session()`; for the web route, the request handler's `with db.session()` block. """ url = validate_url(url) if domain not in DOMAINS: raise ValueError( f"unknown domain {domain!r}; valid: {', '.join(DOMAINS)}" ) title = (title or "").strip() or None focus = (focus or "").strip() or None existing = sess.query(Source).filter_by(url=url).first() if existing is not None: return AddResult(source=existing, added=False) source_type = ( SourceType.ARTICLE if is_article_url(url) else SourceType.VIDEO ) source = Source( url=url, title=title, domain=domain, focus=focus, source_type=source_type, status=SourceStatus.PENDING, ingested_at=utcnow(), ) sess.add(source) sess.flush() return AddResult(source=source, added=True) # --------------------------------------------------------------------------- # YouTube playlist fan-out # --------------------------------------------------------------------------- _YT_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"} # Hard ceiling shared between the CLI and the web route. yt-dlp will only # enumerate up to this many entries via `playlistend`. Stops accidental # 327-video queue floods. DEFAULT_PLAYLIST_MAX_ITEMS = 50 def is_youtube_playlist_url(url: str) -> bool: """Detect a YouTube *playlist* URL — strict. Returns True only for the `/playlist?list=…` form on a known YouTube host. A `/watch?v=…&list=…` URL is ambiguous (user typically pasted a single video that happens to live inside a playlist) — we treat it as a single video. To fan out from a watch URL, paste the canonical `/playlist?list=…` form instead. """ if not url: return False try: parsed = urlparse(url.strip()) except ValueError: return False if parsed.scheme not in ("http", "https"): return False if parsed.netloc not in _YT_HOSTS: return False if parsed.path.rstrip("/") != "/playlist": return False q = parse_qs(parsed.query) return bool(q.get("list")) def expand_youtube_playlist( url: str, *, max_items: int = DEFAULT_PLAYLIST_MAX_ITEMS ) -> list[dict]: """Enumerate a playlist into per-video dicts without downloading. Uses yt-dlp's `extract_flat=True` — one network call, gets back the playlist's entries with `id` + `title`. Builds a canonical `https://www.youtube.com/watch?v={id}` URL for each. Empty / private / placeholder entries (missing `id`) are skipped silently. `max_items` is plumbed straight to yt-dlp's `playlistend` so we never enumerate more than asked even on a 1000-entry playlist. """ # Lazy import: yt-dlp is a runtime dep but importing at module level # would pull it into every consumer of the service module, and the # tower already pulls it for the downloader path. Lazy keeps the # web/dev import graph thin and lets the test suite mock this whole # function without yt-dlp installed. import yt_dlp # noqa: PLC0415 ydl_opts = { "quiet": True, "no_warnings": True, "skip_download": True, "extract_flat": True, "ignoreerrors": True, "playlistend": max_items, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=False) or {} entries = info.get("entries") or [] out: list[dict] = [] for entry in entries: if not entry: # yt-dlp returns None placeholders for private / removed videos continue vid = entry.get("id") if not vid: continue out.append( { "url": f"https://www.youtube.com/watch?v={vid}", "title": entry.get("title"), } ) return out @dataclass class PlaylistAddResult: """Per-entry tallies from a playlist fan-out. `expanded` is what came back from the expander (after yt-dlp's own filtering); the three counters always sum to that number. """ expanded: int = 0 added: int = 0 duplicates: int = 0 failed: int = 0 # (url, reason) per failure, capped so the flash doesn't blow up the page. failures: list[tuple[str, str]] = field(default_factory=list) def add_playlist( sess: Session, *, url: str, domain: str = "development", focus: Optional[str] = None, max_items: int = DEFAULT_PLAYLIST_MAX_ITEMS, expander: Optional[Callable[..., list[dict]]] = None, ) -> PlaylistAddResult: """Expand a playlist URL and queue one source row per video. Calls `add_source` for each entry so URL validation, source_type detection, and the UNIQUE dedupe path are identical to the single-add flow — there's only ever one way a source row lands in the queue. Partial failures are tolerated: a bad entry doesn't abort the batch. The per-entry `title` is taken from yt-dlp's playlist metadata (overrides any caller-supplied title — single playlist title would be wrong for N videos). `expander` is an injection seam for tests; default is the live yt-dlp-backed `expand_youtube_playlist`. """ if domain not in DOMAINS: raise ValueError( f"unknown domain {domain!r}; valid: {', '.join(DOMAINS)}" ) fn = expander or expand_youtube_playlist try: entries = fn(url, max_items=max_items) except Exception as exc: # noqa: BLE001 — yt-dlp can raise lots of things logger.warning("playlist expand failed for %s: %s", url, exc) raise ValueError(f"could not expand playlist: {exc}") from exc result = PlaylistAddResult(expanded=len(entries)) for entry in entries: entry_url = entry.get("url", "") try: sub = add_source( sess, url=entry_url, domain=domain, focus=focus, title=entry.get("title"), ) if sub.added: result.added += 1 else: result.duplicates += 1 except Exception as exc: # noqa: BLE001 — must not abort the batch result.failed += 1 if len(result.failures) < 10: result.failures.append((entry_url, str(exc))) logger.warning( "playlist entry add failed: %s — %s", entry_url, exc ) return result # --------------------------------------------------------------------------- # Edit + retry helpers (shared by web routes and CLI) # --------------------------------------------------------------------------- # Fields the edit-mode form is allowed to mutate. URL is intentionally # excluded — it's the UNIQUE dedupe key and editing it risks collisions # plus breaks the embeddings 4-tuple identity (source_id is stable but # semantic identity drifts). EDITABLE_METADATA_FIELDS = ("domain", "title", "focus") def update_source_metadata( sess: Session, *, source_id: int, domain: Optional[str] = None, title: Optional[str] = None, focus: Optional[str] = None, ) -> Source: """Update editable metadata on a source row. Empty strings collapse to NULL for title/focus (matches `add_source` semantics). Domain is required to stay in the configured set — bumping the constraint is a separate decision. Raises ValueError on validation failures and LookupError if the row is missing. Caller owns the transaction boundary. """ source = sess.query(Source).filter(Source.id == source_id).first() if source is None: raise LookupError(f"source id={source_id} not found") if domain is not None: if domain not in DOMAINS: raise ValueError( f"unknown domain {domain!r}; valid: {', '.join(DOMAINS)}" ) source.domain = domain if title is not None: source.title = title.strip() or None if focus is not None: source.focus = focus.strip() or None source.updated_at = utcnow() sess.flush() return source # Retry modes — keep the surface small. Anything else is a separate ask. RETRY_FULL = "full" RETRY_EXTRACT = "extract" RETRY_MODES = (RETRY_FULL, RETRY_EXTRACT) def reset_source_for_retry( sess: Session, *, source_id: int, mode: str = RETRY_FULL, ) -> Source: """Reset a source so the pipeline re-processes it. Modes: - "full": status → PENDING. Article pull or video download + transcribe will happen again on the appropriate worker. - "extract": status → TRANSCRIBED. Requires the row to already carry a transcript (transcript_text). The dev scheduler will re-run extraction without re-pulling the source. Cheap. Always clears `claimed_by` / `claimed_at` / `error_message` so the queue dance can pick it up cleanly and stale errors don't bleed into the UI. Raises ValueError on bad mode / missing transcript for extract mode, and LookupError if the row is missing. """ if mode not in RETRY_MODES: raise ValueError( f"unknown retry mode {mode!r}; valid: {', '.join(RETRY_MODES)}" ) source = sess.query(Source).filter(Source.id == source_id).first() if source is None: raise LookupError(f"source id={source_id} not found") if mode == RETRY_EXTRACT: if not (source.transcript_text and source.transcript_text.strip()): raise ValueError( "extract-only retry requires an existing transcript; " "use full retry instead" ) source.status = SourceStatus.TRANSCRIBED else: source.status = SourceStatus.PENDING source.claimed_by = None source.claimed_at = None source.error_message = None source.updated_at = utcnow() sess.flush() return source __all__ = [ "AddResult", "DEFAULT_PLAYLIST_MAX_ITEMS", "EDITABLE_METADATA_FIELDS", "PlaylistAddResult", "RETRY_EXTRACT", "RETRY_FULL", "RETRY_MODES", "add_playlist", "add_source", "expand_youtube_playlist", "is_article_url", "is_youtube_playlist_url", "reset_source_for_retry", "update_source_metadata", "validate_url", ]