Skip to content

Add Pocket Casts Provider#3127

Open
yfhyou wants to merge 21 commits intomusic-assistant:devfrom
yfhyou:pocketcasts
Open

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

Conversation

@yfhyou
Copy link

@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
  • 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

OzGav and others added 12 commits February 9, 2026 20:59
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>
@github-actions
Copy link
Contributor

github-actions bot commented Feb 9, 2026

🔒 Dependency Security Report

✅ No dependency changes detected in this PR.

@yfhyou
Copy link
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
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
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.

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>
@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
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.

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>
@yfhyou yfhyou marked this pull request as draft February 12, 2026 16:07
- 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>
yfhyou and others added 5 commits February 13, 2026 13:22
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>
@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
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.

Comment on lines +921 to +924
# Case 2: Near or past the end (within 45 seconds of real duration)
# Threshold must exceed MA's 30-second callback interval to guarantee
# the last callback before episode end triggers completion.
if real_duration > 0 and position >= (real_duration - 45):
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

The 45-second threshold for marking episodes as played (line 924) and the 30-second callback interval (line 922) are magic numbers that would be clearer as named constants at the module or class level. Consider defining COMPLETION_THRESHOLD_SECONDS = 45 and CALLBACK_INTERVAL_SECONDS = 30 to make the relationship between these values explicit and easier to maintain if the callback interval changes.

Copilot uses AI. Check for mistakes.
duration = int(ep.get("duration", 0))

# Consider fully played if > 90%
fully_played = duration > 0 and (played_up_to / duration) > 0.9
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

The 0.9 threshold for determining "fully played" status appears in four different places (lines 475, 659, 782, 836). This duplicated magic number should be extracted as a module-level or class-level constant like FULLY_PLAYED_THRESHOLD = 0.9 to ensure consistency and make it easier to adjust if needed.

Copilot uses AI. Check for mistakes.
Comment on lines +558 to +563
folders = [
("up_next", "Up Next"),
("new_releases", "New Releases"),
("in_progress", "In Progress"),
("starred", "Starred"),
("history", "History"),
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

The special folder names ("up_next", "new_releases", "in_progress", "starred", "history") are duplicated as string literals across multiple locations: line 558-563 (_create_browse_folders), line 506-515 (_get_special_folder_episodes), and line 614 (browse method check). Consider extracting these as class-level or module-level constants to avoid typos and make maintenance easier. For example, define a constant like SPECIAL_FOLDERS = ("up_next", "new_releases", "in_progress", "starred", "history") and use it consistently.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +96
if response.status != 200:
raise PocketCastsAPIError(
f"Failed to get episodes for {podcast_uuid}: {response.status}"
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

Unlike other API methods in this client, get_podcast_episodes does not check for 401/403 authentication errors before the general non-200 check. This endpoint uses the unauthenticated podcast-api.pocketcasts.com domain (line 91), so auth errors are unlikely. However, for consistency with the pattern established by all other methods (see lines 75-76, 113-114, 134-135, etc.), consider adding an explicit 401/403 check that raises LoginError, even if authentication isn't strictly required for this endpoint.

Suggested change
if response.status != 200:
raise PocketCastsAPIError(
f"Failed to get episodes for {podcast_uuid}: {response.status}"
if response.status in (401, 403):
raise LoginError(f"Authentication failed with status {response.status}")
if response.status != 200:
text = await response.text()
raise PocketCastsAPIError(
f"Failed to get episodes for {podcast_uuid}: {response.status} - {text}"

Copilot uses AI. Check for mistakes.
Comment on lines +640 to +663
# Create lookup maps for status
in_progress_map = {ep.get("uuid"): ep for ep in in_progress}
history_set = {ep.get("uuid") for ep in history}

for episode_data in episodes:
episode_item = self._convert_episode(episode_data, item_path)
if episode_item:
episode_uuid = episode_data.get("uuid")

# Check in-progress status
if episode_uuid in in_progress_map:
ip_data = in_progress_map[episode_uuid]
played_up_to = ip_data.get("playedUpTo", 0)
duration = ip_data.get("duration", episode_data.get("duration", 0))

episode_item.resume_position_ms = played_up_to * 1000

# Consider played if > 90% complete
if duration > 0:
episode_item.fully_played = (played_up_to / duration) > 0.9

# If in history but not in-progress, likely fully played
elif episode_uuid in history_set:
episode_item.fully_played = True
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

The logic for enriching episodes with playback status (lines 640-663) is duplicated from get_podcast_episodes (lines 456-488). This includes creating the same lookup maps and applying the same status logic. Consider extracting this into a helper method like _enrich_episodes_with_status that takes episodes and applies the in-progress/history lookups, returning the enriched list. This would improve maintainability by ensuring both code paths use identical logic.

Copilot uses AI. Check for mistakes.
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.

2 participants