/
/
1"""Tests for the podcastfeed -> Mass parsing helpers."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, Any, cast
6
7from aiohttp.client import ClientError
8from music_assistant_models.enums import LinkType
9
10from music_assistant.helpers.podcast_parsers import (
11 enrich_episode_chapters,
12 find_episode_stream_url,
13 get_cached_podcast,
14 get_podcastparser_dict,
15 get_stream_url_from_episode,
16 parse_chapters_from_json,
17 parse_podcast_episode,
18 parse_podcast_persons,
19 refresh_cached_podcast,
20)
21
22if TYPE_CHECKING:
23 import aiohttp
24 from music_assistant_models.media_items import PodcastEpisode
25
26 from music_assistant.mass import MusicAssistant
27
28
29def _episode(**overrides: Any) -> dict[str, Any]:
30 """Return a minimal podcastparser episode dict with a valid enclosure."""
31 episode: dict[str, Any] = {
32 "title": "Episode 1",
33 "enclosures": [{"url": "https://example.com/ep1.mp3"}],
34 }
35 episode.update(overrides)
36 return episode
37
38
39def _parse(episode: dict[str, Any]) -> PodcastEpisode | None:
40 """Parse an episode dict with the required boilerplate args."""
41 return parse_podcast_episode(
42 episode=episode,
43 prov_podcast_id="podcast-1",
44 episode_cnt=1,
45 instance_id="podcastfeed--test",
46 domain="podcastfeed",
47 )
48
49
50class _FakeResponse:
51 """Minimal stand-in for an aiohttp response yielding a fixed JSON payload."""
52
53 def __init__(self, payload: Any) -> None:
54 self._payload = payload
55
56 async def json(self, **kwargs: Any) -> Any:
57 return self._payload
58
59
60class _FakeGetContext:
61 """
62 Stand-in for aiohttp's request context manager.
63
64 Mirrors aiohttp where the error (e.g. raise_for_status / timeout) surfaces on
65 entering the context, so enrichment must guard the ``async with`` itself.
66 """
67
68 def __init__(self, session: _FakeSession) -> None:
69 self._session = session
70
71 async def __aenter__(self) -> _FakeResponse:
72 if self._session._error is not None:
73 raise self._session._error
74 return _FakeResponse(self._session._payload)
75
76 async def __aexit__(self, *exc_info: object) -> bool:
77 return False
78
79
80class _FakeSession:
81 """Minimal stand-in for an aiohttp ClientSession used by chapter enrichment."""
82
83 def __init__(self, *, payload: Any = None, error: Exception | None = None) -> None:
84 self._payload = payload
85 self._error = error
86 self.calls = 0
87 self.last_url: str | None = None
88
89 def get(self, url: str, **kwargs: Any) -> _FakeGetContext:
90 self.calls += 1
91 self.last_url = url
92 return _FakeGetContext(self)
93
94
95# --- enclosure / stream url selection ---------------------------------------------------------
96
97
98def test_stream_url_prefers_audio_over_leading_image() -> None:
99 """A leading image media:content enclosure is skipped in favor of the audio one (#5920)."""
100 audio_url = "https://rss.wbur.org/the-midnight-rebellion/ep1.mp3"
101 cover_url = "https://rss.wbur.org/the-midnight-rebellion/cover.jpg"
102 episode = _episode(
103 enclosures=[
104 {"url": cover_url, "mime_type": "image/jpeg"},
105 {"url": audio_url, "mime_type": "audio/mpeg"},
106 {"url": audio_url, "mime_type": "audio/mpeg"},
107 ]
108 )
109 assert get_stream_url_from_episode(episode=episode) == audio_url
110
111
112def test_stream_url_accepts_bogus_mime_type() -> None:
113 """A real audio enclosure with a bogus declared mime type is still used (#5692)."""
114 episode = _episode(
115 enclosures=[{"url": "https://example.com/ep1.mp3", "mime_type": "application/octet-stream"}]
116 )
117 assert get_stream_url_from_episode(episode=episode) == "https://example.com/ep1.mp3"
118
119
120def test_stream_url_accepts_video_enclosure() -> None:
121 """A video/mp4 enclosure is accepted as a playable stream."""
122 video_url = "https://example.com/ep1.mp4"
123 episode = _episode(enclosures=[{"url": video_url, "mime_type": "video/mp4"}])
124 assert get_stream_url_from_episode(episode=episode) == video_url
125
126
127def test_stream_url_skips_enclosures_without_url() -> None:
128 """An enclosure missing a url is skipped in favor of a later usable one."""
129 episode = _episode(
130 enclosures=[
131 {"mime_type": "audio/mpeg"},
132 {"url": "https://example.com/ep1.mp3", "mime_type": "audio/mpeg"},
133 ]
134 )
135 assert get_stream_url_from_episode(episode=episode) == "https://example.com/ep1.mp3"
136
137
138def test_stream_url_image_only_returns_none() -> None:
139 """An episode whose only enclosure is an image has no playable stream."""
140 episode = _episode(
141 enclosures=[{"url": "https://example.com/cover.jpg", "mime_type": "image/jpeg"}]
142 )
143 assert get_stream_url_from_episode(episode=episode) is None
144 assert _parse(episode) is None
145
146
147def test_stream_url_first_audio_enclosure_wins() -> None:
148 """When several audio enclosures are declared, the first one wins."""
149 episode = _episode(
150 enclosures=[
151 {"url": "https://example.com/ep1-first.mp3", "mime_type": "audio/mpeg"},
152 {"url": "https://example.com/ep1-second.mp3", "mime_type": "audio/mpeg"},
153 ]
154 )
155 assert get_stream_url_from_episode(episode=episode) == "https://example.com/ep1-first.mp3"
156
157
158def test_stream_url_missing_mime_type_falls_back_to_url() -> None:
159 """The default `_episode()` enclosure has no mime_type key and still resolves (fallback)."""
160 assert get_stream_url_from_episode(episode=_episode()) == "https://example.com/ep1.mp3"
161
162
163# --- description -----------------------------------------------------------------------------
164
165
166def test_description_is_populated() -> None:
167 """A non-empty episode description ends up on the episode metadata."""
168 mass_episode = _parse(_episode(description="All about parsing podcasts."))
169 assert mass_episode is not None
170 assert mass_episode.metadata.description == "All about parsing podcasts."
171
172
173def test_empty_description_left_unset() -> None:
174 """An empty description (podcastparser's default) leaves metadata.description as None."""
175 mass_episode = _parse(_episode(description=""))
176 assert mass_episode is not None
177 assert mass_episode.metadata.description is None
178
179
180def test_missing_description_left_unset() -> None:
181 """An absent description key leaves metadata.description as None."""
182 mass_episode = _parse(_episode())
183 assert mass_episode is not None
184 assert mass_episode.metadata.description is None
185
186
187# --- parent podcast reference ----------------------------------------------------------------
188
189
190def test_podcast_reference_uses_podcast_name() -> None:
191 """The parent-podcast reference is named after the podcast, not the episode."""
192 mass_episode = parse_podcast_episode(
193 episode=_episode(title="Episode 1"),
194 prov_podcast_id="podcast-1",
195 episode_cnt=1,
196 podcast_name="My Show",
197 instance_id="podcastfeed--test",
198 domain="podcastfeed",
199 )
200 assert mass_episode is not None
201 assert mass_episode.podcast.name == "My Show"
202 assert mass_episode.name == "Episode 1"
203
204
205def test_podcast_reference_falls_back_to_episode_title() -> None:
206 """Without a podcast name, the reference falls back to the episode title."""
207 mass_episode = _parse(_episode(title="Some Episode"))
208 assert mass_episode is not None
209 assert mass_episode.podcast.name == "Some Episode"
210
211
212# --- episode position (itunes:episode number) -----------------------------------------------
213
214
215def test_episode_position_uses_itunes_episode_number() -> None:
216 """A declared itunes:episode number drives the episode position over the feed order."""
217 mass_episode = _parse(_episode(number=5))
218 assert mass_episode is not None
219 assert mass_episode.position == 5
220
221
222def test_episode_position_falls_back_to_feed_order() -> None:
223 """Without an episode number, position falls back to the feed enumeration order (cnt=1)."""
224 mass_episode = _parse(_episode())
225 assert mass_episode is not None
226 assert mass_episode.position == 1
227
228
229# --- inline (Podlove Simple Chapters) --------------------------------------------------------
230
231
232def test_inline_chapters_populated() -> None:
233 """Inline chapters are attached, including a chapter that starts at 0."""
234 episode = _episode(
235 chapters=[
236 {"title": "Intro", "start": 0},
237 {"title": "Topic", "start": 90.0},
238 ]
239 )
240 mass_episode = _parse(episode)
241 assert mass_episode is not None
242 chapters = mass_episode.metadata.chapters
243 assert chapters is not None
244 assert [c.name for c in chapters] == ["Intro", "Topic"]
245 # the opening chapter (start == 0) must survive; a truthiness check would drop it
246 assert chapters[0].start == 0
247
248
249def test_inline_chapters_absent_leaves_none() -> None:
250 """No inline chapters key leaves metadata.chapters as None."""
251 mass_episode = _parse(_episode())
252 assert mass_episode is not None
253 assert mass_episode.metadata.chapters is None
254
255
256# --- podcast:chapters JSON parsing -----------------------------------------------------------
257
258
259def test_parse_chapters_from_json_maps_fields() -> None:
260 """Valid chapters JSON maps startTime/endTime/title onto MediaItemChapters."""
261 data = {
262 "version": "1.2.0",
263 "chapters": [
264 {"startTime": 0, "title": "Intro"},
265 {"startTime": 90, "endTime": 120, "title": "Sponsor"},
266 ],
267 }
268 chapters = parse_chapters_from_json(data)
269 assert [(c.position, c.name, c.start, c.end) for c in chapters] == [
270 (1, "Intro", 0.0, None),
271 (2, "Sponsor", 90.0, 120.0),
272 ]
273
274
275def test_parse_chapters_from_json_skips_invalid_and_hidden() -> None:
276 """Entries missing startTime/title, hidden (toc: false), or non-dict are skipped."""
277 data = {
278 "chapters": [
279 {"title": "no start"},
280 {"startTime": 10},
281 {"startTime": 20, "title": "hidden", "toc": False},
282 "not-a-dict",
283 {"startTime": 30, "title": "kept"},
284 ]
285 }
286 chapters = parse_chapters_from_json(data)
287 assert [c.name for c in chapters] == ["kept"]
288 assert chapters[0].position == 1
289
290
291def test_parse_chapters_from_json_malformed_returns_empty() -> None:
292 """A missing or non-list chapters key yields no chapters."""
293 assert parse_chapters_from_json({}) == []
294 assert parse_chapters_from_json({"chapters": "nope"}) == []
295
296
297def test_parse_chapters_from_json_skips_malformed_values() -> None:
298 """Non-numeric/non-finite startTimes and non-string titles are dropped, not stored."""
299 data = {
300 "chapters": [
301 {"startTime": "01:30", "title": "non-numeric start"},
302 {"startTime": float("nan"), "title": "nan start"},
303 {"startTime": float("inf"), "title": "inf start"},
304 {"startTime": 10, "title": {"x": 1}},
305 {"startTime": 20, "title": 123},
306 {"startTime": 30, "title": "kept", "endTime": float("nan")},
307 ]
308 }
309 chapters = parse_chapters_from_json(data)
310 # only the well-formed entry survives, and its non-finite endTime collapses to None
311 assert [(c.name, c.start, c.end) for c in chapters] == [("kept", 30.0, None)]
312
313
314# --- chapter enrichment (external JSON fetch) ------------------------------------------------
315
316
317async def test_enrich_fetches_json_chapters() -> None:
318 """Chapters are fetched from chapters_json_url when the episode has none inline."""
319 mass_episode = _parse(_episode())
320 assert mass_episode is not None
321 session = _FakeSession(payload={"chapters": [{"startTime": 0, "title": "Intro"}]})
322 await enrich_episode_chapters(
323 session=cast("aiohttp.ClientSession", session),
324 chapters_json_url="https://example.com/ch.json",
325 mass_episode=mass_episode,
326 )
327 assert mass_episode.metadata.chapters is not None
328 assert [c.name for c in mass_episode.metadata.chapters] == ["Intro"]
329
330
331async def test_enrich_swallows_fetch_error() -> None:
332 """A failed chapter fetch is ignored and leaves the episode without chapters."""
333 mass_episode = _parse(_episode())
334 assert mass_episode is not None
335 session = _FakeSession(error=ClientError("boom"))
336 await enrich_episode_chapters(
337 session=cast("aiohttp.ClientSession", session),
338 chapters_json_url="https://example.com/ch.json",
339 mass_episode=mass_episode,
340 )
341 assert mass_episode.metadata.chapters is None
342
343
344async def test_enrich_swallows_timeout() -> None:
345 """A timed-out chapter fetch is ignored: TimeoutError is not a ClientError."""
346 mass_episode = _parse(_episode())
347 assert mass_episode is not None
348 session = _FakeSession(error=TimeoutError())
349 await enrich_episode_chapters(
350 session=cast("aiohttp.ClientSession", session),
351 chapters_json_url="https://example.com/ch.json",
352 mass_episode=mass_episode,
353 )
354 assert mass_episode.metadata.chapters is None
355
356
357async def test_enrich_skips_when_inline_chapters_present() -> None:
358 """Inline chapters win: no JSON fetch is performed when chapters already exist."""
359 mass_episode = _parse(_episode(chapters=[{"title": "Inline", "start": 0}]))
360 assert mass_episode is not None
361 session = _FakeSession(payload={"chapters": [{"startTime": 5, "title": "FromJson"}]})
362 await enrich_episode_chapters(
363 session=cast("aiohttp.ClientSession", session),
364 chapters_json_url="https://example.com/ch.json",
365 mass_episode=mass_episode,
366 )
367 assert session.calls == 0
368 assert mass_episode.metadata.chapters is not None
369 assert [c.name for c in mass_episode.metadata.chapters] == ["Inline"]
370
371
372async def test_enrich_skips_when_no_url() -> None:
373 """A falsy chapters URL is a no-op: no fetch, no chapters."""
374 mass_episode = _parse(_episode())
375 assert mass_episode is not None
376 session = _FakeSession(payload={"chapters": [{"startTime": 0, "title": "Intro"}]})
377 await enrich_episode_chapters(
378 session=cast("aiohttp.ClientSession", session),
379 chapters_json_url=None,
380 mass_episode=mass_episode,
381 )
382 assert session.calls == 0
383 assert mass_episode.metadata.chapters is None
384
385
386async def test_enrich_ignores_non_dict_payload() -> None:
387 """A JSON payload that is not an object (e.g. a list) leaves the episode without chapters."""
388 mass_episode = _parse(_episode())
389 assert mass_episode is not None
390 session = _FakeSession(payload=[{"startTime": 0, "title": "Intro"}])
391 await enrich_episode_chapters(
392 session=cast("aiohttp.ClientSession", session),
393 chapters_json_url="https://example.com/ch.json",
394 mass_episode=mass_episode,
395 )
396 assert session.calls == 1
397 assert mass_episode.metadata.chapters is None
398
399
400async def test_enrich_ignores_empty_chapters_payload() -> None:
401 """A document whose chapters all filter out leaves metadata.chapters as None."""
402 mass_episode = _parse(_episode())
403 assert mass_episode is not None
404 session = _FakeSession(payload={"chapters": []})
405 await enrich_episode_chapters(
406 session=cast("aiohttp.ClientSession", session),
407 chapters_json_url="https://example.com/ch.json",
408 mass_episode=mass_episode,
409 )
410 assert session.calls == 1
411 assert mass_episode.metadata.chapters is None
412
413
414# --- episode webpage, explicit flag, performers ----------------------------------------------
415
416
417def test_episode_link_maps_to_website_link() -> None:
418 """An item <link> becomes a WEBSITE link on the episode metadata."""
419 mass_episode = _parse(_episode(link="https://example.com/ep1"))
420 assert mass_episode is not None
421 assert mass_episode.metadata.links is not None
422 link = next(iter(mass_episode.metadata.links))
423 assert (link.type, link.url) == (LinkType.WEBSITE, "https://example.com/ep1")
424
425
426def test_episode_missing_link_leaves_links_unset() -> None:
427 """Without an item <link>, metadata.links stays None."""
428 mass_episode = _parse(_episode())
429 assert mass_episode is not None
430 assert mass_episode.metadata.links is None
431
432
433def test_episode_explicit_only_set_when_declared() -> None:
434 """The explicit flag is set from the feed value, and left untouched when absent."""
435 explicit_ep = _parse(_episode(explicit=True))
436 assert explicit_ep is not None
437 assert explicit_ep.metadata.explicit is True
438 not_explicit = _parse(_episode(explicit=False))
439 assert not_explicit is not None
440 assert not_explicit.metadata.explicit is False
441 # absent key must not coerce to False over the model default
442 unknown = _parse(_episode())
443 assert unknown is not None
444 assert unknown.metadata.explicit is None
445
446
447def test_episode_performers_from_persons() -> None:
448 """podcast:person entries land on metadata.performers as names."""
449 mass_episode = _parse(
450 _episode(persons=[{"name": "Jane Host", "role": "host"}, {"name": "Joe Guest"}])
451 )
452 assert mass_episode is not None
453 assert mass_episode.metadata.performers == {"Jane Host", "Joe Guest"}
454
455
456# --- parse_podcast_persons -------------------------------------------------------------------
457
458
459def test_parse_podcast_persons_dedupes_and_trims() -> None:
460 """Names are trimmed, de-duplicated case-insensitively, and order-preserved."""
461 persons = [{"name": " Alice "}, {"name": "alice"}, {"name": "Bob"}]
462 assert parse_podcast_persons(persons) == ["Alice", "Bob"]
463
464
465def test_parse_podcast_persons_skips_blank_and_malformed() -> None:
466 """Blank names, missing names, and non-dict/non-str entries are skipped."""
467 persons = [{"name": ""}, {"role": "host"}, {"name": 123}, "Carol", 7]
468 assert parse_podcast_persons(persons) == ["Carol"]
469
470
471def test_parse_podcast_persons_non_list_returns_empty() -> None:
472 """Any non-list input yields no names, so callers need not guard."""
473 assert parse_podcast_persons(None) == []
474 assert parse_podcast_persons("nope") == []
475
476
477# --- find_episode_stream_url -----------------------------------------------------------------
478
479
480def test_find_episode_stream_url_matches_guid() -> None:
481 """An episode with a usable guid is found by that guid."""
482 feed = {"episodes": [_episode(guid="ep-1"), _episode(guid="ep-2", enclosures=[{"url": "b"}])]}
483 assert find_episode_stream_url(parsed_feed=feed, guid_or_stream_url="ep-2") == "b"
484
485
486def test_find_episode_stream_url_falls_back_to_stream_url() -> None:
487 """A guid containing a space is unusable as an id, so the stream url identifies it."""
488 feed = {"episodes": [_episode(guid="not a guid")]}
489 assert (
490 find_episode_stream_url(parsed_feed=feed, guid_or_stream_url="https://example.com/ep1.mp3")
491 == "https://example.com/ep1.mp3"
492 )
493 # the unusable guid must not match
494 assert find_episode_stream_url(parsed_feed=feed, guid_or_stream_url="not a guid") is None
495
496
497def test_find_episode_stream_url_skips_episodes_without_enclosure() -> None:
498 """An episode without a playable enclosure does not stop the search."""
499 feed = {"episodes": [{"title": "no audio"}, _episode(guid="ep-2", enclosures=[{"url": "b"}])]}
500 assert find_episode_stream_url(parsed_feed=feed, guid_or_stream_url="ep-2") == "b"
501
502
503def test_find_episode_stream_url_unknown_returns_none() -> None:
504 """An unknown identifier yields None rather than raising."""
505 assert find_episode_stream_url(parsed_feed={"episodes": []}, guid_or_stream_url="x") is None
506
507
508# --- feed retrieval and caching ----------------------------------------------------------------
509
510
511FEED_URL = "https://example.com/feed.xml"
512FEED_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
513<rss version="2.0"><channel>
514<title>Feed One</title>
515<item>
516<title>Episode 1</title>
517<guid>ep-1</guid>
518<enclosure url="https://example.com/ep1.mp3" type="audio/mpeg" length="1"/>
519</item>
520</channel></rss>
521"""
522
523
524class _FakeFeedResponse:
525 """Minimal stand-in for an aiohttp response yielding raw feed bytes."""
526
527 def __init__(self, body: bytes) -> None:
528 self._body = body
529
530 async def read(self) -> bytes:
531 return self._body
532
533
534class _FakeFeedGetContext:
535 """Request context manager recording whether the response was released again."""
536
537 def __init__(self, session: _FakeFeedSession) -> None:
538 self._session = session
539
540 async def __aenter__(self) -> _FakeFeedResponse:
541 error = self._session.errors.pop(0) if self._session.errors else None
542 if error is not None:
543 raise error
544 return _FakeFeedResponse(self._session.body)
545
546 async def __aexit__(self, *exc_info: object) -> bool:
547 self._session.released += 1
548 return False
549
550
551class _FakeFeedSession:
552 """Session stand-in serving a fixed feed body, optionally failing the first attempts."""
553
554 def __init__(self, *, body: bytes = FEED_XML, errors: list[Exception] | None = None) -> None:
555 self.body = body
556 self.errors = errors or []
557 self.calls = 0
558 self.released = 0
559 self.headers: list[dict[str, str]] = []
560
561 def get(self, url: str, headers: dict[str, str], **kwargs: Any) -> _FakeFeedGetContext:
562 self.calls += 1
563 self.headers.append(headers)
564 return _FakeFeedGetContext(self)
565
566
567class _FakeCache:
568 """In-memory stand-in for the cache controller, keyed like the real one."""
569
570 def __init__(self) -> None:
571 self.store: dict[tuple[str, str, int], Any] = {}
572 self.sets = 0
573
574 async def get(self, key: str, provider: str, category: int, default: Any = None) -> Any:
575 return self.store.get((key, provider, category), default)
576
577 async def set(self, key: str, provider: str, category: int, data: Any, expiration: int) -> None:
578 self.sets += 1
579 self.store[(key, provider, category)] = data
580
581
582class _FakeMass:
583 """Stand-in exposing only what the podcast cache helpers use."""
584
585 def __init__(self, session: _FakeFeedSession) -> None:
586 self.http_session = session
587 self.cache = _FakeCache()
588
589
590def _fake_mass(session: _FakeFeedSession) -> MusicAssistant:
591 return cast("MusicAssistant", _FakeMass(session))
592
593
594async def test_get_podcastparser_dict_releases_the_response() -> None:
595 """The feed response is released again, on the retry path as well."""
596 session = _FakeFeedSession(errors=[ClientError("no user agent allowed")])
597 parsed_feed = await get_podcastparser_dict(
598 session=cast("aiohttp.ClientSession", session), feed_url=FEED_URL
599 )
600 assert parsed_feed["title"] == "Feed One"
601 # the first attempt failed on entering the context, so only the second one is released
602 assert session.calls == 2
603 assert session.released == 1
604 assert session.headers[0] == {"User-Agent": "Mozilla/5.0"}
605
606
607async def test_get_cached_podcast_stores_and_reuses_the_feed() -> None:
608 """A miss retrieves and caches the feed, a subsequent call is served from the cache."""
609 session = _FakeFeedSession()
610 mass = _fake_mass(session)
611 parsed_feed = await get_cached_podcast(
612 mass=mass, provider_instance_id="podcastfeed--test", feed_url=FEED_URL
613 )
614 assert parsed_feed["title"] == "Feed One"
615 assert session.calls == 1
616 await get_cached_podcast(mass=mass, provider_instance_id="podcastfeed--test", feed_url=FEED_URL)
617 assert session.calls == 1
618
619
620async def test_refresh_cached_podcast_always_updates_the_cache() -> None:
621 """A sync must refresh the cached feed, also when a valid entry exists."""
622 session = _FakeFeedSession()
623 mass = _fake_mass(session)
624 await get_cached_podcast(mass=mass, provider_instance_id="podcastfeed--test", feed_url=FEED_URL)
625 assert session.calls == 1
626 session.body = FEED_XML.replace(b"Feed One", b"Feed Renamed")
627 parsed_feed = await refresh_cached_podcast(
628 mass=mass, provider_instance_id="podcastfeed--test", feed_url=FEED_URL
629 )
630 assert session.calls == 2
631 assert parsed_feed["title"] == "Feed Renamed"
632 # the refreshed feed is what subsequent (cached) reads see
633 cached_feed = await get_cached_podcast(
634 mass=mass, provider_instance_id="podcastfeed--test", feed_url=FEED_URL
635 )
636 assert cached_feed["title"] == "Feed Renamed"
637 assert session.calls == 2
638
639
640def test_find_episode_stream_url_matches_empty_guid() -> None:
641 """An empty guid is used as episode id by the parser, so it must resolve as one."""
642 feed = {"episodes": [_episode(guid=""), _episode(guid="ep-2", enclosures=[{"url": "b"}])]}
643 assert find_episode_stream_url(parsed_feed=feed, guid_or_stream_url="") == (
644 "https://example.com/ep1.mp3"
645 )
646