test_radio_source.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer die Radio-Quelle (RadioSource) und RadioStation.
  4. Testet HTTP-Radio-Streaming mit ICY-Metadaten-Unterstuetzung
  5. sowie die Fuzzy-Suche ueber Radio-Sender.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. from pathlib import Path
  10. from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
  11. import pytest
  12. from trixy_core.music.sources.base import SourceState, SourceType, SourceInfo
  13. from trixy_core.music.track import Track, TrackMetadata, TrackState
  14. # =============================================================================
  15. # Hilfsfunktionen und Fixtures
  16. # =============================================================================
  17. def _make_station_dict(
  18. name: str = "TestRadio",
  19. url: str = "https://stream.testradio.de/live.mp3",
  20. genre: str = "Pop",
  21. country: str = "DE",
  22. description: str = "",
  23. ) -> dict[str, str]:
  24. """Erzeugt ein Sender-Dict fuer RadioSource-Konfiguration."""
  25. return {
  26. "name": name,
  27. "url": url,
  28. "genre": genre,
  29. "country": country,
  30. "description": description,
  31. }
  32. @pytest.fixture
  33. def radio_source():
  34. """Erstellt eine RadioSource-Instanz mit Standardwerten."""
  35. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  36. from plugins.music.sources.radio import RadioSource
  37. return RadioSource(
  38. favorites=[
  39. _make_station_dict("SWR3", "https://swr3.de/live.mp3", "Pop"),
  40. _make_station_dict("1LIVE", "https://1live.de/live.mp3", "Pop/Rock"),
  41. _make_station_dict("Fritz", "https://fritz.de/live.mp3", "Alternative", description="Berliner Jugendradio"),
  42. ],
  43. include_defaults=False,
  44. )
  45. @pytest.fixture
  46. async def connected_radio(radio_source):
  47. """Erstellt eine verbundene RadioSource."""
  48. await radio_source.connect()
  49. return radio_source
  50. # =============================================================================
  51. # RadioStation-Tests
  52. # =============================================================================
  53. class TestRadioStation:
  54. """Tests fuer die RadioStation-Datenklasse."""
  55. def test_erstellung(self):
  56. """RadioStation kann mit Name und URL erstellt werden."""
  57. from plugins.music.sources.radio import RadioStation
  58. station = RadioStation(name="TestFM", url="https://test.fm/stream.mp3")
  59. assert station.name == "TestFM"
  60. assert station.url == "https://test.fm/stream.mp3"
  61. assert station.genre == ""
  62. assert station.country == "DE"
  63. assert station.description == ""
  64. def test_erstellung_mit_allen_feldern(self):
  65. """RadioStation akzeptiert alle optionalen Felder."""
  66. from plugins.music.sources.radio import RadioStation
  67. station = RadioStation(
  68. name="RockFM",
  69. url="https://rock.fm/live.mp3",
  70. genre="Rock",
  71. country="AT",
  72. description="Oesterreichisches Rockradio",
  73. )
  74. assert station.genre == "Rock"
  75. assert station.country == "AT"
  76. assert station.description == "Oesterreichisches Rockradio"
  77. def test_to_track_konvertierung(self):
  78. """to_track() erzeugt korrektes Track-Objekt."""
  79. from plugins.music.sources.radio import RadioStation
  80. station = RadioStation(
  81. name="JazzFM",
  82. url="https://jazz.fm/stream.mp3",
  83. genre="Jazz",
  84. country="DE",
  85. description="Jazz und Swing",
  86. )
  87. track = station.to_track()
  88. assert isinstance(track, Track)
  89. assert track.uri == "radio:JazzFM"
  90. assert track.source_type == "radio"
  91. assert track.source_id == "JazzFM"
  92. assert track.state == TrackState.AVAILABLE
  93. def test_to_track_metadaten(self):
  94. """to_track() setzt korrekte Metadaten-Felder."""
  95. from plugins.music.sources.radio import RadioStation
  96. station = RadioStation(
  97. name="KlassikFM",
  98. url="https://klassik.fm/stream.mp3",
  99. genre="Klassik",
  100. )
  101. track = station.to_track()
  102. assert track.metadata.title == "KlassikFM"
  103. assert track.metadata.artist == "Live Radio"
  104. assert track.metadata.genre == "Klassik"
  105. assert track.metadata.duration_ms == 0 # Live = keine Dauer
  106. def test_to_track_custom_felder(self):
  107. """to_track() setzt stream_url und country in custom."""
  108. from plugins.music.sources.radio import RadioStation
  109. station = RadioStation(name="Test", url="https://test.de/s.mp3", country="CH")
  110. track = station.to_track()
  111. assert track.metadata.custom["country"] == "CH"
  112. assert track.metadata.custom["stream_url"] == "https://test.de/s.mp3"
  113. def test_to_track_extra_felder(self):
  114. """to_track() setzt is_live, genre, country und description in extra."""
  115. from plugins.music.sources.radio import RadioStation
  116. station = RadioStation(
  117. name="TestFM", url="https://test.fm/s.mp3",
  118. genre="Pop", country="DE", description="Testbeschreibung",
  119. )
  120. track = station.to_track()
  121. assert track.extra["stream_url"] == "https://test.fm/s.mp3"
  122. assert track.extra["is_live"] is True
  123. assert track.extra["genre"] == "Pop"
  124. assert track.extra["country"] == "DE"
  125. assert track.extra["description"] == "Testbeschreibung"
  126. # =============================================================================
  127. # connect()-Tests
  128. # =============================================================================
  129. class TestRadioSourceConnect:
  130. """Tests fuer die connect()-Methode."""
  131. @pytest.mark.asyncio
  132. async def test_aiohttp_nicht_verfuegbar(self):
  133. """connect() gibt False zurueck wenn aiohttp nicht installiert."""
  134. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", False):
  135. from plugins.music.sources.radio import RadioSource
  136. source = RadioSource()
  137. result = await source.connect()
  138. assert result is False
  139. assert source.state == SourceState.UNAVAILABLE
  140. @pytest.mark.asyncio
  141. async def test_mit_favoriten(self):
  142. """connect() laedt Favoriten-Sender korrekt."""
  143. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  144. from plugins.music.sources.radio import RadioSource
  145. source = RadioSource(
  146. favorites=[
  147. _make_station_dict("FM1", "https://fm1.de/s.mp3", "Pop"),
  148. _make_station_dict("FM2", "https://fm2.de/s.mp3", "Rock"),
  149. ],
  150. include_defaults=False,
  151. )
  152. result = await source.connect()
  153. assert result is True
  154. assert source.state == SourceState.AVAILABLE
  155. assert source.station_count == 2
  156. @pytest.mark.asyncio
  157. async def test_keine_favoriten_mit_defaults(self):
  158. """connect() laedt Standard-Sender wenn keine Favoriten."""
  159. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  160. from plugins.music.sources.radio import RadioSource
  161. source = RadioSource(favorites=[], include_defaults=True)
  162. result = await source.connect()
  163. assert result is True
  164. assert source.station_count == 13 # 13 Standard-Sender
  165. @pytest.mark.asyncio
  166. async def test_keine_favoriten_ohne_defaults(self):
  167. """connect() gibt False zurueck ohne Sender."""
  168. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  169. from plugins.music.sources.radio import RadioSource
  170. source = RadioSource(favorites=[], include_defaults=False)
  171. result = await source.connect()
  172. assert result is False
  173. assert source.state == SourceState.UNAVAILABLE
  174. @pytest.mark.asyncio
  175. async def test_ungueltige_favoriten_werden_uebersprungen(self):
  176. """connect() ueberspringt Favoriten ohne Name oder URL."""
  177. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  178. from plugins.music.sources.radio import RadioSource
  179. source = RadioSource(
  180. favorites=[
  181. {"name": "", "url": "https://no-name.de/s.mp3"},
  182. {"name": "NoURL", "url": ""},
  183. _make_station_dict("Gueltig", "https://ok.de/s.mp3"),
  184. ],
  185. include_defaults=False,
  186. )
  187. result = await source.connect()
  188. assert result is True
  189. assert source.station_count == 1
  190. @pytest.mark.asyncio
  191. async def test_connect_registriert_tracks(self):
  192. """connect() fuegt Tracks zum internen _tracks-Dict hinzu."""
  193. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  194. from plugins.music.sources.radio import RadioSource
  195. source = RadioSource(
  196. favorites=[_make_station_dict("TestFM", "https://test.fm/s.mp3")],
  197. include_defaults=False,
  198. )
  199. await source.connect()
  200. assert source.track_count == 1
  201. # =============================================================================
  202. # disconnect()-Tests
  203. # =============================================================================
  204. class TestRadioSourceDisconnect:
  205. """Tests fuer die disconnect()-Methode."""
  206. @pytest.mark.asyncio
  207. async def test_disconnect_loescht_zustand(self, connected_radio):
  208. """disconnect() loescht Sender, Tracks und Metadaten."""
  209. source = connected_radio
  210. assert source.station_count > 0
  211. await source.disconnect()
  212. assert source.station_count == 0
  213. assert source.track_count == 0
  214. assert source.current_metadata == {}
  215. assert source.state == SourceState.UNAVAILABLE
  216. # =============================================================================
  217. # Properties-Tests
  218. # =============================================================================
  219. class TestRadioSourceProperties:
  220. """Tests fuer station_count, stations und current_metadata."""
  221. @pytest.mark.asyncio
  222. async def test_station_count(self, connected_radio):
  223. """station_count gibt korrekte Anzahl zurueck."""
  224. assert connected_radio.station_count == 3
  225. @pytest.mark.asyncio
  226. async def test_stations_defensive_kopie(self, connected_radio):
  227. """stations gibt eine Kopie zurueck (keine Referenz)."""
  228. source = connected_radio
  229. stations = source.stations
  230. original_count = len(stations)
  231. stations.append(MagicMock()) # Aendern der Kopie
  232. assert len(source.stations) == original_count
  233. @pytest.mark.asyncio
  234. async def test_current_metadata_initial_leer(self, connected_radio):
  235. """current_metadata ist initial leer."""
  236. assert connected_radio.current_metadata == {}
  237. @pytest.mark.asyncio
  238. async def test_current_metadata_defensive_kopie(self, connected_radio):
  239. """current_metadata gibt eine Kopie zurueck."""
  240. source = connected_radio
  241. source._current_metadata = {"artist": "Test", "title": "Song"}
  242. meta = source.current_metadata
  243. meta["extra"] = "Hack"
  244. assert "extra" not in source.current_metadata
  245. # =============================================================================
  246. # search()-Tests
  247. # =============================================================================
  248. class TestRadioSourceSearch:
  249. """Tests fuer die search()-Methode."""
  250. @pytest.mark.asyncio
  251. async def test_exakter_name_match(self, connected_radio):
  252. """search() gibt Score 100 bei exaktem Namens-Match."""
  253. results = await connected_radio.search("SWR3")
  254. assert len(results) >= 1
  255. assert results[0].metadata.title == "SWR3"
  256. @pytest.mark.asyncio
  257. async def test_name_enthaelt_query(self, connected_radio):
  258. """search() findet Sender wenn Name den Query enthaelt."""
  259. results = await connected_radio.search("LIVE")
  260. assert len(results) >= 1
  261. found_names = [r.metadata.title for r in results]
  262. assert "1LIVE" in found_names
  263. @pytest.mark.asyncio
  264. async def test_genre_match(self, connected_radio):
  265. """search() findet Sender ueber Genre-Match."""
  266. results = await connected_radio.search("Alternative")
  267. assert len(results) >= 1
  268. found_names = [r.metadata.title for r in results]
  269. assert "Fritz" in found_names
  270. @pytest.mark.asyncio
  271. async def test_beschreibung_match(self, connected_radio):
  272. """search() findet Sender ueber Beschreibung."""
  273. results = await connected_radio.search("Berliner")
  274. assert len(results) >= 1
  275. found_names = [r.metadata.title for r in results]
  276. assert "Fritz" in found_names
  277. @pytest.mark.asyncio
  278. async def test_wort_teilmatch(self, connected_radio):
  279. """search() findet Sender bei Wort-Teilmatch im Namen."""
  280. results = await connected_radio.search("SWR Live")
  281. # "SWR" passt auf SWR3 (Wort-Match)
  282. found_names = [r.metadata.title for r in results]
  283. assert "SWR3" in found_names
  284. @pytest.mark.asyncio
  285. async def test_kein_match(self, connected_radio):
  286. """search() gibt leere Liste bei keinem Treffer zurueck."""
  287. results = await connected_radio.search("XYZGibtEsNicht")
  288. assert results == []
  289. @pytest.mark.asyncio
  290. async def test_respektiert_limit(self, connected_radio):
  291. """search() begrenzt Ergebnisse auf limit."""
  292. # Alle drei Sender haben "Pop" im Genre oder enthalten Teilmatch
  293. results = await connected_radio.search("Pop", limit=1)
  294. assert len(results) <= 1
  295. @pytest.mark.asyncio
  296. async def test_case_insensitive(self, connected_radio):
  297. """search() ist gross-/kleinschreibungsunabhaengig."""
  298. results_upper = await connected_radio.search("SWR3")
  299. results_lower = await connected_radio.search("swr3")
  300. assert len(results_upper) == len(results_lower)
  301. assert results_upper[0].metadata.title == results_lower[0].metadata.title
  302. @pytest.mark.asyncio
  303. async def test_sortierung_nach_score(self, connected_radio):
  304. """search() sortiert Ergebnisse nach Score (hoechster zuerst)."""
  305. # "Fritz" — exakter Name-Match = 100
  306. # Wenn wir nach "Fritz" suchen, sollte Fritz zuerst kommen
  307. results = await connected_radio.search("Fritz")
  308. assert len(results) >= 1
  309. assert results[0].metadata.title == "Fritz"
  310. @pytest.mark.asyncio
  311. async def test_suche_nicht_verbunden(self):
  312. """search() gibt leere Liste zurueck wenn nicht verbunden."""
  313. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  314. from plugins.music.sources.radio import RadioSource
  315. source = RadioSource(include_defaults=False)
  316. # Nicht verbunden (state = UNKNOWN)
  317. results = await source.search("SWR3")
  318. assert results == []
  319. @pytest.mark.asyncio
  320. async def test_score_prioritaet(self):
  321. """search() priorisiert exakten Name > Contains > Genre > Beschreibung."""
  322. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  323. from plugins.music.sources.radio import RadioSource
  324. source = RadioSource(
  325. favorites=[
  326. _make_station_dict("Jazz", "https://a.de/s.mp3", "Rock", description="Kein Jazz hier"),
  327. _make_station_dict("RockJazz", "https://b.de/s.mp3", "Jazz"),
  328. _make_station_dict("Pop", "https://c.de/s.mp3", "Pop", description="Jazz Abende"),
  329. ],
  330. include_defaults=False,
  331. )
  332. await source.connect()
  333. results = await source.search("Jazz")
  334. assert len(results) == 3
  335. # Exakt (Jazz) → Contains (RockJazz) → Genre (RockJazz hat Jazz-Genre) oder Beschreibung
  336. assert results[0].metadata.title == "Jazz" # Score 100
  337. # =============================================================================
  338. # prepare_track()-Tests
  339. # =============================================================================
  340. class TestRadioSourcePrepareTrack:
  341. """Tests fuer die prepare_track()-Methode."""
  342. @pytest.mark.asyncio
  343. async def test_mit_stream_url(self, connected_radio):
  344. """prepare_track() gibt True zurueck wenn stream_url vorhanden."""
  345. track = Track(
  346. source_type="radio",
  347. source_id="TestFM",
  348. extra={"stream_url": "https://stream.testfm.de/live.mp3"},
  349. )
  350. result = await connected_radio.prepare_track(track)
  351. assert result is True
  352. assert track.state == TrackState.AVAILABLE
  353. @pytest.mark.asyncio
  354. async def test_ohne_stream_url(self, connected_radio):
  355. """prepare_track() gibt False zurueck ohne stream_url."""
  356. track = Track(
  357. source_type="radio",
  358. source_id="NoStream",
  359. extra={},
  360. )
  361. result = await connected_radio.prepare_track(track)
  362. assert result is False
  363. assert track.state == TrackState.ERROR
  364. assert "Keine Stream-URL" in track.error_message
  365. @pytest.mark.asyncio
  366. async def test_leere_stream_url(self, connected_radio):
  367. """prepare_track() gibt False zurueck bei leerer stream_url."""
  368. track = Track(
  369. source_type="radio",
  370. source_id="EmptyURL",
  371. extra={"stream_url": ""},
  372. )
  373. result = await connected_radio.prepare_track(track)
  374. assert result is False
  375. assert track.state == TrackState.ERROR
  376. # =============================================================================
  377. # _parse_icy_metadata()-Tests
  378. # =============================================================================
  379. class TestRadioSourceIcyMetadata:
  380. """Tests fuer die ICY-Metadaten-Verarbeitung."""
  381. @pytest.mark.asyncio
  382. async def test_artist_title_format(self, connected_radio):
  383. """_parse_icy_metadata() parst 'Artist - Title' korrekt."""
  384. track = Track(source_type="radio", extra={"station_name": "TestFM"})
  385. connected_radio._parse_icy_metadata(
  386. "StreamTitle='Die Toten Hosen - Tage wie diese';", track
  387. )
  388. meta = connected_radio.current_metadata
  389. assert meta["artist"] == "Die Toten Hosen"
  390. assert meta["title"] == "Tage wie diese"
  391. @pytest.mark.asyncio
  392. async def test_nur_titel(self, connected_radio):
  393. """_parse_icy_metadata() setzt nur title wenn kein Separator."""
  394. track = Track(source_type="radio", extra={})
  395. connected_radio._parse_icy_metadata(
  396. "StreamTitle='Nachrichten um 12';", track
  397. )
  398. meta = connected_radio.current_metadata
  399. assert meta["artist"] == ""
  400. assert meta["title"] == "Nachrichten um 12"
  401. @pytest.mark.asyncio
  402. async def test_leerer_streamtitle(self, connected_radio):
  403. """_parse_icy_metadata() ignoriert leeren StreamTitle."""
  404. connected_radio._current_metadata = {"artist": "Alt", "title": "Vorher"}
  405. track = Track(source_type="radio", extra={})
  406. connected_radio._parse_icy_metadata("StreamTitle='';", track)
  407. # Metadaten sollten nicht aktualisiert werden
  408. assert connected_radio.current_metadata["artist"] == "Alt"
  409. @pytest.mark.asyncio
  410. async def test_kein_streamtitle_match(self, connected_radio):
  411. """_parse_icy_metadata() macht nichts ohne StreamTitle-Pattern."""
  412. connected_radio._current_metadata = {"artist": "Orig", "title": "Song"}
  413. track = Track(source_type="radio", extra={})
  414. connected_radio._parse_icy_metadata("SomeOtherData=value;", track)
  415. assert connected_radio.current_metadata["artist"] == "Orig"
  416. @pytest.mark.asyncio
  417. async def test_metadaten_werden_aktualisiert(self, connected_radio):
  418. """current_metadata wird nach parse aktualisiert."""
  419. assert connected_radio.current_metadata == {}
  420. track = Track(source_type="radio", extra={})
  421. connected_radio._parse_icy_metadata(
  422. "StreamTitle='Rammstein - Du Hast';", track
  423. )
  424. meta = connected_radio.current_metadata
  425. assert meta["artist"] == "Rammstein"
  426. assert meta["title"] == "Du Hast"
  427. @pytest.mark.asyncio
  428. async def test_track_custom_felder_aktualisiert(self, connected_radio):
  429. """_parse_icy_metadata() aktualisiert Track-Metadaten custom-Felder."""
  430. track = Track(
  431. source_type="radio",
  432. metadata=TrackMetadata(title="TestFM", custom={}),
  433. extra={"station_name": "TestFM"},
  434. )
  435. connected_radio._parse_icy_metadata(
  436. "StreamTitle='Seeed - Dickes B';", track
  437. )
  438. assert track.metadata.custom["now_playing_artist"] == "Seeed"
  439. assert track.metadata.custom["now_playing_title"] == "Dickes B"
  440. # =============================================================================
  441. # check_stream()-Tests
  442. # =============================================================================
  443. class TestRadioSourceCheckStream:
  444. """Tests fuer die check_stream()-Methode."""
  445. @pytest.mark.asyncio
  446. async def test_stream_erreichbar(self, connected_radio):
  447. """check_stream() gibt True zurueck bei HTTP 200."""
  448. import plugins.music.sources.radio as radio_mod
  449. mock_resp = MagicMock()
  450. mock_resp.status = 200
  451. # session.head(url) muss ein async context manager sein
  452. mock_head_ctx = AsyncMock()
  453. mock_head_ctx.__aenter__.return_value = mock_resp
  454. mock_head_ctx.__aexit__.return_value = False
  455. mock_session = MagicMock()
  456. mock_session.head.return_value = mock_head_ctx
  457. # ClientSession() muss ein async context manager sein
  458. mock_session_ctx = AsyncMock()
  459. mock_session_ctx.__aenter__.return_value = mock_session
  460. mock_session_ctx.__aexit__.return_value = False
  461. mock_aiohttp = MagicMock()
  462. mock_aiohttp.ClientTimeout = MagicMock()
  463. mock_aiohttp.ClientSession.return_value = mock_session_ctx
  464. with patch.object(radio_mod, "aiohttp", mock_aiohttp):
  465. result = await connected_radio.check_stream("https://stream.test.de/live.mp3")
  466. assert result is True
  467. @pytest.mark.asyncio
  468. async def test_stream_nicht_erreichbar(self, connected_radio):
  469. """check_stream() gibt False zurueck bei Verbindungsfehler."""
  470. import plugins.music.sources.radio as radio_mod
  471. # ClientSession als async context manager, head() wirft Exception
  472. mock_session = MagicMock()
  473. mock_session.head.side_effect = Exception("Connection refused")
  474. mock_session_ctx = AsyncMock()
  475. mock_session_ctx.__aenter__.return_value = mock_session
  476. mock_session_ctx.__aexit__.return_value = False
  477. mock_aiohttp = MagicMock()
  478. mock_aiohttp.ClientTimeout = MagicMock()
  479. mock_aiohttp.ClientSession.return_value = mock_session_ctx
  480. with patch.object(radio_mod, "aiohttp", mock_aiohttp):
  481. result = await connected_radio.check_stream("https://down.test.de/s.mp3")
  482. assert result is False
  483. @pytest.mark.asyncio
  484. async def test_check_stream_aiohttp_fehlt(self):
  485. """check_stream() gibt False zurueck ohne aiohttp."""
  486. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", False):
  487. from plugins.music.sources.radio import RadioSource
  488. source = RadioSource()
  489. result = await source.check_stream("https://test.de/s.mp3")
  490. assert result is False
  491. # =============================================================================
  492. # get_info()-Tests
  493. # =============================================================================
  494. class TestRadioSourceGetInfo:
  495. """Tests fuer die get_info()-Methode."""
  496. @pytest.mark.asyncio
  497. async def test_get_info_felder(self, connected_radio):
  498. """get_info() gibt korrekte SourceInfo zurueck.
  499. Hinweis: radio.py nutzt self._source_type / self._name statt
  500. self.source_type / self.name — bekannter Bug. Wir setzen die
  501. privaten Attribute damit get_info() nicht abstuerzt.
  502. """
  503. # Workaround fuer Bug: _source_type / _name statt source_type / name
  504. connected_radio._source_type = connected_radio.source_type
  505. connected_radio._name = connected_radio.name
  506. info = connected_radio.get_info()
  507. assert isinstance(info, SourceInfo)
  508. assert info.name == "Radio"
  509. assert info.state == SourceState.AVAILABLE
  510. assert info.track_count == 3
  511. assert info.supports_search is True
  512. assert info.supports_streaming is True
  513. assert info.supports_artwork is False
  514. assert "station_count" in info.metadata
  515. assert info.metadata["station_count"] == 3
  516. @pytest.mark.asyncio
  517. async def test_get_info_sender_liste(self, connected_radio):
  518. """get_info() enthaelt Sender-Namen in metadata."""
  519. # Workaround fuer Bug: _source_type / _name statt source_type / name
  520. connected_radio._source_type = connected_radio.source_type
  521. connected_radio._name = connected_radio.name
  522. info = connected_radio.get_info()
  523. station_names = info.metadata["stations"]
  524. assert "SWR3" in station_names
  525. assert "1LIVE" in station_names
  526. assert "Fritz" in station_names
  527. # =============================================================================
  528. # scan()-Tests
  529. # =============================================================================
  530. class TestRadioSourceScan:
  531. """Tests fuer die scan()-Methode."""
  532. @pytest.mark.asyncio
  533. async def test_scan_gibt_sender_anzahl(self, connected_radio):
  534. """scan() gibt Anzahl der Sender zurueck."""
  535. result = await connected_radio.scan()
  536. assert result == 3
  537. # =============================================================================
  538. # get_track()-Tests
  539. # =============================================================================
  540. class TestRadioSourceGetTrack:
  541. """Tests fuer die get_track()-Methode."""
  542. @pytest.mark.asyncio
  543. async def test_track_nach_id(self, connected_radio):
  544. """get_track() findet registrierten Track nach ID."""
  545. # Tracks werden beim connect() registriert
  546. tracks = connected_radio.tracks
  547. assert len(tracks) > 0
  548. first_track = tracks[0]
  549. result = await connected_radio.get_track(first_track.id)
  550. assert result is not None
  551. assert result.id == first_track.id
  552. @pytest.mark.asyncio
  553. async def test_track_nicht_gefunden(self, connected_radio):
  554. """get_track() gibt None zurueck bei unbekannter ID."""
  555. result = await connected_radio.get_track("unbekannte_id_xyz")
  556. assert result is None
  557. # =============================================================================
  558. # _DEFAULT_STATIONS-Tests
  559. # =============================================================================
  560. class TestDefaultStations:
  561. """Tests fuer die vordefinierten Standard-Sender."""
  562. def test_anzahl_default_sender(self):
  563. """_DEFAULT_STATIONS enthaelt 13 Sender."""
  564. from plugins.music.sources.radio import _DEFAULT_STATIONS
  565. assert len(_DEFAULT_STATIONS) == 13
  566. def test_default_sender_haben_name_und_url(self):
  567. """Alle Standard-Sender haben name und url."""
  568. from plugins.music.sources.radio import _DEFAULT_STATIONS
  569. for station in _DEFAULT_STATIONS:
  570. assert "name" in station and station["name"], f"Sender ohne Name: {station}"
  571. assert "url" in station and station["url"], f"Sender ohne URL: {station}"
  572. def test_bekannte_sender_vorhanden(self):
  573. """Wichtige deutsche Sender sind in den Defaults enthalten."""
  574. from plugins.music.sources.radio import _DEFAULT_STATIONS
  575. namen = [s["name"] for s in _DEFAULT_STATIONS]
  576. assert "SWR3" in namen
  577. assert "1LIVE" in namen
  578. assert "Deutschlandfunk" in namen
  579. assert "Klassik Radio" in namen
  580. assert "FluxFM" in namen
  581. def test_default_sender_haben_genre(self):
  582. """Alle Standard-Sender haben ein Genre."""
  583. from plugins.music.sources.radio import _DEFAULT_STATIONS
  584. for station in _DEFAULT_STATIONS:
  585. assert "genre" in station and station["genre"], f"Sender ohne Genre: {station['name']}"
  586. # =============================================================================
  587. # Konstruktor-Tests
  588. # =============================================================================
  589. class TestRadioSourceKonstruktor:
  590. """Tests fuer den RadioSource-Konstruktor."""
  591. def test_standardwerte(self):
  592. """Konstruktor setzt korrekte Standardwerte."""
  593. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  594. from plugins.music.sources.radio import RadioSource
  595. source = RadioSource()
  596. assert source.source_type == SourceType.URL
  597. assert source.name == "Radio"
  598. assert source._buffer_size == 16384
  599. assert source._icy_metadata is True
  600. assert source._include_defaults is True
  601. def test_benutzerdefinierte_werte(self):
  602. """Konstruktor akzeptiert benutzerdefinierte Parameter."""
  603. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  604. from plugins.music.sources.radio import RadioSource
  605. source = RadioSource(
  606. name="MeinRadio",
  607. buffer_size=32768,
  608. icy_metadata=False,
  609. include_defaults=False,
  610. )
  611. assert source.name == "MeinRadio"
  612. assert source._buffer_size == 32768
  613. assert source._icy_metadata is False
  614. assert source._include_defaults is False
  615. def test_anfangszustand(self):
  616. """Anfangszustand ist UNKNOWN."""
  617. with patch("plugins.music.sources.radio._AIOHTTP_AVAILABLE", True):
  618. from plugins.music.sources.radio import RadioSource
  619. source = RadioSource()
  620. assert source.state == SourceState.UNKNOWN
  621. assert source.station_count == 0
  622. assert source.current_metadata == {}