Skip to content

Add Pocket Casts Provider#3127

Draft
yfhyou wants to merge 26 commits intomusic-assistant:devfrom
yfhyou:pocketcasts
Draft

Add Pocket Casts Provider#3127
yfhyou wants to merge 26 commits intomusic-assistant:devfrom
yfhyou:pocketcasts

Conversation

@yfhyou
Copy link
Copy Markdown

@yfhyou yfhyou commented Feb 9, 2026

Summary

Adds a new music provider for Pocketcasts podcast service, allowing users to:

  • Access their Pocketcasts podcast library
  • Browse subscribed podcasts and 5 special folders (Up Next, New Releases, In Progress, Starred, History)
  • Search for new podcasts
  • Subscribe/unsubscribe to podcasts (syncs back to Pocketcasts)
  • Sync playback progress bidirectionally with Pocketcasts
  • Resume playback from saved positions

Features

Feature Status
LIBRARY_PODCASTS
LIBRARY_PODCASTS_EDIT
BROWSE
SEARCH

Implementation

  • 4 files, ~1900 lines total
  • manifest.json - Provider configuration
  • api_client.py - Custom API client for Pocketcasts (reverse-engineered API)
  • __init__.py - Main provider implementation
  • STATUS.md - Development documentation

Playback Sync

  • Progress syncs every 30 seconds during playback
  • Episode completion triggers: mark played → remove from Up Next → archive
  • Starting playback adds episode to Pocketcasts Up Next queue and history list
  • Handles duration discrepancy between static API and actual episode length

Test plan

  • Login with valid/invalid credentials
  • Browse podcast library and episodes
  • Play episodes with resume position
  • Subscribe/unsubscribe to podcasts
  • Progress sync to Pocketcasts
  • Episode completion sequence
  • Search functionality
  • Large libraries (100+ podcasts)

Notes

  • Uses unofficial/undocumented Pocketcasts API (reverse-engineered)
  • Authentication uses JWT bearer tokens (~5 month validity)
  • No additional Python dependencies required

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 9, 2026

🔒 Dependency Security Report

✅ No dependency changes detected in this PR.

@yfhyou
Copy link
Copy Markdown
Author

yfhyou commented Feb 10, 2026

Hello! I have updated this provider starting with the great work that @OzGav did. I was going to use a seperate API library, but decided to use the local api_client.py that was already created.
There are still a few possible missing features, and somethings that don't match between pocketcasts and MA.
You can track a lot of the progress in the STATUS.md. A few things to consider:

  • there is no 'favorites' podcast option in pocket casts, only subscribe/unsubscribe so the favorites is local to MA only
  • there is no way to 'favorite' an episode in MA, so there is only the 'starred' items playlist in browse
  • There sometimes seems to be a discrepency between what the pocket casts API duration and actual duration are. I tried to setup the syncing best as possible for different scenarios, but better testing probably still needs to happen.
  • The episode list doesn't always seem to reflect the actual from the API - probably needs a little more investigation.
  • Listen statistics are not synced at the moment so playing does not add to the user statistics. (/user/stats/add endpoint)
  • As far as I can tell MA cannot change the playback speed.

Overall this should be a usable provider with basic sync functions.
Big shoutout to Claude Code - it is an amazing tool.

@yfhyou yfhyou marked this pull request as ready for review February 10, 2026 14:22
Copilot AI review requested due to automatic review settings February 10, 2026 14:22
@OzGav
Copy link
Copy Markdown
Contributor

OzGav commented Feb 10, 2026

This will be reviewed fully in due course however a couple of things. I wont be the code owner. You can remove the status.md file. You need to add icon.svg and icon_monochrome.svg

@yfhyou yfhyou marked this pull request as draft February 10, 2026 15:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@yfhyou yfhyou marked this pull request as ready for review February 11, 2026 20:37
Copilot AI review requested due to automatic review settings February 11, 2026 20:37
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 5 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@yfhyou yfhyou marked this pull request as draft February 12, 2026 16:07
@yfhyou yfhyou marked this pull request as ready for review February 17, 2026 20:53
Copilot AI review requested due to automatic review settings February 17, 2026 20:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 5 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@OzGav
Copy link
Copy Markdown
Contributor

OzGav commented Feb 18, 2026

Please address the copilot suggestiions and resolve them with a comment.

@MarvinSchenkel
Copy link
Copy Markdown
Contributor

MarvinSchenkel commented Mar 4, 2026

Please apply @use_cache decorators to functions that hit the API to avoid hammering them. Look at other providers for inspiration, but same values would be:

  • search() -> @use_cache(3600 * 24 * 7)
  • get_podcast() -> @use_cache(3600 * 24)
  • get_podcast_episode() -> @use_cache(3600)

@MarvinSchenkel
Copy link
Copy Markdown
Contributor

Marking this PR as draft so we can keep track of which PRs needs our attention. Please mark as 'Ready for review' when you want us to have another look 🙏 .

@MarvinSchenkel MarvinSchenkel marked this pull request as draft March 4, 2026 10:22
@OzGav OzGav added this to the 2.9.0 milestone Mar 15, 2026
yfhyou and others added 21 commits March 24, 2026 12:31
Fixes:
- Bug music-assistant#1: Replace self.lookup_key with self.instance_id
  - Episodes were failing to convert due to missing attribute
  - Changed lines 169, 174, 215 in __init__.py
  - Tested and verified working

Documentation:
- Add STATUS.md with complete development tracking
  - Implementation status (24 features implemented)
  - Testing results from 2026-02-01
  - Bug documentation (1 fixed, 1 resolved, 1 investigating)
  - API endpoints reference
  - Contributing guidelines
  - Changelog

Testing:
- Verified login, podcast list, episodes work correctly
- Identified resume position issue for further investigation
- Documented all findings in STATUS.md

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Episodes were always starting at 0:00 instead of resuming from the last played position. Investigation revealed two separate issues:

1. get_resume_position() was returning seconds, but Music Assistant expects milliseconds
2. StreamDetails was missing the allow_seek=True flag, which prevented ffmpeg from applying the -ss seek parameter

Both issues have been fixed and tested. Episodes now correctly resume from their saved positions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updated STATUS.md with extensive documentation of Pocketcasts API capabilities:

- Mapped 7 Pocketcasts features to Music Assistant provider features (RECOMMENDATIONS, FAVORITE_PODCASTS_EDIT, PLAYLIST features, LYRICS, etc.)
- Replaced all guessed API endpoints with verified endpoints from unofficial Pocketcasts API documentation
- Documented 20+ additional API endpoints organized by domain and functionality
- Created phased implementation priority plan (Phase 1-4)
- Cleaned up duplicate Library Management items (subscribe/unsubscribe)
- Added notes on unconfirmed features (Transcripts, Filters)

This provides a clear roadmap for future Pocketcasts provider development.

Reference: https://github.com/yfhyou/api_pocketcasts/blob/main/reference/endpoints.md

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added comprehensive documentation about Pocketcasts authentication tokens:

Authentication Findings:
- Pocketcasts API supports two token types:
  1. Mobile/API tokens (scope: "mobile") - valid ~5 months, no refresh needed
  2. Web player tokens (scope: "webplayer") - valid 1 hour, requires refresh
- Current implementation uses mobile tokens (long-lived)
- Token refresh is not required for our use case

Documentation Updates:
- Added "Authentication & Token Management" section explaining token types
- Moved token refresh to Phase 4 (low priority) with rationale
- Clarified that /user/token endpoint is for web player refresh only
- Added explanation of JWT claims (iat, exp) in mobile tokens
- Marked login error handling as tested and working

This explains why the provider continues working after the supposed "1 hour expiry" -
we're using mobile tokens that last months, not the short-lived web player tokens.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implemented 5 special browse folders that appear at root level:
- Up Next: User's queued episodes
- New Releases: Recent episodes from subscriptions
- In Progress: Episodes currently being listened to
- Starred: Favorited episodes
- History: Recently played episodes

API Client Changes:
- Added get_up_next_episodes() for POST /up_next/list
  * Returns dict with UUIDs as keys (unique response format)
- Added get_new_releases() for POST /user/new_releases
- Added get_starred_episodes() for POST /user/starred
- Added get_history() for POST /user/history
- Fixed type hints in __aexit__ for proper async context manager

Provider Changes:
- Added _create_browse_folders() helper to generate folder list
- Added _get_special_folder_episodes() to fetch folder-specific episodes
- Updated browse() to show folders at root and handle folder paths
- Special handling for Up Next endpoint's unique response format:
  * Returns episodes as dict with UUIDs as keys (not a list)
  * Modified iteration to handle both dict and list formats
  * Extract episode UUID from dict key when missing from data
  * Handle podcast field as both string (Up Next) and object (others)

Testing:
- All 5 browse folders tested and working
- Episodes display correctly in all folders
- Playback works from all folder types

Documentation:
- Updated STATUS.md with implementation details
- Added changelog entry for 2026-02-02
- Documented special handling for Up Next endpoint
- Updated test results and next steps

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add complete library management functionality allowing users to subscribe
and unsubscribe from podcasts directly through Music Assistant, syncing
changes back to Pocketcasts account.

API Client Changes:
- Add subscribe_podcast() method (POST /user/podcast/subscribe)
- Add unsubscribe_podcast() method (POST /user/podcast/unsubscribe)
- Enhanced error logging with response status and reason

Provider Changes:
- Implement library_add() for subscribing to podcasts
- Implement library_remove() for unsubscribing from podcasts
- Both methods delegate to base class for non-podcast media types
- Full error handling and logging

Testing:
- Verified subscribe/unsubscribe work correctly in UI
- Tested API error handling with invalid UUIDs
- API validates UUID format (400 for malformed)
- API accepts well-formed UUIDs even if non-existent (200)
- Not a concern in practice - UUIDs come from Pocketcasts APIs

Documentation:
- Mark LIBRARY_PODCASTS_EDIT as fully implemented
- Clarify FAVORITE_PODCASTS_EDIT not applicable to Pocketcasts
- Document API behavior regarding UUID validation
- Add comprehensive testing notes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add on_played() callback for syncing playback progress every 30 seconds
- Mark episodes as played when within 45 seconds of actual end
- Use /user/episode endpoint for accurate duration (fixes early completion)
- Add episode to Up Next via play_now() when starting playback
- Archive completed episodes and remove from Up Next queue
- Unarchive episodes when replaying from earlier position
- Ignore MA's fully_played flag (uses wrong duration from static API)

API methods added:
- get_episode_details() - Fetch real duration and playback status
- mark_episode_played() - Mark episode as played (status=3)
- mark_episode_unplayed() - Reset to unplayed (status=1)
- archive_episode() - Archive/unarchive episodes
- remove_from_up_next() - Remove from Up Next queue
- play_now() - Add to Up Next at top position

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Reduced from 868 to 362 lines by consolidating redundant sections:
- Merged feature status sections into single Feature Status table
- Combined potential/priority/not-implemented into Roadmap section
- Simplified known issues and testing status into compact tables
- Condensed changelog while preserving key information

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add provider icons (icon.svg, icon_monochrome.svg), fix bugs found in
Copilot code review: correct provider field in _convert_podcast to use
instance_id, add missing media_type to StreamDetails, fix str(None) bug
in config handling, add exception chaining in get_podcast, cache episode
duration in on_played to reduce API calls. Also remove STATUS.md from
PR, fix manifest name to "Pocket Casts", remove duplicate import and
unused PLAY_URL constant, and remove verbose search response logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
API client methods now raise PocketCastsAPIError on non-200 responses
and LoginError on 401/403, instead of silently returning empty lists.
This lets callers distinguish between "empty library" and "API failure",
and ensures handle_async_init fails fast on auth problems. Added
try/except to _get_special_folder_episodes, the one unprotected caller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add SVG browse folder icons (up next, new releases, in progress,
  starred, history) matching the Pocket Casts web player style
- Embed icons as base64 data URIs for self-contained operation
- Add remotely_accessible=True to podcast thumbnail MediaItemImage
- Refactor _create_browse_folders to use a loop with icon lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace get_podcast_episodes() + linear scan with get_episode_details()
which fetches a single episode directly via /user/episode. This avoids
downloading the entire episode list on every playback start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Normalize position (treat None as 0) since MA's mark_item_played allows
seconds_played=None. Gate the "mark unplayed" branch on not is_playing
to avoid conflating playback at position 0 with the explicit unplayed action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Inject the shared MA http_session into PocketCastsClient via constructor
instead of creating and managing a standalone aiohttp.ClientSession.
This is consistent with other providers and reuses the shared connector,
User-Agent, and response class. Remove session ownership (context manager,
close in unload) since the session lifecycle is managed by MA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Call /history/do when an episode begins playing to add it to the
Pocket Casts listening history, alongside the existing play_now call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace duplicated 0.9 threshold and special folder name literals with
FULLY_PLAYED_THRESHOLD and SPECIAL_FOLDERS module-level constants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All other API methods raise LoginError on 401/403 responses. Add the
same check to get_podcast_episodes to match the established pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both get_podcast_episodes and browse contained identical logic for
applying in-progress and history playback status to episode objects.
Extract this into _enrich_episode_with_status to ensure both paths
stay in sync and the browse path now also gets the debug logging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move api_client import above TYPE_CHECKING block
- Move FULLY_PLAYED_THRESHOLD and SPECIAL_FOLDERS before SUPPORTED_FEATURES
- Fix codeowners format in manifest.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Caches search (7 days), get_podcast (24h), and get_podcast_episode (1h)
to avoid hammering the Pocket Casts API on repeated requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@yfhyou
Copy link
Copy Markdown
Author

yfhyou commented Mar 24, 2026

Please apply @use_cache decorators to functions that hit the API to avoid hammering them. Look at other providers for inspiration, but same values would be:

* `search()` -> `@use_cache(3600 * 24 * 7)`

* `get_podcast()` -> `@use_cache(3600 * 24)`

* `get_podcast_episode()` -> `@use_cache(3600)`

This was added in eb326d2. I hope it is as you intended!

@yfhyou yfhyou marked this pull request as ready for review March 24, 2026 18:40
Comment on lines +13 to +18
class PocketCastsAPIError(Exception):
"""Base exception for Pocket Casts API errors."""


class LoginError(PocketCastsAPIError):
"""Login failed."""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to use the MA errors directly or raise aiohttp.ClientError-derived exceptions and let the provider translate them


return success

except Exception as err:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wherever you have this catch-all it needs to be narrowed to the specific error you are looking for.

LOGGER.info("Found %d podcasts for query '%s'", len(podcasts), query)
return podcasts

async def get_podcast_details(self, podcast_uuid: str) -> dict[str, Any] | None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unusual as it is trying two endpoints in sequence regardless of whether the first succeeded with valid data? And the second endpoint appears to send json=None?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore, this function does not seem to get called at all?

LOGGER.debug("Retrieved %d in-progress episodes", len(episodes))
return episodes

async def get_up_next_episodes(self) -> dict[str, dict[str, Any]] | list[dict[str, Any]]:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type should be normalised here to avoid the caller having to do work

@@ -0,0 +1,589 @@
"""Simple Pocket Casts API client built from scratch."""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read methods have no try/except for network errors — a connection failure will raise an unhandled aiohttp.ClientError all the way up. The provider doesn't handle it, (it uses bare except Exception), so it falls through to unhelpful logging.


try:
# This endpoint returns a 302 redirect to the static JSON with timestamp
async with self.mass.http_session.get(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you bypassing the api_client here?

allow_seek=True,
)

@use_cache(3600 * 24 * 7)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems long for podcasts which can change daily


except Exception as err:
LOGGER.exception("Error adding podcast to library: %s", err)
return False
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to raise. Same for next function

LOGGER.exception("Failed to initialize: %s", err)
raise LoginFailed(f"Failed to initialize Pocket Casts: {err}") from err

async def unload(self, is_removed: bool = False) -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the override which does nothing?

# Get the podcast UUID from the item
podcast_uuid = item.item_id

LOGGER.info("Adding podcast to library: %s (%s)", item.name, podcast_uuid)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these infos are going to spam the log. If they are necessary at all they should be at debug level.

"documentation": "https://music-assistant.io/music-providers/pocketcasts/",
"type": "music",
"requirements": [],
"codeowners": "[@yfhyou]",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an array, so ["yfhyou"]

Copy link
Copy Markdown
Contributor

@MarvinSchenkel MarvinSchenkel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please have a look at our comments. Marking this PR as draft , feel free to mark it as 'ready for review' again when you want us to have another look

LOGGER.info("Found %d podcasts for query '%s'", len(podcasts), query)
return podcasts

async def get_podcast_details(self, podcast_uuid: str) -> dict[str, Any] | None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore, this function does not seem to get called at all?

@MarvinSchenkel MarvinSchenkel marked this pull request as draft April 2, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants