test_spotify_source.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer die Spotify-Quelle (SpotifySource).
  4. Testet die Spotify Web API Integration via spotipy sowie
  5. den Audio-Download via spotdl.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import os
  10. import subprocess
  11. from pathlib import Path
  12. from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
  13. import pytest
  14. from trixy_core.music.sources.base import SourceState, SourceType
  15. from trixy_core.music.track import Track, TrackMetadata, TrackState
  16. # =============================================================================
  17. # Hilfsfunktionen und Fixtures
  18. # =============================================================================
  19. def _spotify_api_response(items: list[dict] | None = None) -> dict:
  20. """Erzeugt eine Spotify-API-Antwort fuer search()."""
  21. if items is None:
  22. items = [_spotify_track_item()]
  23. return {"tracks": {"items": items}}
  24. def _spotify_track_item(
  25. track_id: str = "abc123",
  26. name: str = "Testlied",
  27. artist_name: str = "Testkuenstler",
  28. album_name: str = "Testalbum",
  29. duration_ms: int = 210000,
  30. popularity: int = 75,
  31. explicit: bool = False,
  32. release_date: str = "2024-03-15",
  33. track_number: int = 3,
  34. disc_number: int = 1,
  35. artwork_url: str = "https://img.spotify.com/cover.jpg",
  36. external_url: str = "https://open.spotify.com/track/abc123",
  37. uri: str = "spotify:track:abc123",
  38. preview_url: str = "https://p.scdn.co/preview.mp3",
  39. extra_artists: list[dict] | None = None,
  40. ) -> dict:
  41. """Erzeugt ein einzelnes Spotify-Track-Item."""
  42. artists = extra_artists or [{"name": artist_name}]
  43. return {
  44. "id": track_id,
  45. "name": name,
  46. "duration_ms": duration_ms,
  47. "popularity": popularity,
  48. "explicit": explicit,
  49. "track_number": track_number,
  50. "disc_number": disc_number,
  51. "uri": uri,
  52. "external_urls": {"spotify": external_url},
  53. "preview_url": preview_url,
  54. "artists": artists,
  55. "album": {
  56. "name": album_name,
  57. "release_date": release_date,
  58. "images": [{"url": artwork_url}],
  59. },
  60. }
  61. @pytest.fixture
  62. def spotify_source(tmp_path: Path):
  63. """Erstellt eine SpotifySource-Instanz fuer Tests."""
  64. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True):
  65. from plugins.music.sources.spotify import SpotifySource
  66. return SpotifySource(
  67. client_id="test_id",
  68. client_secret="test_secret",
  69. cache_dir=str(tmp_path / "spotify_cache"),
  70. max_cache_size_mb=50,
  71. max_results=10,
  72. market="DE",
  73. )
  74. @pytest.fixture
  75. def connected_source(tmp_path: Path):
  76. """Erstellt eine verbundene SpotifySource mit gemocktem API-Client."""
  77. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True):
  78. from plugins.music.sources.spotify import SpotifySource
  79. source = SpotifySource(
  80. client_id="test_id",
  81. client_secret="test_secret",
  82. cache_dir=str(tmp_path / "spotify_cache"),
  83. max_cache_size_mb=50,
  84. )
  85. # Zustand manuell setzen fuer Unit-Tests ohne echte Verbindung
  86. mock_sp = MagicMock()
  87. source._sp = mock_sp
  88. source._state = SourceState.AVAILABLE
  89. source._spotdl_available = True
  90. source._cache_dir.mkdir(parents=True, exist_ok=True)
  91. return source, mock_sp
  92. # =============================================================================
  93. # Konstruktor-Tests
  94. # =============================================================================
  95. class TestSpotifySourceKonstruktor:
  96. """Tests fuer den SpotifySource-Konstruktor."""
  97. def test_standardwerte(self, spotify_source):
  98. """Konstruktor setzt korrekte Standardwerte."""
  99. assert spotify_source.source_type == SourceType.SPOTIFY
  100. assert spotify_source.name == "Spotify"
  101. assert spotify_source._market == "DE"
  102. assert spotify_source._max_results == 10
  103. assert spotify_source._max_cache_size_mb == 50
  104. def test_quelltyp_ist_spotify(self, spotify_source):
  105. """source_type ist SourceType.SPOTIFY."""
  106. assert spotify_source.source_type == SourceType.SPOTIFY
  107. def test_cache_verzeichnis_als_path(self, tmp_path):
  108. """cache_dir wird als Path gespeichert."""
  109. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True):
  110. from plugins.music.sources.spotify import SpotifySource
  111. source = SpotifySource(cache_dir=str(tmp_path / "sp_cache"))
  112. assert isinstance(source._cache_dir, Path)
  113. def test_anfangszustand_unbekannt(self, spotify_source):
  114. """Anfangszustand ist UNKNOWN (von MusicSource geerbt)."""
  115. assert spotify_source.state == SourceState.UNKNOWN
  116. def test_kein_client_initial(self, spotify_source):
  117. """Kein Spotify-Client vor connect()."""
  118. assert spotify_source._sp is None
  119. assert spotify_source._spotdl_available is False
  120. # =============================================================================
  121. # connect()-Tests
  122. # =============================================================================
  123. class TestSpotifySourceConnect:
  124. """Tests fuer die connect()-Methode."""
  125. @pytest.mark.asyncio
  126. async def test_spotipy_nicht_verfuegbar(self, tmp_path):
  127. """connect() gibt False zurueck wenn spotipy nicht installiert."""
  128. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", False):
  129. from plugins.music.sources.spotify import SpotifySource
  130. source = SpotifySource(
  131. client_id="test", client_secret="test",
  132. cache_dir=str(tmp_path / "cache"),
  133. )
  134. result = await source.connect()
  135. assert result is False
  136. assert source.state == SourceState.UNAVAILABLE
  137. @pytest.mark.asyncio
  138. async def test_client_id_fehlt(self, tmp_path):
  139. """connect() gibt False zurueck bei fehlender client_id."""
  140. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True):
  141. from plugins.music.sources.spotify import SpotifySource
  142. source = SpotifySource(
  143. client_id="", client_secret="test_secret",
  144. cache_dir=str(tmp_path / "cache"),
  145. )
  146. result = await source.connect()
  147. assert result is False
  148. assert source.state == SourceState.UNAVAILABLE
  149. @pytest.mark.asyncio
  150. async def test_client_secret_fehlt(self, tmp_path):
  151. """connect() gibt False zurueck bei fehlendem client_secret."""
  152. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True):
  153. from plugins.music.sources.spotify import SpotifySource
  154. source = SpotifySource(
  155. client_id="test_id", client_secret="",
  156. cache_dir=str(tmp_path / "cache"),
  157. )
  158. result = await source.connect()
  159. assert result is False
  160. assert source.state == SourceState.UNAVAILABLE
  161. @pytest.mark.asyncio
  162. async def test_api_test_fehlgeschlagen(self, tmp_path):
  163. """connect() gibt False zurueck wenn API-Test fehlschlaegt."""
  164. import plugins.music.sources.spotify as spotify_mod
  165. mock_sp_instance = MagicMock()
  166. mock_sp_instance.search.side_effect = Exception("Ungueltige Credentials")
  167. mock_spotipy = MagicMock()
  168. mock_spotipy.Spotify.return_value = mock_sp_instance
  169. # spotipy und SpotifyClientCredentials muessen als Modul-Attribute existieren
  170. with patch.object(spotify_mod, "_SPOTIPY_AVAILABLE", True), \
  171. patch.object(spotify_mod, "spotipy", mock_spotipy, create=True), \
  172. patch.object(spotify_mod, "SpotifyClientCredentials", MagicMock(), create=True):
  173. source = spotify_mod.SpotifySource(
  174. client_id="bad_id", client_secret="bad_secret",
  175. cache_dir=str(tmp_path / "cache"),
  176. )
  177. result = await source.connect()
  178. assert result is False
  179. assert source.state == SourceState.ERROR
  180. assert source._sp is None
  181. @pytest.mark.asyncio
  182. async def test_erfolgreiche_verbindung_mit_spotdl(self, tmp_path):
  183. """connect() erfolgreich mit spotdl verfuegbar."""
  184. import plugins.music.sources.spotify as spotify_mod
  185. mock_sp_instance = MagicMock()
  186. mock_sp_instance.search.return_value = {"tracks": {"items": []}}
  187. mock_spotipy = MagicMock()
  188. mock_spotipy.Spotify.return_value = mock_sp_instance
  189. with patch.object(spotify_mod, "_SPOTIPY_AVAILABLE", True), \
  190. patch.object(spotify_mod, "spotipy", mock_spotipy, create=True), \
  191. patch.object(spotify_mod, "SpotifyClientCredentials", MagicMock(), create=True), \
  192. patch("plugins.music.sources.spotify.shutil.which", return_value="/usr/bin/spotdl"):
  193. source = spotify_mod.SpotifySource(
  194. client_id="valid_id", client_secret="valid_secret",
  195. cache_dir=str(tmp_path / "cache"),
  196. )
  197. result = await source.connect()
  198. assert result is True
  199. assert source.state == SourceState.AVAILABLE
  200. assert source._spotdl_available is True
  201. @pytest.mark.asyncio
  202. async def test_erfolgreiche_verbindung_ohne_spotdl(self, tmp_path):
  203. """connect() erfolgreich — Suche funktioniert, Download nicht."""
  204. import plugins.music.sources.spotify as spotify_mod
  205. mock_sp_instance = MagicMock()
  206. mock_sp_instance.search.return_value = {"tracks": {"items": []}}
  207. mock_spotipy = MagicMock()
  208. mock_spotipy.Spotify.return_value = mock_sp_instance
  209. with patch.object(spotify_mod, "_SPOTIPY_AVAILABLE", True), \
  210. patch.object(spotify_mod, "spotipy", mock_spotipy, create=True), \
  211. patch.object(spotify_mod, "SpotifyClientCredentials", MagicMock(), create=True), \
  212. patch("plugins.music.sources.spotify.shutil.which", return_value=None):
  213. source = spotify_mod.SpotifySource(
  214. client_id="valid_id", client_secret="valid_secret",
  215. cache_dir=str(tmp_path / "cache"),
  216. )
  217. result = await source.connect()
  218. assert result is True
  219. assert source.state == SourceState.AVAILABLE
  220. assert source._spotdl_available is False
  221. # =============================================================================
  222. # disconnect()-Tests
  223. # =============================================================================
  224. class TestSpotifySourceDisconnect:
  225. """Tests fuer die disconnect()-Methode."""
  226. @pytest.mark.asyncio
  227. async def test_disconnect_loescht_zustand(self, connected_source):
  228. """disconnect() setzt Client auf None und State auf UNAVAILABLE."""
  229. source, _ = connected_source
  230. assert source._sp is not None
  231. await source.disconnect()
  232. assert source._sp is None
  233. assert source.state == SourceState.UNAVAILABLE
  234. # =============================================================================
  235. # Properties-Tests
  236. # =============================================================================
  237. class TestSpotifySourceProperties:
  238. """Tests fuer has_api und has_download Properties."""
  239. def test_has_api_mit_client(self, connected_source):
  240. """has_api gibt True zurueck wenn Client vorhanden."""
  241. source, _ = connected_source
  242. assert source.has_api is True
  243. def test_has_api_ohne_client(self, spotify_source):
  244. """has_api gibt False zurueck ohne Client."""
  245. assert spotify_source.has_api is False
  246. def test_has_download_mit_spotdl(self, connected_source):
  247. """has_download gibt True zurueck wenn spotdl verfuegbar."""
  248. source, _ = connected_source
  249. assert source.has_download is True
  250. def test_has_download_ohne_spotdl(self, connected_source):
  251. """has_download gibt False zurueck ohne spotdl."""
  252. source, _ = connected_source
  253. source._spotdl_available = False
  254. assert source.has_download is False
  255. # =============================================================================
  256. # search()-Tests
  257. # =============================================================================
  258. class TestSpotifySourceSearch:
  259. """Tests fuer die search()-Methode."""
  260. @pytest.mark.asyncio
  261. async def test_suche_ohne_verbindung(self, spotify_source):
  262. """search() gibt leere Liste zurueck ohne Verbindung."""
  263. result = await spotify_source.search("Test")
  264. assert result == []
  265. @pytest.mark.asyncio
  266. async def test_suche_parst_api_antwort_korrekt(self, connected_source):
  267. """search() parst Spotify-API-Antwort mit allen Metadaten."""
  268. source, mock_sp = connected_source
  269. mock_sp.search.return_value = _spotify_api_response([
  270. _spotify_track_item(
  271. track_id="xyz789",
  272. name="Nachtlied",
  273. artist_name="Mondband",
  274. album_name="Sternenlicht",
  275. duration_ms=195000,
  276. artwork_url="https://img.spotify.com/mond.jpg",
  277. release_date="2023-07-20",
  278. track_number=5,
  279. popularity=88,
  280. explicit=True,
  281. ),
  282. ])
  283. results = await source.search("Nachtlied")
  284. assert len(results) == 1
  285. track = results[0]
  286. assert track.metadata.title == "Nachtlied"
  287. assert track.metadata.artist == "Mondband"
  288. assert track.metadata.album == "Sternenlicht"
  289. assert track.metadata.artwork_url == "https://img.spotify.com/mond.jpg"
  290. assert track.metadata.duration_ms == 195000
  291. assert track.metadata.year == 2023
  292. assert track.metadata.track_number == 5
  293. assert track.source_type == "spotify"
  294. assert track.source_id == "xyz789"
  295. assert track.extra["popularity"] == 88
  296. assert track.extra["explicit"] is True
  297. @pytest.mark.asyncio
  298. async def test_suche_mehrere_kuenstler(self, connected_source):
  299. """search() verbindet mehrere Kuenstler mit Komma."""
  300. source, mock_sp = connected_source
  301. mock_sp.search.return_value = _spotify_api_response([
  302. _spotify_track_item(
  303. extra_artists=[
  304. {"name": "Kuenstler A"},
  305. {"name": "Kuenstler B"},
  306. {"name": "Kuenstler C"},
  307. ],
  308. ),
  309. ])
  310. results = await source.search("Multi")
  311. assert len(results) == 1
  312. assert results[0].metadata.artist == "Kuenstler A, Kuenstler B, Kuenstler C"
  313. @pytest.mark.asyncio
  314. async def test_suche_leere_ergebnisse(self, connected_source):
  315. """search() gibt leere Liste bei keine Treffer zurueck."""
  316. source, mock_sp = connected_source
  317. mock_sp.search.return_value = {"tracks": {"items": []}}
  318. results = await source.search("GibtEsNicht")
  319. assert results == []
  320. @pytest.mark.asyncio
  321. async def test_suche_respektiert_limit(self, connected_source):
  322. """search() begrenzt Ergebnisse auf limit."""
  323. source, mock_sp = connected_source
  324. items = [_spotify_track_item(track_id=f"id_{i}", name=f"Track {i}") for i in range(10)]
  325. mock_sp.search.return_value = _spotify_api_response(items)
  326. results = await source.search("Track", limit=3)
  327. # Pruefe dass der API-Aufruf mit dem Limit erfolgt
  328. mock_sp.search.assert_called_once()
  329. call_kwargs = mock_sp.search.call_args
  330. assert call_kwargs[1].get("limit", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else None) is not None
  331. @pytest.mark.asyncio
  332. async def test_suche_fehlerbehandlung(self, connected_source):
  333. """search() gibt leere Liste bei Fehler zurueck."""
  334. source, mock_sp = connected_source
  335. mock_sp.search.side_effect = Exception("API-Fehler")
  336. results = await source.search("Test")
  337. assert results == []
  338. @pytest.mark.asyncio
  339. async def test_suche_ohne_tracks_key(self, connected_source):
  340. """search() behandelt fehlenden 'tracks' Key korrekt."""
  341. source, mock_sp = connected_source
  342. mock_sp.search.return_value = {}
  343. results = await source.search("Test")
  344. assert results == []
  345. @pytest.mark.asyncio
  346. async def test_suche_none_item_wird_uebersprungen(self, connected_source):
  347. """search() ueberspringt None-Items in der Ergebnisliste."""
  348. source, mock_sp = connected_source
  349. mock_sp.search.return_value = _spotify_api_response([
  350. None,
  351. _spotify_track_item(name="Gueltiger Track"),
  352. None,
  353. ])
  354. results = await source.search("Test")
  355. assert len(results) == 1
  356. assert results[0].metadata.title == "Gueltiger Track"
  357. @pytest.mark.asyncio
  358. async def test_suche_album_ohne_bilder(self, connected_source):
  359. """search() setzt leere artwork_url bei fehlendem Album-Bild."""
  360. source, mock_sp = connected_source
  361. item = _spotify_track_item()
  362. item["album"]["images"] = []
  363. mock_sp.search.return_value = _spotify_api_response([item])
  364. results = await source.search("Test")
  365. assert results[0].metadata.artwork_url == ""
  366. @pytest.mark.asyncio
  367. async def test_suche_release_date_parsing(self, connected_source):
  368. """search() parst Release-Datum korrekt (nur Jahr)."""
  369. source, mock_sp = connected_source
  370. item = _spotify_track_item(release_date="2020")
  371. mock_sp.search.return_value = _spotify_api_response([item])
  372. results = await source.search("Test")
  373. assert results[0].metadata.year == 2020
  374. @pytest.mark.asyncio
  375. async def test_suche_extra_felder(self, connected_source):
  376. """search() speichert spotify_uri, spotify_url und preview_url in extra."""
  377. source, mock_sp = connected_source
  378. mock_sp.search.return_value = _spotify_api_response([
  379. _spotify_track_item(
  380. uri="spotify:track:abc123",
  381. external_url="https://open.spotify.com/track/abc123",
  382. preview_url="https://p.scdn.co/preview.mp3",
  383. ),
  384. ])
  385. results = await source.search("Test")
  386. track = results[0]
  387. assert track.extra["spotify_uri"] == "spotify:track:abc123"
  388. assert track.extra["spotify_url"] == "https://open.spotify.com/track/abc123"
  389. assert track.extra["preview_url"] == "https://p.scdn.co/preview.mp3"
  390. @pytest.mark.asyncio
  391. async def test_suche_effektives_limit(self, connected_source):
  392. """search() nutzt min(limit, max_results) als effektives Limit."""
  393. source, mock_sp = connected_source
  394. source._max_results = 3
  395. mock_sp.search.return_value = _spotify_api_response([])
  396. await source.search("Test", limit=10)
  397. call_args = mock_sp.search.call_args
  398. # Pruefe dass limit=3 uebergeben wurde (min(10, 3) = 3)
  399. assert call_args[1].get("limit") == 3 or (len(call_args[0]) > 1 and call_args[0][1] == 3)
  400. # =============================================================================
  401. # prepare_track()-Tests
  402. # =============================================================================
  403. class TestSpotifySourcePrepareTrack:
  404. """Tests fuer die prepare_track()-Methode."""
  405. @pytest.mark.asyncio
  406. async def test_cache_treffer(self, connected_source):
  407. """prepare_track() gibt True zurueck bei Cache-Treffer."""
  408. source, _ = connected_source
  409. # Cache-Datei erstellen
  410. cache_file = source._cache_dir / "track_001.mp3"
  411. cache_file.write_bytes(b"\xff\xfb\x90\x00" * 100)
  412. track = Track(
  413. source_type="spotify",
  414. source_id="track_001",
  415. extra={"spotify_url": "https://open.spotify.com/track/track_001"},
  416. )
  417. result = await source.prepare_track(track)
  418. assert result is True
  419. assert track.path == cache_file
  420. assert track.state == TrackState.AVAILABLE
  421. @pytest.mark.asyncio
  422. async def test_spotdl_nicht_verfuegbar(self, connected_source):
  423. """prepare_track() gibt False zurueck wenn spotdl fehlt."""
  424. source, _ = connected_source
  425. source._spotdl_available = False
  426. track = Track(
  427. source_type="spotify",
  428. source_id="track_001",
  429. extra={"spotify_url": "https://open.spotify.com/track/track_001"},
  430. )
  431. result = await source.prepare_track(track)
  432. assert result is False
  433. @pytest.mark.asyncio
  434. async def test_erfolgreicher_download(self, connected_source):
  435. """prepare_track() laedt Track via spotdl herunter."""
  436. source, _ = connected_source
  437. output_path = source._cache_dir / "dl_track.mp3"
  438. def fake_download(url, track_id):
  439. output_path.parent.mkdir(parents=True, exist_ok=True)
  440. target = source._cache_dir / f"{track_id}.mp3"
  441. target.write_bytes(b"\xff\xfb\x90\x00" * 100)
  442. return target
  443. with patch.object(source, "_download_sync", side_effect=fake_download):
  444. track = Track(
  445. source_type="spotify",
  446. source_id="dl_track",
  447. extra={"spotify_url": "https://open.spotify.com/track/dl_track"},
  448. )
  449. result = await source.prepare_track(track)
  450. assert result is True
  451. assert track.state == TrackState.AVAILABLE
  452. assert track.path is not None
  453. assert track.path.exists()
  454. @pytest.mark.asyncio
  455. async def test_download_fehlgeschlagen(self, connected_source):
  456. """prepare_track() setzt ERROR wenn Download fehlschlaegt."""
  457. source, _ = connected_source
  458. with patch.object(source, "_download_sync", return_value=None):
  459. track = Track(
  460. source_type="spotify",
  461. source_id="fail_track",
  462. extra={"spotify_url": "https://open.spotify.com/track/fail_track"},
  463. )
  464. result = await source.prepare_track(track)
  465. assert result is False
  466. assert track.state == TrackState.ERROR
  467. @pytest.mark.asyncio
  468. async def test_download_exception(self, connected_source):
  469. """prepare_track() behandelt Exceptions korrekt."""
  470. source, _ = connected_source
  471. with patch.object(source, "_download_sync", side_effect=Exception("Netzwerkfehler")):
  472. track = Track(
  473. source_type="spotify",
  474. source_id="exc_track",
  475. extra={"spotify_url": "https://open.spotify.com/track/exc_track"},
  476. )
  477. result = await source.prepare_track(track)
  478. assert result is False
  479. assert track.state == TrackState.ERROR
  480. assert "Netzwerkfehler" in track.error_message
  481. @pytest.mark.asyncio
  482. async def test_spotify_url_aus_extra(self, connected_source):
  483. """prepare_track() nutzt spotify_url aus extra-Feld."""
  484. source, _ = connected_source
  485. def fake_download(url, track_id):
  486. assert url == "https://open.spotify.com/track/url_track"
  487. target = source._cache_dir / f"{track_id}.mp3"
  488. target.write_bytes(b"\xff\xfb" * 50)
  489. return target
  490. with patch.object(source, "_download_sync", side_effect=fake_download):
  491. track = Track(
  492. source_type="spotify",
  493. source_id="url_track",
  494. extra={"spotify_url": "https://open.spotify.com/track/url_track"},
  495. )
  496. result = await source.prepare_track(track)
  497. assert result is True
  498. @pytest.mark.asyncio
  499. async def test_spotify_uri_konvertierung_zu_url(self, connected_source):
  500. """prepare_track() konvertiert spotify:track:ID zu URL."""
  501. source, _ = connected_source
  502. captured_url = {}
  503. def fake_download(url, track_id):
  504. captured_url["url"] = url
  505. target = source._cache_dir / f"{track_id}.mp3"
  506. target.write_bytes(b"\xff\xfb" * 50)
  507. return target
  508. with patch.object(source, "_download_sync", side_effect=fake_download):
  509. track = Track(
  510. source_type="spotify",
  511. source_id="uri_track",
  512. extra={
  513. "spotify_url": "",
  514. "spotify_uri": "spotify:track:URI_ABC123",
  515. },
  516. )
  517. result = await source.prepare_track(track)
  518. assert result is True
  519. assert captured_url["url"] == "https://open.spotify.com/track/URI_ABC123"
  520. @pytest.mark.asyncio
  521. async def test_keine_url_und_kein_uri(self, connected_source):
  522. """prepare_track() gibt False zurueck ohne URL/URI."""
  523. source, _ = connected_source
  524. track = Track(
  525. source_type="spotify",
  526. source_id="no_url_track",
  527. extra={},
  528. )
  529. result = await source.prepare_track(track)
  530. assert result is False
  531. # =============================================================================
  532. # _get_cached_path()-Tests
  533. # =============================================================================
  534. class TestSpotifySourceCachePath:
  535. """Tests fuer _get_cached_path()."""
  536. def test_cache_datei_gefunden(self, connected_source):
  537. """_get_cached_path() gibt Pfad zurueck wenn Datei existiert."""
  538. source, _ = connected_source
  539. cache_file = source._cache_dir / "found_track.mp3"
  540. cache_file.write_bytes(b"\xff\xfb" * 50)
  541. result = source._get_cached_path("found_track")
  542. assert result == cache_file
  543. def test_cache_datei_nicht_gefunden(self, connected_source):
  544. """_get_cached_path() gibt None zurueck wenn Datei fehlt."""
  545. source, _ = connected_source
  546. result = source._get_cached_path("missing_track")
  547. assert result is None
  548. def test_cache_verzeichnis_existiert_nicht(self, tmp_path):
  549. """_get_cached_path() gibt None zurueck wenn Verzeichnis fehlt."""
  550. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True):
  551. from plugins.music.sources.spotify import SpotifySource
  552. source = SpotifySource(cache_dir=str(tmp_path / "nicht_vorhanden"))
  553. result = source._get_cached_path("test")
  554. assert result is None
  555. # =============================================================================
  556. # cleanup_cache()-Tests
  557. # =============================================================================
  558. class TestSpotifySourceCleanupCache:
  559. """Tests fuer die cleanup_cache()-Methode."""
  560. @pytest.mark.asyncio
  561. async def test_unter_limit_keine_entfernung(self, connected_source):
  562. """cleanup_cache() entfernt nichts wenn Cache unter Limit."""
  563. source, _ = connected_source
  564. source._max_cache_size_mb = 100 # 100 MB Limit
  565. # Kleine Datei erstellen (weit unter 100 MB)
  566. (source._cache_dir / "small.mp3").write_bytes(b"\x00" * 1024)
  567. removed = await source.cleanup_cache()
  568. assert removed == 0
  569. assert (source._cache_dir / "small.mp3").exists()
  570. @pytest.mark.asyncio
  571. async def test_ueber_limit_entfernt_aelteste(self, connected_source):
  572. """cleanup_cache() entfernt aelteste Dateien bei Ueberschreitung."""
  573. source, _ = connected_source
  574. # Minimales Limit: 1 Byte (wird immer ueberschritten)
  575. source._max_cache_size_mb = 0
  576. import time
  577. f1 = source._cache_dir / "alt.mp3"
  578. f1.write_bytes(b"\x00" * 512)
  579. # Aeltere Modifikationszeit erzwingen
  580. os.utime(f1, (1000000, 1000000))
  581. f2 = source._cache_dir / "neu.mp3"
  582. f2.write_bytes(b"\x00" * 512)
  583. removed = await source.cleanup_cache()
  584. assert removed >= 1
  585. # Die aelteste Datei sollte entfernt werden
  586. assert not f1.exists()
  587. @pytest.mark.asyncio
  588. async def test_cache_verzeichnis_fehlt(self, tmp_path):
  589. """cleanup_cache() gibt 0 zurueck bei fehlendem Verzeichnis."""
  590. with patch("plugins.music.sources.spotify._SPOTIPY_AVAILABLE", True):
  591. from plugins.music.sources.spotify import SpotifySource
  592. source = SpotifySource(cache_dir=str(tmp_path / "nope"))
  593. removed = await source.cleanup_cache()
  594. assert removed == 0
  595. # =============================================================================
  596. # scan()-Tests
  597. # =============================================================================
  598. class TestSpotifySourceScan:
  599. """Tests fuer die scan()-Methode."""
  600. @pytest.mark.asyncio
  601. async def test_scan_gibt_null_zurueck(self, spotify_source):
  602. """scan() gibt immer 0 zurueck (kein Scanning bei Spotify)."""
  603. result = await spotify_source.scan()
  604. assert result == 0
  605. # =============================================================================
  606. # get_track()-Tests
  607. # =============================================================================
  608. class TestSpotifySourceGetTrack:
  609. """Tests fuer die get_track()-Methode."""
  610. @pytest.mark.asyncio
  611. async def test_track_aus_internem_cache(self, connected_source):
  612. """get_track() findet Track im internen _tracks-Dict."""
  613. source, _ = connected_source
  614. track = Track(
  615. id="test_id_123",
  616. source_type="spotify",
  617. source_id="sp_123",
  618. )
  619. source._tracks["test_id_123"] = track
  620. result = await source.get_track("test_id_123")
  621. assert result is track
  622. @pytest.mark.asyncio
  623. async def test_track_nicht_gefunden(self, connected_source):
  624. """get_track() gibt None zurueck bei unbekannter ID."""
  625. source, _ = connected_source
  626. result = await source.get_track("unbekannt")
  627. assert result is None
  628. # =============================================================================
  629. # get_metadata()-Tests
  630. # =============================================================================
  631. class TestSpotifySourceGetMetadata:
  632. """Tests fuer die get_metadata()-Methode."""
  633. @pytest.mark.asyncio
  634. async def test_metadaten_zurueckgeben(self, connected_source):
  635. """get_metadata() gibt Track-Metadaten zurueck."""
  636. source, _ = connected_source
  637. meta = TrackMetadata(title="Testlied", artist="Testband", album="Testalbum")
  638. track = Track(source_type="spotify", metadata=meta)
  639. result = await source.get_metadata(track)
  640. assert result is meta
  641. assert result.title == "Testlied"
  642. assert result.artist == "Testband"
  643. # =============================================================================
  644. # get_audio_stream()-Tests
  645. # =============================================================================
  646. class TestSpotifySourceAudioStream:
  647. """Tests fuer die get_audio_stream()-Methode."""
  648. @pytest.mark.asyncio
  649. async def test_stream_liefert_chunks(self, connected_source):
  650. """get_audio_stream() liefert Datei-Inhalt als Chunks."""
  651. source, _ = connected_source
  652. audio_data = b"\xff\xfb\x90\x00" * 20000 # ~80 KB
  653. audio_file = source._cache_dir / "stream_test.mp3"
  654. audio_file.write_bytes(audio_data)
  655. track = Track(source_type="spotify", path=audio_file)
  656. chunks = []
  657. async for chunk in source.get_audio_stream(track):
  658. chunks.append(chunk)
  659. combined = b"".join(chunks)
  660. assert combined == audio_data
  661. @pytest.mark.asyncio
  662. async def test_stream_ohne_pfad(self, connected_source):
  663. """get_audio_stream() liefert nichts ohne gueltige Datei."""
  664. source, _ = connected_source
  665. track = Track(source_type="spotify", path=None)
  666. chunks = []
  667. async for chunk in source.get_audio_stream(track):
  668. chunks.append(chunk)
  669. assert chunks == []
  670. @pytest.mark.asyncio
  671. async def test_stream_datei_nicht_vorhanden(self, connected_source):
  672. """get_audio_stream() liefert nichts bei nicht existierender Datei."""
  673. source, _ = connected_source
  674. track = Track(
  675. source_type="spotify",
  676. path=source._cache_dir / "nicht_da.mp3",
  677. )
  678. chunks = []
  679. async for chunk in source.get_audio_stream(track):
  680. chunks.append(chunk)
  681. assert chunks == []
  682. # =============================================================================
  683. # _download_sync()-Tests
  684. # =============================================================================
  685. class TestSpotifySourceDownloadSync:
  686. """Tests fuer die synchrone Download-Methode."""
  687. def test_erfolgreicher_download_direkt(self, connected_source):
  688. """_download_sync() gibt Pfad zurueck bei erfolgreichem spotdl-Lauf."""
  689. source, _ = connected_source
  690. output_path = source._cache_dir / "dl_123.mp3"
  691. def fake_run(cmd, **kwargs):
  692. # Simuliere spotdl: Datei anlegen
  693. output_path.write_bytes(b"\xff\xfb" * 100)
  694. return MagicMock(returncode=0, stderr=b"")
  695. with patch("subprocess.run", side_effect=fake_run):
  696. result = source._download_sync(
  697. "https://open.spotify.com/track/dl_123", "dl_123"
  698. )
  699. assert result == output_path
  700. def test_download_timeout(self, connected_source):
  701. """_download_sync() gibt None zurueck bei Timeout."""
  702. source, _ = connected_source
  703. with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("spotdl", 120)):
  704. result = source._download_sync(
  705. "https://open.spotify.com/track/timeout", "timeout"
  706. )
  707. assert result is None
  708. def test_download_spotdl_nicht_gefunden(self, connected_source):
  709. """_download_sync() gibt None zurueck wenn spotdl Binary fehlt."""
  710. source, _ = connected_source
  711. with patch("subprocess.run", side_effect=FileNotFoundError()):
  712. result = source._download_sync(
  713. "https://open.spotify.com/track/nope", "nope"
  714. )
  715. assert result is None
  716. def test_download_fallback_dateisuche(self, connected_source):
  717. """_download_sync() findet umbenannte Datei als Fallback."""
  718. source, _ = connected_source
  719. # spotdl legt Datei unter anderem Namen an
  720. renamed_file = source._cache_dir / "Artist - Title.mp3"
  721. renamed_file.write_bytes(b"\xff\xfb" * 100)
  722. def fake_run(cmd, **kwargs):
  723. # returncode 0 aber Datei unter erwartetem Namen existiert nicht
  724. return MagicMock(returncode=0, stderr=b"")
  725. with patch("subprocess.run", side_effect=fake_run):
  726. result = source._download_sync(
  727. "https://open.spotify.com/track/renamed", "renamed"
  728. )
  729. # Sollte die umbenannte Datei finden und umbenennen
  730. assert result is not None