diff --git a/alembic/versions/d4a72f51c8e3_v4_sources_transcript_segments.py b/alembic/versions/d4a72f51c8e3_v4_sources_transcript_segments.py new file mode 100644 index 0000000..fe1f6b5 --- /dev/null +++ b/alembic/versions/d4a72f51c8e3_v4_sources_transcript_segments.py @@ -0,0 +1,42 @@ +"""v4 second_brain: sources.transcript_segments JSONB + +Revision ID: d4a72f51c8e3 +Revises: c8f1d9e34b7a +Create Date: 2026-05-25 13:00:00.000000 + +Adds a JSONB column to `sources` for the tower-side worker to persist +segment-level transcription output (start/end/text per segment + word- +level timing when faster-whisper returns it). Additive: `transcript_text` +remains the canonical full-text the extractor consumes; this column is a +secondary index for future use (chunked retrieval, timestamp-anchored +quoting, etc.). + +JSONB rather than JSON so equality/containment ops are indexable later +without a re-migration. No index added yet — Travis flagged "no +gold-plating" and the column may sit unused for a while. + +Same lovebug-no-CREATE-on-petalbrain guard as the prior migrations: +env.py has already bootstrapped the schema, this just SETs search_path +and emits DDL inside it. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "d4a72f51c8e3" +down_revision: str | Sequence[str] | None = "c8f1d9e34b7a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("SET search_path TO second_brain") + op.execute("ALTER TABLE sources ADD COLUMN transcript_segments JSONB") + + +def downgrade() -> None: + op.execute("SET search_path TO second_brain") + op.execute("ALTER TABLE sources DROP COLUMN IF EXISTS transcript_segments") diff --git a/src/second_brain/main.py b/src/second_brain/main.py index 2e96bee..29d870e 100644 --- a/src/second_brain/main.py +++ b/src/second_brain/main.py @@ -11,8 +11,8 @@ Commands: from __future__ import annotations +import sys from typing import Optional -from urllib.parse import urlparse import click @@ -49,33 +49,74 @@ def cli() -> None: @click.option("--focus", "-f", default=None, help="Optional focus directive.") @click.option("--title", "-t", default=None, help="Override title.") def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> None: - """Queue a source URL for processing.""" + """Queue a source URL for processing. + + Pasting a YouTube playlist URL (the `/playlist?list=…` form) auto- + expands into one source row per video — no flag, just paste. + `watch?v=…&list=…` is treated as a single video (use the canonical + playlist URL to fan out). + """ + from second_brain.sources_service import ( + DEFAULT_PLAYLIST_MAX_ITEMS, + add_playlist, + add_source, + is_youtube_playlist_url, + ) + config = load_config() config.ensure_dirs() db = get_database() - source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO + # --- playlist fan-out path --------------------------------------------- + if is_youtube_playlist_url(url): + try: + with db.session() as sess: + result = add_playlist( + sess, + url=url, + domain=domain, + focus=focus, + max_items=DEFAULT_PLAYLIST_MAX_ITEMS, + ) + except ValueError as exc: + click.echo(f"[add] Error: {exc}", err=True) + sys.exit(2) - with db.session() as sess: - existing = sess.query(Source).filter_by(url=url).first() - if existing: - click.echo( - f"[add] Already queued (id={existing.id}, status={existing.status.value})" - ) - return - - source = Source( - url=url, - title=title, - domain=domain, - focus=focus, - source_type=source_type, - status=SourceStatus.PENDING, - ingested_at=utcnow(), + click.echo( + f"[add] Playlist expanded: {result.expanded} entr" + f"{'y' if result.expanded == 1 else 'ies'} " + f"(cap {DEFAULT_PLAYLIST_MAX_ITEMS})" ) - sess.add(source) + click.echo( + f" added={result.added} duplicates={result.duplicates} " + f"failed={result.failed}" + ) + for u, why in result.failures[:5]: + click.echo(f" ! {u}: {why}", err=True) + return - click.echo(f"[add] Queued {source_type.value}: {url}") + # --- single-source path ------------------------------------------------ + try: + with db.session() as sess: + result = add_source( + sess, url=url, domain=domain, focus=focus, title=title + ) + # Capture display fields inside the session — `result.source` + # detaches on commit and accessing it later would error. + source_id = result.source.id + source_url = result.source.url + source_type_value = result.source.source_type.value + existing_status = result.source.status.value + added = result.added + except ValueError as exc: + click.echo(f"[add] Error: {exc}", err=True) + sys.exit(2) + + if not added: + click.echo(f"[add] Already queued (id={source_id}, status={existing_status})") + return + + click.echo(f"[add] Queued {source_type_value}: {source_url}") click.echo(f" domain={domain}" + (f" focus={focus}" if focus else "")) @@ -348,19 +389,5 @@ def transcribe_worker( click.echo(f"[transcribe-worker] exiting; processed={processed}") -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _is_article(url: str) -> bool: - parsed = urlparse(url) - video_hosts = { - "youtube.com", "www.youtube.com", "youtu.be", - "vimeo.com", "www.vimeo.com", - } - return parsed.netloc not in video_hosts - - if __name__ == "__main__": cli() diff --git a/src/second_brain/models.py b/src/second_brain/models.py index daa42b1..1f67131 100644 --- a/src/second_brain/models.py +++ b/src/second_brain/models.py @@ -29,6 +29,7 @@ from sqlalchemy import ( Text, Time, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -113,6 +114,11 @@ class Source(Base): media_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) transcript_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) transcript_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + # Segment-level output from faster-whisper. List of dicts + # `{id, start, end, text, words: [{start, end, word, probability}, …]}`. + # transcript_text remains the canonical full text the extractor reads; + # this is an additive index for future timestamp-anchored use cases. + transcript_segments: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True) # Metadata from the source published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) diff --git a/src/second_brain/scheduler/transcribe_worker.py b/src/second_brain/scheduler/transcribe_worker.py index 57a2527..d54469d 100644 --- a/src/second_brain/scheduler/transcribe_worker.py +++ b/src/second_brain/scheduler/transcribe_worker.py @@ -293,10 +293,28 @@ class TranscribeWorker: write_srt(segments, srt_path) source.transcript_path = str(srt_path) source.transcript_text = text + # transcript_segments is additive — if the JSONB write blows up + # (oversize row, malformed dict, etc.) we still want the + # canonical text + status transition to land. Worst case: this + # row's segment payload is missing and a backfill picks it up + # later; we never crash the pipeline over it. + try: + source.transcript_segments = segments + except Exception as exc: + logger.warning( + "transcript_segments persist failed for source %s: %s", + source.id, + exc, + ) source.status = SourceStatus.TRANSCRIBED source.updated_at = utcnow() release_claim(sess, source.id) - logger.info("transcribed source %s — %d chars", source.id, len(text)) + logger.info( + "transcribed source %s — %d chars, %d segment(s)", + source.id, + len(text), + len(segments), + ) # ------------------------------------------------------------------ # Helpers diff --git a/src/second_brain/sources_service.py b/src/second_brain/sources_service.py new file mode 100644 index 0000000..ac59244 --- /dev/null +++ b/src/second_brain/sources_service.py @@ -0,0 +1,288 @@ +"""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 + + +__all__ = [ + "AddResult", + "DEFAULT_PLAYLIST_MAX_ITEMS", + "PlaylistAddResult", + "add_playlist", + "add_source", + "expand_youtube_playlist", + "is_article_url", + "is_youtube_playlist_url", + "validate_url", +] diff --git a/src/second_brain/transcribe.py b/src/second_brain/transcribe.py index 8c7f201..943cd5c 100644 --- a/src/second_brain/transcribe.py +++ b/src/second_brain/transcribe.py @@ -21,7 +21,7 @@ from __future__ import annotations import logging import os from pathlib import Path -from typing import Iterable, Optional +from typing import Any, Iterable, Optional logger = logging.getLogger(__name__) @@ -116,8 +116,12 @@ class Transcriber: ) -> tuple[str, list[dict]]: """Transcribe `media_file`. Returns (full_text, segments). - Each segment dict has start/end (float seconds) and text (str) — - same shape the SRT writer needs. + Segments are converted via `segment_to_dict` — each one carries + `id/start/end/text` plus a `words` list when faster-whisper + returns word-level timing (we always pass `word_timestamps=True`). + + `transcript_text` is the joined segment text — same contract the + extractor has consumed since before segment capture existed. """ self._load() assert self._impl is not None # for the type-checker @@ -126,6 +130,7 @@ class Transcriber: str(media_file), language=language, beam_size=beam_size, + word_timestamps=True, ) logger.info( "transcribe start: %s language=%s duration=%.1fs", @@ -137,14 +142,71 @@ class Transcriber: segments: list[dict] = [] text_parts: list[str] = [] for seg in segments_iter: - t = (seg.text or "").strip() - segments.append({"start": float(seg.start), "end": float(seg.end), "text": t}) + d = segment_to_dict(seg) + segments.append(d) + t = d["text"] if t: text_parts.append(t) return " ".join(text_parts), segments +# --------------------------------------------------------------------------- +# Segment serialisation +# --------------------------------------------------------------------------- + + +def _word_to_dict(w: Any) -> dict | None: + """Convert a faster-whisper `Word` to a plain dict, or None on failure. + + Done per-word so one malformed item can't drop the whole segment. + """ + try: + return { + "start": float(w.start), + "end": float(w.end), + "word": getattr(w, "word", "") or "", + "probability": float(getattr(w, "probability", 0.0) or 0.0), + } + except Exception: # noqa: BLE001 — defensive against partial outputs + return None + + +def segment_to_dict(seg: Any) -> dict: + """Convert a faster-whisper `Segment` to a JSON-safe plain dict. + + Shape: + { + "id": int, + "start": float, "end": float, + "text": str, # stripped + "words": [ # empty list when word_timestamps was off + {"start": float, "end": float, "word": str, "probability": float}, + … + ], + } + + The faster-whisper objects are NamedTuple-shaped (CTranslate2 output) + and aren't JSON-serialisable directly; flattening to dicts also lets + us drop the upstream dependency from any consumer that later reads + the JSONB column. + """ + text = (getattr(seg, "text", "") or "").strip() + words_raw = getattr(seg, "words", None) or [] + words: list[dict] = [] + for w in words_raw: + d = _word_to_dict(w) + if d is not None: + words.append(d) + return { + "id": int(getattr(seg, "id", 0) or 0), + "start": float(getattr(seg, "start", 0.0) or 0.0), + "end": float(getattr(seg, "end", 0.0) or 0.0), + "text": text, + "words": words, + } + + # --------------------------------------------------------------------------- # SRT writer # --------------------------------------------------------------------------- @@ -168,4 +230,4 @@ def _fmt_timestamp(seconds: float) -> str: return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" -__all__ = ["Transcriber", "resolve_settings", "write_srt"] +__all__ = ["Transcriber", "resolve_settings", "segment_to_dict", "write_srt"] diff --git a/src/second_brain/web/app.py b/src/second_brain/web/app.py index 59f8304..350a2aa 100644 --- a/src/second_brain/web/app.py +++ b/src/second_brain/web/app.py @@ -29,6 +29,12 @@ from second_brain.settings_store import ( parse_time_or_none, update_settings, ) +from second_brain.sources_service import ( + DEFAULT_PLAYLIST_MAX_ITEMS, + add_playlist, + add_source, + is_youtube_playlist_url, +) # --------------------------------------------------------------------------- # App setup @@ -290,6 +296,21 @@ def _extraction_to_dict(e: Optional[Extraction]) -> Optional[dict]: } +def _playlist_flash(pr) -> str: + """Human-readable summary for a playlist fan-out. Matches the CLI tone.""" + if pr.expanded == 0: + return "Playlist returned no entries" + parts = [f"Queued {pr.added} video{'' if pr.added == 1 else 's'}"] + extras = [] + if pr.duplicates: + extras.append(f"{pr.duplicates} duplicate{'' if pr.duplicates == 1 else 's'} skipped") + if pr.failed: + extras.append(f"{pr.failed} failed") + if extras: + parts.append(f"({', '.join(extras)})") + return " ".join(parts) + + def _source_to_response(s: Source) -> SourceResponse: return SourceResponse( id=s.id, @@ -309,45 +330,105 @@ def _source_to_response(s: Source) -> SourceResponse: # --------------------------------------------------------------------------- +def _build_dashboard_context( + sess, + *, + add_flash: Optional[str] = None, + add_error: Optional[str] = None, +) -> dict: + """Collect everything the dashboard partial needs from one session. + + Materialise inside the session so the keys stay plain str → int / + detached dicts; templates never touch a closed session. + """ + rows = ( + sess.query(Source.status, func.count(Source.id)) + .group_by(Source.status) + .all() + ) + counts = {s.value: 0 for s in SourceStatus} + for status, n in rows: + counts[status.value] = int(n) + total = sum(counts.values()) + + recent_rows = ( + sess.query(Source).order_by(Source.ingested_at.desc()).limit(10).all() + ) + recent = [_source_to_dict(s) for s in recent_rows] + + settings_summary = _settings_to_dict(get_settings(sess)) + + return { + "counts": counts, + "total": total, + "recent": recent, + "statuses_in_order": [s.value for s in SourceStatus], + "settings": settings_summary, + "domains": DOMAINS, + "add_flash": add_flash, + "add_error": add_error, + } + + @app.get("/dashboard", response_class=HTMLResponse) async def dashboard(request: Request): """Overview: status counts, recent activity, and a settings snapshot.""" db = get_database() with db.session() as sess: - # Counts by status. Materialise inside the session so the keys stay - # plain str → int and templates don't try to touch a closed session. - rows = ( - sess.query(Source.status, func.count(Source.id)) - .group_by(Source.status) - .all() + ctx = _build_dashboard_context(sess) + + return templates.TemplateResponse(request, "dashboard.html", ctx) + + +@app.post("/sources/add", response_class=HTMLResponse) +async def sources_add( + request: Request, + url: str = Form(...), + domain: str = Form("development"), + title: Optional[str] = Form(None), + focus: Optional[str] = Form(None), +): + """HTMX endpoint — queue a source, re-render the dashboard body.""" + db = get_database() + add_flash: Optional[str] = None + add_error: Optional[str] = None + + try: + with db.session() as sess: + if is_youtube_playlist_url(url): + pr = add_playlist( + sess, + url=url, + domain=domain, + focus=focus, + max_items=DEFAULT_PLAYLIST_MAX_ITEMS, + ) + add_flash = _playlist_flash(pr) + else: + result = add_source( + sess, url=url, domain=domain, focus=focus, title=title + ) + # Snapshot the message inside the session — `result.source` + # detaches on commit. + if result.added: + add_flash = ( + f"Queued {result.source.source_type.value}: " + f"{result.source.url}" + ) + else: + add_flash = ( + f"Already queued (id={result.source.id}, " + f"status={result.source.status.value})" + ) + except ValueError as exc: + add_error = str(exc) + + with db.session() as sess: + ctx = _build_dashboard_context( + sess, add_flash=add_flash, add_error=add_error ) - counts = {s.value: 0 for s in SourceStatus} - for status, n in rows: - counts[status.value] = int(n) - total = sum(counts.values()) - recent_rows = ( - sess.query(Source) - .order_by(Source.ingested_at.desc()) - .limit(10) - .all() - ) - recent = [_source_to_dict(s) for s in recent_rows] - - settings_row = get_settings(sess) - settings_summary = _settings_to_dict(settings_row) - - return templates.TemplateResponse( - request, - "dashboard.html", - { - "counts": counts, - "total": total, - "recent": recent, - "statuses_in_order": [s.value for s in SourceStatus], - "settings": settings_summary, - }, - ) + return templates.TemplateResponse(request, "_dashboard_body.html", ctx) # --------------------------------------------------------------------------- diff --git a/src/second_brain/web/templates/_dashboard_body.html b/src/second_brain/web/templates/_dashboard_body.html new file mode 100644 index 0000000..dfc1133 --- /dev/null +++ b/src/second_brain/web/templates/_dashboard_body.html @@ -0,0 +1,217 @@ +{# HTMX-replaceable dashboard body. POST /sources/add re-renders this + partial; hx-target="#dashboard-body" + hx-swap="outerHTML" keeps the + wrapping div in place for the next swap. #} +{% set status_colors = { + 'pending': 'bg-gray-100 text-gray-700', + 'pulled': 'bg-blue-100 text-blue-700', + 'transcribed': 'bg-yellow-100 text-yellow-700', + 'analyzed': 'bg-orange-100 text-orange-700', + 'accepted': 'bg-green-100 text-green-700', + 'published': 'bg-purple-100 text-purple-700', + 'failed': 'bg-red-100 text-red-700' +} %} +{% set domain_colors = { + 'development': 'bg-cyan-50 text-cyan-700 border-cyan-200', + 'content': 'bg-pink-50 text-pink-700 border-pink-200', + 'business': 'bg-amber-50 text-amber-700 border-amber-200', + 'homelab': 'bg-teal-50 text-teal-700 border-teal-200' +} %} +
video on a known host (YouTube / Vimeo) → tower; otherwise treated as an article
++ updated {{ settings.updated_at }} +
+{{ source.title }}
+{{ source.url }}
+No sources yet.
+ {% endif %} +- updated {{ settings.updated_at }} -
-{{ source.title }}
-{{ source.url }}
-No sources yet.
- {% endif %} -{{ source.url }}
+{{ source.url }}
{% if source.focus %} -Focus: {{ source.focus }}
+Focus: {{ source.focus }}
{% endif %}