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