"""Tests für das Moflix-Stream-Plugin. Mockt _get_json() auf Instance-Ebene um reale HTTP-Requests zu vermeiden. Testet u.a. den Cross-Invocation-Cache-Miss-Bug (leere Instanz ohne Vorsuche). """ import asyncio from addon.plugins.moflix_plugin import MoflixPlugin, GENRE_SLUGS, COLLECTION_SLUGS, _unpack_packer # --------------------------------------------------------------------------- # JSON-Fixtures (realistische Moflix-API-Antworten) # --------------------------------------------------------------------------- SEARCH_RESPONSE = { "results": [ { "id": "123", "name": "Breaking Bad", "is_series": True, "description": "Chemie-Lehrer wird Drogenboss.", "poster": "https://cdn.example.com/bb.jpg", "backdrop": "https://cdn.example.com/bb-bg.jpg", "model_type": "title", }, { "id": "456", "name": "Inception", "is_series": False, "description": "Ein Traum im Traum.", "poster": "https://cdn.example.com/inc.jpg", "backdrop": "https://cdn.example.com/inc-bg.jpg", "model_type": "title", }, # Personen-Eintrag – soll übersprungen werden {"id": "789", "name": "Christopher Nolan", "model_type": "person"}, ] } TITLE_RESPONSE_SERIES = { "title": { "id": "123", "name": "Breaking Bad", "description": "Chemie-Lehrer wird Drogenboss.", "poster": "https://cdn.example.com/bb.jpg", "backdrop": "https://cdn.example.com/bb-bg.jpg", "rating": 9.5, "release_date": "2008-01-20", }, "seasons": { "data": [ {"number": 2, "title_id": "1002"}, # absichtlich unsortiert {"number": 1, "title_id": "1001"}, ] }, } TITLE_RESPONSE_MOVIE = { "title": { "id": "456", "name": "Inception", "description": "Ein Traum im Traum.", "poster": "https://cdn.example.com/inc.jpg", "backdrop": "https://cdn.example.com/inc-bg.jpg", "rating": 8.8, "release_date": "2010-07-15", "videos": [ # gupload.xyz wird übersprungen (_VIDEO_SKIP_DOMAINS) {"quality": "1080p", "src": "https://gupload.xyz/data/e/deadbeef", "name": "Mirror 1"}, # vidara.to wird bevorzugt {"quality": "1080p", "src": "https://vidara.to/e/inc7testXYZ", "name": "Mirror 2"}, ], }, "seasons": {"data": []}, } EPISODES_RESPONSE = { "pagination": { "data": [ {"episode_number": 1, "name": "Pilot", "primary_video": {"id": 1}}, {"episode_number": 2, "name": "Cat's in the Bag", "primary_video": {"id": 2}}, # primary_video=None → überspringen {"episode_number": 3, "name": "Kein Video", "primary_video": None}, ] } } # Episoden-Detail-Response (für stream_link_for, enthält videos[] mit src-URLs) EPISODE_DETAIL_RESPONSE = { "episode": { "videos": [ # gupload.xyz wird übersprungen {"quality": "1080p", "src": "https://gupload.xyz/data/e/ep1hash", "name": "Mirror 1"}, # vidara.to wird bevorzugt → dieser src wird zurückgegeben {"quality": "1080p", "src": "https://vidara.to/e/ep1vidara", "name": "Mirror 2"}, # YouTube → immer überspringen {"quality": None, "src": "https://youtube.com/watch?v=abc", "name": "Trailer"}, ] } } VIDARA_STREAM_RESPONSE = { "filecode": "ep1vidara", "streaming_url": "https://cdn.example.com/hls/ep1/master.m3u8", "subtitles": None, "thumbnail": "https://cdn.example.com/thumb.jpg", "title": "", } # Minimales HTML mit p.a.c.k.e.r.-obfuskiertem JS (VidHide-Format). # Packed-String kodiert: # var links={"hls2":"https://cdn.example.com/hls/test/master.m3u8"}; # jwplayer("vplayer").setup({sources:[{file:links.hls2,type:"hls"}]}); # mit base=36 und keywords: var|links|hls2|jwplayer|vplayer|setup|sources|file|type VIDHIDE_HTML = ( "" ) CHANNEL_RESPONSE = { "channel": { "content": { "data": [ { "id": "100", "name": "Squid Game", "is_series": True, "description": "Spiele.", "poster": "https://cdn.example.com/sq.jpg", "backdrop": "", }, { "id": "200", "name": "The Crown", "is_series": True, "description": "", "poster": "", "backdrop": "", }, ] } } } # --------------------------------------------------------------------------- # Hilfsfunktion: URL-basiertes Mock-Routing # --------------------------------------------------------------------------- def make_json_router(**routes): """Erzeugt eine _get_json-Mock, die URL-abhängig antwortet. Schlüssel = Substring der URL, Wert = zurückzugebende JSON-Daten. Reihenfolge: spezifischere Schlüssel zuerst übergeben (dict-Reihenfolge). """ def _router(url, headers=None): for key, response in routes.items(): if key in url: return response return None return _router # --------------------------------------------------------------------------- # Tests: search_titles # --------------------------------------------------------------------------- def test_search_titles_returns_names(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: SEARCH_RESPONSE) titles = asyncio.run(plugin.search_titles("breaking")) assert "Breaking Bad" in titles assert "Inception" in titles # Person-Eintrag darf nicht auftauchen assert "Christopher Nolan" not in titles def test_search_populates_cache(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: SEARCH_RESPONSE) asyncio.run(plugin.search_titles("breaking")) # URL-Cache assert "Breaking Bad" in plugin._title_to_url assert "/api/v1/titles/123" in plugin._title_to_url["Breaking Bad"] # is_series-Cache assert plugin._is_series["Breaking Bad"] is True assert plugin._is_series["Inception"] is False # Metadaten-Cache assert plugin._title_meta["Breaking Bad"][0] == "Chemie-Lehrer wird Drogenboss." assert plugin._title_meta["Inception"][1] == "https://cdn.example.com/inc.jpg" def test_search_empty_query_returns_empty(): plugin = MoflixPlugin() titles = asyncio.run(plugin.search_titles("")) assert titles == [] # --------------------------------------------------------------------------- # Tests: seasons_for # --------------------------------------------------------------------------- def test_seasons_for_series_after_search(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", make_json_router( search=SEARCH_RESPONSE, titles=TITLE_RESPONSE_SERIES, )) asyncio.run(plugin.search_titles("breaking")) seasons = plugin.seasons_for("Breaking Bad") # Staffeln korrekt sortiert assert seasons == ["Staffel 1", "Staffel 2"] def test_seasons_for_film_returns_film(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: SEARCH_RESPONSE) asyncio.run(plugin.search_titles("inception")) seasons = plugin.seasons_for("Inception") assert seasons == ["Film"] def test_seasons_for_caches_season_api_ids(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", make_json_router( search=SEARCH_RESPONSE, titles=TITLE_RESPONSE_SERIES, )) asyncio.run(plugin.search_titles("breaking")) plugin.seasons_for("Breaking Bad") assert plugin._season_api_ids[("Breaking Bad", 1)] == "1001" assert plugin._season_api_ids[("Breaking Bad", 2)] == "1002" def test_seasons_for_cache_miss_triggers_resolve(monkeypatch): """Bug-Regression: seasons_for() ohne Vorsuche (leere Instanz = Kodi-Neuaufruf). _resolve_title() muss automatisch eine Suche starten und den Cache befüllen. """ plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", make_json_router( search=SEARCH_RESPONSE, titles=TITLE_RESPONSE_SERIES, )) # KEIN asyncio.run(search_titles(...)) – simuliert leere Instanz seasons = plugin.seasons_for("Breaking Bad") assert seasons == ["Staffel 1", "Staffel 2"] def test_seasons_for_unknown_title_returns_empty(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: {"results": []}) seasons = plugin.seasons_for("Unbekannter Titel XYZ") assert seasons == [] # --------------------------------------------------------------------------- # Tests: episodes_for # --------------------------------------------------------------------------- def test_episodes_for_series(monkeypatch): plugin = MoflixPlugin() # "/titles/123" matcht nur die Titel-Detail-URL (id=123), nicht die Episoden-URL (id=1001) monkeypatch.setattr(plugin, "_get_json", make_json_router( **{"search": SEARCH_RESPONSE, "/titles/123": TITLE_RESPONSE_SERIES, "episodes": EPISODES_RESPONSE} )) asyncio.run(plugin.search_titles("breaking")) plugin.seasons_for("Breaking Bad") episodes = plugin.episodes_for("Breaking Bad", "Staffel 1") assert episodes == ["Episode 1 – Pilot", "Episode 2 – Cat's in the Bag"] # Episode ohne primary_video (Nr. 3) darf nicht enthalten sein assert len(episodes) == 2 def test_episodes_for_film_returns_title(): plugin = MoflixPlugin() result = plugin.episodes_for("Inception", "Film") assert result == ["Inception"] def test_episodes_cache_hit(monkeypatch): """Zweiter episodes_for()-Aufruf darf keine neuen _get_json-Calls auslösen.""" call_count = {"n": 0} def counting_router(url, headers=None): call_count["n"] += 1 return make_json_router( search=SEARCH_RESPONSE, titles=TITLE_RESPONSE_SERIES, episodes=EPISODES_RESPONSE, )(url) plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", counting_router) asyncio.run(plugin.search_titles("breaking")) plugin.seasons_for("Breaking Bad") plugin.episodes_for("Breaking Bad", "Staffel 1") calls_after_first = call_count["n"] # Zweiter Aufruf – kein neuer HTTP-Call plugin.episodes_for("Breaking Bad", "Staffel 1") assert call_count["n"] == calls_after_first # --------------------------------------------------------------------------- # Tests: stream_link_for # --------------------------------------------------------------------------- def test_stream_link_for_episode_returns_vidara_src(monkeypatch): """stream_link_for() für Episode gibt vidara.to-URL aus episode.videos[] zurück.""" plugin = MoflixPlugin() # Reihenfolge: spezifischere Keys zuerst # "episodes/1" matcht die Detail-URL .../episodes/1?... # "episodes" matcht die Listen-URL .../episodes?... monkeypatch.setattr(plugin, "_get_json", make_json_router( **{ "search": SEARCH_RESPONSE, "/titles/123": TITLE_RESPONSE_SERIES, "episodes/1": EPISODE_DETAIL_RESPONSE, "episodes": EPISODES_RESPONSE, } )) asyncio.run(plugin.search_titles("breaking")) plugin.seasons_for("Breaking Bad") plugin.episodes_for("Breaking Bad", "Staffel 1") link = plugin.stream_link_for("Breaking Bad", "Staffel 1", "Episode 1 – Pilot") # gupload.xyz wird übersprungen, vidara.to bevorzugt assert link == "https://vidara.to/e/ep1vidara" def test_stream_link_for_episode_cache_miss(monkeypatch): """stream_link_for() funktioniert auch ohne Vorsuche (leere Instanz).""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", make_json_router( **{ "search": SEARCH_RESPONSE, "/titles/123": TITLE_RESPONSE_SERIES, "episodes/1": EPISODE_DETAIL_RESPONSE, "episodes": EPISODES_RESPONSE, } )) link = plugin.stream_link_for("Breaking Bad", "Staffel 1", "Episode 1 – Pilot") assert link == "https://vidara.to/e/ep1vidara" def test_stream_link_for_movie(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", make_json_router( search=SEARCH_RESPONSE, titles=TITLE_RESPONSE_MOVIE, )) asyncio.run(plugin.search_titles("inception")) link = plugin.stream_link_for("Inception", "Film", "Inception") # gupload.xyz übersprungen, vidara.to bevorzugt assert link == "https://vidara.to/e/inc7testXYZ" def test_stream_link_for_movie_cache_miss(monkeypatch): """Film-Stream auch ohne Vorsuche (leere Instanz via _resolve_title).""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", make_json_router( search=SEARCH_RESPONSE, titles=TITLE_RESPONSE_MOVIE, )) link = plugin.stream_link_for("Inception", "Film", "Inception") assert link == "https://vidara.to/e/inc7testXYZ" # --------------------------------------------------------------------------- # Tests: _hosters_from_videos # --------------------------------------------------------------------------- def test_hosters_skips_gupload(): plugin = MoflixPlugin() videos = [ {"src": "https://gupload.xyz/data/e/hash", "name": "GUpload"}, {"src": "https://moflix-stream.link/e/abc", "name": "Mirror-HDCloud"}, ] hosters = plugin._hosters_from_videos(videos) assert "https://gupload.xyz/data/e/hash" not in hosters.values() assert "https://moflix-stream.link/e/abc" in hosters.values() def test_hosters_skips_youtube(): plugin = MoflixPlugin() videos = [ {"src": "https://youtube.com/watch?v=xyz", "name": "YouTube"}, {"src": "https://vidara.to/e/real123", "name": "Vidara"}, ] hosters = plugin._hosters_from_videos(videos) assert len(hosters) == 1 assert "https://vidara.to/e/real123" in hosters.values() def test_hosters_all_skipped_returns_empty(): plugin = MoflixPlugin() videos = [ {"src": "https://gupload.xyz/data/e/hash"}, {"src": "https://youtube.com/watch?v=xyz"}, ] assert plugin._hosters_from_videos(videos) == {} def test_hosters_empty_returns_empty(): plugin = MoflixPlugin() assert plugin._hosters_from_videos([]) == {} def test_available_hosters_for_returns_names(): plugin = MoflixPlugin() videos = [ {"src": "https://vidara.to/e/xyz", "name": "Vidara-720"}, {"src": "https://moflix-stream.click/e/abc", "name": "Mirror-HDCloud"}, ] # Mock _videos_for um direkt zu testen plugin._videos_for = lambda *a, **kw: videos # type: ignore[assignment] names = plugin.available_hosters_for("Test", "Film", "Test") assert len(names) == 2 # --------------------------------------------------------------------------- # Tests: resolve_stream_link / _resolve_vidara # --------------------------------------------------------------------------- def test_resolve_stream_link_vidara_returns_hls(monkeypatch): """resolve_stream_link() ruft vidara.to-API auf und gibt streaming_url zurück.""" plugin = MoflixPlugin() def mock_get_json(url, headers=None): if "vidara.to" in url: return VIDARA_STREAM_RESPONSE return None monkeypatch.setattr(plugin, "_get_json", mock_get_json) result = plugin.resolve_stream_link("https://vidara.to/e/ep1vidara") assert result == "https://cdn.example.com/hls/ep1/master.m3u8" def test_resolve_stream_link_vidara_api_fails_returns_none(monkeypatch): """Wenn vidara-API None zurückgibt und ResolveURL nicht klappt → None.""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: None) result = plugin.resolve_stream_link("https://vidara.to/e/broken123") # Weder vidara-API noch ResolveURL → None (kein unauflösbarer Link) assert result is None def test_resolve_stream_link_non_vidhide_tries_resolveurl(monkeypatch): """Für sonstige URLs wird ResolveURL aufgerufen; ohne Installation → None.""" plugin = MoflixPlugin() result = plugin.resolve_stream_link("https://moflix-stream.link/e/somefilm") # Ohne ResolveURL-Installation → None assert result is None # --------------------------------------------------------------------------- # Tests: Channel-Browse (popular, genre, collection) # --------------------------------------------------------------------------- def test_popular_series_returns_titles(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: CHANNEL_RESPONSE) titles = plugin.popular_series() assert titles == ["Squid Game", "The Crown"] # Cache muss befüllt sein assert "Squid Game" in plugin._title_to_url def test_channel_empty_response_returns_empty(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: None) assert plugin.popular_series() == [] assert plugin.new_titles() == [] def test_channel_malformed_response_returns_empty(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: {"channel": {}}) assert plugin.popular_series() == [] def test_titles_for_genre(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: CHANNEL_RESPONSE) titles = plugin.titles_for_genre("Action") assert "Squid Game" in titles def test_titles_for_unknown_genre_returns_empty(): plugin = MoflixPlugin() assert plugin.titles_for_genre("Unbekanntes Genre XYZ") == [] def test_titles_for_collection(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: CHANNEL_RESPONSE) titles = plugin.titles_for_collection("James Bond Collection") assert "Squid Game" in titles # --------------------------------------------------------------------------- # Tests: genres / collections / capabilities # --------------------------------------------------------------------------- def test_genres_returns_sorted_list(): plugin = MoflixPlugin() genres = plugin.genres() assert genres == sorted(GENRE_SLUGS.keys()) assert "Action" in genres assert "Horror" in genres def test_collections_returns_sorted_list(): plugin = MoflixPlugin() colls = plugin.collections() assert colls == sorted(COLLECTION_SLUGS.keys()) assert "James Bond Collection" in colls def test_capabilities(): plugin = MoflixPlugin() caps = plugin.capabilities() assert "popular_series" in caps assert "new_titles" in caps assert "genres" in caps assert "collections" in caps # --------------------------------------------------------------------------- # Tests: metadata_for # --------------------------------------------------------------------------- def test_metadata_from_cache(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: SEARCH_RESPONSE) asyncio.run(plugin.search_titles("breaking")) # Metadaten-Abruf darf jetzt keinen neuen HTTP-Call auslösen call_count = {"n": 0} def no_call(url, headers=None): call_count["n"] += 1 return None monkeypatch.setattr(plugin, "_get_json", no_call) info, art, _ = plugin.metadata_for("Breaking Bad") assert info.get("plot") == "Chemie-Lehrer wird Drogenboss." assert art.get("poster") == "https://cdn.example.com/bb.jpg" assert call_count["n"] == 0 # kein HTTP-Call def test_metadata_api_fallback(monkeypatch): """Metadaten werden via API geladen wenn nicht im Cache.""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", make_json_router( search=SEARCH_RESPONSE, titles=TITLE_RESPONSE_SERIES, )) asyncio.run(plugin.search_titles("breaking")) # Cache leeren um API-Fallback zu erzwingen plugin._title_meta.clear() info, art, _ = plugin.metadata_for("Breaking Bad") assert info.get("plot") == "Chemie-Lehrer wird Drogenboss." assert "year" in info assert info["year"] == "2008" def test_metadata_unknown_title_returns_empty(): plugin = MoflixPlugin() info, art, streams = plugin.metadata_for("Unbekannt") assert info == {"title": "Unbekannt"} assert art == {} assert streams is None # --------------------------------------------------------------------------- # Tests: _unpack_packer # --------------------------------------------------------------------------- def test_unpack_packer_basic(): """_unpack_packer() entpackt ein p.a.c.k.e.r.-Fragment korrekt.""" packed = ( "eval(function(p,a,c,k,e,d){return p}" "('0 1={\"2\":\"https://cdn.example.com/hls/test/master.m3u8\"};'," "36,3,'var|links|hls2'.split('|'),0,0))" ) result = _unpack_packer(packed) assert 'var links={"hls2":"https://cdn.example.com/hls/test/master.m3u8"}' in result def test_unpack_packer_preserves_url(): """URLs in String-Literalen werden durch den Unpacker nicht korrumpiert.""" packed = ( "eval(function(p,a,c,k,e,d){return p}" "('0 1={\"2\":\"https://cdn.example.com/hls/test/master.m3u8\"};'," "36,3,'var|links|hls2'.split('|'),0,0))" ) result = _unpack_packer(packed) assert "https://cdn.example.com/hls/test/master.m3u8" in result def test_unpack_packer_no_match_returns_input(): """Wenn kein p.a.c.k.e.r.-Muster gefunden wird, wird der Input unverändert zurückgegeben.""" raw = "var x = 1; console.log(x);" assert _unpack_packer(raw) == raw def test_unpack_packer_full_vidhide_fixture(): """Entpackt die VIDHIDE_HTML-Fixture und findet hls2-URL.""" result = _unpack_packer(VIDHIDE_HTML) assert '"hls2":"https://cdn.example.com/hls/test/master.m3u8"' in result assert "jwplayer" in result assert "links.hls2" in result # --------------------------------------------------------------------------- # Tests: _resolve_vidhide / resolve_stream_link (VidHide) # --------------------------------------------------------------------------- def test_resolve_vidhide_extracts_hls_url(monkeypatch): """_resolve_vidhide() gibt den hls2-Stream-Link mit Kodi-Header-Suffix zurück.""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_html", lambda url, headers=None, fresh_session=False: VIDHIDE_HTML) result = plugin._resolve_vidhide("https://moflix-stream.click/embed/kqocffe8ipcf") assert result is not None assert result.startswith("https://cdn.example.com/hls/test/master.m3u8|") assert "Referer=" in result assert "User-Agent=" in result def test_resolve_vidhide_no_packer_returns_none(monkeypatch): """_resolve_vidhide() gibt None zurück wenn kein p.a.c.k.e.r. in der Seite.""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_html", lambda url, headers=None, fresh_session=False: "no packer here") result = plugin._resolve_vidhide("https://moflix-stream.click/embed/abc") assert result is None def test_resolve_vidhide_html_fetch_fails_returns_none(monkeypatch): """_resolve_vidhide() gibt None zurück wenn _get_html() fehlschlägt.""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_html", lambda url, headers=None, fresh_session=False: None) result = plugin._resolve_vidhide("https://moflix-stream.click/embed/abc") assert result is None def test_resolve_stream_link_vidhide_returns_hls(monkeypatch): """resolve_stream_link() ruft _resolve_vidhide() auf und gibt HLS-URL mit Header-Suffix zurück.""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_html", lambda url, headers=None, fresh_session=False: VIDHIDE_HTML) result = plugin.resolve_stream_link("https://moflix-stream.click/embed/kqocffe8ipcf") assert result is not None assert result.startswith("https://cdn.example.com/hls/test/master.m3u8|") assert "Referer=" in result assert "User-Agent=" in result def test_resolve_stream_link_vidhide_fallback_on_failure(monkeypatch): """Wenn VidHide-Resolver fehlschlägt, wird None zurückgegeben (kein unauflösbarer Link).""" plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_html", lambda url, headers=None, fresh_session=False: None) result = plugin.resolve_stream_link("https://moflix-stream.click/embed/broken") # Kein VidHide-Ergebnis → None (Kodi zeigt "Kein Stream"-Dialog) assert result is None # --------------------------------------------------------------------------- # Tests: _best_src_from_videos – moflix-stream.click nicht mehr übersprungen # --------------------------------------------------------------------------- def test_hosters_vidhide_not_skipped(): """moflix-stream.click ist nicht mehr in _VIDEO_SKIP_DOMAINS.""" plugin = MoflixPlugin() videos = [ {"src": "https://moflix-stream.click/embed/abc123", "name": "Mirror-VidHide"}, ] hosters = plugin._hosters_from_videos(videos) assert "https://moflix-stream.click/embed/abc123" in hosters.values() def test_hosters_vidara_present(): """vidara.to wird korrekt als Hoster erkannt.""" plugin = MoflixPlugin() videos = [ {"src": "https://moflix-stream.click/embed/abc123", "name": "Mirror-VidHide"}, {"src": "https://vidara.to/e/xyz789", "name": "Vidara-720"}, ] hosters = plugin._hosters_from_videos(videos) assert len(hosters) == 2 assert "https://vidara.to/e/xyz789" in hosters.values() def test_stream_link_for_movie_vidhide_only(monkeypatch): """Film mit nur moflix-stream.click Mirror: stream_link_for() gibt VidHide-src zurück.""" plugin = MoflixPlugin() plugin._title_to_url["The Bluff"] = "https://moflix-stream.xyz/api/v1/titles/789?load=videos" plugin._is_series["The Bluff"] = False def mock_get_json(_url, _headers=None): return { "title": { "videos": [ {"quality": "1080p", "src": "https://moflix-stream.click/embed/kqocffe8ipcf", "name": "Mirror 1"}, ], }, } monkeypatch.setattr(plugin, "_get_json", mock_get_json) link = plugin.stream_link_for("The Bluff", "Film", "The Bluff") assert link == "https://moflix-stream.click/embed/kqocffe8ipcf"