soulsync/tests/test_video_process_youtube_wishlist.py
BoulderBadgeDad c47dccc7d7 youtube fulfillment: drain wishlist -> download queue
new 'Download YouTube Wishlist' automation: pushes a polite batch of wished
youtube videos into the shared video_downloads queue + spawns the yt-dlp worker
per video. skips in-flight ones (no double-grab); big backlogs drain over
several scheduled runs (batch_size, default 3). needs the youtube library folder
set.

- download_monitor: SKIP source='youtube' rows (owned by their worker thread, no
  slskd transfer to match) — surgical, slskd path untouched.
- db.youtube_wishlist_to_download(): flat newest-first list of wished videos with
  channel/title/date/thumb for organising.

block + registration + icon/label + drift test. all seam-tested.
2026-06-25 19:30:02 -07:00

135 lines
5.3 KiB
Python

"""Drain side of the YouTube fulfillment lane: enqueue wished videos into the download
queue in polite batches, skipping in-flight ones. Pure selection + handler with all I/O
injected, plus the DB query that feeds it.
"""
from __future__ import annotations
import pytest
from core.automation.handlers.video_process_youtube_wishlist import (
auto_video_process_youtube_wishlist,
select_to_enqueue,
)
class _Deps:
def __init__(self):
self.progress = []
def update_progress(self, automation_id, **kw):
self.progress.append(kw)
def _v(vid, title="T", date="2024-01-01"):
return {"video_id": vid, "channel_title": "Chan", "video_title": title,
"thumbnail_url": "/t.jpg", "published_at": date}
# ── pure batching ─────────────────────────────────────────────────────────────
def test_select_skips_inflight_and_caps_batch():
wanted = [_v("a"), _v("b"), _v("c"), _v("d")]
picks = select_to_enqueue(wanted, active_ids=["b"], batch_size=2)
assert [p["video_id"] for p in picks] == ["a", "c"] # b skipped (in flight), capped at 2
def test_select_zero_batch_means_no_cap():
wanted = [_v("a"), _v("b"), _v("c")]
assert len(select_to_enqueue(wanted, [], 0)) == 3
def test_select_drops_idless():
assert select_to_enqueue([{"video_title": "no id"}], [], 5) == []
# ── handler ───────────────────────────────────────────────────────────────────
def _run(wanted, *, active=None, root="/yt", batch=3):
enq = []
def enqueue(video, r):
enq.append((video["video_id"], r))
return len(enq)
deps = _Deps()
res = auto_video_process_youtube_wishlist(
{"_automation_id": "a", "batch_size": batch}, deps,
youtube_root=lambda: root, fetch_wanted=lambda: wanted,
active_ids=lambda: list(active or []), enqueue=enqueue)
return res, enq, deps
def test_enqueues_a_batch_and_reports_remaining():
wanted = [_v("a"), _v("b"), _v("c"), _v("d"), _v("e")]
res, enq, _ = _run(wanted, batch=2)
assert res["status"] == "completed" and res["queued"] == 2
assert [vid for vid, _ in enq] == ["a", "b"]
assert res["remaining"] == 3 # 5 wanted - 2 queued
assert enq[0][1] == "/yt" # root passed through
def test_inflight_videos_are_not_requeued():
wanted = [_v("a"), _v("b")]
res, enq, _ = _run(wanted, active=["a", "b"], batch=5)
assert enq == [] and res["queued"] == 0
def test_missing_youtube_folder_is_an_error():
res, enq, deps = _run([_v("a")], root="")
assert res["status"] == "error" and "library folder" in res["error"]
assert enq == [] and any(p.get("status") == "error" for p in deps.progress)
def test_nothing_wanted_is_a_clean_noop():
res, enq, _ = _run([])
assert res["status"] == "completed" and res["queued"] == 0 and enq == []
def test_one_bad_enqueue_does_not_stop_the_batch():
def enqueue(video, r):
if video["video_id"] == "a":
raise RuntimeError("queue full")
return 1
res = auto_video_process_youtube_wishlist(
{"_automation_id": "x", "batch_size": 5}, _Deps(),
youtube_root=lambda: "/yt", fetch_wanted=lambda: [_v("a"), _v("b")],
active_ids=lambda: [], enqueue=enqueue)
assert res["status"] == "completed" and res["queued"] == 1 # b still queued
def test_top_level_error_is_caught():
def boom():
raise RuntimeError("db down")
res = auto_video_process_youtube_wishlist({"_automation_id": "x"}, _Deps(),
youtube_root=lambda: "/yt", fetch_wanted=boom)
assert res["status"] == "error" and "db down" in res["error"]
# ── the DB query that feeds it ────────────────────────────────────────────────
from database.video_database import VideoDatabase # noqa: E402
@pytest.fixture()
def db(tmp_path):
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
def test_youtube_wishlist_to_download_shape(db):
db.add_videos_to_wishlist(
{"youtube_id": "UC1", "title": "Cool Channel", "avatar_url": "/a.jpg"},
[{"youtube_id": "v1", "title": "First", "published_at": "2024-03-01", "thumbnail_url": "/1.jpg"},
{"youtube_id": "v2", "title": "Second", "published_at": "2024-05-01", "thumbnail_url": "/2.jpg"}])
rows = db.youtube_wishlist_to_download()
assert [r["video_id"] for r in rows] == ["v2", "v1"] # newest upload first
top = rows[0]
assert top["channel_id"] == "UC1" and top["channel_title"] == "Cool Channel"
assert top["video_title"] == "Second" and top["published_at"] == "2024-05-01"
assert top["thumbnail_url"] == "/2.jpg"
def test_youtube_wishlist_to_download_excludes_movies(db):
db.add_movie_to_wishlist(99, "A Movie")
db.add_videos_to_wishlist({"youtube_id": "UC1", "title": "Ch"},
[{"youtube_id": "v1", "title": "Vid", "published_at": "2024-01-01"}])
rows = db.youtube_wishlist_to_download()
assert [r["video_id"] for r in rows] == ["v1"] # the movie isn't a youtube video