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,110 @@
|
||||
"""Typed settings accessors and form validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from youtube_automate import settings as settings_module
|
||||
from youtube_automate.settings import DEFAULTS, Settings, validate, validate_all
|
||||
|
||||
|
||||
class TestAccessors:
|
||||
def test_missing_key_returns_the_default(self, settings):
|
||||
assert settings.get_int("retention_days") == 14
|
||||
assert settings.get_str("sub_langs") == "en.*"
|
||||
assert settings.get_bool("write_subs") is True
|
||||
|
||||
def test_unknown_key_never_raises(self, settings):
|
||||
assert settings.get_str("no_such_key") == ""
|
||||
assert settings.get_int("no_such_key") == 0
|
||||
|
||||
def test_set_then_get(self, settings):
|
||||
settings.set("retention_days", "30")
|
||||
assert settings.get_int("retention_days") == 30
|
||||
|
||||
def test_set_overwrites(self, settings):
|
||||
settings.set("retention_days", "30")
|
||||
settings.set("retention_days", "45")
|
||||
assert settings.get_int("retention_days") == 45
|
||||
|
||||
def test_corrupt_integer_falls_back_to_the_default(self, settings):
|
||||
settings.set("retention_days", "not a number")
|
||||
assert settings.get_int("retention_days") == 14
|
||||
|
||||
@pytest.mark.parametrize("truthy", ["true", "True", "1", "yes", "on"])
|
||||
def test_bool_truthy_forms(self, settings, truthy):
|
||||
settings.set("write_subs", truthy)
|
||||
assert settings.get_bool("write_subs") is True
|
||||
|
||||
@pytest.mark.parametrize("falsy", ["false", "False", "0", "no", "off", ""])
|
||||
def test_bool_falsy_forms(self, settings, falsy):
|
||||
settings.set("write_subs", falsy)
|
||||
assert settings.get_bool("write_subs") is False
|
||||
|
||||
def test_corrupt_bool_falls_back_to_the_default(self, settings):
|
||||
settings.set("write_subs", "maybe")
|
||||
assert settings.get_bool("write_subs") is True
|
||||
|
||||
def test_all_editable_covers_every_default(self, settings):
|
||||
values = settings.all_editable()
|
||||
assert set(values) == set(DEFAULTS)
|
||||
|
||||
def test_secrets_are_not_editable(self):
|
||||
for key in settings_module.SECRET_KEYS:
|
||||
assert key not in settings_module.EDITABLE
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"key, value",
|
||||
[
|
||||
("retention_days", "14"),
|
||||
("backfill_days", "0"),
|
||||
("max_height", "720"),
|
||||
("min_duration_seconds", "120"),
|
||||
("disk_cap_gb", "0"),
|
||||
("write_subs", "true"),
|
||||
("sponsorblock_mark", "false"),
|
||||
("jellyfin_url", "http://127.0.0.1:8096"),
|
||||
("pot_provider_url", "https://example.com:4416"),
|
||||
("sub_langs", "en.*"),
|
||||
],
|
||||
)
|
||||
def test_accepts_good_values(self, key, value):
|
||||
ok, _ = validate(key, value)
|
||||
assert ok
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key, value",
|
||||
[
|
||||
("retention_days", "abc"),
|
||||
("retention_days", "0"),
|
||||
("retention_days", "-5"),
|
||||
("max_height", "10"),
|
||||
("min_duration_seconds", "-1"),
|
||||
("write_subs", "maybe"),
|
||||
("jellyfin_url", "not-a-url"),
|
||||
("jellyfin_url", "ftp://host/"),
|
||||
("jellyfin_url", "http://"),
|
||||
("sub_langs", ""),
|
||||
],
|
||||
)
|
||||
def test_rejects_bad_values(self, key, value):
|
||||
ok, message = validate(key, value)
|
||||
assert not ok
|
||||
assert message
|
||||
|
||||
def test_validate_all_reports_each_bad_field(self):
|
||||
errors = validate_all(
|
||||
{"retention_days": "abc", "max_height": "720", "jellyfin_url": "nope"}
|
||||
)
|
||||
assert set(errors) == {"retention_days", "jellyfin_url"}
|
||||
|
||||
def test_validate_all_ignores_unknown_keys(self):
|
||||
assert validate_all({"not_a_setting": "x"}) == {}
|
||||
|
||||
def test_empty_api_key_is_acceptable(self):
|
||||
ok, _ = validate("jellyfin_api_key", "")
|
||||
assert ok
|
||||
|
||||
def test_whitespace_is_tolerated(self):
|
||||
ok, _ = validate("retention_days", " 21 ")
|
||||
assert ok
|
||||
Reference in New Issue
Block a user