"""Recovery paths: retryable statuses, recorded skip reasons, disk<->DB reconcile. The motivating incident: 511 videos were stored as `no_subtitles` because the language policy was manual-only while the channel publishes only auto-generated captions. `reset_errors` could not reach them, and nothing recorded why they were skipped, so the failure was both invisible and irreversible. """ from __future__ import annotations from pathlib import Path from yt_scraper.extract import describe_missing_subtitle from yt_scraper.segments import reconcile_markdown from yt_scraper.store import Store, VideoRef def _store(tmp_path) -> Store: store = Store(tmp_path / "state.db") store.upsert_channel("UC1", "@alpha", "Alpha", 0) return store # ---------------------------------------------------------------- reset def test_reset_reaches_no_subtitles_not_just_error(tmp_path): store = _store(tmp_path) store.upsert_videos([ VideoRef("a", "UC1", "A", "https://y/watch?v=a"), VideoRef("b", "UC1", "B", "https://y/watch?v=b"), VideoRef("c", "UC1", "C", "https://y/watch?v=c"), ]) store.mark_status("a", "no_subtitles", "policy rejected auto captions") store.mark_error("b", "rate limited") store.mark_done("c", "markdown/c.md", "es", "auto", False) n = store.reset_videos("UC1", ("error", "no_subtitles")) assert n == 2 assert store.get_video("a").status == "pending" assert store.get_video("b").status == "pending" assert store.get_video("c").status == "done", "finished work must not be re-queued" def test_reset_never_touches_done_even_if_asked(tmp_path): store = _store(tmp_path) store.upsert_videos([VideoRef("c", "UC1", "C", "https://y/watch?v=c")]) store.mark_done("c", "markdown/c.md", "es", "auto", False) assert store.reset_videos("UC1", ("done",)) == 0 assert store.get_video("c").status == "done" def test_reset_errors_still_only_resets_errors(tmp_path): """The narrower legacy helper keeps its old meaning.""" store = _store(tmp_path) store.upsert_videos([ VideoRef("a", "UC1", "A", "https://y/watch?v=a"), VideoRef("b", "UC1", "B", "https://y/watch?v=b"), ]) store.mark_status("a", "no_subtitles") store.mark_error("b", "boom") assert store.reset_errors("UC1") == 1 assert store.get_video("a").status == "no_subtitles" assert store.get_video("b").status == "pending" def test_permanent_failures_are_excluded_from_retries(tmp_path): """Members-only videos cannot be fixed by retrying; re-running them only spends requests the recoverable videos need.""" store = _store(tmp_path) store.upsert_videos([ VideoRef(v, "UC1", v, f"https://y/watch?v={v}") for v in ("members", "throttled", "priv") ]) store.mark_error("members", "ERROR: [youtube] x: Join this channel to get access to members-only content") store.mark_error("throttled", "ERROR: Video unavailable. The current session has been rate-limited by YouTube") store.mark_error("priv", "ERROR: Private video. Sign in if you've been granted access") counts = store.retryable_counts("UC1") assert counts["error"] == 1, "only the throttled one is worth retrying" assert counts["permanent"] == 2 assert store.reset_videos("UC1", ("error",)) == 1 assert store.get_video("throttled").status == "pending" assert store.get_video("members").status == "error" assert store.get_video("priv").status == "error" def test_permanent_failures_can_be_reset_when_explicitly_asked(tmp_path): store = _store(tmp_path) store.upsert_videos([VideoRef("members", "UC1", "M", "https://y/watch?v=members")]) store.mark_error("members", "ERROR: members-only content") assert store.reset_videos("UC1", ("error",)) == 0 assert store.reset_videos("UC1", ("error",), include_permanent=True) == 1 assert store.get_video("members").status == "pending" def test_rate_limit_wording_is_never_treated_as_permanent(tmp_path): """The throttling message is the one that must stay retryable.""" store = _store(tmp_path) store.upsert_videos([VideoRef("t", "UC1", "T", "https://y/watch?v=t")]) store.mark_error( "t", "ERROR: [youtube] t: Video unavailable. This content isn't available, try again later. " "The current session has been rate-limited by YouTube for up to an hour.", ) assert store.retryable_counts("UC1")["permanent"] == 0 assert store.reset_videos("UC1", ("error",)) == 1 def test_retryable_counts_reports_both_statuses(tmp_path): store = _store(tmp_path) store.upsert_videos([ VideoRef(v, "UC1", v, f"https://y/watch?v={v}") for v in ("a", "b", "c") ]) store.mark_status("a", "no_subtitles") store.mark_status("b", "no_subtitles") store.mark_error("c", "boom") assert store.retryable_counts("UC1") == {"error": 1, "no_subtitles": 2, "permanent": 0} # ---------------------------------------------------------------- skip reasons def test_mark_status_records_the_reason(tmp_path): store = _store(tmp_path) store.upsert_videos([VideoRef("a", "UC1", "A", "https://y/watch?v=a")]) store.mark_status("a", "no_subtitles", "no track matched the language policy") assert "language policy" in store.get_video("a").error_msg def test_describe_distinguishes_no_captions_from_policy_rejection(): none_at_all = describe_missing_subtitle({"subtitles": {}, "automatic_captions": {}}, {"es": "manual"}) assert "no caption tracks published" in none_at_all auto_only = describe_missing_subtitle( {"subtitles": {}, "automatic_captions": {"es": [{"url": "u", "ext": "json3"}]}}, {"es": "manual"}, ) assert "ONLY auto-generated" in auto_only assert "'any' or 'auto'" in auto_only def test_describe_does_not_blame_config_when_mode_already_allows_auto(): msg = describe_missing_subtitle( {"subtitles": {}, "automatic_captions": {"de": [{"url": "u", "ext": "json3"}]}}, {"es": "any"}, ) assert "ONLY auto-generated" not in msg assert "no track matched the language policy" in msg # ---------------------------------------------------------------- reconcile _MD = """--- video_id: "{vid}" title: "T" upload_date: "2026-01-02" --- ## Transcript **00:00** · hola mundo """ def test_reconcile_marks_done_when_the_md_is_already_on_disk(tmp_path): """The exact symptom the user reported: a .md exists but the row still shows a failure, and nothing reconciles it without a server restart.""" store = _store(tmp_path) store.upsert_videos([VideoRef("vid1", "UC1", "T", "https://y/watch?v=vid1")]) store.mark_status("vid1", "no_subtitles", "stale failure") md_root = tmp_path / "markdown" / "Alpha" md_root.mkdir(parents=True) (md_root / "2026-01-02_t.md").write_text(_MD.format(vid="vid1"), encoding="utf-8") result = reconcile_markdown(store, tmp_path / "markdown") row = store.get_video("vid1") assert row.status == "done" assert row.markdown_path == "markdown/Alpha/2026-01-02_t.md" assert result["repaired_done"] == 1 def test_reconcile_requeues_rows_whose_md_vanished_only_with_prune(tmp_path): store = _store(tmp_path) store.upsert_videos([ VideoRef("gone", "UC1", "G", "https://y/watch?v=gone"), VideoRef("vid1", "UC1", "T", "https://y/watch?v=vid1"), ]) store.mark_done("gone", "markdown/Alpha/nope.md", "es", "auto", False) md_root = tmp_path / "markdown" / "Alpha" md_root.mkdir(parents=True) (md_root / "2026-01-02_t.md").write_text(_MD.format(vid="vid1"), encoding="utf-8") # default is non-destructive assert reconcile_markdown(store, tmp_path / "markdown")["missing_md"] == 0 assert store.get_video("gone").status == "done" result = reconcile_markdown(store, tmp_path / "markdown", prune=True) assert result["missing_md"] == 1 assert store.get_video("gone").status == "pending" def test_prune_refuses_to_demote_everything_when_the_root_is_empty(tmp_path): """Pointed at a wrong or not-yet-populated markdown root, prune must be a no-op rather than wiping every finished video in the database.""" store = _store(tmp_path) store.upsert_videos([VideoRef("c", "UC1", "C", "https://y/watch?v=c")]) store.mark_done("c", "markdown/Alpha/c.md", "es", "auto", False) (tmp_path / "markdown").mkdir() result = reconcile_markdown(store, tmp_path / "markdown", prune=True) assert result["missing_md"] == 0 assert store.get_video("c").status == "done" def test_reconcile_is_idempotent(tmp_path): store = _store(tmp_path) store.upsert_videos([VideoRef("vid1", "UC1", "T", "https://y/watch?v=vid1")]) md_root = tmp_path / "markdown" / "Alpha" md_root.mkdir(parents=True) (md_root / "2026-01-02_t.md").write_text(_MD.format(vid="vid1"), encoding="utf-8") first = reconcile_markdown(store, tmp_path / "markdown") second = reconcile_markdown(store, tmp_path / "markdown") assert first["repaired_done"] == 1 assert second["repaired_done"] == 0 assert second["missing_md"] == 0 assert store.get_video("vid1").status == "done" def test_a_bad_encoding_does_not_abort_the_whole_scan(tmp_path): """UnicodeDecodeError is a ValueError, not an OSError. Letting it escape aborted the loop, so one bad file silently hid every later one.""" store = _store(tmp_path) store.upsert_videos([ VideoRef("v1", "UC1", "A", "https://y/watch?v=v1"), VideoRef("v3", "UC1", "C", "https://y/watch?v=v3"), ]) md_root = tmp_path / "markdown" / "Alpha" md_root.mkdir(parents=True) (md_root / "a.md").write_text(_MD.format(vid="v1"), encoding="utf-8") (md_root / "b.md").write_bytes(b"---\nvideo_id: \xe9\xe9\xe9\n---\n") (md_root / "c.md").write_text(_MD.format(vid="v3"), encoding="utf-8") result = reconcile_markdown(store, tmp_path / "markdown") assert store.get_video("v3").status == "done", "the file after the bad one must still import" assert result["repaired_done"] == 2 # ---------------------------------------------------------------- re-render def test_re_render_updates_markdown_path_and_removes_the_old_file(tmp_path): """re-render used a raw compact upload_date while process_video uses the hyphenated form, so it wrote a SECOND .md and never told the DB — leaving the app serving the older file. Measured on real data: 94 files, 61 rows.""" from yt_scraper.config import Config from yt_scraper.pipeline import re_render_videos store = _store(tmp_path) store.upsert_videos([VideoRef("v1", "UC1", "Mi Video", "https://y/watch?v=v1", "20240519", 60)]) store.update_video_metadata( "v1", view_count=1, like_count=1, tags=None, thumbnail=None, description=None, chapters_json="[]", segments_json='[{"start": 0.0, "end": 2.0, "text": "hola"}]', ) md_root = tmp_path / "markdown" old_dir = md_root / "Alpha" old_dir.mkdir(parents=True) (old_dir / "2024-05-19_mi-video.md").write_text("stale", encoding="utf-8") store.mark_done("v1", "markdown/Alpha/2024-05-19_mi-video.md", "es", "auto", False) cfg = Config( database_path=str(tmp_path / "state.db"), output_dir=str(md_root), template_path=str(Path("templates/video.md.j2").resolve()), ) assert re_render_videos(store, cfg) == 1 files = sorted(p.name for p in md_root.rglob("*.md")) assert len(files) == 1, f"re-render must not leave an orphan beside it: {files}" row = store.get_video("v1") assert row.markdown_path.replace("\\", "/").endswith(files[0]) assert (md_root.parent / row.markdown_path).exists() assert "stale" not in (md_root.parent / row.markdown_path).read_text(encoding="utf-8") def test_re_render_is_idempotent(tmp_path): from yt_scraper.config import Config from yt_scraper.pipeline import re_render_videos store = _store(tmp_path) store.upsert_videos([VideoRef("v1", "UC1", "Mi Video", "https://y/watch?v=v1", "20240519", 60)]) store.update_video_metadata( "v1", view_count=None, like_count=None, tags=None, thumbnail=None, description=None, chapters_json="[]", segments_json='[{"start": 0.0, "end": 2.0, "text": "hola"}]', ) store.mark_done("v1", "markdown/Alpha/whatever.md", "es", "auto", False) cfg = Config( database_path=str(tmp_path / "state.db"), output_dir=str(tmp_path / "markdown"), template_path=str(Path("templates/video.md.j2").resolve()), ) re_render_videos(store, cfg) first = store.get_video("v1").markdown_path re_render_videos(store, cfg) assert store.get_video("v1").markdown_path == first assert len(list((tmp_path / "markdown").rglob("*.md"))) == 1 def test_reconcile_reports_stale_duplicates_and_deletes_them_only_with_prune(tmp_path): """The 33 leftover files the old re-render wrote under a second filename: the DB points at one, the other is dead weight.""" store = _store(tmp_path) store.upsert_videos([VideoRef("vid1", "UC1", "T", "https://y/watch?v=vid1")]) md_root = tmp_path / "markdown" / "Alpha" md_root.mkdir(parents=True) canonical = md_root / "2026-01-02_t.md" duplicate = md_root / "20260102_t.md" canonical.write_text(_MD.format(vid="vid1"), encoding="utf-8") duplicate.write_text(_MD.format(vid="vid1"), encoding="utf-8") store.mark_done("vid1", "markdown/Alpha/2026-01-02_t.md", "es", "auto", False) result = reconcile_markdown(store, tmp_path / "markdown") assert result["stale_dupe"] == 1 assert duplicate.exists(), "reporting only by default" assert store.get_video("vid1").markdown_path == "markdown/Alpha/2026-01-02_t.md" result = reconcile_markdown(store, tmp_path / "markdown", prune=True) assert result["stale_dupe"] == 1 assert not duplicate.exists() assert canonical.exists(), "the file the DB points at must survive" assert store.get_video("vid1").status == "done" def test_reconcile_counts_orphan_markdown(tmp_path): store = _store(tmp_path) md_root = tmp_path / "markdown" / "Alpha" md_root.mkdir(parents=True) (md_root / "ghost.md").write_text(_MD.format(vid="not-in-db"), encoding="utf-8") result = reconcile_markdown(store, tmp_path / "markdown") assert result["orphan_md"] == 1 assert result["repaired_done"] == 0