14 KiB
Discovery-Only Scrape Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add one-channel and all-channel discovery jobs that register new YouTube videos as pending without downloading or processing their content.
Architecture: Extend the existing sequential JobManager with opts.mode == "discover". Reuse POST /api/scrape, its SSE stream, and its job widget; add UI actions in Channels and Videos that pass either a channel ID or no channel ID. Keep the existing full scrape and processing endpoints unchanged.
Tech Stack: Python 3.10+, FastAPI, SQLite via the existing Store, yt-dlp flat discovery, Alpine.js CDN SPA, pytest.
Global Constraints
- Discovery-only jobs must not call
process_video(). - Discovery-only jobs must not download transcripts, Markdown, audio, thumbnails, or avatars.
- All HTTP requests must use the existing job queue and SSE event stream.
- All database access must remain inside
Storemethods. - Preserve existing user changes in the dirty worktree and edit only the files listed below.
Task 1: Make catalog insertion counts accurate
Files:
- Modify:
src/yt_scraper/store.py:266-283 - Test:
tests/test_store_platform.py
Interfaces:
-
Consumes: existing
Store.upsert_videos(refs: list[VideoRef])callers. -
Produces: the same integer return type, now equal to the number of distinct references that were not already present.
-
Step 1: Write the failing test
Add this test after the existing store filtering tests:
def test_upsert_videos_reports_only_new_rows(seeded_store):
inserted = seeded_store.upsert_videos([
VideoRef("v1", "UC1", "Updated title", "https://y/watch?v=v1", "20240101", 120),
VideoRef("v4", "UC1", "New video", "https://y/watch?v=v4", "20240501", 180),
])
assert inserted == 1
assert seeded_store.get_video("v1").title == "Updated title"
assert seeded_store.get_video("v4").status == "pending"
- Step 2: Run the focused test to verify it fails
Run: python -m pytest tests/test_store_platform.py::test_upsert_videos_reports_only_new_rows -q
Expected: FAIL because SQLite's ON CONFLICT DO UPDATE currently makes rowcount nonzero for an existing row.
- Step 3: Implement the minimal count fix
Inside Store.upsert_videos, load the existing IDs for the incoming references before the loop, deduplicate the incoming IDs with a set, and increment inserted only when an incoming distinct ID was absent. Keep the current upsert SQL so existing rows still refresh title, upload date, and duration.
The essential implementation shape is:
incoming = {r.video_id for r in refs}
with self._cursor() as cur:
existing = set()
if incoming:
placeholders = ",".join("?" for _ in incoming)
cur.execute(
f"SELECT video_id FROM videos WHERE video_id IN ({placeholders})",
list(incoming),
)
existing = {row["video_id"] for row in cur.fetchall()}
for r in refs:
# existing SQL remains here
if r.video_id not in existing:
inserted += 1
existing.add(r.video_id)
- Step 4: Run the focused and regression tests
Run: python -m pytest tests/test_store_platform.py -q
Expected: PASS for all store tests.
- Step 5: Commit
Do not commit automatically in this workspace unless the user explicitly requests a commit. Leave the focused diff ready for review.
Task 2: Add discovery-only job execution
Files:
- Modify:
src/yt_scraper/webapp/jobs.py:88-269 - Test:
tests/test_webapp_jobs.py
Interfaces:
-
Consumes:
JobManager.enqueue(channel_id, opts)withopts={"mode": "discover"}and optionalchannel_id. -
Produces: queued jobs whose SSE events contain per-channel discovery counts and whose terminal event contains a summary.
-
Step 1: Write failing job tests
Append tests using a fake discover_channel and a fake process_video:
def test_discovery_job_registers_new_videos_without_processing(tmp_path, monkeypatch):
store = Store(tmp_path / "state.db")
store.upsert_channel("UC1", "@alpha", "Alpha", 1)
store.upsert_videos([
VideoRef("old", "UC1", "Old", "https://y/watch?v=old", "20240101", 60)
])
store.mark_status("old", "done")
store.create_job("discover-job", "UC1", {"mode": "discover"})
calls = []
def fake_discover(url, sleep_subrequests=2.0):
calls.append(url)
return ("UC1", "Alpha", None, [
VideoRef("new", "UC1", "New", "https://y/watch?v=new", "20240501", 90),
VideoRef("old", "UC1", "Old", "https://y/watch?v=old", "20240101", 60),
])
processed = []
monkeypatch.setattr("yt_scraper.webapp.jobs.discover_channel", fake_discover)
monkeypatch.setattr("yt_scraper.webapp.jobs.process_video", lambda *a, **k: processed.append(a))
manager = JobManager(store, Config(database_path=str(tmp_path / "state.db"), output_dir=str(tmp_path / "markdown")))
manager._run_job("discover-job")
assert calls == ["https://www.youtube.com/@alpha/videos"]
assert store.get_video("new").status == "pending"
assert store.get_video("old").status == "done"
assert processed == []
assert store.get_job("discover-job").status == "done"
assert any(event["event"] == "done" and event["data"]["new_videos"] == 1
for event in manager.events_since("discover-job", 0))
def test_all_channel_discovery_continues_after_one_error(tmp_path, monkeypatch):
store = Store(tmp_path / "state.db")
store.upsert_channel("UC1", "@one", "One", 0)
store.upsert_channel("UC2", "@two", "Two", 0)
store.create_job("discover-all", None, {"mode": "discover"})
def fake_discover(url, sleep_subrequests=2.0):
if "@one" in url:
raise RuntimeError("temporary failure")
return ("UC2", "Two", None, [VideoRef("new2", "UC2", "New", "https://y/watch?v=new2")])
monkeypatch.setattr("yt_scraper.webapp.jobs.discover_channel", fake_discover)
manager = JobManager(store, Config(database_path=str(tmp_path / "state.db"), output_dir=str(tmp_path / "markdown")))
manager._run_job("discover-all")
assert store.get_video("new2").status == "pending"
assert store.get_job("discover-all").status == "done"
done = [e for e in manager.events_since("discover-all", 0) if e["event"] == "done"][-1]
assert done["data"]["errors"] == 1
- Step 2: Run the focused tests to verify they fail
Run: python -m pytest tests/test_webapp_jobs.py::test_discovery_job_registers_new_videos_without_processing tests/test_webapp_jobs.py::test_all_channel_discovery_continues_after_one_error -q
Expected: FAIL because _run_job currently routes both cases to _run_channel, which tries to process pending videos and cannot interpret an all-channel discovery job.
- Step 3: Implement discovery dispatch and execution
In JobManager._run_job, route opts.get("mode") == "discover" to a new _run_discovery(job_id) before the existing audio/video/channel branches.
Implement _run_discovery with this behavior:
def _run_discovery(self, job_id: str) -> None:
job = self.store.get_job(job_id)
if not job:
return
channels = ([self.store.get_channel(job.channel_id)] if job.channel_id
else self.store.list_channels())
channels = [c for c in channels if c]
self.store.update_job(job_id, status="running", total=len(channels), completed=0)
completed = 0
totals = {"new_videos": 0, "known_videos": 0, "errors": 0}
for channel in channels:
if job_id in self._cancel:
self.store.update_job(job_id, status="cancelled", completed=completed, finished=True)
self._emit(job_id, "cancelled", {"completed": completed, "total": len(channels)})
return
try:
# Build the stored handle/channel URL, call discover_channel, filter
# using cfg.include_shorts/cfg.include_live, upsert the channel and refs.
# Do not call deep_channel_avatar, cache helpers, or process_video.
new_count = self.store.upsert_videos(refs)
known_count = len({r.video_id for r in refs}) - new_count
totals["new_videos"] += new_count
totals["known_videos"] += known_count
self._emit(job_id, "progress", {"channel_id": channel["channel_id"], "new_videos": new_count, "known_videos": known_count, "completed": completed + 1, "total": len(channels)})
except Exception as exc:
totals["errors"] += 1
self._emit(job_id, "log", {"msg": f"discovery failed for {channel.get('name') or channel['channel_id']}: {exc}"})
completed += 1
self.store.update_job(job_id, completed=completed)
self.store.update_job(job_id, status="done", completed=completed, finished=True)
self._emit(job_id, "done", {"completed": completed, "total": len(channels), **totals})
Use the existing _resolve_channel_url for each stored channel. Preserve the existing processing path untouched. For a single-channel exception, emit an error terminal event/status; for all-channel jobs, continue and report the error count as above.
- Step 4: Run focused job tests
Run: python -m pytest tests/test_webapp_jobs.py -q
Expected: PASS for both existing audio tests and the new discovery tests.
- Step 5: Run the full Python suite
Run: python -m pytest tests/ -q
Expected: all tests pass with no network access.
Task 3: Expose the discovery mode through the API
Files:
- Modify:
src/yt_scraper/webapp/api.py:111-117 - Test:
tests/test_webapp_jobs.py(job boundary coverage; no API test fixture exists in the repository).
Interfaces:
-
Consumes: JSON
{"channel_id": "UC...", "opts": {"mode": "discover"}}or the same body withoutchannel_idfor all channels. -
Produces: the existing
{"job_id": "..."}response and a queuedJobManagerjob. -
Step 1: Add request-shape coverage at the job boundary
Extend the discovery job tests to enqueue manager.enqueue(None, {"mode": "discover"}) and verify that it completes as an all-channel job. This covers the exact payload shape the API forwards; the repository has no existing FastAPI router test fixture.
- Step 2: Implement the minimal API change
Keep the endpoint response unchanged. Normalize discovery options and permit a missing channel only for discovery:
opts = (payload or {}).get("opts", {}) or {}
if opts.get("mode") == "discover":
opts = {"mode": "discover"}
job_id = jobs.enqueue(channel_id, opts)
return {"job_id": job_id}
Do not add a second synchronous discovery endpoint.
- Step 3: Run the API/job regression tests
Run: python -m pytest tests/test_webapp_jobs.py tests/test_store_platform.py -q
Expected: PASS.
Task 4: Add one-channel and all-channel UI controls
Files:
- Modify:
src/yt_scraper/webapp/static/app.js:45-73, 758-815 - Modify:
src/yt_scraper/webapp/static/index.html:167-229, 231-319
Interfaces:
-
Consumes:
POST /api/scrapediscovery jobs and SSEprogress/donepayloads. -
Produces: buttons for channel-specific and all-channel discovery, with refresh and result feedback.
-
Step 1: Add the failing static contract checks
Before editing, use a lightweight repository check to confirm the new labels and method are absent:
Run: rg "startDiscovery|Investigar nuevos|Investigar todos" src/yt_scraper/webapp/static
Expected: no matches.
- Step 2: Add the Alpine discovery action
Add startDiscovery(channelId) near startScrape(). It posts {channel_id: channelId || null, opts: {mode: "discover"}}, subscribes with kind discovery, and prevents duplicate active jobs. Extend the SSE done handler to parse the payload and show N video(s) nuevo(s) encontrado(s) for discovery jobs. Refresh channels, videos, and dashboard after completion.
- Step 3: Add controls to Channels and Videos
In Channels, add the global header button and one row button calling startDiscovery(c.channel_id). In Videos, add a header action calling startDiscovery(filters.channel || null). Disable each while jobActive() is true and while the channel list is empty. Preserve .md, Process, and Audio actions.
- Step 4: Verify the static contract and inspect the rendered paths
Run: rg "startDiscovery|Investigar nuevos|Investigar todos|mode: \"discover\"" src/yt_scraper/webapp/static
Expected: matches in app.js and index.html for the method, payload, and both scopes. Inspect the changed Alpine expressions to ensure the existing app.js script remains before Alpine in index.html.
Task 5: Final verification and review
Files:
-
Review:
src/yt_scraper/store.py -
Review:
src/yt_scraper/webapp/jobs.py -
Review:
src/yt_scraper/webapp/api.py -
Review:
src/yt_scraper/webapp/static/app.js -
Review:
src/yt_scraper/webapp/static/index.html -
Step 1: Run the complete test suite
Run: python -m pytest tests/ -q
Expected: all tests pass.
- Step 2: Review the diff for scope and unintended downloads
Run: git diff -- src/yt_scraper/store.py src/yt_scraper/webapp/jobs.py src/yt_scraper/webapp/api.py src/yt_scraper/webapp/static/app.js src/yt_scraper/webapp/static/index.html tests/test_store_platform.py tests/test_webapp_jobs.py
Confirm discovery code contains no calls to process_video, cache_thumbnail, cache_channel_avatar, extract_video, or audio routes.
- Step 3: Check worktree status
Run: git status --short
Confirm only intended feature files and the two planning documents are changed or untracked; do not modify or revert unrelated existing user changes.