fix(r5): address the /code-review high round — 10 findings
- HLS sweep only deletes directories matching the session-name shape; the root is admin-configurable and may be a shared path - playlist unwrap picks the first entry that actually downloaded, not entries[0] - concat quoting keeps backslashes literal (ffmpeg treats them so inside '') - ArrowUp/Down defer while the focused element can still scroll that way - the staging fallback skips yt-dlp working files (.part-Frag/.ytdl/.temp/.fNNN) - the boot HLS sweep runs off the event loop - volume persistence is debounced (was a setItem per wheel notch) and a spin no longer loses notches to the iframe's lagging getVolume - _Breaker.run owns the whole open/call/record protocol; four call sites shrink - should_prune extracted and unit-tested, plus RSS error-vs-empty tests - the worker exits non-zero when the schema never lands
This commit is contained in:
@@ -38,6 +38,22 @@ class TestProducedFile:
|
||||
(tmp_path / "huge.mp4.part").write_bytes(b"x" * 999)
|
||||
assert _produced_file({}, staging) == tmp_path / "big.mp4"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"leftover",
|
||||
["vid.f137.mp4", "vid.mp4.part-Frag12", "vid.ytdl", "vid.temp.mp4", "vid.mp4.part"],
|
||||
)
|
||||
def test_ytdlp_working_files_never_win(self, tmp_path, leftover):
|
||||
# Each of these can legitimately be the BIGGEST file in staging (a video-only per-format
|
||||
# stream especially) — storing one would serve a silent or truncated "download".
|
||||
(tmp_path / leftover).write_bytes(b"x" * 5000)
|
||||
(tmp_path / "vid.mp4").write_bytes(b"x" * 10)
|
||||
assert _produced_file({}, tmp_path) == tmp_path / "vid.mp4"
|
||||
|
||||
def test_only_leftovers_is_treated_as_no_output(self, tmp_path):
|
||||
(tmp_path / "vid.f251.webm").write_bytes(b"x" * 5000)
|
||||
with pytest.raises(RuntimeError):
|
||||
_produced_file({}, tmp_path)
|
||||
|
||||
def test_no_output_at_all_raises(self, tmp_path):
|
||||
staging = _staging(tmp_path, "only.jpg")
|
||||
with pytest.raises(RuntimeError):
|
||||
@@ -53,6 +69,22 @@ class TestMediaInfo:
|
||||
info = {"id": "vid", "requested_downloads": [{"filepath": "/x.mp4"}]}
|
||||
assert _media_info(info) is info
|
||||
|
||||
def test_picks_the_entry_that_actually_downloaded(self):
|
||||
# A page whose first entry was skipped: taking entries[0] would label the produced file
|
||||
# with the teaser's id/title.
|
||||
info = {
|
||||
"id": "page",
|
||||
"entries": [
|
||||
{"id": "teaser", "title": "Teaser"},
|
||||
{"id": "main", "title": "Main", "requested_downloads": [{"filepath": "/x.mp4"}]},
|
||||
],
|
||||
}
|
||||
assert _media_info(info)["id"] == "main"
|
||||
|
||||
def test_falls_back_to_the_first_entry_when_none_downloaded(self):
|
||||
info = {"id": "page", "entries": [{"id": "a"}, {"id": "b"}]}
|
||||
assert _media_info(info)["id"] == "a"
|
||||
|
||||
def test_wrapper_that_downloaded_itself_wins_over_its_entries(self):
|
||||
info = {"id": "vid", "requested_downloads": [{"filepath": "/x.mp4"}], "entries": [{"id": "other"}]}
|
||||
assert _media_info(info)["id"] == "vid"
|
||||
@@ -98,8 +130,10 @@ class TestConcatQuote:
|
||||
# A pre-0.51.0 file can still be named with an apostrophe; unescaped it breaks the list.
|
||||
assert _concat_quote(PurePosixPath("/m/Don't_Look.mp4")) == "/m/Don'\\''t_Look.mp4"
|
||||
|
||||
def test_backslash_is_doubled(self):
|
||||
assert _concat_quote(PurePosixPath("/m/a\\b.mp4")) == "/m/a\\\\b.mp4"
|
||||
def test_backslash_stays_literal(self):
|
||||
# Inside single quotes ffmpeg treats `\` literally — doubling it would point at a
|
||||
# different (non-existent) path.
|
||||
assert _concat_quote(PurePosixPath("/m/a\\b.mp4")) == "/m/a\\b.mp4"
|
||||
|
||||
def test_ordinary_path_is_unchanged(self):
|
||||
assert _concat_quote(PurePosixPath("/m/clip.mp4")) == "/m/clip.mp4"
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
"""Guards that stop one bad YouTube answer from cascading: the playlist-push circuit breaker."""
|
||||
from app.sync.playlists import _Breaker
|
||||
"""Guards that stop one bad YouTube answer from cascading: the subscription prune guard, the
|
||||
playlist-push circuit breaker, and RSS telling a failure apart from an empty feed."""
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.sync.playlists import FAILED, _Breaker
|
||||
from app.sync.subscriptions import should_prune
|
||||
from app.youtube import rss
|
||||
from app.youtube.client import YouTubeError
|
||||
|
||||
|
||||
class TestShouldPrune:
|
||||
def test_a_normal_fetch_prunes(self):
|
||||
assert should_prune(fetched_count=12, existing_count=15) is True
|
||||
|
||||
def test_an_empty_fetch_against_existing_rows_never_prunes(self):
|
||||
# The data-loss case: one anomalous empty-but-200 response must not wipe 15 rows.
|
||||
assert should_prune(fetched_count=0, existing_count=15) is False
|
||||
|
||||
def test_an_empty_fetch_with_nothing_local_is_fine(self):
|
||||
# A brand-new account: nothing to lose, so the (no-op) prune may run.
|
||||
assert should_prune(fetched_count=0, existing_count=0) is True
|
||||
|
||||
def test_a_single_remaining_subscription_still_prunes(self):
|
||||
assert should_prune(fetched_count=1, existing_count=200) is True
|
||||
|
||||
|
||||
class TestBreaker:
|
||||
@@ -26,3 +49,72 @@ class TestBreaker:
|
||||
b.hit(False)
|
||||
b.hit(True)
|
||||
assert not b.open()
|
||||
|
||||
|
||||
class TestBreakerRun:
|
||||
def test_returns_the_ops_result_and_leaves_failures_alone(self):
|
||||
b, failed = _Breaker(), []
|
||||
assert b.run(lambda: "item-1", lambda: failed.append("v1")) == "item-1"
|
||||
assert failed == []
|
||||
|
||||
def test_a_youtube_error_records_the_failure_and_counts_towards_the_trip(self):
|
||||
b, failed = _Breaker(limit=2), []
|
||||
assert b.run(lambda: (_ for _ in ()).throw(YouTubeError("quota")), lambda: failed.append("v1")) is FAILED
|
||||
assert failed == ["v1"] and b.open()
|
||||
b.run(lambda: (_ for _ in ()).throw(YouTubeError("quota")), lambda: failed.append("v2"))
|
||||
assert not b.open()
|
||||
|
||||
def test_a_tripped_breaker_never_calls_the_op(self):
|
||||
b, failed, calls = _Breaker(limit=1), [], []
|
||||
b.run(lambda: (_ for _ in ()).throw(YouTubeError("quota")), lambda: failed.append("v1"))
|
||||
assert b.run(lambda: calls.append("called"), lambda: failed.append("v2")) is FAILED
|
||||
assert calls == [] # the whole point: no more quota spent
|
||||
assert failed == ["v1", "v2"] # …but the item is still reported
|
||||
|
||||
def test_other_exceptions_are_not_swallowed(self):
|
||||
# Only YouTubeError is a "write failed" signal; a bug must still surface.
|
||||
with pytest.raises(ValueError):
|
||||
_Breaker().run(lambda: (_ for _ in ()).throw(ValueError("bug")), lambda: None)
|
||||
|
||||
|
||||
class TestRssErrorVsEmpty:
|
||||
"""A failed poll must RAISE (so the caller leaves `last_rss_at` alone); only a genuinely
|
||||
empty feed may return []."""
|
||||
|
||||
def _patch(self, monkeypatch, **kwargs):
|
||||
def fake_get(url, **_):
|
||||
if "exc" in kwargs:
|
||||
raise kwargs["exc"]
|
||||
return httpx.Response(
|
||||
kwargs["status"],
|
||||
content=kwargs.get("body", b""),
|
||||
request=httpx.Request("GET", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rss.httpx, "get", fake_get)
|
||||
|
||||
def test_non_200_raises(self, monkeypatch):
|
||||
self._patch(monkeypatch, status=404)
|
||||
with pytest.raises(rss.RssError):
|
||||
rss.fetch_channel_feed("UC_dead")
|
||||
|
||||
def test_transport_error_raises(self, monkeypatch):
|
||||
self._patch(monkeypatch, exc=httpx.ConnectError("no route"))
|
||||
with pytest.raises(rss.RssError):
|
||||
rss.fetch_channel_feed("UC_offline")
|
||||
|
||||
def test_an_empty_but_valid_feed_returns_empty(self, monkeypatch):
|
||||
feed = b"""<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>"""
|
||||
self._patch(monkeypatch, status=200, body=feed)
|
||||
assert rss.fetch_channel_feed("UC_quiet") == []
|
||||
|
||||
def test_entries_are_parsed(self, monkeypatch):
|
||||
feed = b"""<?xml version="1.0"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:yt="http://www.youtube.com/xml/schemas/2015">
|
||||
<entry><yt:videoId>abc123</yt:videoId><title>Hello</title>
|
||||
<published>2026-07-23T10:00:00+00:00</published></entry>
|
||||
</feed>"""
|
||||
self._patch(monkeypatch, status=200, body=feed)
|
||||
out = rss.fetch_channel_feed("UC_live")
|
||||
assert [e["id"] for e in out] == ["abc123"]
|
||||
assert out[0]["channel_id"] == "UC_live"
|
||||
|
||||
Reference in New Issue
Block a user