/
/
/
1"""ARD Audiotek Music Provider for Music Assistant."""
2
3from __future__ import annotations
4
5from collections.abc import AsyncGenerator, Sequence
6from datetime import datetime
7from typing import TYPE_CHECKING, Any
8
9from gql import Client
10from gql.transport.aiohttp import AIOHTTPTransport
11from music_assistant_models.config_entries import ConfigEntry
12from music_assistant_models.enums import (
13 ConfigEntryType,
14 ContentType,
15 ImageType,
16 LinkType,
17 MediaType,
18 ProviderFeature,
19 StreamType,
20)
21from music_assistant_models.errors import LoginFailed, MediaNotFoundError, UnplayableMediaError
22from music_assistant_models.media_items import (
23 AudioFormat,
24 BrowseFolder,
25 ItemMapping,
26 MediaItemImage,
27 MediaItemLink,
28 MediaItemType,
29 Podcast,
30 PodcastEpisode,
31 ProviderMapping,
32 Radio,
33 SearchResults,
34)
35from music_assistant_models.streamdetails import StreamDetails
36
37from music_assistant.constants import CONF_PASSWORD
38from music_assistant.controllers.cache import use_cache
39from music_assistant.helpers.datetime import from_utc_timestamp, future_timestamp, utc
40from music_assistant.models.music_provider import MusicProvider
41from music_assistant.providers.ard_audiothek.database_queries import (
42 get_history_query,
43 get_subscriptions_query,
44 livestream_query,
45 organizations_query,
46 publication_services_query,
47 publications_list_query,
48 search_radios_query,
49 search_shows_query,
50 show_episode_query,
51 show_length_query,
52 show_query,
53 update_history_entry,
54)
55
56if TYPE_CHECKING:
57 from aiohttp import ClientSession
58 from music_assistant_models.config_entries import ProviderConfig
59 from music_assistant_models.provider import ProviderManifest
60
61 from music_assistant.mass import MusicAssistant
62 from music_assistant.models import ProviderInstanceType
63
64# Config for login
65CONF_EMAIL = "email"
66CONF_TOKEN_BEARER = "token"
67CONF_EXPIRY_TIME = "token_expiry"
68CONF_USERID = "user_id"
69CONF_DISPLAY_NAME = "display_name"
70
71# Constants for config actions
72CONF_ACTION_AUTH = "authenticate"
73CONF_ACTION_CLEAR_AUTH = "clear_auth"
74
75# General config
76CONF_MAX_BITRATE = "max_num_episodes"
77CONF_PODCAST_FINISHED = "podcast_finished_time"
78
79IDENTITY_TOOLKIT_BASE_URL = "https://identitytoolkit.googleapis.com/v1/accounts"
80IDENTITY_TOOLKIT_TOKEN = "AIzaSyCEvA_fVGNMRcS9F-Ubaaa0y0qBDUMlh90"
81ARD_ACCOUNTS_URL = "https://accounts.ard.de"
82ARD_AUDIOTHEK_GRAPHQL = "https://api.ardaudiothek.de/graphql"
83
84SUPPORTED_FEATURES = {
85 ProviderFeature.BROWSE,
86 ProviderFeature.SEARCH,
87 ProviderFeature.LIBRARY_PODCASTS,
88}
89
90
91async def setup(
92 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
93) -> ProviderInstanceType:
94 """Initialize provider(instance) with given configuration."""
95 return ARDAudiothek(mass, manifest, config, SUPPORTED_FEATURES)
96
97
98async def _login(session: ClientSession, email: str, password: str) -> tuple[str, str, str]:
99 response = await session.post(
100 f"{IDENTITY_TOOLKIT_BASE_URL}:signInWithPassword?key={IDENTITY_TOOLKIT_TOKEN}",
101 headers={"User-Agent": "Music Assistant", "Origin": ARD_ACCOUNTS_URL},
102 json={
103 "returnSecureToken": True,
104 "email": email,
105 "password": password,
106 "clientType": "CLIENT_TYPE_WEB",
107 },
108 )
109 data = await response.json()
110 if "error" in data:
111 if data["error"]["message"] == "EMAIL_NOT_FOUND":
112 raise LoginFailed("Email address is not registered")
113 if data["error"]["message"] == "INVALID_PASSWORD":
114 raise LoginFailed("Password is wrong")
115 token = data["idToken"]
116 uid = data["localId"]
117
118 response = await session.post(
119 f"{IDENTITY_TOOLKIT_BASE_URL}:lookup?key={IDENTITY_TOOLKIT_TOKEN}",
120 headers={"User-Agent": "Music Assistant", "Origin": ARD_ACCOUNTS_URL},
121 json={
122 "idToken": token,
123 },
124 )
125 data = await response.json()
126 if "error" in data:
127 if data["error"]["message"] == "EMAIL_NOT_FOUND":
128 raise LoginFailed("Email address is not registered")
129 if data["error"]["message"] == "INVALID_PASSWORD":
130 raise LoginFailed("Password is wrong")
131
132 return token, uid, data["users"][0]["displayName"]
133
134
135def _create_aiohttptransport(headers: dict[str, str] | None = None) -> AIOHTTPTransport:
136 return AIOHTTPTransport(url=ARD_AUDIOTHEK_GRAPHQL, headers=headers, ssl=True)
137
138
139class ARDAudiothek(MusicProvider):
140 """ARD Audiothek Music provider."""
141
142 @property
143 def max_concurrent_streams(self) -> None:
144 """Allow unlimited concurrent upstream source streams."""
145 return None
146
147 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
148 """
149 Return the configuration (options) entries for the ARD Audiothek provider.
150
151 Credentials and the token/user-id/expiry stash are collected/persisted by the
152 setup flow; the options surface only shows who is signed in plus the tunables.
153 """
154 display_name = str(self.get_setup_value(CONF_DISPLAY_NAME) or "")
155 email = str(self.get_setup_value(CONF_EMAIL) or "")
156 return (
157 ConfigEntry(
158 key="label_text",
159 type=ConfigEntryType.LABEL,
160 translation_params=[display_name, email.replace("@", "(at)")],
161 hidden=not display_name,
162 ),
163 ConfigEntry(
164 key=CONF_MAX_BITRATE,
165 type=ConfigEntryType.INTEGER,
166 required=False,
167 default_value=0,
168 ),
169 ConfigEntry(
170 key=CONF_PODCAST_FINISHED,
171 type=ConfigEntryType.INTEGER,
172 required=False,
173 default_value=95,
174 ),
175 )
176
177 async def get_client(self) -> Client:
178 """
179 Wrap the client creation procedure to recreate client.
180
181 This happens when the token is expired or user credentials are updated.
182 """
183 _email = self.get_setup_value(CONF_EMAIL)
184 _password = self.get_setup_value(CONF_PASSWORD)
185 self.token = self.get_setup_value(CONF_TOKEN_BEARER)
186 self.user_id = self.get_setup_value(CONF_USERID)
187 self.token_expire = from_utc_timestamp(
188 float(str(self.get_setup_value(CONF_EXPIRY_TIME, 0)))
189 )
190
191 self.max_bitrate = int(float(str(self.config.get_value(CONF_MAX_BITRATE))))
192
193 if (
194 _email is not None
195 and _password is not None
196 and (self.token is None or self.user_id is None or self.token_expire < utc())
197 ):
198 self.token, self.user_id, _display_name = await _login(
199 self.mass.http_session, str(_email), str(_password)
200 )
201 self._update_setup_data(CONF_TOKEN_BEARER, self.token)
202 self._update_setup_data(CONF_USERID, self.user_id)
203 self._update_setup_data(CONF_DISPLAY_NAME, _display_name)
204 self._update_setup_data(CONF_EXPIRY_TIME, str(future_timestamp(hours=1)))
205 self._client_initialized = False
206
207 if not self._client_initialized:
208 headers = None
209 if self.token:
210 headers = {"Authorization": f"Bearer {self.token}"}
211
212 self._client = Client(
213 transport=_create_aiohttptransport(headers),
214 fetch_schema_from_transport=True,
215 )
216 self._client_initialized = True
217
218 return self._client
219
220 async def handle_async_init(self) -> None:
221 """Pass config values to client and initialize."""
222 self._client_initialized = False
223 await self.get_client()
224
225 async def _update_progress(self) -> None:
226 if not self.user_id:
227 self.remote_progress = {}
228 return
229
230 async with await self.get_client() as session:
231 get_history_query.variable_values = {"loginId": self.user_id}
232 result = (await session.execute(get_history_query))["allEndUsers"]["nodes"][0][
233 "history"
234 ]["nodes"]
235
236 new_progress = {} # type: dict[str, tuple[bool, float]]
237 time_limit = int(str(self.config.get_value(CONF_PODCAST_FINISHED)))
238 for x in result:
239 core_id = x["item"]["coreId"]
240 if core_id is None:
241 continue
242 duration = x["item"]["duration"]
243 if duration is None:
244 continue
245 progress = x["progress"]
246 time_limit_reached = (progress / duration) * 100 > time_limit
247 new_progress[core_id] = (time_limit_reached, progress)
248 self.remote_progress = new_progress
249
250 def _get_progress(self, episode_id: str) -> tuple[bool, int]:
251 if episode_id in self.remote_progress:
252 return self.remote_progress[episode_id][0], int(
253 self.remote_progress[episode_id][1] * 1000
254 )
255 return False, 0
256
257 async def get_resume_position(
258 self, item_id: str, media_type: MediaType
259 ) -> tuple[bool, int, datetime | None]:
260 """Return: finished, position_ms."""
261 assert media_type == MediaType.PODCAST_EPISODE
262 await self._update_progress()
263
264 return *self._get_progress(item_id), None
265
266 async def on_played(
267 self,
268 media_type: MediaType,
269 prov_item_id: str,
270 fully_played: bool,
271 position: int,
272 media_item: MediaItemType,
273 is_playing: bool = False,
274 ) -> None:
275 """Update progress."""
276 if not self.user_id:
277 return
278 if media_item is None or not isinstance(media_item, PodcastEpisode):
279 return
280 if media_type != MediaType.PODCAST_EPISODE:
281 return
282 async with await self.get_client() as session:
283 update_history_entry.variable_values = {"itemId": prov_item_id, "progress": position}
284 await session.execute(
285 update_history_entry,
286 )
287
288 @property
289 def is_streaming_provider(self) -> bool:
290 """Search and lookup always search remote."""
291 return True
292
293 @use_cache(3600 * 24 * 7) # cache for 7 days
294 async def search(
295 self,
296 search_query: str,
297 media_types: list[MediaType],
298 limit: int = 5,
299 ) -> SearchResults:
300 """
301 Perform search on musicprovider.
302
303 :param search_query: Search query.
304 :param media_types: A list of media_types to include.
305 :param limit: Number of items to return in the search (per type).
306 """
307 podcasts = []
308 radios = []
309
310 if MediaType.PODCAST in media_types:
311 async with await self.get_client() as session:
312 search_shows_query.variable_values = {"query": search_query, "limit": limit}
313 search_shows = (await session.execute(search_shows_query))["search"]["shows"][
314 "nodes"
315 ]
316
317 for element in search_shows:
318 podcasts += [
319 _parse_podcast(
320 self.domain,
321 self.instance_id,
322 element,
323 element["coreId"],
324 )
325 ]
326
327 if MediaType.RADIO in media_types:
328 async with await self.get_client() as session:
329 search_radios_query.variable_values = {
330 "filter": {"title": {"includesInsensitive": search_query}},
331 "first": limit,
332 }
333 search_radios = (await session.execute(search_radios_query))[
334 "permanentLivestreams"
335 ]["nodes"]
336
337 for element in search_radios:
338 radios += [
339 _parse_radio(
340 self.domain,
341 self.instance_id,
342 element,
343 element["coreId"],
344 )
345 ]
346
347 return SearchResults(podcasts=podcasts, radio=radios)
348
349 @use_cache(3600 * 24 * 7) # cache for 7 days
350 async def get_radio(self, prov_radio_id: str) -> Radio:
351 """Get full radio details by id."""
352 # Get full details of a single Radio station.
353 # Mandatory only if you reported LIBRARY_RADIOS in the supported_features.
354 async with await self.get_client() as session:
355 livestream_query.variable_values = {"coreId": prov_radio_id}
356 rad = (await session.execute(livestream_query))["permanentLivestreamByCoreId"]
357 if not rad:
358 raise MediaNotFoundError("Radio not found.")
359 return _parse_radio(
360 self.domain,
361 self.instance_id,
362 rad,
363 prov_radio_id,
364 )
365
366 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
367 """
368 Retrieve library/subscribed podcasts from the provider.
369
370 Minified podcast information is enough.
371 """
372 if not self.user_id:
373 return
374 async with await self.get_client() as session:
375 get_subscriptions_query.variable_values = {"loginId": self.user_id}
376 result = (await session.execute(get_subscriptions_query))["allEndUsers"]["nodes"][0][
377 "subscriptions"
378 ]["programSets"]["nodes"]
379 for show in result:
380 yield await self.get_podcast(show["subscribedProgramSet"]["coreId"])
381
382 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
383 """
384 Browse through the ARD Audiothek.
385
386 This supports browsing through Podcasts and Radio stations.
387 :param path: The path to browse, (e.g. provider_id://artists).
388 """
389 part_parts = path.split("://")[1].split("/")
390 organization = part_parts[0] if part_parts else ""
391 provider = part_parts[1] if len(part_parts) > 1 else ""
392 radio_station = part_parts[2] if len(part_parts) > 2 else ""
393
394 if not organization:
395 return await self.get_organizations(path)
396
397 if not provider:
398 # list radios for specific organization
399 return await self.get_publication_services(path, organization)
400
401 if not radio_station:
402 return await self.get_publications_list(provider)
403
404 return []
405
406 @use_cache(3600 * 24 * 7) # cache for 7 days
407 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
408 """Get podcast."""
409 async with await self.get_client() as session:
410 show_query.variable_values = {"showId": prov_podcast_id}
411 result = (await session.execute(show_query))["show"]
412 if not result:
413 raise MediaNotFoundError("Podcast not found.")
414
415 return _parse_podcast(
416 self.domain,
417 self.instance_id,
418 result,
419 prov_podcast_id,
420 )
421
422 async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
423 """Get podcast episodes."""
424 await self._update_progress()
425 depublished_filter = {"isPublished": {"equalTo": True}}
426 async with await self.get_client() as session:
427 show_length_query.variable_values = {
428 "showId": prov_podcast_id,
429 "filter": depublished_filter,
430 }
431 length = await session.execute(show_length_query)
432 length = length["show"]["items"]["totalCount"]
433 step_size = 128
434 for offset in range(0, length, step_size):
435 show_query.variable_values = {
436 "showId": prov_podcast_id,
437 "first": step_size,
438 "offset": offset,
439 "filter": depublished_filter,
440 }
441 result = (await session.execute(show_query))["show"]
442 for idx, episode in enumerate(result["items"]["nodes"]):
443 if len(episode["audioList"]) == 0:
444 continue
445 if episode["status"] == "DEPUBLISHED":
446 continue
447 episode_id = episode["coreId"]
448
449 progress = self._get_progress(episode_id)
450 yield _parse_podcast_episode(
451 self.domain,
452 self.instance_id,
453 episode,
454 episode_id,
455 result["title"],
456 offset + idx,
457 progress,
458 )
459
460 @use_cache(3600 * 24) # cache for 24 hours
461 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
462 """Get single podcast episode."""
463 await self._update_progress()
464 async with await self.get_client() as session:
465 show_episode_query.variable_values = {"coreId": prov_episode_id}
466 result = (await session.execute(show_episode_query))["itemByCoreId"]
467 if not result:
468 raise MediaNotFoundError("Podcast episode not found")
469 progress = self._get_progress(prov_episode_id)
470 return _parse_podcast_episode(
471 self.domain,
472 self.instance_id,
473 result,
474 result["showId"],
475 result["show"]["title"],
476 result["rowId"],
477 progress,
478 )
479
480 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
481 """Get streamdetails for a radio station."""
482 async with await self.get_client() as session:
483 if media_type == MediaType.RADIO:
484 livestream_query.variable_values = {"coreId": item_id}
485 result = (await session.execute(livestream_query))["permanentLivestreamByCoreId"]
486 seek = False
487 elif media_type == MediaType.PODCAST_EPISODE:
488 show_episode_query.variable_values = {"coreId": item_id}
489 result = (await session.execute(show_episode_query))["itemByCoreId"]
490 seek = True
491
492 streams = result["audioList"]
493 if len(streams) == 0:
494 raise MediaNotFoundError("No stream available.")
495
496 def filter_func(val: dict[str, Any]) -> bool:
497 if self.max_bitrate == 0:
498 return True
499 return int(val["audioBitrate"]) < self.max_bitrate
500
501 filtered_streams = list(filter(filter_func, streams))
502 if len(filtered_streams) == 0:
503 raise UnplayableMediaError("No stream exceeding the minimum bitrate available.")
504 selected_stream = max(filtered_streams, key=lambda x: x["audioBitrate"])
505
506 return StreamDetails(
507 provider=self.domain,
508 item_id=item_id,
509 audio_format=AudioFormat(
510 content_type=ContentType.try_parse(selected_stream["audioCodec"]),
511 ),
512 media_type=media_type,
513 stream_type=StreamType.HTTP,
514 path=fix_url(selected_stream["href"]),
515 can_seek=seek,
516 allow_seek=seek,
517 )
518
519 @use_cache(3600 * 24 * 7) # cache for 7 days
520 async def get_organizations(self, path: str) -> list[BrowseFolder]:
521 """Create a list of all available organizations."""
522 async with await self.get_client() as session:
523 result = (await session.execute(organizations_query))["organizations"]["nodes"]
524 organizations = []
525
526 for org in result:
527 if all(
528 b["coreId"] is None for b in org["publicationServicesByOrganizationName"]["nodes"]
529 ):
530 # No available station
531 continue
532 image = None
533 for pub in org["publicationServicesByOrganizationName"]["nodes"]:
534 pub_title = pub["title"].lower()
535 org_name = org["name"].lower()
536 org_title = org["title"].lower()
537 if pub_title in (org_name, org_title) or pub_title.replace(" ", "") == org_name:
538 image = create_media_image(self.domain, pub["imagesList"])
539 break
540 organizations += [
541 BrowseFolder(
542 item_id=org["coreId"],
543 provider=self.domain,
544 path=path + org["coreId"],
545 image=image,
546 name=org["title"],
547 )
548 ]
549
550 return organizations
551
552 @use_cache(3600 * 24 * 7) # cache for 7 days
553 async def get_publication_services(self, path: str, core_id: str) -> list[BrowseFolder]:
554 """Create a list of publications for a given organization."""
555 async with await self.get_client() as session:
556 publication_services_query.variable_values = {"coreId": core_id}
557 result = (await session.execute(publication_services_query))["organizationByCoreId"][
558 "publicationServicesByOrganizationName"
559 ]["nodes"]
560 publications = []
561
562 for pub in result:
563 if not pub["coreId"]:
564 continue
565 publications += [
566 BrowseFolder(
567 item_id=pub["coreId"],
568 provider=self.domain,
569 path=path + "/" + pub["coreId"],
570 image=create_media_image(self.domain, pub["imagesList"]),
571 name=pub["title"],
572 )
573 ]
574
575 return publications
576
577 @use_cache(3600 * 24 * 7) # cache for 7 days
578 async def get_publications_list(self, core_id: str) -> list[Radio | Podcast]:
579 """Create list of available radio stations and shows for a publication service."""
580 async with await self.get_client() as session:
581 publications_list_query.variable_values = {"coreId": core_id}
582 result = (await session.execute(publications_list_query))["publicationServiceByCoreId"]
583
584 publications = [] # type: list[Radio | Podcast]
585
586 if not result:
587 raise MediaNotFoundError("Publication service not found.")
588
589 for rad in result["permanentLivestreams"]["nodes"]:
590 if not rad["coreId"]:
591 continue
592
593 radio = _parse_radio(self.domain, self.instance_id, rad, rad["coreId"])
594
595 publications += [radio]
596
597 for pod in result["shows"]["nodes"]:
598 if not pod["coreId"]:
599 continue
600
601 podcast = _parse_podcast(
602 self.domain,
603 self.instance_id,
604 pod,
605 pod["coreId"],
606 )
607 publications += [podcast]
608
609 return publications
610
611
612def _parse_social_media(
613 homepage_url: str | None, social_media_accounts: list[dict[str, None | str]]
614) -> set[MediaItemLink]:
615 return_set = set()
616 if homepage_url:
617 return_set.add(MediaItemLink(type=LinkType.WEBSITE, url=homepage_url))
618 for entry in social_media_accounts:
619 if entry["url"]:
620 link_type = None
621 match entry["service"]:
622 case "FACEBOOK":
623 link_type = LinkType.FACEBOOK
624 case "INSTAGRAM":
625 link_type = LinkType.INSTAGRAM
626 case "TIKTOK":
627 link_type = LinkType.TIKTOK
628 if link_type:
629 return_set.add(MediaItemLink(type=link_type, url=entry["url"]))
630 return return_set
631
632
633def _parse_podcast(
634 domain: str,
635 instance_id: str,
636 podcast_query: dict[str, Any],
637 podcast_id: str,
638) -> Podcast:
639 podcast = Podcast(
640 name=podcast_query["title"],
641 item_id=podcast_id,
642 publisher=podcast_query["publicationService"]["title"],
643 provider=instance_id,
644 provider_mappings={
645 ProviderMapping(
646 item_id=podcast_id,
647 provider_domain=domain,
648 provider_instance=instance_id,
649 )
650 },
651 total_episodes=podcast_query["items"]["totalCount"],
652 )
653
654 podcast.metadata.links = _parse_social_media(
655 podcast_query["publicationService"]["homepageUrl"],
656 podcast_query["publicationService"]["socialMediaAccounts"],
657 )
658
659 podcast.metadata.description = podcast_query["synopsis"]
660 podcast.metadata.genres = {r["title"] for r in podcast_query["editorialCategoriesList"]}
661
662 podcast.metadata.add_image(create_media_image(domain, podcast_query["imagesList"]))
663
664 return podcast
665
666
667def _parse_radio(
668 domain: str,
669 instance_id: str,
670 radio_query: dict[str, Any],
671 radio_id: str,
672) -> Radio:
673 radio = Radio(
674 name=radio_query["title"],
675 item_id=radio_id,
676 provider=domain,
677 provider_mappings={
678 ProviderMapping(
679 item_id=radio_id,
680 provider_domain=domain,
681 provider_instance=instance_id,
682 )
683 },
684 )
685
686 radio.metadata.links = _parse_social_media(
687 radio_query["publicationService"]["homepageUrl"],
688 radio_query["publicationService"]["socialMediaAccounts"],
689 )
690
691 radio.metadata.description = radio_query["publicationService"]["synopsis"]
692 radio.metadata.genres = {radio_query["publicationService"]["genre"]}
693
694 radio.metadata.add_image(create_media_image(domain, radio_query["imagesList"]))
695
696 return radio
697
698
699def _parse_podcast_episode(
700 domain: str,
701 instance_id: str,
702 episode: dict[str, Any],
703 podcast_id: str,
704 podcast_title: str,
705 idx: int,
706 progress: tuple[bool, int],
707) -> PodcastEpisode:
708 podcast_episode = PodcastEpisode(
709 name=episode["title"],
710 duration=episode["duration"],
711 item_id=episode["coreId"],
712 provider=instance_id,
713 podcast=ItemMapping(
714 item_id=podcast_id,
715 provider=instance_id,
716 name=podcast_title,
717 media_type=MediaType.PODCAST,
718 ),
719 provider_mappings={
720 ProviderMapping(
721 item_id=episode["coreId"],
722 provider_domain=domain,
723 provider_instance=instance_id,
724 )
725 },
726 position=idx,
727 fully_played=progress[0],
728 resume_position_ms=progress[1],
729 )
730
731 podcast_episode.metadata.add_image(create_media_image(domain, episode["imagesList"]))
732 podcast_episode.metadata.description = episode["summary"]
733 return podcast_episode
734
735
736def create_media_image(domain: str, image_list: list[dict[str, str]]) -> MediaItemImage:
737 """Extract the image for hopefully all possible cases."""
738 image_url = ""
739 selected_img = image_list[0] if image_list else None
740 for img in image_list:
741 if img["aspectRatio"] == "1x1":
742 selected_img = img
743 break
744 if selected_img:
745 image_url = selected_img["url"].replace("{width}", str(selected_img["width"]))
746 return MediaItemImage(
747 type=ImageType.THUMB,
748 path=image_url,
749 provider=domain,
750 remotely_accessible=True,
751 )
752
753
754def fix_url(url: str) -> str:
755 """Fix some of the stream urls, which do not provide a protocol."""
756 if url.startswith("//"):
757 url = "https:" + url
758 return url
759