Initial implementation of youtube-automate
A DVR for YouTube subscriptions, laid out so Jellyfin presents each channel as a show and each video as an episode. Cron-driven, idempotent, with a public admin UI for a non-operator. Verified end to end on susan against three real channels: PO tokens, h264 downloads, Jellyfin resolution from local NFOs with all providers disabled, retention and tombstones. Corrections to the original design handover (specs.md documents each with the evidence, and specs.handover-original.md preserves the original): - The format sort selected 360p. Ranking acodec above res makes `bv*` prefer the combined 360p stream, which carries AAC, over the 720p video-only stream whose acodec is none. vcodec now leads, so a video without h264 at 720p yields h264 lower down rather than VP9 this hardware cannot transcode. - yt-dlp now requires a JS runtime and the yt-dlp-ejs solver scripts, which only ship with the [default] extra. Without them the n challenge fails and the mweb formats disappear entirely. - --flat-playlist carries no upload dates, so the specced client-side date filter for backfill was impossible. Backfill is RSS-first. - skipped_old was terminal, so raising a channel's retention appeared to do nothing. Added an explicit rescan. - is_upcoming premieres now defer and retry instead of being skipped forever. - TubeArchivist is gone, so the media root and the tube.jihakuz.xyz vhost were both reclaimed; the latter still pointed at its dead port. 240 offline tests, no network and no real yt-dlp invocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""Locking and crash recovery."""
|
||||
|
||||
import multiprocessing
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import add_video
|
||||
from youtube_automate import config, download, runner, videos
|
||||
|
||||
|
||||
def _hold_lock(path, started, release):
|
||||
from youtube_automate import runner as runner_module
|
||||
|
||||
with runner_module.exclusive_lock(path):
|
||||
started.set()
|
||||
release.wait(timeout=30)
|
||||
|
||||
|
||||
class TestExclusiveLock:
|
||||
def test_acquires_when_free(self, tmp_path):
|
||||
with runner.exclusive_lock(tmp_path / "run.lock"):
|
||||
pass # no exception is the assertion
|
||||
|
||||
def test_can_be_reacquired_after_release(self, tmp_path):
|
||||
path = tmp_path / "run.lock"
|
||||
with runner.exclusive_lock(path):
|
||||
pass
|
||||
with runner.exclusive_lock(path):
|
||||
pass
|
||||
|
||||
def test_second_holder_is_refused(self, tmp_path):
|
||||
path = tmp_path / "run.lock"
|
||||
started = multiprocessing.Event()
|
||||
release = multiprocessing.Event()
|
||||
holder = multiprocessing.Process(
|
||||
target=_hold_lock, args=(path, started, release)
|
||||
)
|
||||
holder.start()
|
||||
try:
|
||||
assert started.wait(timeout=15), "helper never acquired the lock"
|
||||
with pytest.raises(runner.AlreadyRunning):
|
||||
with runner.exclusive_lock(path):
|
||||
pass
|
||||
finally:
|
||||
release.set()
|
||||
holder.join(timeout=15)
|
||||
|
||||
def test_creates_the_parent_directory(self, tmp_path):
|
||||
path = tmp_path / "nested" / "deeper" / "run.lock"
|
||||
with runner.exclusive_lock(path):
|
||||
assert path.exists()
|
||||
|
||||
|
||||
class TestRecover:
|
||||
def test_requeues_downloading_rows(self, conn, channel, media_root):
|
||||
add_video(conn, channel["id"], "a", state=videos.DOWNLOADING)
|
||||
result = runner.recover(conn)
|
||||
assert result["requeued"] == 1
|
||||
assert videos.get(conn, "a")["state"] == videos.PENDING
|
||||
|
||||
def test_clears_work_dir_orphans(self, conn, media_root):
|
||||
(config.WORK_DIR / "half.part").write_bytes(b"x")
|
||||
(config.WORK_DIR / "half.mp4").write_bytes(b"x")
|
||||
result = runner.recover(conn)
|
||||
assert result["orphans"] == 2
|
||||
assert [p.name for p in config.WORK_DIR.iterdir()] == [".ignore"]
|
||||
|
||||
def test_is_a_no_op_on_a_clean_state(self, conn, media_root):
|
||||
assert runner.recover(conn) == {"requeued": 0, "orphans": 0}
|
||||
|
||||
def test_leaves_the_ignore_marker_in_place(self, conn, media_root):
|
||||
download.recover_orphans()
|
||||
assert (config.WORK_DIR / ".ignore").exists()
|
||||
|
||||
|
||||
class TestSummarise:
|
||||
def test_reports_each_stage(self):
|
||||
text = runner.summarise(
|
||||
{
|
||||
"poll": {"queued": 3, "repaired": 1, "failed": 0},
|
||||
"download": {videos.DOWNLOADED: 2, videos.FAILED: 1},
|
||||
"reap": {"deleted": 4, "evicted": 0},
|
||||
}
|
||||
)
|
||||
assert "discovered=3" in text
|
||||
assert "repaired=1" in text
|
||||
assert "downloaded=2" in text
|
||||
assert "failed=1" in text
|
||||
assert "reaped=4" in text
|
||||
|
||||
def test_surfaces_a_download_error(self):
|
||||
text = runner.summarise({"download": {"error": "provider down"}})
|
||||
assert "ERROR=provider down" in text
|
||||
|
||||
def test_handles_empty_input(self):
|
||||
assert "downloaded=0" in runner.summarise({})
|
||||
Reference in New Issue
Block a user