Add ARD Audiothek provider (#2229)
authorJan Feil <11638228+jfeil@users.noreply.github.com>
Thu, 11 Sep 2025 10:17:24 +0000 (12:17 +0200)
committerGitHub <noreply@github.com>
Thu, 11 Sep 2025 10:17:24 +0000 (12:17 +0200)
music_assistant/providers/ard_audiothek/__init__.py [new file with mode: 0644]
music_assistant/providers/ard_audiothek/database_queries.py [new file with mode: 0644]
music_assistant/providers/ard_audiothek/icon.svg [new file with mode: 0644]
music_assistant/providers/ard_audiothek/icon_monochrome.svg [new file with mode: 0644]
music_assistant/providers/ard_audiothek/manifest.json [new file with mode: 0644]
pyproject.toml
requirements_all.txt

diff --git a/music_assistant/providers/ard_audiothek/__init__.py b/music_assistant/providers/ard_audiothek/__init__.py
new file mode 100644 (file)
index 0000000..bd95f8f
--- /dev/null
@@ -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 (file)
index 0000000..1c4b8ec
--- /dev/null
@@ -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 (file)
index 0000000..2053feb
--- /dev/null
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Livello_1" data-name="Livello 1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+  <defs>
+    <style>
+      .cls-1 {
+        fill: #fff;
+      }
+
+      .cls-1, .cls-2, .cls-3 {
+        stroke-width: 0px;
+      }
+
+      .cls-1, .cls-3 {
+        fill-rule: evenodd;
+      }
+
+      .cls-2, .cls-3 {
+        fill: #003480;
+      }
+    </style>
+  </defs>
+  <sodipodi:namedview id="namedview2" bordercolor="#000000" borderopacity="0.25" inkscape:current-layer="Livello_1" inkscape:cx="61.952862" inkscape:cy="318.51852" inkscape:deskcolor="#d1d1d1" inkscape:pagecheckerboard="0" inkscape:pageopacity="0.0" inkscape:showpageshadow="2" inkscape:window-height="1129" inkscape:window-maximized="1" inkscape:window-width="1920" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:zoom="0.7425" pagecolor="#ffffff"/>
+  <g>
+    <path class="cls-1" d="M501.7,444.4c-4.9,12.6-16.4,30.4-32.7,43.1-9.4,7.2-20.6,14.2-36,18.8-16.4,5-36.6,6.5-61.9,6.5h-230.1c-25.2,0-45.3-1.7-61.9-6.5-15.3-4.6-26.6-11.5-36-18.8-16.2-12.5-27.8-30.4-32.7-43.1C.7,418.9.5,390,.5,371.8h0v-230.7h0c0-18.3.2-47.2,10.1-72.6,4.9-12.6,16.4-30.4,32.7-43.1,9.4-7.2,20.6-14.2,36-18.8C95.8,1.7,115.9,0,141.1,0h230.1c25.2,0,45.3,1.7,61.9,6.5,15.3,4.6,26.6,11.5,36,18.8,16.2,12.5,27.8,30.4,32.7,43.1,10.1,25.5,10.1,54.5,10.1,72.6v230.7c0,18.3-.2,47.2-10.1,72.6Z"/>
+    <polygon class="cls-3" points="408.8 287.1 408.8 203.7 325 233.5 325 254.6 352.8 244.2 352.8 307 408.8 287.1"/>
+    <path class="cls-3" d="M376.7,154.9c-33.4.1-64.6,16.4-83.8,43.7l15.1,10.3c25.6-37.7,77-47.5,114.7-21.9,23,15.6,36.6,41.7,36.2,69.5.4,45.8-36.4,83.3-82.2,83.8-.1,0-.3,0-.4,0-27.3-.3-52.7-13.7-68.3-36.1l-15.1,10.7c32.4,45.8,95.9,56.7,141.7,24.3,26.8-19,42.8-49.8,43-82.7.4-55.7-44.4-101.2-100.1-101.7-.3,0-.5,0-.8,0"/>
+    <path class="cls-2" d="M248,209.7h-30.6v92.9h27.4c32.2,0,49.2-16.7,49.2-47.7s-15.9-45.3-46.1-45.3M246.8,287.1h-10.7v-61.5h10.7c18.3,0,27.4,9.9,27.4,31s-8.7,30.6-27.4,30.6"/>
+    <path class="cls-2" d="M89.9,210.5h-23.4l-31.8,92.9h19.1l6-19.1h34.9l6.4,18.7h20.6l-31.8-92.5ZM64.9,268.1l8.7-28.2c1.6-4,2.8-8.1,3.6-12.3.8,3.2,2,7.5,3.6,12.3l9.1,28.2h-25Z"/>
+    <path class="cls-2" d="M184.9,270.8c-1.2-2.4-2.7-4.7-4.4-6.8,11.6-3.4,19.4-14.2,19.1-26.2,0-18.7-11.5-28.2-33.8-28.2h-33v93.3h18.7v-35.3h10.3l20.6,35.3h22.6l-20.3-32.2ZM151.5,225.6h13.1c10.3,0,15.1,4.4,15.1,13.5s-5.2,13.1-15.5,13.1h-12.7v-26.6Z"/>
+  </g>
+</svg>
diff --git a/music_assistant/providers/ard_audiothek/icon_monochrome.svg b/music_assistant/providers/ard_audiothek/icon_monochrome.svg
new file mode 100644 (file)
index 0000000..671eb93
--- /dev/null
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg6" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+  <defs>
+    <style>
+      .cls-1 {
+        fill-rule: evenodd;
+      }
+
+      .cls-1, .cls-2, .cls-3 {
+        fill: #fff;
+      }
+
+      .cls-1, .cls-2, .cls-3, .cls-4 {
+        stroke-width: 0px;
+      }
+
+      .cls-2, .cls-4 {
+        display: none;
+      }
+
+      .cls-4 {
+        fill: #000;
+        fill-opacity: 0;
+      }
+    </style>
+  </defs>
+  <sodipodi:namedview id="namedview6" bordercolor="#000000" borderopacity="0.25" inkscape:current-layer="svg6" inkscape:cx="256" inkscape:cy="256" inkscape:deskcolor="#d1d1d1" inkscape:pagecheckerboard="0" inkscape:pageopacity="0.0" inkscape:showpageshadow="2" inkscape:window-height="1129" inkscape:window-maximized="1" inkscape:window-width="1920" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:zoom="1.7324219" pagecolor="#ffffff"/>
+  <g id="surface1">
+    <path id="path1" class="cls-4" d="M-29.1-76.1h562.1c21.2,0,38.3,17.2,38.3,38.3v562.1c0,21.2-17.2,38.3-38.3,38.3H-29.1c-21.2,0-38.3-17.2-38.3-38.3V-37.8c0-21.2,17.2-38.3,38.3-38.3Z"/>
+    <path id="path2" class="cls-2" d="M198,406.2l113.1-300.2h33.2l-113.1,300.2h-33.2Z"/>
+    <path id="path3" class="cls-2" d="M478.6,406.2l-113.1-300.2h33.2l113.1,300.2h-33.2Z"/>
+    <path id="path4" class="cls-2" d="M0,406.2V106h31.6v300.2H0Z"/>
+    <path id="path5" class="cls-2" d="M71.7,406.2V106h31.6v300.2h-31.6Z"/>
+    <path id="path6" class="cls-2" d="M143.5,406.2V106h31.6v300.2h-31.6Z"/>
+  </g>
+  <polygon class="cls-1" points="432.6 291.5 432.6 195.1 335.7 229.6 335.7 253.9 367.8 242 367.8 314.5 432.6 291.5"/>
+  <path class="cls-1" d="M395.4,138.6c-38.6.1-74.7,19-96.9,50.5l17.4,11.9c29.7-43.6,89.1-55,132.7-25.3,26.6,18.1,42.3,48.3,41.8,80.4.5,53-42,96.4-95.1,96.9-.2,0-.3,0-.5,0-31.5-.3-61-15.9-79-41.8l-17.4,12.4c37.5,53,110.9,65.6,163.9,28.1,31-22,49.5-57.6,49.7-95.6.5-64.4-51.3-117-115.7-117.6-.3,0-.6,0-.9,0"/>
+  <path class="cls-3" d="M246.6,202h-35.4v107.5h31.7c37.2,0,56.9-19.3,56.9-55.1s-18.4-52.3-53.3-52.3M245.2,291.5h-12.4v-71.2h12.4c21.1,0,31.7,11.5,31.7,35.8s-10.1,35.4-31.7,35.4"/>
+  <path class="cls-3" d="M63.8,202.9h-27.1L0,310.4h22l6.9-22h40.4l7.3,21.6h23.9l-36.7-107ZM34.9,269.5l10.1-32.6c1.8-4.6,3.2-9.4,4.1-14.2.9,3.7,2.3,8.7,4.1,14.2l10.6,32.6h-28.9Z"/>
+  <path class="cls-3" d="M173.6,272.7c-1.4-2.8-3.1-5.4-5.1-7.8,13.4-3.9,22.4-16.4,22-30.3,0-21.6-13.3-32.6-39-32.6h-38.1v107.9h21.6v-40.9h11.9l23.9,40.9h26.2l-23.4-37.2ZM135,220.4h15.2c11.9,0,17.4,5.1,17.4,15.6s-6,15.2-17.9,15.2h-14.7v-30.8Z"/>
+</svg>
diff --git a/music_assistant/providers/ard_audiothek/manifest.json b/music_assistant/providers/ard_audiothek/manifest.json
new file mode 100644 (file)
index 0000000..b438836
--- /dev/null
@@ -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
+}
index 6ec380ebd4e2cecd03b0c3f96dcdaa2aee812469..4c0e0950241ef89f167eed33f63c5d2078b1a61c 100644 (file)
@@ -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"}
index 105ec779649f01ea14f54bf8fa526cb09d8c7b06..7417882ab325812b7b8be658e609655d2cc62714 100644 (file)
@@ -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