/
/
/
1"""Tests for the filesystem provider's self-validating podcast episode list cache."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import os
8import pathlib
9from typing import Any
10from unittest.mock import AsyncMock, MagicMock, patch
11
12import pytest
13from music_assistant_models.errors import InvalidDataError, MediaNotFoundError
14from music_assistant_models.media_items import Podcast, PodcastEpisode
15
16from music_assistant.helpers.tags import AudioTags
17from music_assistant.mass import MusicAssistant
18from music_assistant.providers.filesystem_local import LocalFileSystemProvider
19from music_assistant.providers.filesystem_local.constants import (
20 CACHE_CATEGORY_PODCAST_EPISODES,
21 PARTIAL_LISTING_CACHE_EXPIRATION,
22)
23
24PODCAST_FOLDER = "My Podcast"
25INSTANCE_ID = "filesystem_local--test"
26PARSE_TAGS_TARGET = "music_assistant.providers.filesystem_local.async_parse_tags"
27
28
29def _audio_tags(path: str, chapters: bool = False) -> AudioTags:
30 """
31 Build AudioTags as ffprobe would report them for one generated episode file.
32
33 :param path: Absolute path of the file being parsed.
34 :param chapters: Whether to report an embedded chapter.
35 """
36 filename = pathlib.Path(path).name
37 # episode-03.mp3 -> track 3
38 track = int(filename.split("-")[1].split(".")[0])
39 raw: dict[str, object] = {}
40 if chapters:
41 raw["chapters"] = [
42 {"id": 1, "start_time": "0.0", "end_time": "21.0", "tags": {"title": "Intro"}},
43 {"id": 2, "start_time": "21.0", "end_time": "42.0", "tags": {"title": "Outro"}},
44 ]
45 return AudioTags(
46 raw=raw,
47 sample_rate=44100,
48 channels=2,
49 bits_per_sample=16,
50 format="mp3",
51 bit_rate=128,
52 duration=42.0,
53 tags={
54 "album": PODCAST_FOLDER,
55 "title": f"Episode {track}",
56 "track": str(track),
57 "publisher": "ACME Radio",
58 "comment": f"description of episode {track}",
59 },
60 has_cover_image=False,
61 filename=filename,
62 )
63
64
65def _parse_tags_spy(chapters: bool = False, slow_first: bool = False) -> AsyncMock:
66 """
67 Return an AsyncMock standing in for async_parse_tags, counting the files it parses.
68
69 :param chapters: Whether the reported tags include embedded chapters.
70 :param slow_first: Make the first episode finish last, so that a listing collected in
71 task completion order comes out in a different order than the directory listing.
72 """
73
74 async def _parse(path: str, _size: int | None = None) -> AudioTags:
75 if slow_first and path.endswith("episode-01.mp3"):
76 await asyncio.sleep(0.05)
77 return _audio_tags(path, chapters)
78
79 return AsyncMock(side_effect=_parse)
80
81
82def _write_episodes(folder: pathlib.Path, count: int) -> None:
83 """Create `count` dummy episode files; their tags are supplied by the parse spy."""
84 folder.mkdir(parents=True, exist_ok=True)
85 for index in range(1, count + 1):
86 (folder / f"episode-{index:02d}.mp3").write_bytes(b"dummy audio")
87
88
89@pytest.fixture(name="provider")
90async def provider_fixture(
91 mass_minimal: MusicAssistant, tmp_path: pathlib.Path
92) -> LocalFileSystemProvider:
93 """Create a podcasts filesystem provider backed by a real cache database."""
94 # the mass_minimal fixture constructs the cache controller but never sets up its database
95 await mass_minimal.cache.setup(await mass_minimal.config.get_core_config("cache"))
96 base_path = tmp_path / "media"
97 base_path.mkdir()
98 _write_episodes(base_path / PODCAST_FOLDER, 3)
99
100 # __new__ skips __init__, which would resolve the log level and content type through
101 # config plumbing a bare test provider cannot satisfy
102 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
103 provider.mass = mass_minimal
104 provider.config = MagicMock()
105 provider.config.instance_id = INSTANCE_ID
106 provider.manifest = MagicMock()
107 provider.manifest.domain = "filesystem_local"
108 provider.cache = mass_minimal.cache
109 provider.logger = logging.getLogger("test.filesystem_local")
110 provider.base_path = str(base_path)
111 provider.media_content_type = "podcasts"
112 provider.write_access = False
113 provider.sync_running = False
114 return provider
115
116
117async def test_second_listing_is_served_from_cache(provider: LocalFileSystemProvider) -> None:
118 """A second listing of an unchanged folder parses no files at all."""
119 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
120 first = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
121 assert parse_tags.await_count == 3
122
123 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
124 second = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
125 parse_tags.assert_not_awaited()
126
127 assert [x.to_dict() for x in second] == [x.to_dict() for x in first]
128
129
130async def test_listing_keeps_scandir_order(provider: LocalFileSystemProvider) -> None:
131 """Episodes are yielded in (natural sorted) directory order, cold and cached alike."""
132 expected = [f"{PODCAST_FOLDER}/episode-{index:02d}.mp3" for index in (1, 2, 3)]
133 # the first episode parses slowest, so collecting in task completion order would
134 # produce [02, 03, 01] here - the order this test exists to rule out
135 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy(slow_first=True)):
136 cold = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
137 cached = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
138
139 assert [x.item_id for x in cold] == expected
140 assert [x.item_id for x in cached] == expected
141
142
143async def test_parsing_stays_within_the_concurrency_limit(
144 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
145) -> None:
146 """
147 A cold listing never runs more parse tasks at once than _SYNC_CONCURRENCY allows.
148
149 Parsing shells out to ffprobe through the shared thread executor, so an unbounded fan-out
150 over a large podcast starves every other thread pool user for the duration of the listing.
151 TaskManager.create_task does not honour the limit it was constructed with - only
152 create_task_with_limit acquires the semaphore - which makes this a one-word regression.
153 """
154 episode_count = provider._SYNC_CONCURRENCY * 2 + 8
155 _write_episodes(tmp_path / "media" / "Big Podcast", episode_count)
156 in_flight = 0
157 peak = 0
158
159 async def _parse(path: str, _size: int | None = None) -> AudioTags:
160 nonlocal in_flight, peak
161 in_flight += 1
162 peak = max(peak, in_flight)
163 # hold the slot open, so tasks started together overlap here rather than
164 # running to completion one by one and never registering as concurrent
165 await asyncio.sleep(0.01)
166 in_flight -= 1
167 return _audio_tags(path)
168
169 with patch(PARSE_TAGS_TARGET, new=AsyncMock(side_effect=_parse)) as parse_tags:
170 result = [x async for x in provider.get_podcast_episodes("Big Podcast")]
171
172 assert parse_tags.await_count == episode_count
173 assert len(result) == episode_count
174 assert peak <= provider._SYNC_CONCURRENCY
175 # without this the assertion above would also hold for a fully serialized listing,
176 # which is not what the limit is for
177 assert peak > 1
178
179
180async def test_added_file_invalidates_cache(
181 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
182) -> None:
183 """A new episode file is picked up on the next listing, with no sync in between."""
184 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
185 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
186
187 (tmp_path / "media" / PODCAST_FOLDER / "episode-04.mp3").write_bytes(b"dummy audio")
188
189 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
190 result = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
191
192 assert parse_tags.await_count == 4
193 assert f"{PODCAST_FOLDER}/episode-04.mp3" in [x.item_id for x in result]
194
195
196async def test_added_cover_art_reaches_the_listing(
197 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
198) -> None:
199 """
200 Cover art added to the folder shows up on the next listing.
201
202 Local images are read while parsing and embedded in every cached episode (as the parent
203 podcast's image, and as the episode thumbnail for files without embedded art), so art
204 that leaves the cache valid would stay invisible for the life of the entry. Note this
205 covers *adding* a file rather than overwriting one: the parsed image is a bare path with
206 no version suffix, so replacing cover.jpg in place is served fresh from disk regardless.
207 """
208 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
209 first = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
210 assert isinstance(first[0].podcast, Podcast)
211 assert first[0].podcast.image is None
212
213 (tmp_path / "media" / PODCAST_FOLDER / "cover.jpg").write_bytes(b"art")
214
215 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
216 second = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
217
218 assert parse_tags.await_count == 3, "the folder signature should have invalidated"
219 assert isinstance(second[0].podcast, Podcast)
220 # not just re-parsed: the new art has to survive the folder-images cache too
221 assert second[0].podcast.image is not None
222 assert second[0].podcast.image.path == f"{PODCAST_FOLDER}/cover.jpg"
223
224
225async def test_edited_metadata_json_is_not_refrozen_from_the_inner_cache(
226 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
227) -> None:
228 """
229 An edited metadata.json reaches the re-parsed listing instead of a stale copy of itself.
230
231 The folder's metadata.json and artwork are read through their own per-folder caches, and
232 neither is checksum validated. A re-parse triggered by the folder signature must not be
233 filled from those, or the very edit that invalidated the listing is frozen back into it -
234 permanently, because the new entry's signature then matches the folder.
235 """
236 metadata_file = tmp_path / "media" / PODCAST_FOLDER / "metadata.json"
237 metadata_file.write_text('{"title": "Old Name"}')
238 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
239 first = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
240 assert isinstance(first[0].podcast, Podcast)
241 assert first[0].podcast.name == "Old Name"
242
243 # a different length as well as different content: the signature's mtime is whole
244 # seconds, so within one second only the size distinguishes the two files
245 metadata_file.write_text('{"title": "A Brand New Name"}')
246
247 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
248 second = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
249
250 assert parse_tags.await_count == 3, "the folder signature should have invalidated"
251 assert isinstance(second[0].podcast, Podcast)
252 assert second[0].podcast.name == "A Brand New Name"
253
254
255async def test_cold_parse_reads_the_folder_artwork_once(
256 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
257) -> None:
258 """
259 Invalidating the artwork cache does not make every parse task go and rebuild it.
260
261 Each episode embeds the folder's artwork, read through a per-folder cache that this
262 listing has just had to drop. Left empty, all _SYNC_CONCURRENCY parse tasks miss it at
263 once and each repeats the same scandir, which on the high latency transports that lower
264 that limit is the expensive part.
265 """
266 (tmp_path / "media" / PODCAST_FOLDER / "cover.jpg").write_bytes(b"cover")
267 scandir_calls: list[str] = []
268 original_scandir = provider._scandir
269
270 async def _spy_scandir(path: str) -> Any:
271 scandir_calls.append(path)
272 return await original_scandir(path)
273
274 with (
275 patch.object(provider, "_scandir", _spy_scandir),
276 patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags,
277 ):
278 episodes = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
279
280 assert parse_tags.await_count == 3, "this has to be the cold path to prove anything"
281 assert len(episodes) == 3
282 # the listing's own scandir, plus one to fill the artwork cache for all three episodes
283 assert len(scandir_calls) == 2, scandir_calls
284
285
286async def test_case_variant_metadata_json_is_covered_by_the_signature(
287 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
288) -> None:
289 """
290 A folder holding `Metadata.json` still invalidates its listing when that file is edited.
291
292 _get_podcast_metadata locates the file through exists(), which resolves
293 case-insensitively on APFS, SMB and NTFS - so the parse embeds a `Metadata.json` that a
294 case-sensitive signature check leaves uncovered, freezing the listing for the lifetime of
295 the entry. This asserts on invalidation rather than on the embedded name, so it holds on
296 case-sensitive filesystems too, where the parse never reads the file in the first place.
297 """
298 metadata_file = tmp_path / "media" / PODCAST_FOLDER / "Metadata.json"
299 metadata_file.write_text('{"title": "Old Name"}')
300 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
301 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
302
303 # a different length as well as different content: the signature's mtime is whole
304 # seconds, so within one second only the size distinguishes the two files
305 metadata_file.write_text('{"title": "A Brand New Name"}')
306
307 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
308 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
309
310 assert parse_tags.await_count == 3, "the folder signature should have invalidated"
311
312
313async def test_removed_file_invalidates_cache(
314 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
315) -> None:
316 """A deleted episode file is dropped on the next listing, with no sync in between."""
317 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
318 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
319
320 (tmp_path / "media" / PODCAST_FOLDER / "episode-02.mp3").unlink()
321
322 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
323 result = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
324
325 assert parse_tags.await_count == 2
326 assert [x.item_id for x in result] == [
327 f"{PODCAST_FOLDER}/episode-01.mp3",
328 f"{PODCAST_FOLDER}/episode-03.mp3",
329 ]
330
331
332async def test_touched_file_invalidates_cache(
333 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
334) -> None:
335 """A file whose modification time changed is re-parsed on the next listing."""
336 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
337 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
338
339 episode_file = tmp_path / "media" / PODCAST_FOLDER / "episode-02.mp3"
340 # the signature uses whole-second mtimes, so move it well clear of the original
341 stat = episode_file.stat()
342 os.utime(episode_file, (stat.st_atime, stat.st_mtime + 120))
343
344 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
345 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
346
347 assert parse_tags.await_count == 3
348
349
350async def test_resized_file_invalidates_cache(
351 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
352) -> None:
353 """A file whose size changed is re-parsed even if the mtime is restored."""
354 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
355 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
356
357 episode_file = tmp_path / "media" / PODCAST_FOLDER / "episode-02.mp3"
358 stat = episode_file.stat()
359 episode_file.write_bytes(b"dummy audio, but longer")
360 os.utime(episode_file, (stat.st_atime, stat.st_mtime))
361
362 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
363 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
364
365 assert parse_tags.await_count == 3
366
367
368async def test_empty_folder_yields_nothing_and_is_cached(
369 provider: LocalFileSystemProvider,
370 tmp_path: pathlib.Path,
371 monkeypatch: pytest.MonkeyPatch,
372) -> None:
373 """An empty podcast folder lists nothing, is a cache hit next time, and stays not-found."""
374 (tmp_path / "media" / "Empty Podcast").mkdir()
375 writes = 0
376 original_set = provider.mass.cache.set
377
378 async def _counting_set(*args: Any, **kwargs: Any) -> None:
379 nonlocal writes
380 if kwargs.get("category") == CACHE_CATEGORY_PODCAST_EPISODES:
381 writes += 1
382 await original_set(*args, **kwargs)
383
384 monkeypatch.setattr(provider.mass.cache, "set", _counting_set)
385
386 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
387 assert [x async for x in provider.get_podcast_episodes("Empty Podcast")] == []
388 assert [x async for x in provider.get_podcast_episodes("Empty Podcast")] == []
389 parse_tags.assert_not_awaited()
390 # an empty listing is still a real cache entry, so a hit has to be told from a miss by
391 # presence and not by truthiness: only the first call may write. Counting parses cannot
392 # see this - an empty folder has nothing to parse either way
393 assert writes == 1
394
395 with pytest.raises(MediaNotFoundError):
396 await provider.get_podcast("Empty Podcast")
397
398
399async def test_missing_folder_raises(provider: LocalFileSystemProvider) -> None:
400 """A podcast folder that does not exist fails rather than caching an empty listing."""
401 with pytest.raises(FileNotFoundError):
402 [x async for x in provider.get_podcast_episodes("No Such Podcast")]
403
404
405async def test_refresh_bypasses_cache(
406 provider: LocalFileSystemProvider, mass_minimal: MusicAssistant
407) -> None:
408 """A forced refresh (music/refresh_item) re-reads the folder instead of using the cache."""
409 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
410 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
411
412 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
413 async with mass_minimal.cache.handle_refresh(True):
414 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
415
416 assert parse_tags.await_count == 3
417
418
419async def test_partial_listing_is_cached_only_briefly(
420 provider: LocalFileSystemProvider, tmp_path: pathlib.Path
421) -> None:
422 """
423 One unreadable file keeps the rest of the listing, cached under a short expiration.
424
425 Storing nothing at all would mean a permanently corrupt file re-parses every other file
426 in the folder on every single request, for as long as it sits there - the cost this cache
427 exists to remove, silently withheld from the one user who most needs it. The entry is kept
428 instead, short lived because the dropped episode cannot reappear before it expires.
429 """
430 expirations: list[int | None] = []
431 original_set = provider.mass.cache.set
432
433 async def _spy_set(*args: Any, **kwargs: Any) -> Any:
434 if kwargs.get("category") == CACHE_CATEGORY_PODCAST_EPISODES:
435 expirations.append(kwargs.get("expiration"))
436 return await original_set(*args, **kwargs)
437
438 def _side_effect(path: str, _size: int | None = None) -> AudioTags:
439 if path.endswith("episode-02.mp3"):
440 raise InvalidDataError("Unable to parse file")
441 return _audio_tags(path)
442
443 with (
444 patch.object(provider.mass.cache, "set", _spy_set),
445 patch(PARSE_TAGS_TARGET, new=AsyncMock(side_effect=_side_effect)) as parse_tags,
446 ):
447 partial = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
448 assert parse_tags.await_count == 3
449 assert [x.item_id for x in partial] == [
450 f"{PODCAST_FOLDER}/episode-01.mp3",
451 f"{PODCAST_FOLDER}/episode-03.mp3",
452 ]
453 # not the year a complete listing gets, or the missing episode would be hidden for it
454 assert expirations == [PARTIAL_LISTING_CACHE_EXPIRATION]
455
456 # the readable files are served from the cache rather than parsed again
457 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
458 cached = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
459 parse_tags.assert_not_awaited()
460 assert len(cached) == 2
461
462 # the folder signature still recovers the listing ahead of that expiration: a file that
463 # becomes readable by being rewritten moves the signature and re-parses immediately
464 (tmp_path / "media" / PODCAST_FOLDER / "episode-02.mp3").write_bytes(b"dummy audio fixed")
465
466 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
467 recovered = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
468 assert parse_tags.await_count == 3
469 assert len(recovered) == 3
470
471
472async def test_cached_episode_survives_serialization(provider: LocalFileSystemProvider) -> None:
473 """Everything the players and UI need survives the cache round trip."""
474 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy(chapters=True)):
475 [fresh, *_] = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
476
477 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy(chapters=True)) as parse_tags:
478 [cached, *_] = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
479 # the second listing has to come out of the cache, otherwise everything below would
480 # compare two freshly parsed episodes and prove nothing about serialization
481 parse_tags.assert_not_awaited()
482
483 # the scalar fields are covered by the to_dict() comparison in the cache hit test;
484 # what needs its own assertions is what a wrong deserialization would silently produce
485 assert isinstance(cached, PodcastEpisode)
486 assert [x.name for x in cached.metadata.chapters or []] == ["Intro", "Outro"]
487 # the podcast attribute is a Podcast | ItemMapping union: it must resolve to Podcast,
488 # because get_podcast() reads the parent podcast straight off a listed episode
489 assert isinstance(cached.podcast, Podcast)
490 assert isinstance(fresh.podcast, Podcast)
491 assert cached.podcast.publisher == "ACME Radio"
492 assert (
493 next(iter(cached.provider_mappings)).audio_format
494 == next(iter(fresh.provider_mappings)).audio_format
495 )
496
497
498async def test_get_podcast_uses_cached_listing(provider: LocalFileSystemProvider) -> None:
499 """get_podcast reads the parent podcast off the cached listing without parsing files."""
500 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
501 [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
502
503 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()) as parse_tags:
504 podcast = await provider.get_podcast(PODCAST_FOLDER)
505
506 parse_tags.assert_not_awaited()
507 assert isinstance(podcast, Podcast)
508 assert podcast.name == PODCAST_FOLDER
509
510
511async def test_no_resume_state_is_cached(provider: LocalFileSystemProvider) -> None:
512 """Resume info applied to a listed episode never ends up in the provider cache."""
513 with patch(PARSE_TAGS_TARGET, new=_parse_tags_spy()):
514 episodes = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
515 # the controller applies per-user resume info to the objects the provider yields
516 for episode in episodes:
517 episode.fully_played = True
518 episode.resume_position_ms = 120000
519
520 cached = [x async for x in provider.get_podcast_episodes(PODCAST_FOLDER)]
521
522 assert len(cached) == 3
523 assert all(x.fully_played is None for x in cached)
524 assert all(not x.resume_position_ms for x in cached)
525