second-brain/src/second_brain/sources_service.py
Travis Herbranson 5131f3ccf6 web+cli: edit-mode for source metadata + retry/re-run actions
Adds per-record edit mode and retry to the source detail page, plus a
CLI `second-brain retry <id>` for parity.

Edit mode exposes domain (select), title, focus on /sources/{id}.
URL stays read-only — it's the UNIQUE dedupe key and the embeddings
identity tuple. Save goes through a new service helper
`update_source_metadata` so the web route stays a thin wrapper.

Retry exposes two modes, both available on any record:
  - full: status -> PENDING (re-pull + re-process)
  - extract: status -> TRANSCRIBED (re-extract only, requires an
    existing transcript; offered when source_type=video and a
    transcript is present, to avoid the expensive re-download path)

Both modes clear claimed_by/claimed_at and error_message so the queue
dance picks it up cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:08:26 -04:00

401 lines
12 KiB
Python

"""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",
]