Keep only the newest 50 episodes of each podcast
A show that has published for years is unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, and the device it syncs onto is a 30 GB SD card that also holds the rest of the library. Nothing stopped the 6-hourly poll from eventually filling it. Cap each show's folder at general.podcast_episode_limit (default 50, null to keep everything), pruning the oldest past that after each sync pass. The same limit caps what is downloaded, and it has to be one number for both. Prune to the newest N but keep fetching everything the feed offers, and every poll would re-download exactly the episodes the previous one deleted - forever, at full size, since missing_episodes() decides purely from what is on disk. There is a test for that specific loop. Pruning only touches files named the way this module names them (YYYYMMDD - Title.ext), so feed.txt, folder.jpg, the failed-download record and anything placed by hand are all left alone; a parse that fails means "not ours", not "delete it". An episode's sidecar cover goes with it. A pass that only deleted still reports a change, because the library needs the rescan just as much as it does after a download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,19 @@ general:
|
|||||||
# a half-finished .tmp - is ignored.
|
# a half-finished .tmp - is ignored.
|
||||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||||
|
|
||||||
|
# How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
|
||||||
|
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
|
||||||
|
#
|
||||||
|
# The same number caps what gets downloaded, which is what makes the folder settle.
|
||||||
|
# Prune to the newest N but fetch everything the feed offers, and every poll would
|
||||||
|
# re-download the episodes the last one deleted.
|
||||||
|
#
|
||||||
|
# Lowering this DELETES the episodes that fall outside the window on the next poll,
|
||||||
|
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
|
||||||
|
# keep every episode and mind the free space yourself.
|
||||||
|
podcast_episode_limit: 50
|
||||||
|
|
||||||
# The web front-end. Omit the whole section to run without it.
|
# The web front-end. Omit the whole section to run without it.
|
||||||
#
|
#
|
||||||
# There is no authentication: this is a device on a home network. The settings panel
|
# There is no authentication: this is a device on a home network. The settings panel
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ def _build_services(
|
|||||||
on_change=lambda: app.rescan_library(
|
on_change=lambda: app.rescan_library(
|
||||||
broadcast=web_service.hub.broadcast_library if web_service else None
|
broadcast=web_service.hub.broadcast_library if web_service else None
|
||||||
),
|
),
|
||||||
|
episode_limit=app.config.general.podcast_episode_limit,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from ruamel.yaml.error import YAMLError
|
|||||||
|
|
||||||
from musicmouse.color import ColorRGBW, parse_color
|
from musicmouse.color import ColorRGBW, parse_color
|
||||||
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
||||||
|
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -288,6 +289,14 @@ class GeneralConfig(_Strict):
|
|||||||
|
|
||||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
|
#: How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
#: published for years grows without bound and will eventually fill the device's SD
|
||||||
|
#: card. ``null`` keeps every episode, and minding the free space is then on you.
|
||||||
|
#:
|
||||||
|
#: Lowering this *deletes* the episodes that fall outside the window on the next
|
||||||
|
#: poll, and an episode that has aged out of its feed cannot be fetched again.
|
||||||
|
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serial_simulated(self) -> bool:
|
def serial_simulated(self) -> bool:
|
||||||
return self.serial_port == SIMULATE
|
return self.serial_port == SIMULATE
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ a feed offers real per-episode artwork, it's saved alongside as a same-named sid
|
|||||||
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
||||||
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
||||||
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
||||||
|
A show folder is also kept to a fixed number of the newest episodes
|
||||||
|
(:data:`DEFAULT_EPISODE_LIMIT`), so a long-running feed cannot fill the device's disk.
|
||||||
|
The same limit caps what is downloaded, which is what stops the two halves fighting:
|
||||||
|
prune what is older than the newest N, download only the newest N, and the folder
|
||||||
|
settles instead of re-fetching every episode it just deleted.
|
||||||
|
|
||||||
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
||||||
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
||||||
"""
|
"""
|
||||||
@@ -37,6 +43,7 @@ from musicmouse.library.sections import SECTIONS
|
|||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DEFAULT_EPISODE_LIMIT",
|
||||||
"FEED_MARKER_NAME",
|
"FEED_MARKER_NAME",
|
||||||
"AudioExtractionError",
|
"AudioExtractionError",
|
||||||
"Episode",
|
"Episode",
|
||||||
@@ -46,7 +53,9 @@ __all__ = [
|
|||||||
"episode_filename",
|
"episode_filename",
|
||||||
"find_feed_shows",
|
"find_feed_shows",
|
||||||
"missing_episodes",
|
"missing_episodes",
|
||||||
|
"newest_episodes",
|
||||||
"parse_feed",
|
"parse_feed",
|
||||||
|
"prune_show",
|
||||||
"resolve_episode_cover",
|
"resolve_episode_cover",
|
||||||
"sync_all_shows",
|
"sync_all_shows",
|
||||||
"sync_show",
|
"sync_show",
|
||||||
@@ -54,6 +63,15 @@ __all__ = [
|
|||||||
|
|
||||||
FEED_MARKER_NAME: Final = "feed.txt"
|
FEED_MARKER_NAME: Final = "feed.txt"
|
||||||
|
|
||||||
|
#: How many episodes of one show to keep, newest first. A show that has published for
|
||||||
|
#: years is otherwise unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, which
|
||||||
|
#: does not fit next to the rest of the library on a Pi's SD card.
|
||||||
|
#:
|
||||||
|
#: This caps downloads as well as deletions, and it has to be one number for both. Prune
|
||||||
|
#: to the newest N but download everything the feed offers, and every poll would
|
||||||
|
#: re-fetch the episodes the last one deleted, forever.
|
||||||
|
DEFAULT_EPISODE_LIMIT: Final = 50
|
||||||
|
|
||||||
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
||||||
#: not a real extension (tracking-redirect URLs are common in the wild).
|
#: not a real extension (tracking-redirect URLs are common in the wild).
|
||||||
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
||||||
@@ -287,6 +305,80 @@ def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episod
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def newest_episodes(episodes: list[Episode], keep: int | None) -> list[Episode]:
|
||||||
|
"""The ``keep`` most recently published episodes, newest first.
|
||||||
|
|
||||||
|
Applied to the *feed* before anything is downloaded. Without it, capping the folder
|
||||||
|
would be pointless: the next poll would see every pruned episode as missing again.
|
||||||
|
"""
|
||||||
|
ordered = sorted(episodes, key=lambda episode: episode.published, reverse=True)
|
||||||
|
return ordered if keep is None else ordered[:keep]
|
||||||
|
|
||||||
|
|
||||||
|
def _episode_date(name: str) -> datetime | None:
|
||||||
|
"""The date out of a ``YYYYMMDD - Title.ext`` filename, or ``None`` if it has none.
|
||||||
|
|
||||||
|
Deliberately strict. A file that does not follow the convention is one this module
|
||||||
|
did not write - something dropped in by hand under another name - and it is left
|
||||||
|
alone rather than guessed at, because the alternative is deleting somebody's file
|
||||||
|
on a parse that happened to fail.
|
||||||
|
"""
|
||||||
|
stem = Path(name).stem
|
||||||
|
if len(stem) < 8 or not stem[:8].isdigit():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(stem[:8], "%Y%m%d").replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None = None) -> int:
|
||||||
|
"""Delete all but the ``keep`` newest episodes in ``folder``. Returns how many went.
|
||||||
|
|
||||||
|
Only touches files named the way :func:`episode_filename` names them, so a
|
||||||
|
``feed.txt``, a ``folder.jpg``, the failed-download record and any hand-named file
|
||||||
|
are all safe. An episode's sidecar cover image goes with it - it shares the stem,
|
||||||
|
and leaving it behind would strand art for a track that no longer exists.
|
||||||
|
|
||||||
|
``keep`` of ``None`` disables pruning entirely.
|
||||||
|
"""
|
||||||
|
if keep is None:
|
||||||
|
return 0
|
||||||
|
audio_extensions = extensions if extensions is not None else _KNOWN_EXTENSIONS
|
||||||
|
|
||||||
|
dated: list[tuple[datetime, Path]] = []
|
||||||
|
for path in folder.iterdir():
|
||||||
|
if not path.is_file() or path.suffix.lower() not in audio_extensions:
|
||||||
|
continue
|
||||||
|
published = _episode_date(path.name)
|
||||||
|
if published is not None:
|
||||||
|
dated.append((published, path))
|
||||||
|
|
||||||
|
if len(dated) <= keep:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
dated.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
||||||
|
removed = 0
|
||||||
|
for _published, path in dated[keep:]:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
_log.warning("Could not delete old episode %s: %s", path, exc)
|
||||||
|
continue
|
||||||
|
removed += 1
|
||||||
|
for image_extension in _IMAGE_EXTENSIONS:
|
||||||
|
sidecar = path.with_suffix(image_extension)
|
||||||
|
try:
|
||||||
|
sidecar.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
_log.debug("Could not delete cover %s: %s", sidecar, exc)
|
||||||
|
if removed:
|
||||||
|
_log.info(
|
||||||
|
"Pruned %d old episode(s) from %s, keeping the newest %d", removed, folder.name, keep
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
||||||
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
||||||
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
||||||
@@ -437,14 +529,28 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
|
|||||||
return _extract_og_image(response.text)
|
return _extract_og_image(response.text)
|
||||||
|
|
||||||
|
|
||||||
async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool:
|
async def sync_show(
|
||||||
"""Download every episode in ``feed_url`` that ``folder`` doesn't have yet.
|
client: httpx2.AsyncClient,
|
||||||
|
folder: Path,
|
||||||
|
feed_url: str,
|
||||||
|
*,
|
||||||
|
keep: int | None = DEFAULT_EPISODE_LIMIT,
|
||||||
|
) -> bool:
|
||||||
|
"""Bring ``folder`` up to date with the newest ``keep`` episodes of ``feed_url``.
|
||||||
|
|
||||||
Returns whether anything changed. Errors - an unreachable feed, a malformed one, a
|
Downloads what is missing from that window and deletes what has fallen out of it.
|
||||||
single broken enclosure - are logged and swallowed here so one bad show never stops
|
Both halves use the same ``keep``, which is what makes the folder settle - see
|
||||||
the others or takes down the poll loop. An episode that fails is remembered and left
|
:data:`DEFAULT_EPISODE_LIMIT`.
|
||||||
alone for :data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead
|
|
||||||
enclosure doesn't get hammered on every poll.
|
Returns whether anything changed, deletions included: a pruned folder needs a
|
||||||
|
rescan just as much as a downloaded episode does, or the library keeps offering
|
||||||
|
tracks whose files are gone.
|
||||||
|
|
||||||
|
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
||||||
|
logged and swallowed here so one bad show never stops the others or takes down the
|
||||||
|
poll loop. An episode that fails is remembered and left alone for
|
||||||
|
:data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead enclosure
|
||||||
|
doesn't get hammered on every poll.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||||
@@ -453,7 +559,7 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
|
|||||||
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pending = missing_episodes(folder, parse_feed(response.content))
|
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
|
||||||
failed = _load_failed_downloads(folder)
|
failed = _load_failed_downloads(folder)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
changed = False
|
changed = False
|
||||||
@@ -490,13 +596,20 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
|
|||||||
pending_filenames = {filename for _episode, filename in pending}
|
pending_filenames = {filename for _episode, filename in pending}
|
||||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
||||||
_save_failed_downloads(folder, failed)
|
_save_failed_downloads(folder, failed)
|
||||||
|
|
||||||
|
# After downloading, not before: an episode that just arrived is one of the newest
|
||||||
|
# and must be counted when deciding what falls off the end.
|
||||||
|
if prune_show(folder, keep):
|
||||||
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = DEFAULT_EPISODE_LIMIT
|
||||||
|
) -> bool:
|
||||||
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
||||||
changed = False
|
changed = False
|
||||||
for _section_name, folder, feed_url in find_feed_shows(root):
|
for _section_name, folder, feed_url in find_feed_shows(root):
|
||||||
if await sync_show(client, folder, feed_url):
|
if await sync_show(client, folder, feed_url, keep=keep):
|
||||||
changed = True
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from typing import Final
|
|||||||
import httpx2
|
import httpx2
|
||||||
|
|
||||||
from musicmouse.library import MusicLibrary
|
from musicmouse.library import MusicLibrary
|
||||||
from musicmouse.library.podcast_feeds import sync_all_shows
|
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT, sync_all_shows
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,11 +34,13 @@ class PodcastFeedService:
|
|||||||
client: httpx2.AsyncClient,
|
client: httpx2.AsyncClient,
|
||||||
on_change: Callable[[], Awaitable[None]],
|
on_change: Callable[[], Awaitable[None]],
|
||||||
interval: float = _CHECK_INTERVAL_SECONDS,
|
interval: float = _CHECK_INTERVAL_SECONDS,
|
||||||
|
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.library = library
|
self.library = library
|
||||||
self.client = client
|
self.client = client
|
||||||
self.on_change = on_change
|
self.on_change = on_change
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
|
self.episode_limit = episode_limit
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Checks immediately at startup, then every `interval` seconds.
|
"""Checks immediately at startup, then every `interval` seconds.
|
||||||
@@ -51,7 +53,8 @@ class PodcastFeedService:
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if await sync_all_shows(self.client, self.library.root):
|
keep = self.episode_limit
|
||||||
|
if await sync_all_shows(self.client, self.library.root, keep=keep):
|
||||||
await self.on_change()
|
await self.on_change()
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("Podcast feed check failed; will retry next interval")
|
_log.exception("Podcast feed check failed; will retry next interval")
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from musicmouse.library.podcast_feeds import (
|
|||||||
episode_filename,
|
episode_filename,
|
||||||
find_feed_shows,
|
find_feed_shows,
|
||||||
missing_episodes,
|
missing_episodes,
|
||||||
|
newest_episodes,
|
||||||
parse_feed,
|
parse_feed,
|
||||||
|
prune_show,
|
||||||
resolve_episode_cover,
|
resolve_episode_cover,
|
||||||
sync_all_shows,
|
sync_all_shows,
|
||||||
sync_show,
|
sync_show,
|
||||||
@@ -630,3 +632,148 @@ async def test_sync_show_survives_a_corrupt_failure_record(tmp_path: Path) -> No
|
|||||||
|
|
||||||
assert changed is True
|
assert changed is True
|
||||||
assert (folder / "20250101 - Episode One.mp3").exists()
|
assert (folder / "20250101 - Episode One.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- episode retention
|
||||||
|
|
||||||
|
|
||||||
|
def _episode(day: int, *, title: str | None = None) -> Episode:
|
||||||
|
return Episode(
|
||||||
|
title=title or f"Episode {day}",
|
||||||
|
published=datetime(2025, 1, day, tzinfo=UTC),
|
||||||
|
enclosure_url=f"http://example.com/ep{day}.mp3",
|
||||||
|
enclosure_type="audio/mpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_episode(folder: Path, name: str) -> Path:
|
||||||
|
path = folder / name
|
||||||
|
path.write_bytes(b"audio")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_episodes_takes_the_most_recent_and_orders_them_newest_first() -> None:
|
||||||
|
episodes = [_episode(1), _episode(5), _episode(3)]
|
||||||
|
assert [e.published.day for e in newest_episodes(episodes, 2)] == [5, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_episodes_keeps_everything_when_the_limit_is_none() -> None:
|
||||||
|
episodes = [_episode(1), _episode(5), _episode(3)]
|
||||||
|
assert len(newest_episodes(episodes, None)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_deletes_only_the_oldest_beyond_the_limit(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
for day in range(1, 6):
|
||||||
|
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
|
||||||
|
|
||||||
|
assert prune_show(folder, 2) == 3
|
||||||
|
|
||||||
|
remaining = sorted(path.name for path in folder.iterdir())
|
||||||
|
assert remaining == ["20250104 - Episode 4.mp3", "20250105 - Episode 5.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_is_a_noop_below_the_limit(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Episode 1.mp3")
|
||||||
|
assert prune_show(folder, 50) == 0
|
||||||
|
assert prune_show(folder, None) == 0
|
||||||
|
assert (folder / "20250101 - Episode 1.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_takes_the_sidecar_cover_with_the_episode(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Old.mp3")
|
||||||
|
(folder / "20250101 - Old.jpg").write_bytes(b"art")
|
||||||
|
_write_episode(folder, "20250102 - New.mp3")
|
||||||
|
(folder / "20250102 - New.jpg").write_bytes(b"art")
|
||||||
|
|
||||||
|
assert prune_show(folder, 1) == 1
|
||||||
|
|
||||||
|
assert not (folder / "20250101 - Old.jpg").exists()
|
||||||
|
assert (folder / "20250102 - New.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_leaves_everything_it_did_not_name(tmp_path: Path) -> None:
|
||||||
|
"""A folder holds more than episodes, and a hand-placed file is not ours to delete."""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
for day in range(1, 4):
|
||||||
|
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
|
||||||
|
(folder / "feed.txt").write_text("http://example.com/feed.xml\n")
|
||||||
|
(folder / "folder.jpg").write_bytes(b"art")
|
||||||
|
(folder / ".failed-downloads.json").write_text("{}")
|
||||||
|
_write_episode(folder, "Grandpa's tape.mp3")
|
||||||
|
|
||||||
|
prune_show(folder, 1)
|
||||||
|
|
||||||
|
assert (folder / "feed.txt").exists()
|
||||||
|
assert (folder / "folder.jpg").exists()
|
||||||
|
assert (folder / ".failed-downloads.json").exists()
|
||||||
|
assert (folder / "Grandpa's tape.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_prunes_to_the_limit_after_downloading(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
if str(request.url) == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
return httpx2.Response(200, content=b"audio-bytes")
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
episodes = sorted(path.name for path in folder.iterdir() if path.suffix == ".mp3")
|
||||||
|
assert episodes == ["20250103 - Episode Two_ Special_Chars_.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_does_not_redownload_what_it_pruned(tmp_path: Path) -> None:
|
||||||
|
"""The whole point of capping downloads as well as deletions.
|
||||||
|
|
||||||
|
Prune to the newest N but fetch everything the feed offers, and each poll would
|
||||||
|
re-download the episodes the previous one deleted - forever, at full size.
|
||||||
|
"""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
downloads: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
if str(request.url) == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
downloads.append(str(request.url))
|
||||||
|
return httpx2.Response(200, content=b"audio-bytes")
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
first_pass = len(downloads)
|
||||||
|
changed_again = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed_again is False
|
||||||
|
assert len(downloads) == first_pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_reports_a_change_when_it_only_pruned(tmp_path: Path) -> None:
|
||||||
|
"""A deletion needs a rescan as much as a download does, or the library goes on
|
||||||
|
offering tracks whose files are gone."""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Episode One.mp3")
|
||||||
|
_write_episode(folder, "20250103 - Episode Two_ Special_Chars_.mp3")
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert not (folder / "20250101 - Episode One.mp3").exists()
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ async def test_run_checks_immediately_and_calls_on_change_only_when_something_ch
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def fake_sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
|
||||||
|
) -> bool:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
return calls == 1 # only the very first pass finds something new
|
return calls == 1 # only the very first pass finds something new
|
||||||
@@ -63,7 +65,9 @@ async def test_run_survives_a_failing_pass(
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def fake_sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
|
||||||
|
) -> bool:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
if calls == 1:
|
if calls == 1:
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ if [[ $COPY_CACHE -eq 1 ]]; then
|
|||||||
for part in covers analysis; do
|
for part in covers analysis; do
|
||||||
[[ -d "${CACHE}/${part}" ]] || continue
|
[[ -d "${CACHE}/${part}" ]] || continue
|
||||||
echo "--- ${part}"
|
echo "--- ${part}"
|
||||||
|
# rsync creates the last path component but not a missing one above it, and on a
|
||||||
|
# fresh device neither .musicmouse-cache nor its subdirectory exists yet.
|
||||||
|
[[ $DRY_RUN -eq 1 ]] || ssh "$SSH_TARGET" "mkdir -p '${DEST}/.musicmouse-cache/${part}'"
|
||||||
# No --delete: the device may have analysed tracks this machine never saw.
|
# No --delete: the device may have analysed tracks this machine never saw.
|
||||||
rsync "${RSYNC_OPTS[@]}" \
|
rsync "${RSYNC_OPTS[@]}" \
|
||||||
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
|
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
|
||||||
|
|||||||
Reference in New Issue
Block a user