From: Jan Feil <11638228+jfeil@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:17:24 +0000 (+0200) Subject: Add ARD Audiothek provider (#2229) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=324967e95ff9582d52ddf8d95ca82081bf103d80;p=music-assistant-server.git Add ARD Audiothek provider (#2229) --- diff --git a/music_assistant/providers/ard_audiothek/__init__.py b/music_assistant/providers/ard_audiothek/__init__.py new file mode 100644 index 00000000..bd95f8f2 --- /dev/null +++ b/music_assistant/providers/ard_audiothek/__init__.py @@ -0,0 +1,822 @@ +"""ARD Audiotek Music Provider for Music Assistant.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Sequence +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from gql import Client +from gql.transport.aiohttp import AIOHTTPTransport +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ( + ConfigEntryType, + ContentType, + ImageType, + LinkType, + MediaType, + ProviderFeature, + StreamType, +) +from music_assistant_models.errors import LoginFailed, MediaNotFoundError, UnplayableMediaError +from music_assistant_models.media_items import ( + AudioFormat, + BrowseFolder, + ItemMapping, + MediaItemImage, + MediaItemLink, + MediaItemType, + Podcast, + PodcastEpisode, + ProviderMapping, + Radio, + SearchResults, +) +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.constants import CONF_PASSWORD +from music_assistant.controllers.cache import use_cache +from music_assistant.models.music_provider import MusicProvider +from music_assistant.providers.ard_audiothek.database_queries import ( + get_history_query, + get_subscriptions_query, + livestream_query, + organizations_query, + publication_services_query, + publications_list_query, + search_radios_query, + search_shows_query, + show_episode_query, + show_length_query, + show_query, + update_history_entry, +) + +if TYPE_CHECKING: + from aiohttp import ClientSession + from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + +# Config for login +CONF_EMAIL = "email" +CONF_TOKEN_BEARER = "token" +CONF_EXPIRY_TIME = "token_expiry" +CONF_USERID = "user_id" +CONF_DISPLAY_NAME = "display_name" + +# Constants for config actions +CONF_ACTION_AUTH = "authenticate" +CONF_ACTION_CLEAR_AUTH = "clear_auth" + +# General config +CONF_MAX_BITRATE = "max_num_episodes" +CONF_PODCAST_FINISHED = "podcast_finished_time" + +IDENTITY_TOOLKIT_BASE_URL = "https://identitytoolkit.googleapis.com/v1/accounts" +IDENTITY_TOOLKIT_TOKEN = "AIzaSyCEvA_fVGNMRcS9F-Ubaaa0y0qBDUMlh90" +ARD_ACCOUNTS_URL = "https://accounts.ard.de" +ARD_AUDIOTHEK_GRAPHQL = "https://api.ardaudiothek.de/graphql" + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return ARDAudiothek(mass, manifest, config) + + +async def _login(session: ClientSession, email: str, password: str) -> tuple[str, str, str]: + response = await session.post( + f"{IDENTITY_TOOLKIT_BASE_URL}:signInWithPassword?key={IDENTITY_TOOLKIT_TOKEN}", + headers={"User-Agent": "Music Assistant", "Origin": ARD_ACCOUNTS_URL}, + json={ + "returnSecureToken": True, + "email": email, + "password": password, + "clientType": "CLIENT_TYPE_WEB", + }, + ) + data = await response.json() + if "error" in data: + if data["error"]["message"] == "EMAIL_NOT_FOUND": + raise LoginFailed("Email address is not registered") + if data["error"]["message"] == "INVALID_PASSWORD": + raise LoginFailed("Password is wrong") + token = data["idToken"] + uid = data["localId"] + + response = await session.post( + f"{IDENTITY_TOOLKIT_BASE_URL}:lookup?key={IDENTITY_TOOLKIT_TOKEN}", + headers={"User-Agent": "Music Assistant", "Origin": ARD_ACCOUNTS_URL}, + json={ + "idToken": token, + }, + ) + data = await response.json() + if "error" in data: + if data["error"]["message"] == "EMAIL_NOT_FOUND": + raise LoginFailed("Email address is not registered") + if data["error"]["message"] == "INVALID_PASSWORD": + raise LoginFailed("Password is wrong") + + return token, uid, data["users"][0]["displayName"] + + +def _create_aiohttptransport(headers: dict[str, str] | None = None) -> AIOHTTPTransport: + return AIOHTTPTransport(url=ARD_AUDIOTHEK_GRAPHQL, headers=headers, ssl=True) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + if values is None: + values = {} + + authenticated = True + if values.get(CONF_TOKEN_BEARER) is None or values.get(CONF_USERID) is None: + authenticated = False + + return ( + ConfigEntry( + key="label_text", + type=ConfigEntryType.LABEL, + label=f"Successfully signed in as {values.get(CONF_DISPLAY_NAME)} {str(values.get(CONF_EMAIL, '')).replace('@', '(at)')}.", # noqa: E501 + hidden=not authenticated, + ), + ConfigEntry( + key=CONF_EMAIL, + type=ConfigEntryType.STRING, + label="E-Mail", + required=False, + description="E-Mail address of ARD account.", + hidden=authenticated, + value=values.get(CONF_EMAIL), + ), + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + required=False, + description="Password of ARD account.", + hidden=authenticated, + value=values.get(CONF_PASSWORD), + ), + ConfigEntry( + key=CONF_MAX_BITRATE, + type=ConfigEntryType.INTEGER, + label="Maximum bitrate for streams (0 for unlimited)", + required=False, + description="Maximum bitrate for streams. Use 0 for unlimited", + default_value=0, + value=values.get(CONF_MAX_BITRATE), + ), + ConfigEntry( + key=CONF_PODCAST_FINISHED, + type=ConfigEntryType.INTEGER, + label="Percentage required before podcast episode is marked as fully played", + required=False, + description="This setting defines how much of a podcast must be listened to before an " + "episode is marked as fully played", + default_value=95, + value=values.get(CONF_PODCAST_FINISHED), + ), + ConfigEntry( + key=CONF_TOKEN_BEARER, + type=ConfigEntryType.SECURE_STRING, + label="token", + hidden=True, + required=False, + value=values.get(CONF_TOKEN_BEARER), + ), + ConfigEntry( + key=CONF_USERID, + type=ConfigEntryType.SECURE_STRING, + label="uid", + hidden=True, + required=False, + value=values.get(CONF_USERID), + ), + ConfigEntry( + key=CONF_EXPIRY_TIME, + type=ConfigEntryType.SECURE_STRING, + label="token_expiry", + hidden=True, + required=False, + default_value=0, + value=values.get(CONF_EXPIRY_TIME), + ), + ConfigEntry( + key=CONF_DISPLAY_NAME, + type=ConfigEntryType.STRING, + label="username", + hidden=True, + required=False, + value=values.get(CONF_DISPLAY_NAME), + ), + ) + + +class ARDAudiothek(MusicProvider): + """ARD Audiothek Music provider.""" + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return { + ProviderFeature.BROWSE, + ProviderFeature.SEARCH, + ProviderFeature.LIBRARY_RADIOS, + ProviderFeature.LIBRARY_PODCASTS, + } + + async def get_client(self) -> Client: + """Wrap the client creation procedure to recreate client. + + This happens when the token is expired or user credentials are updated. + """ + _email = self.config.get_value(CONF_EMAIL) + _password = self.config.get_value(CONF_PASSWORD) + self.token = self.config.get_value(CONF_TOKEN_BEARER) + self.user_id = self.config.get_value(CONF_USERID) + self.token_expire = datetime.fromtimestamp( + float(str(self.config.get_value(CONF_EXPIRY_TIME))) + ) + + self.max_bitrate = int(float(str(self.config.get_value(CONF_MAX_BITRATE)))) + + if ( + _email is not None + and _password is not None + and (self.token is None or self.user_id is None or self.token_expire < datetime.now()) + ): + self.token, self.user_id, _display_name = await _login( + self.mass.http_session, str(_email), str(_password) + ) + self.update_config_value(CONF_TOKEN_BEARER, self.token, encrypted=True) + self.update_config_value(CONF_USERID, self.user_id, encrypted=True) + self.update_config_value(CONF_DISPLAY_NAME, _display_name) + self.update_config_value( + CONF_EXPIRY_TIME, str((datetime.now() + timedelta(hours=1)).timestamp()) + ) + self._client_initialized = False + + if not self._client_initialized: + headers = None + if self.token: + headers = {"Authorization": f"Bearer {self.token}"} + + self._client = Client( + transport=_create_aiohttptransport(headers), + fetch_schema_from_transport=True, + ) + self._client_initialized = True + + return self._client + + async def handle_async_init(self) -> None: + """Pass config values to client and initialize.""" + self._client_initialized = False + await self.get_client() + + async def _update_progress(self) -> None: + if not self.user_id: + return + + async with await self.get_client() as session: + get_history_query.variable_values = {"loginId": self.user_id} + result = (await session.execute(get_history_query))["allEndUsers"]["nodes"][0][ + "history" + ]["nodes"] + + new_progress = {} # type: dict[str, tuple[bool, float]] + time_limit = int(str(self.config.get_value(CONF_PODCAST_FINISHED))) + for x in result: + core_id = x["item"]["coreId"] + if core_id is None: + continue + duration = x["item"]["duration"] + if duration is None: + continue + progress = x["progress"] + time_limit_reached = (progress / duration) * 100 > time_limit + new_progress[core_id] = (time_limit_reached, progress) + self.remote_progress = new_progress + + def _get_progress(self, episode_id: str) -> tuple[bool, int]: + if episode_id in self.remote_progress: + return self.remote_progress[episode_id][0], int( + self.remote_progress[episode_id][1] * 1000 + ) + return False, 0 + + async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]: + """Return: finished, position_ms.""" + assert media_type == MediaType.PODCAST_EPISODE + await self._update_progress() + + return self._get_progress(item_id) + + async def on_played( + self, + media_type: MediaType, + prov_item_id: str, + fully_played: bool, + position: int, + media_item: MediaItemType, + is_playing: bool = False, + ) -> None: + """Update progress.""" + if not self.user_id: + return + if media_item is None or not isinstance(media_item, PodcastEpisode): + return + if media_type != MediaType.PODCAST_EPISODE: + return + async with await self.get_client() as session: + update_history_entry.variable_values = {"itemId": prov_item_id, "progress": position} + await session.execute( + update_history_entry, + ) + + @property + def is_streaming_provider(self) -> bool: + """Search and lookup always search remote.""" + return True + + async def search( + self, + search_query: str, + media_types: list[MediaType], + limit: int = 5, + ) -> SearchResults: + """Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. + :param limit: Number of items to return in the search (per type). + """ + podcasts = [] + radios = [] + + if MediaType.PODCAST in media_types: + async with await self.get_client() as session: + search_shows_query.variable_values = {"query": search_query, "limit": limit} + search_shows = (await session.execute(search_shows_query))["search"]["shows"][ + "nodes" + ] + + for element in search_shows: + podcasts += [ + _parse_podcast( + self.domain, + self.lookup_key, + self.instance_id, + element, + element["coreId"], + ) + ] + + if MediaType.RADIO in media_types: + async with await self.get_client() as session: + search_radios_query.variable_values = { + "filter": {"title": {"includesInsensitive": search_query}}, + "first": limit, + } + search_radios = (await session.execute(search_radios_query))[ + "permanentLivestreams" + ]["nodes"] + + for element in search_radios: + radios += [ + _parse_radio( + self.domain, + self.instance_id, + element, + element["coreId"], + ) + ] + + return SearchResults(podcasts=podcasts, radio=radios) + + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get full radio details by id.""" + # Get full details of a single Radio station. + # Mandatory only if you reported LIBRARY_RADIOS in the supported_features. + async with await self.get_client() as session: + livestream_query.variable_values = {"coreId": prov_radio_id} + rad = (await session.execute(livestream_query))["permanentLivestreamByCoreId"] + if not rad: + raise MediaNotFoundError("Radio not found.") + return _parse_radio( + self.domain, + self.instance_id, + rad, + prov_radio_id, + ) + + async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: + """Retrieve library/subscribed podcasts from the provider. + + Minified podcast information is enough. + """ + if not self.user_id: + return + async with await self.get_client() as session: + get_subscriptions_query.variable_values = {"loginId": self.user_id} + result = (await session.execute(get_subscriptions_query))["allEndUsers"]["nodes"][0][ + "subscriptions" + ]["programSets"]["nodes"] + for show in result: + yield await self.get_podcast(show["subscribedProgramSet"]["coreId"]) + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse through the ARD Audiothek. + + This supports browsing through Podcasts and Radio stations. + :param path: The path to browse, (e.g. provider_id://artists). + """ + part_parts = path.split("://")[1].split("/") + organization = part_parts[0] if part_parts else "" + provider = part_parts[1] if len(part_parts) > 1 else "" + radio_station = part_parts[2] if len(part_parts) > 2 else "" + + if not organization: + return await self.get_organizations(path) + + if not provider: + # list radios for specific organization + return await self.get_publication_services(path, organization) + + if not radio_station: + return await self.get_publications_list(provider) + + return [] + + async def get_podcast(self, prov_podcast_id: str) -> Podcast: + """Get podcast.""" + async with await self.get_client() as session: + show_query.variable_values = {"showId": prov_podcast_id} + result = (await session.execute(show_query))["show"] + if not result: + raise MediaNotFoundError("Podcast not found.") + + return _parse_podcast( + self.domain, + self.lookup_key, + self.instance_id, + result, + prov_podcast_id, + ) + + async def get_podcast_episodes( + self, prov_podcast_id: str + ) -> AsyncGenerator[PodcastEpisode, None]: + """Get podcast episodes.""" + await self._update_progress() + async with await self.get_client() as session: + show_length_query.variable_values = {"showId": prov_podcast_id} + length = await session.execute(show_length_query) + length = length["show"]["items"]["totalCount"] + step_size = 128 + for offset in range(0, length, step_size): + show_query.variable_values = { + "showId": prov_podcast_id, + "first": step_size, + "offset": offset, + } + result = (await session.execute(show_query))["show"] + for idx, episode in enumerate(result["items"]["nodes"]): + if len(episode["audioList"]) == 0: + continue + if episode["status"] == "DEPUBLISHED": + continue + episode_id = episode["coreId"] + + progress = self._get_progress(episode_id) + yield _parse_podcast_episode( + self.domain, + self.lookup_key, + self.instance_id, + episode, + episode_id, + result["title"], + idx, + progress, + ) + + async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: + """Get single podcast episode.""" + await self._update_progress() + async with await self.get_client() as session: + show_episode_query.variable_values = {"coreId": prov_episode_id} + result = (await session.execute(show_episode_query))["itemByCoreId"] + if not result: + raise MediaNotFoundError("Podcast episode not found") + progress = self._get_progress(prov_episode_id) + return _parse_podcast_episode( + self.domain, + self.lookup_key, + self.instance_id, + result, + result["showId"], + result["show"]["title"], + result["rowId"], + progress, + ) + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Get streamdetails for a radio station.""" + async with await self.get_client() as session: + if media_type == MediaType.RADIO: + livestream_query.variable_values = {"coreId": item_id} + result = (await session.execute(livestream_query))["permanentLivestreamByCoreId"] + seek = False + elif media_type == MediaType.PODCAST_EPISODE: + show_episode_query.variable_values = {"coreId": item_id} + result = (await session.execute(show_episode_query))["itemByCoreId"] + seek = True + + streams = result["audioList"] + if len(streams) == 0: + raise MediaNotFoundError("No stream available.") + + def filter_func(val: dict[str, Any]) -> bool: + if self.max_bitrate == 0: + return True + return int(val["audioBitrate"]) < self.max_bitrate + + filtered_streams = filter(filter_func, streams) + if len(list(filtered_streams)) == 0: + raise UnplayableMediaError("No stream exceeding the minimum bitrate available.") + selected_stream = max(filtered_streams, key=lambda x: x["audioBitrate"]) + + return StreamDetails( + provider=self.domain, + item_id=item_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse(selected_stream["audioCodec"]), + ), + media_type=media_type, + stream_type=StreamType.HTTP, + path=fix_url(selected_stream["href"]), + can_seek=seek, + allow_seek=seek, + ) + + @use_cache(3600) + async def get_organizations(self, path: str) -> list[BrowseFolder]: + """Create a list of all available organizations.""" + async with await self.get_client() as session: + result = (await session.execute(organizations_query))["organizations"]["nodes"] + organizations = [] + + for org in result: + if all( + b["coreId"] is None for b in org["publicationServicesByOrganizationName"]["nodes"] + ): + # No available station + continue + image = None + for pub in org["publicationServicesByOrganizationName"]["nodes"]: + pub_title = pub["title"].lower() + org_name = org["name"].lower() + org_title = org["title"].lower() + if pub_title in (org_name, org_title) or pub_title.replace(" ", "") == org_name: + image = create_media_image(self.domain, pub["imagesList"]) + break + organizations += [ + BrowseFolder( + item_id=org["coreId"], + provider=self.domain, + path=path + org["coreId"], + image=image, + name=org["title"], + ) + ] + + return organizations + + @use_cache(3600) + async def get_publication_services(self, path: str, core_id: str) -> list[BrowseFolder]: + """Create a list of publications for a given organization.""" + async with await self.get_client() as session: + publication_services_query.variable_values = {"coreId": core_id} + result = (await session.execute(publication_services_query))["organizationByCoreId"][ + "publicationServicesByOrganizationName" + ]["nodes"] + publications = [] + + for pub in result: + if not pub["coreId"]: + continue + publications += [ + BrowseFolder( + item_id=pub["coreId"], + provider=self.domain, + path=path + "/" + pub["coreId"], + image=create_media_image(self.domain, pub["imagesList"]), + name=pub["title"], + ) + ] + + return publications + + @use_cache(3600) + async def get_publications_list(self, core_id: str) -> list[Radio | Podcast]: + """Create list of available radio stations and shows for a publication service.""" + async with await self.get_client() as session: + publications_list_query.variable_values = {"coreId": core_id} + result = (await session.execute(publications_list_query))["publicationServiceByCoreId"] + + publications = [] # type: list[Radio | Podcast] + + if not result: + raise MediaNotFoundError("Publication service not found.") + + for rad in result["permanentLivestreams"]["nodes"]: + if not rad["coreId"]: + continue + + radio = _parse_radio(self.domain, self.instance_id, rad, rad["coreId"]) + + publications += [radio] + + for pod in result["shows"]["nodes"]: + if not pod["coreId"]: + continue + + podcast = _parse_podcast( + self.domain, + self.lookup_key, + self.instance_id, + pod, + pod["coreId"], + ) + publications += [podcast] + + return publications + + +def _parse_social_media( + homepage_url: str | None, social_media_accounts: list[dict[str, None | str]] +) -> set[MediaItemLink]: + return_set = set() + if homepage_url: + return_set.add(MediaItemLink(type=LinkType.WEBSITE, url=homepage_url)) + for entry in social_media_accounts: + if entry["url"]: + link_type = None + match entry["service"]: + case "FACEBOOK": + link_type = LinkType.FACEBOOK + case "INSTAGRAM": + link_type = LinkType.INSTAGRAM + case "TIKTOK": + link_type = LinkType.TIKTOK + if link_type: + return_set.add(MediaItemLink(type=link_type, url=entry["url"])) + return return_set + + +def _parse_podcast( + domain: str, + lookup_key: str, + instance_id: str, + podcast_query: dict[str, Any], + podcast_id: str, +) -> Podcast: + podcast = Podcast( + name=podcast_query["title"], + item_id=podcast_id, + publisher=podcast_query["publicationService"]["title"], + provider=lookup_key, + provider_mappings={ + ProviderMapping( + item_id=podcast_id, + provider_domain=domain, + provider_instance=instance_id, + ) + }, + total_episodes=podcast_query["items"]["totalCount"], + ) + + podcast.metadata.links = _parse_social_media( + podcast_query["publicationService"]["homepageUrl"], + podcast_query["publicationService"]["socialMediaAccounts"], + ) + + podcast.metadata.description = podcast_query["synopsis"] + podcast.metadata.genres = {r["title"] for r in podcast_query["editorialCategoriesList"]} + + podcast.metadata.add_image(create_media_image(domain, podcast_query["imagesList"])) + + return podcast + + +def _parse_radio( + domain: str, + instance_id: str, + radio_query: dict[str, Any], + radio_id: str, +) -> Radio: + radio = Radio( + name=radio_query["title"], + item_id=radio_id, + provider=domain, + provider_mappings={ + ProviderMapping( + item_id=radio_id, + provider_domain=domain, + provider_instance=instance_id, + ) + }, + ) + + radio.metadata.links = _parse_social_media( + radio_query["publicationService"]["homepageUrl"], + radio_query["publicationService"]["socialMediaAccounts"], + ) + + radio.metadata.description = radio_query["publicationService"]["synopsis"] + radio.metadata.genres = {radio_query["publicationService"]["genre"]} + + radio.metadata.add_image(create_media_image(domain, radio_query["imagesList"])) + + return radio + + +def _parse_podcast_episode( + domain: str, + lookup_key: str, + instance_id: str, + episode: dict[str, Any], + podcast_id: str, + podcast_title: str, + idx: int, + progress: tuple[bool, int], +) -> PodcastEpisode: + podcast_episode = PodcastEpisode( + name=episode["title"], + duration=episode["duration"], + item_id=episode["coreId"], + provider=lookup_key, + podcast=ItemMapping( + item_id=podcast_id, + provider=lookup_key, + name=podcast_title, + media_type=MediaType.PODCAST, + ), + provider_mappings={ + ProviderMapping( + item_id=episode["coreId"], + provider_domain=domain, + provider_instance=instance_id, + ) + }, + position=idx, + fully_played=progress[0], + resume_position_ms=progress[1], + ) + + podcast_episode.metadata.add_image(create_media_image(domain, episode["imagesList"])) + podcast_episode.metadata.description = episode["summary"] + return podcast_episode + + +def create_media_image(domain: str, image_list: list[dict[str, str]]) -> MediaItemImage: + """Extract the image for hopefully all possible cases.""" + image_url = "" + selected_img = image_list[0] if image_list else None + for img in image_list: + if img["aspectRatio"] == "1x1": + selected_img = img + break + if selected_img: + image_url = selected_img["url"].replace("{width}", str(selected_img["width"])) + return MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=domain, + remotely_accessible=True, + ) + + +def fix_url(url: str) -> str: + """Fix some of the stream urls, which do not provide a protocol.""" + if url.startswith("//"): + url = "https:" + url + return url diff --git a/music_assistant/providers/ard_audiothek/database_queries.py b/music_assistant/providers/ard_audiothek/database_queries.py new file mode 100644 index 00000000..1c4b8eca --- /dev/null +++ b/music_assistant/providers/ard_audiothek/database_queries.py @@ -0,0 +1,342 @@ +"""Helper to provide the GraphQL Queries.""" + +from gql import gql + +image_list = """ +imagesList { + title + url + width + aspectRatio +} +""" + + +audio_list = """ +audioList { + audioBitrate + href + audioCodec + availableFrom + availableTo +} +""" + + +publication_service_metadata = """ + title + genre + synopsis + homepageUrl + socialMediaAccounts { + url + service + } +""" + + +organizations_query = gql( + """ +query Organizations { + organizations { + nodes { + coreId + name + title + publicationServicesByOrganizationName { + nodes { + coreId + title""" + + image_list + + """ + } + } + } + } +} +""" +) + + +publication_services_query = gql( + """ +query PublicationServices ($coreId: String!) { + organizationByCoreId(coreId: $coreId) { + publicationServicesByOrganizationName { + nodes { + coreId + title + synopsis""" + + image_list + + """ + } + } + } +} +""" +) + + +publications_list_query = gql( + """ +query Publications($coreId: String!) { + publicationServiceByCoreId(coreId: $coreId) { + permanentLivestreams { + nodes { + title + coreId + publicationService {""" + + publication_service_metadata + + """ + }""" + + image_list + + """ + } + } + shows { + nodes { + coreId + title + synopsis + items { + totalCount + } + publicationService {""" + + publication_service_metadata + + """ + } + editorialCategoriesList { + title + }""" + + image_list + + """ + } + } + } +} +""" +) + + +livestream_query = gql( + """ +query Livestream($coreId: String!) { + permanentLivestreamByCoreId(coreId: $coreId) { + publisherCoreId + summary + current + title + publicationService {""" + + publication_service_metadata + + """ + }""" + + image_list + + audio_list + + """ + } +} +""" +) + + +show_length_query = gql(""" +query Show($showId: ID!) { + show(id: $showId) { + items { + totalCount + } + } +} +""") + + +show_query = gql( + """ +query Show($showId: ID!, $first: Int, $offset: Int) { + show(id: $showId) { + synopsis + title + showType + items(first: $first, offset: $offset) { + totalCount + nodes { + duration + title + status + episodeNumber + coreId + summary""" + + audio_list + + image_list + + """ + } + } + editorialCategoriesList { + title + } + publicationService {""" + + publication_service_metadata + + """ + }""" + + image_list + + """ + } +} +""" +) + + +show_episode_query = gql( + """ +query ShowEpisode($coreId: String!) { + itemByCoreId(coreId: $coreId) { + show { + title + } + duration + title + episodeNumber + coreId + showId + rowId + synopsis + summary""" + + audio_list + + image_list + + """ + } +} +""" +) + + +search_shows_query = gql( + """ +query Search($query: String, $limit: Int) { + search(query: $query, limit: $limit) { + shows { + totalCount + title + nodes { + synopsis + title + coreId""" + + image_list + + """ + publicationService {""" + + publication_service_metadata + + """ + } + items { + totalCount + } + showType + editorialCategoriesList { + title + } + } + } + } +} +""" +) + + +search_radios_query = gql( + """ +query RadioSearch($filter: PermanentLivestreamFilter, $first: Int) { + permanentLivestreams(filter: $filter, first: $first) { + nodes { + coreId + title""" + + image_list + + """ + publicationService {""" + + publication_service_metadata + + """ + } + } + } +} +""" +) + + +check_login_query = gql( + """ +query CheckLogin($loginId: String!) { + allEndUsers(filter: { loginId: { eq: $loginId } }) { + count + nodes { + id + syncSuccessful + } + } +} +""" +) + + +get_subscriptions_query = gql( + """ +query GetBookmarksByLoginId($loginId: String!, $count: Int = 96) { + allEndUsers(filter: { loginId: { eq: $loginId } }) { + count + nodes { + subscriptions(first: $count, orderBy: LASTLISTENEDAT_DESC) { + programSets { + nodes { + subscribedProgramSet { + coreId + } + } + } + } + } + } +} +""" +) + + +get_history_query = gql( + """ +query GetBookmarksByLoginId($loginId: String!, $count: Int = 96) { + allEndUsers(filter: { loginId: { eq: $loginId } }) { + count + nodes { + history(first: $count, orderBy: LASTLISTENEDAT_DESC) { + nodes { + progress + item { + coreId + duration + } + } + } + } + } +} +""" +) + +update_history_entry = gql( + """ +mutation AddHistoryEntry( + $itemId: ID! + $progress: Float! +) { + upsertHistoryEntry( + input: { + item: { id: $itemId } + progress: $progress + } + ) { + changedHistoryEntry { + id + progress + lastListenedAt + } + } +}""" +) diff --git a/music_assistant/providers/ard_audiothek/icon.svg b/music_assistant/providers/ard_audiothek/icon.svg new file mode 100644 index 00000000..2053febd --- /dev/null +++ b/music_assistant/providers/ard_audiothek/icon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/music_assistant/providers/ard_audiothek/icon_monochrome.svg b/music_assistant/providers/ard_audiothek/icon_monochrome.svg new file mode 100644 index 00000000..671eb93b --- /dev/null +++ b/music_assistant/providers/ard_audiothek/icon_monochrome.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/music_assistant/providers/ard_audiothek/manifest.json b/music_assistant/providers/ard_audiothek/manifest.json new file mode 100644 index 00000000..b4388364 --- /dev/null +++ b/music_assistant/providers/ard_audiothek/manifest.json @@ -0,0 +1,10 @@ +{ + "type": "music", + "domain": "ard_audiothek", + "name": "ARD Audiothek", + "description": "ARD Audiothek Integration", + "codeowners": ["@jfeil"], + "requirements": ["gql[all]==4.0.0"], + "documentation": "https://music-assistant.io/music-providers/ard-audiothek/", + "multi_instance": true +} diff --git a/pyproject.toml b/pyproject.toml index 6ec380eb..4c0e0950 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "shortuuid==1.0.13", "zeroconf==0.147.2", "uv>=0.8.0", + "gql[all]==4.0.0", ] description = "Music Assistant" license = {text = "Apache-2.0"} diff --git a/requirements_all.txt b/requirements_all.txt index 105ec779..7417882a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -24,6 +24,7 @@ cryptography==45.0.6 deezer-python-async==0.3.0 defusedxml==0.7.1 duration-parser==1.0.1 +gql[all]==4.0.0 hass-client==1.2.0 ibroadcastaio==0.4.0 ifaddr==0.2.0