Add gPodder podcast provider (#2036)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Fri, 14 Mar 2025 10:44:31 +0000 (03:44 -0700)
committerGitHub <noreply@github.com>
Fri, 14 Mar 2025 10:44:31 +0000 (11:44 +0100)
music_assistant/providers/gpodder/README.md [new file with mode: 0644]
music_assistant/providers/gpodder/__init__.py [new file with mode: 0644]
music_assistant/providers/gpodder/client.py [new file with mode: 0644]
music_assistant/providers/gpodder/icon.svg [new file with mode: 0644]
music_assistant/providers/gpodder/manifest.json [new file with mode: 0644]

diff --git a/music_assistant/providers/gpodder/README.md b/music_assistant/providers/gpodder/README.md
new file mode 100644 (file)
index 0000000..369cc8a
--- /dev/null
@@ -0,0 +1,4 @@
+The gPodder icon is taken from here
+https://raw.githubusercontent.com/PapirusDevelopmentTeam/papirus-icon-theme/refs/heads/master/Papirus/64x64/apps/gpodder.svg
+as of 2025-03-11, and only the dimension parameters were replaced by a viewBox.
+The papirus-icon-theme is licensed under GPL-3.0.
diff --git a/music_assistant/providers/gpodder/__init__.py b/music_assistant/providers/gpodder/__init__.py
new file mode 100644 (file)
index 0000000..e7e9d22
--- /dev/null
@@ -0,0 +1,626 @@
+"""gPodder provider for Music Assistant.
+
+Tested against opodsync, https://github.com/kd2org/opodsync
+and nextcloud-gpodder, https://github.com/thrillfall/nextcloud-gpodder
+gpodder.net is not supported due to responsiveness/ frequent downtimes of domain.
+
+Note:
+    - it can happen, that we have the guid and use that for identification, but the sync state
+      provider, eg. opodsync might use only the stream url. So always make sure, to compare both
+      when relying on an external service
+    - The service calls have a timestamp (int, unix epoch s), which give the changes since then.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from collections.abc import AsyncGenerator
+from io import BytesIO
+from typing import TYPE_CHECKING, Any
+
+import podcastparser
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    ContentType,
+    EventType,
+    MediaType,
+    ProviderFeature,
+    StreamType,
+)
+from music_assistant_models.errors import (
+    LoginFailed,
+    MediaNotFoundError,
+    ResourceTemporarilyUnavailable,
+)
+from music_assistant_models.media_items import (
+    AudioFormat,
+    MediaItemType,
+    Podcast,
+    PodcastEpisode,
+)
+from music_assistant_models.streamdetails import StreamDetails
+
+from music_assistant.helpers.podcast_parsers import (
+    get_stream_url_and_guid_from_episode,
+    parse_podcast,
+    parse_podcast_episode,
+)
+from music_assistant.models.music_provider import MusicProvider
+from music_assistant.providers.gpodder.client import EpisodeActionNew, GPodderClient
+
+if TYPE_CHECKING:
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+# Config for "classic" gpodder api
+CONF_URL = "url"
+CONF_USERNAME = "username"
+CONF_PASSWORD = "password"
+CONF_DEVICE_ID = "device_id"
+CONF_USING_GPODDER = "using_gpodder"  # hidden, bool, true if not nextcloud used
+
+# Config for nextcloud
+CONF_ACTION_AUTH_NC = "authenticate_nc"
+CONF_TOKEN_NC = "token"
+CONF_URL_NC = "url_nc"
+
+# General config
+CONF_VERIFY_SSL = "verify_ssl"
+CONF_MAX_NUM_EPISODES = "max_num_episodes"
+
+CACHE_CATEGORY_PODCAST_ITEMS = 0  # the individual parsed podcast (dict from podcastparser)
+CACHE_CATEGORY_OTHER = 1
+CACHE_KEY_TIMESTAMP = (
+    "timestamp"  # tuple of two ints, timestamp_subscriptions and timestamp_actions
+)
+CACHE_KEY_FEEDS = "feeds"  # list[str] : all available rss feed urls
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    return GPodder(mass, manifest, config)
+
+
+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 = {}
+
+    if action == CONF_ACTION_AUTH_NC:
+        session = mass.http_session
+        response = await session.post(
+            str(values[CONF_URL_NC]).rstrip("/") + "/index.php/login/v2",
+            headers={"User-Agent": "Music Assistant"},
+        )
+        data = await response.json()
+        poll_endpoint = data["poll"]["endpoint"]
+        poll_token = data["poll"]["token"]
+        login_url = data["login"]
+        session_id = str(values["session_id"])
+        mass.signal_event(EventType.AUTH_SESSION, session_id, login_url)
+        while True:
+            response = await session.post(poll_endpoint, data={"token": poll_token})
+            if response.status not in [200, 404]:
+                raise LoginFailed("The specified url seems not to belong to a nextcloud instance.")
+            if response.status == 200:
+                data = await response.json()
+                values[CONF_TOKEN_NC] = data["appPassword"]
+                break
+            await asyncio.sleep(1)
+
+    authenticated_nc = True
+    if values.get(CONF_TOKEN_NC, None) is None:
+        authenticated_nc = False
+
+    using_gpodder = bool(values.get(CONF_USING_GPODDER, False))
+
+    return (
+        ConfigEntry(
+            key="label_text",
+            type=ConfigEntryType.LABEL,
+            label="Authentication did succeed! Please press save to continue.",
+            hidden=not authenticated_nc,
+        ),
+        ConfigEntry(
+            key="label_gpodder",
+            type=ConfigEntryType.LABEL,
+            label="Authentication with gPodder compatible web service, e.g. opodsync:",
+            hidden=authenticated_nc,
+        ),
+        ConfigEntry(
+            key=CONF_URL,
+            type=ConfigEntryType.STRING,
+            label="gPodder Service URL",
+            required=False,
+            description="URL of gPodder instance.",
+            value=values.get(CONF_URL),
+            hidden=authenticated_nc,
+        ),
+        ConfigEntry(
+            key=CONF_USERNAME,
+            type=ConfigEntryType.STRING,
+            label="Username",
+            required=False,
+            description="Username of gPodder instance.",
+            hidden=authenticated_nc,
+            value=values.get(CONF_USERNAME),
+        ),
+        ConfigEntry(
+            key=CONF_PASSWORD,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Password",
+            required=False,
+            description="Password for gPodder instance.",
+            hidden=authenticated_nc,
+            value=values.get(CONF_PASSWORD),
+        ),
+        ConfigEntry(
+            key=CONF_DEVICE_ID,
+            type=ConfigEntryType.STRING,
+            label="Device ID",
+            required=False,
+            description="Device ID of user.",
+            hidden=authenticated_nc,
+            value=values.get(CONF_DEVICE_ID),
+        ),
+        ConfigEntry(
+            key="label_nextcloud",
+            type=ConfigEntryType.LABEL,
+            label="Authentication with Nextcloud with gPodder Sync (nextcloud-gpodder) installed:",
+            hidden=authenticated_nc or using_gpodder,
+        ),
+        ConfigEntry(
+            key=CONF_URL_NC,
+            type=ConfigEntryType.STRING,
+            label="Nextcloud URL",
+            required=False,
+            description="URL of Nextcloud instance.",
+            value=values.get(CONF_URL_NC),
+            hidden=using_gpodder,
+        ),
+        ConfigEntry(
+            key=CONF_ACTION_AUTH_NC,
+            type=ConfigEntryType.ACTION,
+            label="(Re)Authenticate with Nextcloud",
+            description="This button will redirect you to your Nextcloud instance to authenticate.",
+            action=CONF_ACTION_AUTH_NC,
+            required=False,
+            hidden=using_gpodder,
+        ),
+        ConfigEntry(
+            key="label_general",
+            type=ConfigEntryType.LABEL,
+            label="General config:",
+        ),
+        ConfigEntry(
+            key=CONF_MAX_NUM_EPISODES,
+            type=ConfigEntryType.INTEGER,
+            label="Maximum amount of episodes (0 for unlimited)",
+            required=False,
+            description="Maximum amount of episodes to sync per feed. Use 0 for unlimited",
+            default_value=0,
+            value=values.get(CONF_MAX_NUM_EPISODES),
+        ),
+        ConfigEntry(
+            key=CONF_VERIFY_SSL,
+            type=ConfigEntryType.BOOLEAN,
+            label="Verify SSL",
+            required=False,
+            description="Whether or not to verify the certificate of SSL/TLS connections.",
+            category="advanced",
+            default_value=True,
+            value=values.get(CONF_VERIFY_SSL),
+        ),
+        ConfigEntry(
+            key=CONF_TOKEN_NC,
+            type=ConfigEntryType.SECURE_STRING,
+            label="token",
+            hidden=True,
+            required=False,
+            value=values.get(CONF_TOKEN_NC),
+        ),
+        ConfigEntry(
+            key=CONF_USING_GPODDER,
+            type=ConfigEntryType.BOOLEAN,
+            label="using_gpodder",
+            hidden=True,
+            required=False,
+            value=values.get(CONF_USING_GPODDER),
+        ),
+    )
+
+
+class GPodder(MusicProvider):
+    """gPodder MusicProvider."""
+
+    @property
+    def supported_features(self) -> set[ProviderFeature]:
+        """Features supported by this Provider."""
+        return {
+            ProviderFeature.LIBRARY_PODCASTS,
+            ProviderFeature.BROWSE,
+        }
+
+    async def handle_async_init(self) -> None:
+        """Pass config values to client and initialize."""
+        base_url = str(self.config.get_value(CONF_URL))
+        _username = self.config.get_value(CONF_USERNAME)
+        _password = self.config.get_value(CONF_PASSWORD)
+        _device_id = self.config.get_value(CONF_DEVICE_ID)
+        nc_url = str(self.config.get_value(CONF_URL_NC))
+        nc_token = self.config.get_value(CONF_TOKEN_NC)
+
+        self.max_episodes = int(float(str(self.config.get_value(CONF_MAX_NUM_EPISODES))))
+
+        self._client = GPodderClient(session=self.mass.http_session, logger=self.logger)
+
+        if nc_token is not None:
+            assert nc_url is not None
+            self._client.init_nc(base_url=nc_url, nc_token=str(nc_token))
+        else:
+            self.update_config_value(CONF_USING_GPODDER, True)
+            if _username is None or _password is None or _device_id is None:
+                raise LoginFailed("Must provide username, password and device_id.")
+            username = str(_username)
+            password = str(_password)
+            device_id = str(_device_id)
+
+            if base_url.rstrip("/") == "https://gpodder.net":
+                raise LoginFailed("Do not use gpodder.net. See docs for explanation.")
+            try:
+                await self._client.init_gpodder(
+                    username=username, password=password, base_url=base_url, device=device_id
+                )
+            except RuntimeError as exc:
+                raise LoginFailed("Login failed.") from exc
+
+        timestamps = await self.mass.cache.get(
+            key=CACHE_KEY_TIMESTAMP,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_OTHER,
+            default=None,
+        )
+        if timestamps is None:
+            self.timestamp_subscriptions: int = 0
+            self.timestamp_actions: int = 0
+        else:
+            self.timestamp_subscriptions, self.timestamp_actions = timestamps
+
+        self.logger.debug(
+            "Our timestamps are (subscriptions, actions)  (%s, %s)",
+            self.timestamp_subscriptions,
+            self.timestamp_actions,
+        )
+
+        feeds = await self.mass.cache.get(
+            key=CACHE_KEY_FEEDS,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_OTHER,
+            default=None,
+        )
+        if feeds is None:
+            self.feeds: set[str] = set()
+        else:
+            self.feeds = set(feeds)  # feeds is a list here
+
+        # we are syncing the playlog, but not event based. A simple check in on_played,
+        # should be sufficient
+        self.progress_guard_timestamp = 0.0
+
+    @property
+    def is_streaming_provider(self) -> bool:
+        """Return True if the provider is a streaming provider."""
+        # For streaming providers return True here but for local file based providers return False.
+        # While the streams are remote, the user controls what is added.
+        return False
+
+    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
+        """Retrieve library/subscribed podcasts from the provider."""
+        try:
+            subscriptions = await self._client.get_subscriptions()
+        except RuntimeError:
+            raise ResourceTemporarilyUnavailable(backoff_time=30)
+        if subscriptions is None:
+            return
+
+        for feed_url in subscriptions.add:
+            self.feeds.add(feed_url)
+        for feed_url in subscriptions.remove:
+            try:
+                self.feeds.remove(feed_url)
+            except KeyError:
+                # a podcast might have been added and removed in our absence...
+                continue
+
+        progresses, timestamp_action = await self._client.get_progresses()
+        for feed_url in self.feeds:
+            self.logger.debug("Adding podcast with feed %s to library", feed_url)
+            # parse podcast
+            try:
+                parsed_podcast = await self._get_podcast(feed_url)
+            except RuntimeError:
+                self.logger.warning(f"Was unable to obtain podcast with feed {feed_url}")
+                continue
+            await self._cache_set_podcast(feed_url, parsed_podcast)
+
+            # playlog
+            # be safe, if there should be multiple episodeactions. client already sorts
+            # progresses in descending order.
+            _already_processed = set()
+            _podcast_progresses = [x for x in progresses if x.podcast == feed_url]
+            for _progress in _podcast_progresses:
+                if _progress.episode not in _already_processed:
+                    _already_processed.add(_progress.episode)
+                    # we do not have to add the progress, these would make calls twice,
+                    # and we only use the object to propagate to playlog
+                    self.progress_guard_timestamp = time.time()
+                    _episode_id = f"{feed_url} {_progress.episode}"
+                    mass_episode = await self.get_podcast_episode(_episode_id, add_progress=False)
+                    if isinstance(_progress, EpisodeActionNew):
+                        await self.mass.music.mark_item_unplayed(mass_episode)
+                    else:
+                        await self.mass.music.mark_item_played(
+                            mass_episode,
+                            fully_played=_progress.position >= _progress.total,
+                            seconds_played=_progress.position,
+                        )
+
+            # cache
+            yield parse_podcast(
+                feed_url=feed_url,
+                parsed_feed=parsed_podcast,
+                lookup_key=self.lookup_key,
+                domain=self.domain,
+                instance_id=self.instance_id,
+            )
+
+        self.timestamp_subscriptions = subscriptions.timestamp
+        if timestamp_action is not None:
+            self.timestamp_actions = timestamp_action
+        await self._cache_set_timestamps()
+        await self._cache_set_feeds()
+
+    async def get_podcast(self, prov_podcast_id: str) -> Podcast:
+        """Get Podcast."""
+        parsed_podcast = await self._cache_get_podcast(prov_podcast_id)
+
+        return parse_podcast(
+            feed_url=prov_podcast_id,
+            parsed_feed=parsed_podcast,
+            lookup_key=self.lookup_key,
+            domain=self.domain,
+            instance_id=self.instance_id,
+        )
+
+    async def get_podcast_episodes(
+        self, prov_podcast_id: str, add_progress: bool = True
+    ) -> AsyncGenerator[PodcastEpisode, None]:
+        """Get Podcast episodes. Add progress information."""
+        if add_progress:
+            progresses, timestamp = await self._client.get_progresses()
+        else:
+            progresses, timestamp = [], None
+
+        podcast = await self._cache_get_podcast(prov_podcast_id)
+        podcast_cover = podcast.get("cover_url")
+        parsed_episodes = podcast.get("episodes", [])
+
+        if timestamp is not None:
+            self.timestamp_actions = timestamp
+            await self._cache_set_timestamps()
+
+        for cnt, parsed_episode in enumerate(parsed_episodes):
+            mass_episode = parse_podcast_episode(
+                episode=parsed_episode,
+                prov_podcast_id=prov_podcast_id,
+                episode_cnt=cnt,
+                podcast_cover=podcast_cover,
+                domain=self.domain,
+                lookup_key=self.lookup_key,
+                instance_id=self.instance_id,
+            )
+            stream_url, guid = get_stream_url_and_guid_from_episode(episode=parsed_episode)
+
+            for progress in progresses:
+                # we have to test both, as we are comparing to external input.
+                _test = [progress.guid, progress.episode]
+                if prov_podcast_id == progress.podcast and (guid in _test or stream_url in _test):
+                    self.progress_guard_timestamp = time.time()
+                    if isinstance(progress, EpisodeActionNew):
+                        mass_episode.resume_position_ms = 0
+                        mass_episode.fully_played = False
+
+                        # propagate to playlog
+                        await self.mass.music.mark_item_unplayed(
+                            mass_episode,
+                        )
+                    else:
+                        fully_played = progress.position >= progress.total
+                        resume_position_s = progress.position
+                        mass_episode.resume_position_ms = resume_position_s * 1000
+                        mass_episode.fully_played = fully_played
+
+                        # propagate progress to playlog
+                        await self.mass.music.mark_item_played(
+                            mass_episode,
+                            fully_played=fully_played,
+                            seconds_played=resume_position_s,
+                        )
+                    break
+
+            yield mass_episode
+
+    async def get_podcast_episode(
+        self, prov_episode_id: str, add_progress: bool = True
+    ) -> PodcastEpisode:
+        """Get Podcast Episode. Add progress information."""
+        podcast_id, guid_or_stream_url = prov_episode_id.split(" ")
+        async for mass_episode in self.get_podcast_episodes(podcast_id, add_progress=add_progress):
+            _, _guid_or_stream_url = mass_episode.item_id.split(" ")
+            # this is enough, as internal
+            if guid_or_stream_url == _guid_or_stream_url:
+                return mass_episode
+        raise MediaNotFoundError("Did not find episode.")
+
+    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
+        podcast_id, guid_or_stream_url = item_id.split(" ")
+        stream_url = await self._get_episode_stream_url(podcast_id, guid_or_stream_url)
+        try:
+            progresses, timestamp = await self._client.get_progresses(since=self.timestamp_actions)
+        except RuntimeError:
+            self.logger.warning("Was unable to obtain progresses.")
+            raise NotImplementedError  # fallback to internal position.
+        for action in progresses:
+            _test = [action.guid, action.episode]
+            # progress is external, compare guid and stream_url
+            if action.podcast == podcast_id and (
+                guid_or_stream_url in _test or stream_url in _test
+            ):
+                if timestamp is not None:
+                    self.timestamp_actions = timestamp
+                    await self._cache_set_timestamps()
+                if isinstance(action, EpisodeActionNew):
+                    # no progress, it might have been actively reset
+                    return False, 0
+                _progress = (action.position >= action.total, max(action.position * 1000, 0))
+                self.logger.debug("Found an updated external resume position.")
+                return action.position >= action.total, max(action.position * 1000, 0)
+        self.logger.debug("Did not find an updated resume position, falling back to stored.")
+        # If we did not find a resume position, nothing changed since our last timestamp
+        # we raise NotImplementedError, such that MA falls back to the already stored
+        # resume_position in its playlog.
+        raise NotImplementedError
+
+    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 media_item is None or not isinstance(media_item, PodcastEpisode):
+            return
+        if media_type != MediaType.PODCAST_EPISODE:
+            return
+        if time.time() - self.progress_guard_timestamp <= 5:
+            return
+        podcast_id, guid_or_stream_url = prov_item_id.split(" ")
+        stream_url = await self._get_episode_stream_url(podcast_id, guid_or_stream_url)
+        assert stream_url is not None
+        duration = media_item.duration
+        try:
+            await self._client.update_progress(
+                podcast_id=podcast_id,
+                episode_id=stream_url,
+                guid=guid_or_stream_url,
+                position_s=position,
+                duration_s=duration,
+            )
+            self.logger.debug(f"Updated progress to {position / duration * 100:.2f}%")
+        except RuntimeError as exc:
+            self.logger.debug(exc)
+            self.logger.debug("Failed to update progress.")
+
+    async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
+        """Get streamdetails for item."""
+        podcast_id, guid_or_stream_url = item_id.split(" ")
+        stream_url = await self._get_episode_stream_url(podcast_id, guid_or_stream_url)
+        if stream_url is None:
+            raise MediaNotFoundError
+        return StreamDetails(
+            provider=self.lookup_key,
+            item_id=item_id,
+            audio_format=AudioFormat(
+                content_type=ContentType.try_parse(stream_url),
+            ),
+            media_type=MediaType.PODCAST_EPISODE,
+            stream_type=StreamType.HTTP,
+            path=stream_url,
+            can_seek=True,
+            allow_seek=True,
+        )
+
+    async def _get_episode_stream_url(self, podcast_id: str, guid_or_stream_url: str) -> str | None:
+        podcast = await self._cache_get_podcast(podcast_id)
+        episodes = podcast.get("episodes", [])
+        for cnt, episode in enumerate(episodes):
+            episode_enclosures = episode.get("enclosures", [])
+            if len(episode_enclosures) < 1:
+                raise MediaNotFoundError
+            stream_url: str | None = episode_enclosures[0].get("url", None)
+            if guid_or_stream_url == episode.get("guid", stream_url):
+                return stream_url
+        return None
+
+    async def _get_podcast(self, feed_url: str) -> dict[str, Any]:
+        # see music-assistant/server@6aae82e
+        response = await self.mass.http_session.get(feed_url, headers={"User-Agent": "Mozilla/5.0"})
+        if response.status != 200:
+            raise RuntimeError
+        feed_data = await response.read()
+        feed_stream = BytesIO(feed_data)
+        return podcastparser.parse(feed_url, feed_stream, max_episodes=self.max_episodes)  # type: ignore[no-any-return]
+
+    async def _cache_get_podcast(self, prov_podcast_id: str) -> dict[str, Any]:
+        parsed_podcast = await self.mass.cache.get(
+            key=prov_podcast_id,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_PODCAST_ITEMS,
+            default=None,
+        )
+        if parsed_podcast is None:
+            parsed_podcast = await self._get_podcast(feed_url=prov_podcast_id)
+            await self._cache_set_podcast(feed_url=prov_podcast_id, parsed_podcast=parsed_podcast)
+
+        # this is a dictionary from podcastparser
+        return parsed_podcast  # type: ignore[no-any-return]
+
+    async def _cache_set_podcast(self, feed_url: str, parsed_podcast: dict[str, Any]) -> None:
+        await self.mass.cache.set(
+            key=feed_url,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_PODCAST_ITEMS,
+            data=parsed_podcast,
+            expiration=60 * 60 * 24,  # 1 day
+        )
+
+    async def _cache_set_timestamps(self) -> None:
+        # seven days default
+        await self.mass.cache.set(
+            key=CACHE_KEY_TIMESTAMP,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_OTHER,
+            data=[self.timestamp_subscriptions, self.timestamp_actions],
+        )
+
+    async def _cache_set_feeds(self) -> None:
+        # seven days default
+        await self.mass.cache.set(
+            key=CACHE_KEY_FEEDS,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_OTHER,
+            data=self.feeds,
+        )
diff --git a/music_assistant/providers/gpodder/client.py b/music_assistant/providers/gpodder/client.py
new file mode 100644 (file)
index 0000000..cf5e2b3
--- /dev/null
@@ -0,0 +1,305 @@
+"""Simplest client for gPodder.
+
+Should be compatible with Nextcloud App GPodder Sync, and the original api
+of gpodder.net (mygpo) or drop-in replacements like opodsync.
+Gpodder Sync uses guid optionally.
+"""
+
+import datetime
+import logging
+from contextlib import suppress
+from dataclasses import dataclass, field
+from typing import Any
+
+import aiohttp
+from aiohttp.client_exceptions import ClientResponseError
+from mashumaro.config import BaseConfig
+from mashumaro.mixins.json import DataClassJSONMixin
+from mashumaro.types import Discriminator
+
+
+# https://gpoddernet.readthedocs.io/en/latest/api/reference/subscriptions.html#upload-subscription-changes
+@dataclass(kw_only=True)
+class SubscriptionsChangeRequest(DataClassJSONMixin):
+    """SubscriptionChangeRequest."""
+
+    add: list[str] = field(default_factory=list)
+    remove: list[str] = field(default_factory=list)
+
+
+# https://gpoddernet.readthedocs.io/en/latest/api/reference/subscriptions.html#upload-subscription-changes
+@dataclass(kw_only=True)
+class SubscriptionsGet(SubscriptionsChangeRequest):
+    """SubscriptionsGet."""
+
+    timestamp: int
+
+
+def action_tagger(cls: "type[EpisodeAction]") -> list[str]:
+    """Use action field to distinguish classes.
+
+    NC Gpodder uses upper case values, opodsync lower case.
+    This however does not work with a StrEnum, so plain string as action.
+    """
+    action = cls.__name__.replace("EpisodeAction", "")
+    return [action.upper(), action.lower()]
+
+
+@dataclass(kw_only=True)
+class EpisodeAction(DataClassJSONMixin):
+    """General EpisodeAction.
+
+    See https://gpoddernet.readthedocs.io/en/latest/api/reference/events.html
+    """
+
+    class Config(BaseConfig):
+        """Config."""
+
+        discriminator = Discriminator(
+            field="action", include_subtypes=True, variant_tagger_fn=action_tagger
+        )
+        omit_none = True  # only nextcloud supports guid
+
+    podcast: str
+    episode: str
+    timestamp: str = ""
+    guid: str | None = None
+
+
+@dataclass(kw_only=True)
+class EpisodeActionDownload(EpisodeAction):
+    """EpisodeActionDownload."""
+
+    action: str = "download"
+
+
+@dataclass(kw_only=True)
+class EpisodeActionDelete(EpisodeAction):
+    """EpisodeActionDelete."""
+
+    action: str = "delete"
+
+
+@dataclass(kw_only=True)
+class EpisodeActionNew(EpisodeAction):
+    """EpisodeActionNew."""
+
+    action: str = "new"
+
+
+@dataclass(kw_only=True)
+class EpisodeActionFlattr(EpisodeAction):
+    """EpisodeActionFlattr."""
+
+    action: str = "flattr"
+
+
+@dataclass(kw_only=True)
+class EpisodeActionPlay(EpisodeAction):
+    """EpisodeActionPlay."""
+
+    action: str = "play"
+
+    # all in seconds
+    started: int = 0
+    position: int = 0
+    total: int = 0
+
+
+@dataclass(kw_only=True)
+class EpisodeActionGet(DataClassJSONMixin):
+    """EpisodeActionGet."""
+
+    actions: list[EpisodeAction]
+    timestamp: int
+
+
+class GPodderClient:
+    """GPodderClient."""
+
+    def __init__(
+        self, session: aiohttp.ClientSession, logger: logging.Logger, verify_ssl: bool = True
+    ) -> None:
+        """Init for GPodderClient."""
+        self.session = session
+        self.verify_ssl = verify_ssl
+
+        self.is_nextcloud = False
+        self.base_url: str
+        self.token: str | None
+
+        self.username: str
+        self.device: str
+        self.auth: aiohttp.BasicAuth | None = None  # only for gpodder
+
+        self.logger = logger
+
+        self._nextcloud_prefix = "index.php/apps/gpoddersync"
+
+    def init_nc(self, base_url: str, nc_token: str | None = None) -> None:
+        """Init values for a nextcloud client."""
+        self.is_nextcloud = True
+        self.token = nc_token
+        self.base_url = base_url.rstrip("/")
+
+    async def init_gpodder(self, username: str, password: str, device: str, base_url: str) -> None:
+        """Init via basic auth."""
+        self.username = username
+        self.device = device
+        self.base_url = base_url.rstrip("/")
+        self.auth = aiohttp.BasicAuth(username, password)
+        await self._post(endpoint=f"api/2/auth/{username}/login.json")
+
+    @property
+    def headers(self) -> dict[str, str]:
+        """Session headers."""
+        if self.token is None:
+            raise RuntimeError("Token not set.")
+        return {"Authorization": f"Bearer {self.token}"}
+
+    async def _post(
+        self,
+        endpoint: str,
+        data: dict[str, Any] | list[Any] | None = None,
+    ) -> bytes:
+        """POST request."""
+        try:
+            response = await self.session.post(
+                f"{self.base_url}/{endpoint}",
+                json=data,
+                ssl=self.verify_ssl,
+                headers=self.headers if self.is_nextcloud else None,
+                raise_for_status=True,
+                auth=self.auth,
+            )
+        except ClientResponseError as exc:
+            self.logger.debug(exc)
+            raise RuntimeError(f"API POST call to {endpoint} failed.") from exc
+        if response.status != 200:
+            self.logger.debug(f"Call failed with status {response.status}")
+            raise RuntimeError(f"Api post call failed to {endpoint} failed!")
+        return await response.read()
+
+    async def _get(self, endpoint: str, params: dict[str, str | int] | None = None) -> bytes:
+        """GET request."""
+        response = await self.session.get(
+            f"{self.base_url}/{endpoint}",
+            params=params,
+            ssl=self.verify_ssl,
+            headers=self.headers if self.is_nextcloud else None,
+            auth=self.auth,
+        )
+        status = response.status
+        if response.content_type == "application/json" and status == 200:
+            return await response.read()
+        if status == 404:
+            return b""
+        self.logger.debug(f"Call failed with status {response.status}")
+        raise RuntimeError(f"API GET call to {endpoint} failed.")
+
+    async def get_subscriptions(self, since: int = 0) -> SubscriptionsGet | None:
+        """Get subscriptions.
+
+        since is unix time epoch - this may return none if there are no
+        subscriptions.
+        """
+        if self.is_nextcloud:
+            endpoint = f"{self._nextcloud_prefix}/subscriptions"
+        else:
+            endpoint = f"api/2/subscriptions/{self.username}/{self.device}.json"
+
+        response = await self._get(endpoint, params={"since": since})
+        if not response:
+            return None
+        return SubscriptionsGet.from_json(response)
+
+    async def get_progresses(
+        self, since: int = 0
+    ) -> tuple[list[EpisodeActionPlay | EpisodeActionNew], int | None]:
+        """Get progresses. Timestamp is second return value.
+
+        gpodder net may filter by podcast
+        https://gpoddernet.readthedocs.io/en/latest/api/reference/events.html
+        -> we do not use this for now, since nextcloud implementation is not
+        capable of it. Also, implementation in drop-in replacements varies.
+        """
+        params: dict[str, str | int] = {"since": since}
+        if self.is_nextcloud:
+            endpoint = f"{self._nextcloud_prefix}/episode_action"
+        else:
+            endpoint = f"api/2/episodes/{self.username}.json"
+            params["device"] = self.device
+        response = await self._get(endpoint, params=params)
+        if not response:
+            return [], None
+        actions_response = EpisodeActionGet.from_json(response)
+
+        # play has progress information
+        # new means, there is no progress (i.e. mark unplayed)
+        actions = [
+            x
+            for x in actions_response.actions
+            if isinstance(x, EpisodeActionPlay | EpisodeActionNew)
+        ]
+
+        with suppress(ValueError):
+            actions = sorted(actions, key=lambda x: datetime.datetime.fromisoformat(x.timestamp))[
+                ::-1
+            ]
+
+        return actions, actions_response.timestamp
+
+    async def update_subscriptions(
+        self, add: list[str] | None = None, remove: list[str] | None = None
+    ) -> None:
+        """Update subscriptions."""
+        if add is None:
+            add = []
+        if remove is None:
+            remove = []
+        request = SubscriptionsChangeRequest(add=add, remove=remove)
+        if self.is_nextcloud:
+            endpoint = f"{self._nextcloud_prefix}/subscription_change/create"
+        else:
+            endpoint = f"api/2/subscriptions/{self.username}/{self.device}.json"
+
+        await self._post(endpoint=endpoint, data=request.to_dict())
+
+    async def update_progress(
+        self,
+        *,
+        podcast_id: str,
+        episode_id: str,
+        guid: str | None,
+        position_s: float,
+        duration_s: float,
+    ) -> None:
+        """Update progress."""
+        utc_timestamp = (
+            datetime.datetime.now(datetime.UTC).replace(microsecond=0, tzinfo=None).isoformat()
+        )
+
+        episode_action: EpisodeActionNew | EpisodeActionPlay
+        if position_s == 0:
+            # mark unplayed
+            episode_action = EpisodeActionNew(
+                podcast=podcast_id, episode=episode_id, timestamp=utc_timestamp
+            )
+        else:
+            episode_action = EpisodeActionPlay(
+                podcast=podcast_id,
+                episode=episode_id,
+                timestamp=utc_timestamp,
+                position=int(position_s),
+                started=0,
+                total=int(duration_s),
+            )
+
+        # It is a bit unclear here, if other gpodder alternatives then nextcloud support the guid
+        # for episodes. I didn't see that in the source for opodsync at least...
+        if self.is_nextcloud:
+            episode_action.guid = guid
+            endpoint = f"{self._nextcloud_prefix}/episode_action/create"
+        else:
+            endpoint = f"api/2/episodes/{self.username}.json"
+        await self._post(endpoint=endpoint, data=[episode_action.to_dict()])
diff --git a/music_assistant/providers/gpodder/icon.svg b/music_assistant/providers/gpodder/icon.svg
new file mode 100644 (file)
index 0000000..b9b01dd
--- /dev/null
@@ -0,0 +1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" version="1">
+ <path style="opacity:.2" d="m31.234864 4.9305978c-0.579715 0.00936-1.16651 0.047008-1.755465 0.112109-9.229916 1.0203908-15.953655 8.6347222-15.319661 17.135645l-2.16018 0.241997 1.235988 11.179899 7.205935-0.795993-1.236989-11.183898-2.668575 0.295997c-0.494596-7.284934 5.279552-13.789275 13.19548-14.664267 7.911129-0.8744923 14.998864 4.198562 16.116254 11.407696l-2.348778 0.259758 1.235928 11.180699 6.956137-0.768353-1.235928-11.180699-2.23398 0.246098c-1.17799-7.878929-8.491923-13.604877-16.985846-13.467878z"/>
+ <path style="opacity:.2" d="m30.172873 11.002403c-0.979371 0.03772-1.839903 0.558095-2.515577 1.329088-0.675594 0.770993-1.209269 1.797824-1.658765 3.002973-0.898992 2.410378-1.451667 7.553967-1.757724 10.805338-0.306057 3.25137-0.358397 4.604202-0.218738 7.407177 0.139659 2.802974 0.412436 4.984355 0.989531 6.202344 0.504755 1.06523 1.09117 2.282779 1.343688 3.382369 0.252517 1.09959 0.199698 1.975902-0.447896 2.684776-0.647594 0.708773-2.076181 1.346268-4.822757 1.493546-2.746375 0.147299-6.759738-0.984691-12.449887-2.043661-0.9165116-0.170559-1.6811047-0.185799-2.3461787 0.034479-0.665134 0.220298-1.1858293 0.710274-1.5025264 1.329088-0.6334143 1.237609-0.6903537 5.739384-0.7838129 8.091763-0.03022 0.760653 0.2491177 1.485787 0.7395533 2.079781 0.4904356 0.594055 1.1771094 1.081011 2.0285816 1.506827 1.7028846 0.851632 4.0875631 1.465546 7.0491361 1.902102 5.922947 0.873073 14.146472 1.010671 23.487788 0.469556l0.0078-0.0026h0.0104c3.438169-0.283137 8.87692-0.481755 13.567078-1.673945 2.344978-0.596094 4.513359-1.435827 6.168944-2.719175 1.655545-1.283348 2.789375-3.065172 2.900974-5.337552 0.103759-2.112781-0.0054-5.328707-0.429677-6.345298-0.424316-1.016611-1.333667-1.558926-2.268179-1.578446-1.868903-0.03904-4.070963 0.984551-6.929337 1.655345-3.279971 0.769753-5.59415 1.441121-7.168935 1.321722-1.574806-0.119419-2.361979-0.987455-2.848775-1.613539-0.486796-0.626095-0.705514-1.552266-0.807253-2.729776-0.101719-1.177529-0.08986-2.571776-0.244778-4.040363-0.297997-2.813975-0.859992-7.403977-2.129981-13.289924-0.633994-2.942373-1.439987-7.711566-2.521977-9.794147-0.541995-1.041191-1.14999-1.916783-1.879983-2.549377-0.729993-0.632394-1.609985-1.015591-2.561977-0.978791z"/>
+ <path style="fill:#4d4d4d" d="m31.234864 3.9306156c-0.579715 0.00936-1.16651 0.047008-1.755465 0.112109-9.229916 1.0203908-15.953655 8.6347214-15.319661 17.135645l-2.16018 0.241998 1.235988 11.179898 7.205935-0.795992-1.236989-11.183899-2.668575 0.295997c-0.494596-7.284934 5.279552-13.789275 13.19548-14.664267 7.911129-0.8744925 14.998864 4.198562 16.116254 11.407696l-2.348778 0.259758 1.235928 11.180699 6.956137-0.768353-1.235928-11.180699-2.23398 0.246098c-1.17799-7.8789289-8.491923-13.604877-16.985846-13.467878z"/>
+ <path style="fill:#974fa4" d="m30.172873 10.002421c-0.979371 0.03772-1.839903 0.558095-2.515577 1.329088-0.675594 0.770993-1.209269 1.797824-1.658765 3.002973-0.898992 2.410378-1.451667 7.553967-1.757724 10.805338-0.306057 3.25137-0.358397 4.604202-0.218738 7.407177 0.139659 2.802974 0.412436 4.984355 0.989531 6.202344 0.504755 1.06523 1.09117 3.282411 1.343688 4.382002 0.252517 1.099589 0.199698 1.975901-0.447896 2.684776-0.647594 0.708772-2.076181 1.346267-4.822757 1.493545-2.746375 0.147299-6.759738-0.984691-12.449887-2.043661-0.9165116-0.170559-1.6811047-0.185799-2.3461787 0.034479-0.665134 0.220298-1.1858293 0.710274-1.5025264 1.329088-0.6334143 1.23761-0.6903537 4.739753-0.7838129 7.092131-0.03022 0.760653 0.2491177 1.485787 0.7395533 2.079781 0.4904356 0.594055 1.1771094 1.081011 2.0285816 1.506827 1.7028846 0.851632 4.0875631 1.465546 7.0491361 1.902103 5.922947 0.873072 14.146472 1.01067 23.487788 0.469555l0.0078-0.0026h0.0104c3.438169-0.283137 8.87692-0.481755 13.567078-1.673945 2.344978-0.596094 4.513359-1.435827 6.168944-2.719175 1.655545-1.283348 2.789375-3.065172 2.900974-5.337552 0.103759-2.11278-0.0054-4.329075-0.429677-5.345666-0.424316-1.016611-1.333667-1.558926-2.268179-1.578446-1.868903-0.03904-4.070963 0.984551-6.929337 1.655345-3.279971 0.769753-5.59415 1.441471-7.168935 1.322072-1.574806-0.119419-2.361979-0.987805-2.848775-1.613889-0.486796-0.626094-0.705514-1.552266-0.807253-2.729776-0.101719-1.177528-0.08986-3.571408-0.244778-5.039995-0.297997-2.813975-0.859992-7.403977-2.129981-13.289924-0.633994-2.942373-1.439987-7.711566-2.521977-9.794147-0.541995-1.041191-1.14999-1.916783-1.879983-2.549377-0.729993-0.632394-1.609985-1.0155908-2.561977-0.978791z"/>
+ <path style="opacity:.2;fill:#ffffff" d="m30.173828 10.001953c-0.979371 0.03772-1.839951 0.559085-2.515625 1.330078-0.675594 0.770993-1.21066 1.796804-1.660156 3.001953-0.898992 2.410378-1.451756 7.55527-1.757813 10.806641-0.235338 2.500094-0.315925 3.888536-0.279296 5.666016 0.009338-1.35499 0.089458-2.649285 0.279296-4.666016 0.306057-3.251371 0.858821-8.396263 1.757813-10.806641 0.449496-1.205149 0.984562-2.23096 1.660156-3.001953 0.675674-0.770993 1.536254-1.292358 2.515625-1.330078l-0.001953 0.001953c0.951992-0.0368 1.832507 0.346122 2.5625 0.978516 0.729993 0.632594 1.336911 1.50959 1.878906 2.550781 1.08199 2.082581 1.887491 6.850596 2.521485 9.792969 1.269989 5.885947 1.832862 10.477041 2.130859 13.291016 0.154918 1.468587 0.142422 3.861534 0.244141 5.039062 0.101739 1.17751 0.321797 2.104375 0.808593 2.730469 0.486796 0.626084 1.272851 1.493862 2.847657 1.613281 1.574785 0.119399 3.88995-0.552513 7.169922-1.322266 2.858373-0.670794 5.058831-1.693336 6.927734-1.654296 0.934512 0.01952 1.845215 0.561514 2.269531 1.578124 0.290862 0.696921 0.423577 1.970304 0.451172 3.375 0.030586-1.790046-0.091897-3.514156-0.451172-4.375-0.424316-1.01661-1.335019-1.558604-2.269531-1.578124-1.868903-0.03904-4.069361 0.983502-6.927734 1.654296-3.279971 0.769753-5.595137 1.441665-7.169922 1.322266-1.574806-0.119419-2.360861-0.987197-2.847657-1.613281-0.486796-0.626094-0.706854-1.552959-0.808593-2.730469-0.101719-1.177528-0.089223-3.570475-0.244141-5.039062-0.297997-2.813975-0.86087-7.405069-2.130859-13.291016-0.633994-2.942373-1.439495-7.710388-2.521485-9.792969-0.541995-1.041191-1.148913-1.918187-1.878906-2.550781-0.729993-0.632394-1.610508-1.0153156-2.5625-0.978516l0.001953-0.001953zm-3.726562 34.693359c-0.071481 0.41358-0.234305 0.787497-0.539063 1.121094-0.647594 0.708772-2.077643 1.346863-4.824219 1.494141-2.746375 0.147299-6.759069-0.985952-12.449218-2.044922-0.9165121-0.170559-1.6806295-0.185122-2.3457035 0.035156-0.665134 0.220298-1.1872091 0.709311-1.5039063 1.328125-0.6334143 1.23761-0.6897439 4.741372-0.7832031 7.09375-0.0052126 0.131204 0.0107354 0.25812 0.0234375 0.386719 0.084248-2.313747 0.1767307-5.341293 0.7597656-6.480469 0.3166972-0.618814 0.8387723-1.107827 1.5039063-1.328125 0.665074-0.220278 1.4291914-0.205715 2.3457031-0.035156 5.6901494 1.05897 9.7028434 2.192221 12.449218 2.044922 2.746576-0.147278 4.176625-0.785369 4.824219-1.494141 0.530904-0.581143 0.648165-1.284118 0.539063-2.121094z"/>
+ <path style="opacity:.2" d="m27.972893 27.300085a5.9821459 12.001091 2.5216 1 1 -11.948492 -0.601994 5.9821459 12.001091 2.5216 1 1 11.948492 0.601994z"/>
+ <path style="fill:#ffffff" d="m27.972893 26.300103a5.9821459 12.001091 2.5216 1 1 -11.948492 -0.601994 5.9821459 12.001091 2.5216 1 1 11.948492 0.601994z"/>
+ <path style="fill:#4d4d4d" d="m25.984911 28.096087a2.9826253 4.0160341 6.8892 0 1 -5.972546 -0.192018 2.9826253 4.0160341 6.8892 1 1 5.972546 0.192018z"/>
+ <path style="opacity:.2" d="m45.872731 26.366094a12.052057 6.9014327 83.212 0 1 -13.747876 1.267048 12.052057 6.9014327 83.212 1 1 13.747876 -1.267048z"/>
+ <path style="fill:#ffffff" d="m45.872731 25.366112a12.052057 6.9014325 83.212 0 1 -13.747876 1.267048 12.052057 6.9014325 83.212 1 1 13.747876 -1.267048z"/>
+ <path style="fill:#4d4d4d" d="m41.892767 28.274086a2.877574 4.088963 16.98 0 1 -5.789748 -0.541936 2.877574 4.088963 16.98 1 1 5.789748 0.541936z"/>
+ <path style="opacity:.1;fill:#ffffff" d="m31.234375 3.9296875c-0.579715 0.00936-1.166904 0.0481803-1.755859 0.1132813-8.942193 0.9885822-15.510312 8.1687032-15.335938 16.345703 0.356803-7.755469 6.759803-14.39759 15.335938-15.345703 0.588955-0.0651013 1.176144-0.1039215 1.755859-0.1132815 8.493923-0.1369987 15.808338 5.5878675 16.986328 13.466796l2.234375-0.246093 1.126953 10.193359 0.109375-0.011719-1.236328-11.18164-2.234375 0.246093c-1.17799-7.8789285-8.492405-13.603795-16.986328-13.466796zm14.408203 13.753906l-2.144531 0.236328 0.109375 0.988281 2.240234-0.248047c-0.051435-0.331848-0.128843-0.654139-0.205078-0.976562zm-29.115234 2.740234c-0.024116 0.493084-0.026276 0.990101 0.007812 1.492188l2.669922-0.296875 1.126953 10.197265 0.109375-0.011718-1.236328-11.183594-2.669922 0.294922c-0.011225-0.165332-0.002997-0.327717-0.007812-0.492188zm-2.388672 0.75586l-2.138672 0.240234 0.109375 0.988281 2.050781-0.230469c-0.024997-0.335177-0.019066-0.665975-0.021484-0.998046z"/>
+</svg>
diff --git a/music_assistant/providers/gpodder/manifest.json b/music_assistant/providers/gpodder/manifest.json
new file mode 100644 (file)
index 0000000..903b3cf
--- /dev/null
@@ -0,0 +1,11 @@
+{
+  "type": "music",
+  "domain": "gpodder",
+  "name": "gPodder",
+  "description": "gPodder podcast provider",
+  "codeowners": [
+    "@fmunkes"
+  ],
+  "documentation": "https://music-assistant.io/music-providers/gpodder",
+  "multi_instance": true
+}