Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a75f3d079 |
@@ -27,7 +27,7 @@ spec:
|
||||
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
|
||||
# Any redis major-version bump is a separate migration.
|
||||
- name: redis
|
||||
image: redis:7.4.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
|
||||
@@ -21,13 +21,10 @@ data:
|
||||
the pod's default route — which players on the IoT VLAN cannot route to.
|
||||
The v30iot address lives on the net1 macvlan child (see
|
||||
network-attachment.yaml).
|
||||
- bind_ip must be the same live v30iot address. aiosendspin starts its
|
||||
zeroconf on [bind_ip], or on InterfaceChoice.Default for 0.0.0.0; Default
|
||||
sends mDNS queries via the main-table default route (eth0, net1 routes
|
||||
live in the sbr table), so MA never queries the IoT VLAN for
|
||||
_sendspin._tcp and Voice PEs are only rediscovered when they announce
|
||||
on their own. The Sendspin server has no all-interfaces bind fallback, so
|
||||
bind_ip must be local at start; it is, because it is read from net1 here.
|
||||
- bind_ip must be 0.0.0.0. The Sendspin server (aiosendspin, port 8927) has
|
||||
NO all-interfaces fallback: if bind_ip is a specific IP that is not local
|
||||
it fails to bind and the server never starts, and its proxy then targets
|
||||
that dead IP instead of 127.0.0.1. Binding 0.0.0.0 avoids both.
|
||||
|
||||
The v30iot IP is DHCP and node-dependent (MA is not node-pinned), so a stored
|
||||
IP is a stale assumption the moment MA reschedules or the lease changes -
|
||||
@@ -94,15 +91,15 @@ data:
|
||||
.setdefault("values", {})
|
||||
)
|
||||
old_pub, old_bind = values.get("publish_ip"), values.get("bind_ip")
|
||||
if (old_pub, old_bind) == (ip, ip):
|
||||
if (old_pub, old_bind) == (ip, "0.0.0.0"):
|
||||
print("[reconcile] publish_ip/bind_ip already correct; no change")
|
||||
return 0
|
||||
|
||||
values["publish_ip"] = ip
|
||||
values["bind_ip"] = ip
|
||||
values["bind_ip"] = "0.0.0.0"
|
||||
print(
|
||||
f"[reconcile] publish_ip {old_pub!r} -> {ip!r}, "
|
||||
f"bind_ip {old_bind!r} -> {ip!r}"
|
||||
f"bind_ip {old_bind!r} -> '0.0.0.0'"
|
||||
)
|
||||
|
||||
tmp = SETTINGS + ".reconcile.tmp"
|
||||
|
||||
@@ -44,9 +44,7 @@ spec:
|
||||
# which IoT players can't reach; and
|
||||
# - net1 is DHCP and MA is not node-pinned, so any hard-coded IP goes
|
||||
# stale on the next reschedule or lease change (the Sendspin server
|
||||
# then fails to bind and dies, with no fallback); and
|
||||
# - a 0.0.0.0 bind_ip makes Sendspin's mDNS browse query via eth0, so
|
||||
# Voice PEs on the IoT VLAN are not rediscovered after a restart.
|
||||
# then fails to bind and dies, with no fallback).
|
||||
# So we recompute the v30iot IP from the live interface every boot and
|
||||
# write it into /data/settings.json before MA reads it. Multus attaches
|
||||
# net1 (with its DHCP lease) before any container runs, so this sees the
|
||||
@@ -62,30 +60,6 @@ spec:
|
||||
- name: net-reconcile
|
||||
mountPath: /scripts
|
||||
readOnly: true
|
||||
# Pocket Casts Up Next as a library playlist, pending upstream. Copies
|
||||
# the image's provider and swaps in the patched __init__.py only while
|
||||
# the stock file is the one it was written against (2.10.7 = 2.10.8);
|
||||
# any other version keeps the stock provider.
|
||||
- name: pocketcasts-overlay
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command:
|
||||
- /bin/sh
|
||||
- -euc
|
||||
- |
|
||||
src=$(echo /app/venv/lib/python3*/site-packages/music_assistant/providers/pocketcasts)
|
||||
cp -a "$src/." /overlay/
|
||||
if echo "c88377d887148faf912ff217e021df3cd42bb7071dece0998b07c7bb12eb323e $src/__init__.py" | sha256sum -c --status; then
|
||||
cp /patch/__init__.py /overlay/__init__.py
|
||||
echo "pocketcasts: Up Next playlist patch applied"
|
||||
else
|
||||
echo "pocketcasts: stock provider changed upstream, patch skipped"
|
||||
fi
|
||||
volumeMounts:
|
||||
- name: pocketcasts-overlay
|
||||
mountPath: /overlay
|
||||
- name: pocketcasts-patch
|
||||
mountPath: /patch
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
@@ -107,8 +81,6 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: pocketcasts-overlay
|
||||
mountPath: /app/venv/lib/python3.14/site-packages/music_assistant/providers/pocketcasts
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
@@ -153,11 +125,6 @@ spec:
|
||||
configMap:
|
||||
name: music-assistant-net-reconcile
|
||||
defaultMode: 0555
|
||||
- name: pocketcasts-overlay
|
||||
emptyDir: {}
|
||||
- name: pocketcasts-patch
|
||||
configMap:
|
||||
name: music-assistant-pocketcasts-up-next
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -5,12 +5,6 @@ resources:
|
||||
- network-attachment.yaml
|
||||
- configmap-net-reconcile.yaml
|
||||
- deployment.yaml
|
||||
- po-token-server.yaml
|
||||
- ingress.yaml
|
||||
- kumamonitors.yaml
|
||||
configMapGenerator:
|
||||
# Pocket Casts provider with the Up Next playlist, applied by the
|
||||
# pocketcasts-overlay initContainer. Hashed name: an edit rolls the pod.
|
||||
- name: music-assistant-pocketcasts-up-next
|
||||
namespace: music-assistant
|
||||
files:
|
||||
- __init__.py=pocketcasts-up-next/__init__.py
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
# initContainer (configmap-net-reconcile.yaml) discovers net1's leased
|
||||
# address the same way it used to discover the host's ens19 address —
|
||||
# scanning the pod's own interfaces for a 10.2.30.0/24 address — and
|
||||
# writes it as MA's publish_ip and bind_ip; players reach the
|
||||
# stream/sendspin servers on net1. Requires the cni-dhcp-daemon DaemonSet
|
||||
# in infrastructure/multus.
|
||||
# writes it as MA's publish_ip. bind_ip stays 0.0.0.0, which now binds
|
||||
# eth0 (pod) + net1; players reach the stream/slimproto/sendspin servers
|
||||
# on net1. Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
|
||||
#
|
||||
# Mirrors matter-server-iot; the only difference is MA does not speak
|
||||
# Thread, so the matter NAD's accept_ra_rt_info_max_plen tuning (for the
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# PO token server for the Music Assistant YouTube Music provider.
|
||||
# MA installs the bgutil-ytdlp-pot-provider yt-dlp plugin unpinned at runtime
|
||||
# (latest from PyPI), so the server tracks the same major via image automation.
|
||||
# Unauthenticated by design; reachable only from the MA pod (CNP below).
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: po-token-server
|
||||
namespace: music-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: po-token-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: po-token-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: po-token-server
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: po-token-server
|
||||
image: docker.io/brainicism/bgutil-ytdlp-pot-provider:2.0.2 # {"$imagepolicy": "flux-system:bgutil-pot-provider"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 4416
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ping
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: po-token-server
|
||||
namespace: music-assistant
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: po-token-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 4416
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: po-token-server
|
||||
namespace: music-assistant
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: po-token-server
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": music-assistant
|
||||
app.kubernetes.io/name: music-assistant
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "4416"
|
||||
protocol: TCP
|
||||
# Kubelet probes.
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# YouTube BotGuard challenge fetch.
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
@@ -1,817 +0,0 @@
|
||||
"""Pocket Casts music provider for Music Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from music_assistant_models.enums import (
|
||||
ContentType,
|
||||
ImageType,
|
||||
MediaType,
|
||||
ProviderFeature,
|
||||
StreamType,
|
||||
)
|
||||
from music_assistant_models.errors import (
|
||||
LoginFailed,
|
||||
MediaNotFoundError,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
)
|
||||
from music_assistant_models.media_items import (
|
||||
AudioFormat,
|
||||
BrowseFolder,
|
||||
ItemMapping,
|
||||
MediaItemImage,
|
||||
MediaItemMetadata,
|
||||
MediaItemType,
|
||||
Playlist,
|
||||
Podcast,
|
||||
PodcastEpisode,
|
||||
ProviderMapping,
|
||||
SearchResults,
|
||||
UniqueList,
|
||||
)
|
||||
from music_assistant_models.streamdetails import StreamDetails
|
||||
|
||||
from music_assistant import MusicAssistant
|
||||
from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
|
||||
from music_assistant.controllers.cache import use_cache
|
||||
from music_assistant.helpers.podcast_parsers import rank_episodes_by_date
|
||||
from music_assistant.models.music_provider import MusicProvider
|
||||
|
||||
from .api_client import PocketCastsClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
|
||||
from music_assistant_models.provider import ProviderManifest
|
||||
|
||||
from music_assistant.models import ProviderInstanceType
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FULLY_PLAYED_THRESHOLD = 0.9
|
||||
SPECIAL_FOLDERS = ("up_next", "new_releases", "in_progress", "starred", "history")
|
||||
UP_NEXT_PLAYLIST_ID = "up_next"
|
||||
|
||||
SUPPORTED_FEATURES = {
|
||||
ProviderFeature.LIBRARY_PODCASTS,
|
||||
ProviderFeature.LIBRARY_PLAYLISTS,
|
||||
ProviderFeature.BROWSE,
|
||||
ProviderFeature.SEARCH,
|
||||
ProviderFeature.LIBRARY_PODCASTS_EDIT,
|
||||
}
|
||||
|
||||
BROWSE_FOLDER_ICONS: dict[str, str] = {
|
||||
"up_next": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgZD0iTTMgMi45NzE1OUMzIDIuNTY0OSAzLjQ1OTY4IDIuMzI4MzQgMy43OTA2Mi"
|
||||
"AyLjU2NDcyTDkuNDMwMzkgNi41OTMxM0M5LjcwOTU2IDYuNzkyNTQgOS43MDk1NiA3LjIwNzQ1IDku"
|
||||
"NDMwMzkgNy40MDY4NkwzLjc5MDYyIDExLjQzNTNDMy40NTk2OSAxMS42NzE2IDMgMTEuNDM1MSAzID"
|
||||
"ExLjAyODRWMi45NzE1OVoiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIG9wYWNpdHk9IjAuNS"
|
||||
"IgZD0iTTEyIDdDMTIgNi40NDc3MiAxMi40NDc3IDYgMTMgNkgyMUMyMS41NTIzIDYgMjIgNi40NDc3"
|
||||
"MiAyMiA3QzIyIDcuNTUyMjggMjEuNTUyMyA4IDIxIDhIMTNDMTIuNDQ3NyA4IDEyIDcuNTUyMjggMT"
|
||||
"IgN1pNOSAxMkM5IDExLjQ0NzcgOS40NDc3MiAxMSAxMCAxMUgyMUMyMS41NTIzIDExIDIyIDExLjQ0Nz"
|
||||
"cgMjIgMTJDMjIgMTIuNTUyMyAyMS41NTIzIDEzIDIxIDEzSDEwQzkuNDQ3NzIgMTMgOSAxMi41NTIz"
|
||||
"IDkgMTJaTTEwIDE2QzkuNDQ3NzIgMTYgOSAxNi40NDc3IDkgMTdDOSAxNy41NTIzIDkuNDQ3NzIgMT"
|
||||
"ggMTAgMThIMjFDMjEuNTUyMyAxOCAyMiAxNy41NTIzIDIyIDE3QzIyIDE2LjQ0NzcgMjEuNTUyMyAx"
|
||||
"NiAyMSAxNkgxMFoiLz4KPC9zdmc+Cg=="
|
||||
),
|
||||
"new_releases": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPGcgZmlsbD0i"
|
||||
"IzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMSkiPgogICAgICAgIDxwYXRoIGQ9Ik0xNC"
|
||||
"43OTM1IDQuNTU3MzVMMTUuNzc0MSAwLjc4OTIwOUMxNS45Njg4IDAuMDQxMDUwOSAxNy4wMzExIDAu"
|
||||
"MDQxMDcxOSAxNy4yMjU4IDAuNzg5MjM3TDE4LjIwNjIgNC41NTczMkMxOC4yNzcgNC44MjkxNSAxOC"
|
||||
"40OTM3IDUuMDM4NjggMTguNzY3NyA1LjEwMDIzTDIxLjc0MTkgNS43NjgyM0MyMi41MjI4IDUuOTQz"
|
||||
"NjIgMjIuNTIyOCA3LjA1NjM5IDIxLjc0MTkgNy4yMzE3N0wxOC43Njc3IDcuODk5NzdDMTguNDkzNy"
|
||||
"A3Ljk2MTMyIDE4LjI3NyA4LjE3MDg1IDE4LjIwNjIgOC40NDI2OEwxNy4yMjU4IDEyLjIxMDhDMTcu"
|
||||
"MDMxMSAxMi45NTg5IDE1Ljk2ODggMTIuOTU4OSAxNS43NzQxIDEyLjIxMDhMMTQuNzkzNSA4LjQ0Mj"
|
||||
"Y1QzE0LjcyMjcgOC4xNzA4MyAxNC41MDYxIDcuOTYxMzIgMTQuMjMyIDcuODk5NzdMMTEuMjU4IDcu"
|
||||
"MjMxNzdDMTAuNDc3MSA3LjA1NjM5IDEwLjQ3NzEgNS45NDM2MiAxMS4yNTggNS43NjgyNEwxNC4yMz"
|
||||
"IgNS4xMDAyM0MxNC41MDYxIDUuMDM4NjggMTQuNzIyNyA0LjgyOTE3IDE0Ljc5MzUgNC41NTczNVoi"
|
||||
"Lz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjgiIGQ9Ik01LjI5OTQgOS4yNjgzTDYuMDMwNjYgNy"
|
||||
"4yNzc2M0M2LjE5MTEyIDYuODQwODUgNi44MDg4OCA2Ljg0MDg1IDYuOTY5MzQgNy4yNzc2M0w3Ljcw"
|
||||
"MDYgOS4yNjgzQzcuNzU0MjUgOS40MTQzNSA3Ljg3Mjg0IDkuNTI3MSA4LjAyMTQxIDkuNTczMzJMOS"
|
||||
"40NjU0MSAxMC4wMjI2QzkuOTM0MDMgMTAuMTY4MyA5LjkzNDAzIDEwLjgzMTYgOS40NjU0MSAxMC45"
|
||||
"Nzc0TDguMDIxNCAxMS40MjY3QzcuODcyODMgMTEuNDcyOSA3Ljc1NDI1IDExLjU4NTYgNy43MDA2ID"
|
||||
"ExLjczMTdMNi45NjkzMyAxMy43MjI0QzYuODA4ODggMTQuMTU5MiA2LjE5MTEyIDE0LjE1OTIgNi4w"
|
||||
"MzA2NiAxMy43MjI0TDUuMjk5NCAxMS43MzE3QzUuMjQ1NzUgMTEuNTg1NiA1LjEyNzE3IDExLjQ3Mj"
|
||||
"kgNC45Nzg2IDExLjQyNjdMMy41MzQ1OSAxMC45Nzc0QzMuMDY1OTcgMTAuODMxNiAzLjA2NTk3IDEw"
|
||||
"LjE2ODMgMy41MzQ1OSAxMC4wMjI2TDQuOTc4NTkgOS41NzMzMkM1LjEyNzE2IDkuNTI3MSA1LjI0NT"
|
||||
"c1IDkuNDE0MzUgNS4yOTk0IDkuMjY4M1oiLz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjYiIGQ9"
|
||||
"Ik0xMC42ODgyIDE2LjAxNEwxMS41MjQ3IDEzLjQ1NDNDMTEuNjc0OSAxMi45OTQ3IDEyLjMyNTEgMT"
|
||||
"IuOTk0NyAxMi40NzUzIDEzLjQ1NDNMMTMuMzExOCAxNi4wMTRDMTMuMzYwNCAxNi4xNjI3IDEzLjQ3"
|
||||
"NTcgMTYuMjggMTMuNjIzNSAxNi4zMzEyTDE1LjYzNSAxNy4wMjc1QzE2LjA4MzYgMTcuMTgyOCAxNi"
|
||||
"4wODM2IDE3LjgxNzIgMTUuNjM1IDE3Ljk3MjVMMTMuNjIzNSAxOC42Njg4QzEzLjQ3NTcgMTguNzIg"
|
||||
"MTMuMzYwNCAxOC44MzczIDEzLjMxMTggMTguOTg2TDEyLjQ3NTMgMjEuNTQ1N0MxMi4zMjUxIDIyLj"
|
||||
"AwNTMgMTEuNjc0OSAyMi4wMDUzIDExLjUyNDcgMjEuNTQ1N0wxMC42ODgyIDE4Ljk4NkMxMC42Mzk2"
|
||||
"IDE4LjgzNzMgMTAuNTI0MyAxOC43MiAxMC4zNzY1IDE4LjY2ODhMOC4zNjQ5OCAxNy45NzI1QzcuOT"
|
||||
"E2MzggMTcuODE3MiA3LjkxNjM5IDE3LjE4MjggOC4zNjQ5OCAxNy4wMjc1TDEwLjM3NjUgMTYuMzMx"
|
||||
"MkMxMC41MjQzIDE2LjI4IDEwLjYzOTYgMTYuMTYyNyAxMC42ODgyIDE2LjAxNFoiLz4KICAgIDwvZz"
|
||||
"4KPC9zdmc+Cg=="
|
||||
),
|
||||
"in_progress": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBkPSJNNCAxMkM0IDE2LjQxODMgNy41ODE3MiAyMCAxMi"
|
||||
"AyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3MiAxNi40MTgzIDQgMTIgNEM3"
|
||||
"LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJaTTE4IDEyQzE4IDE1LjMxMzcgMTUuMzEzNyAxOCAxMiAxOE"
|
||||
"M4LjY4NjI5IDE4IDYgMTUuMzEzNyA2IDEyQzYgOC42ODYyOSA4LjY4NjI5IDYgMTIgNkMxNS4zMTM3"
|
||||
"IDYgMTggOC42ODYyOSAxOCAxMloiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGQ9Ik0xNi45Mj"
|
||||
"UzIDE4LjMwNDFDMjAuNDA2OSAxNS41ODM5IDIxLjAyNDMgMTAuNTU2NCAxOC4zMDQxIDcuMDc0NzJD"
|
||||
"MTYuODI2OSA1LjE4Mzk0IDE0LjYxNjcgNC4wODMyNyAxMi4yNjUgNC4wMDQyNEMxMS43MTMxIDMuOT"
|
||||
"g1NjkgMTEuMjUwNiA0LjQxODExIDExLjIzMiA0Ljk3MDA4QzExLjIxMzUgNS41MjIwNiAxMS42NDU5"
|
||||
"IDUuOTg0NTYgMTIuMTk3OSA2LjAwMzExQzEzLjk2MzkgNi4wNjI0NiAxNS42MTkzIDYuODg2ODYgMT"
|
||||
"YuNzI4MSA4LjMwNjA1QzE4Ljc2ODIgMTAuOTE3MyAxOC4zMDUyIDE0LjY4OCAxNS42OTQgMTYuNzI4"
|
||||
"MUMxNS4yNTg4IDE3LjA2ODEgMTUuMTgxNiAxNy42OTY1IDE1LjUyMTYgMTguMTMxOEMxNS44NjE2ID"
|
||||
"E4LjU2NyAxNi40OTAxIDE4LjY0NDEgMTYuOTI1MyAxOC4zMDQxWiIvPgo8L3N2Zz4K"
|
||||
),
|
||||
"starred": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSwgLTAuNSkiIGQ9Ik0xMS41ODMxIDE3Lj"
|
||||
"Q3NzZMOC4wNzM4NCAxOS4yOTMzQzcuMDk0MTMgMTkuODAwMiA2LjQ0NjgxIDE5LjMxOTcgNi42MjQ2"
|
||||
"NiAxOC4yNDA0TDcuMjY3MDcgMTQuMzQxOEw0LjQ1NTgxIDExLjU2NTRDMy42NzA5NyAxMC43OTAzID"
|
||||
"MuOTI3OTEgMTAuMDI2MiA1LjAwOTM1IDkuODYxNzdMOC45MTU2NSA5LjI2ODAxTDEwLjY4NzUgNS43"
|
||||
"MzYzOEMxMS4xODIxIDQuNzUwNDIgMTEuOTg4MiA0Ljc1ODY3IDEyLjQ3ODggNS43MzYzOEwxNC4yNT"
|
||||
"A2IDkuMjY4MDFMMTguMTU2OSA5Ljg2MTc3QzE5LjI0NzQgMTAuMDI3NSAxOS40ODg3IDEwLjc5Njgg"
|
||||
"MTguNzEwNCAxMS41NjU0TDE1Ljg5OTIgMTQuMzQxOEwxNi41NDE2IDE4LjI0MDRDMTYuNzIwOSAxOS"
|
||||
"4zMjg4IDE2LjA2MzkgMTkuNzk2IDE1LjA5MjQgMTkuMjkzM0wxMS41ODMxIDE3LjQ3NzZaIi8+Cjwv"
|
||||
"c3ZnPgo="
|
||||
),
|
||||
"history": (
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
|
||||
"ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
|
||||
"bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC41IDEzQz"
|
||||
"EwLjUgMTMuNTUyMyAxMC45NDc3IDE0IDExLjUgMTRIMTQuNUMxNS4wNTIzIDE0IDE1LjUgMTMuNTUy"
|
||||
"MyAxNS41IDEzQzE1LjUgMTIuNDQ3NyAxNS4wNTIzIDEyIDE0LjUgMTJIMTIuNUwxMi41IDEwQzEyLj"
|
||||
"UgOS40NDc3MiAxMi4wNTIzIDkgMTEuNSA5QzEwLjk0NzcgOSAxMC41IDkuNDQ3NzIgMTAuNSAxMFYx"
|
||||
"M1oiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1y"
|
||||
"dWxlPSJldmVub2RkIiBkPSJNMTIgMThDMTUuMzEzNyAxOCAxOCAxNS4zMTM3IDE4IDEyQzE4IDguNj"
|
||||
"g2MjkgMTUuMzEzNyA2IDEyIDZDOC42ODYyOSA2IDYgOC42ODYyOSA2IDEyQzYgMTUuMzEzNyA4LjY4"
|
||||
"NjI5IDE4IDEyIDE4Wk0xMiAyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3Mi"
|
||||
"AxNi40MTgzIDQgMTIgNEM3LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJDNCAxNi40MTgzIDcuNTgxNzIg"
|
||||
"MjAgMTIgMjBaIi8+Cjwvc3ZnPgo="
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def setup(
|
||||
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
|
||||
) -> ProviderInstanceType:
|
||||
"""Initialize provider(instance) with given configuration."""
|
||||
return PocketCastsProvider(mass, manifest, config, SUPPORTED_FEATURES)
|
||||
|
||||
|
||||
class PocketCastsProvider(MusicProvider):
|
||||
"""Provider for Pocket Casts podcast service."""
|
||||
|
||||
_client: PocketCastsClient
|
||||
# episode uuids already mirrored to Pocket Casts Up Next/history this session, keyed as a
|
||||
# set since multi-room playback can have several episodes in progress on one instance
|
||||
_announced_episodes: set[str]
|
||||
|
||||
async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
|
||||
"""Return Config entries to configure this provider."""
|
||||
return ()
|
||||
|
||||
async def handle_async_init(self) -> None:
|
||||
"""Handle async initialization of the provider."""
|
||||
email = self.get_setup_value(CONF_USERNAME)
|
||||
password = self.get_setup_value(CONF_PASSWORD)
|
||||
if not email or not password:
|
||||
raise LoginFailed("Email and password are required for Pocket Casts")
|
||||
self._announced_episodes = set()
|
||||
self._client = PocketCastsClient(self.mass.http_session, self.logger)
|
||||
await self._client.login(str(email), str(password))
|
||||
|
||||
async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
|
||||
"""Get all podcasts from the user's library."""
|
||||
for podcast_data in await self._client.get_subscribed_podcasts():
|
||||
yield self._convert_podcast(podcast_data)
|
||||
|
||||
async def library_add(self, item: MediaItemType) -> bool:
|
||||
"""
|
||||
Subscribe to a podcast.
|
||||
|
||||
:param item: The media item to add to the library.
|
||||
"""
|
||||
if not isinstance(item, Podcast):
|
||||
return await super().library_add(item)
|
||||
await self._client.subscribe_podcast(item.item_id)
|
||||
return True
|
||||
|
||||
async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
|
||||
"""
|
||||
Unsubscribe from a podcast.
|
||||
|
||||
:param prov_item_id: The provider item ID to remove from the library.
|
||||
:param media_type: The media type of the item.
|
||||
"""
|
||||
if media_type != MediaType.PODCAST:
|
||||
return await super().library_remove(prov_item_id, media_type)
|
||||
await self._client.unsubscribe_podcast(prov_item_id)
|
||||
return True
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def get_podcast(self, prov_podcast_id: str) -> Podcast:
|
||||
"""
|
||||
Get full details for a podcast.
|
||||
|
||||
:param prov_podcast_id: The provider podcast id.
|
||||
"""
|
||||
podcast_data = await self._client.get_podcast(prov_podcast_id)
|
||||
if not podcast_data:
|
||||
raise MediaNotFoundError(
|
||||
f"podcast://{prov_podcast_id} not found on provider {self.domain}"
|
||||
)
|
||||
return self._convert_podcast(podcast_data)
|
||||
|
||||
async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
|
||||
"""
|
||||
Get all episodes for a podcast, enriched with playback status.
|
||||
|
||||
:param prov_podcast_id: The provider podcast id.
|
||||
"""
|
||||
# fetch episode metadata, user status and show notes in parallel
|
||||
(podcast_name, episodes), in_progress, history, show_notes = await asyncio.gather(
|
||||
self._client.get_podcast_episodes(prov_podcast_id),
|
||||
self._client.get_in_progress_episodes(),
|
||||
self._client.get_history(),
|
||||
self._get_show_notes(prov_podcast_id),
|
||||
)
|
||||
in_progress_map = {ep.get("uuid"): ep for ep in in_progress}
|
||||
history_map = {ep.get("uuid"): ep for ep in history}
|
||||
|
||||
# the full-podcast payload carries no episode number, so rank on the publication date
|
||||
positions = rank_episodes_by_date([ep.get("published") or None for ep in episodes])
|
||||
for position, episode_data in zip(positions, episodes, strict=True):
|
||||
episode_item = self._convert_episode(
|
||||
episode_data,
|
||||
prov_podcast_id,
|
||||
show_notes.get(episode_data.get("uuid", "")),
|
||||
podcast_name,
|
||||
position=position,
|
||||
)
|
||||
if episode_item:
|
||||
self._enrich_episode_with_status(
|
||||
episode_item, episode_data, in_progress_map, history_map
|
||||
)
|
||||
yield episode_item
|
||||
|
||||
async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
|
||||
"""Get the user's Up Next queue as a playlist."""
|
||||
yield self._get_up_next_playlist()
|
||||
|
||||
async def get_playlist(self, prov_playlist_id: str) -> Playlist:
|
||||
"""
|
||||
Get full details for a playlist.
|
||||
|
||||
:param prov_playlist_id: The provider playlist id.
|
||||
"""
|
||||
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
|
||||
raise MediaNotFoundError(
|
||||
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
|
||||
)
|
||||
return self._get_up_next_playlist()
|
||||
|
||||
async def get_playlist_tracks(
|
||||
self, prov_playlist_id: str, page: int = 0
|
||||
) -> list[PodcastEpisode]:
|
||||
"""
|
||||
Get the episodes of a playlist, in Up Next order.
|
||||
|
||||
:param prov_playlist_id: The provider playlist id.
|
||||
:param page: Zero-based page index; Up Next is returned whole on the first page.
|
||||
"""
|
||||
if prov_playlist_id != UP_NEXT_PLAYLIST_ID:
|
||||
raise MediaNotFoundError(
|
||||
f"playlist://{prov_playlist_id} not found on provider {self.domain}"
|
||||
)
|
||||
if page > 0:
|
||||
return []
|
||||
episodes = await self._get_special_folder_episodes(UP_NEXT_PLAYLIST_ID)
|
||||
# the Up Next payload carries no duration or playback status, and marking an episode
|
||||
# played reports its duration as the position, so take both from the episode details
|
||||
details = await asyncio.gather(
|
||||
*(self.get_podcast_episode(episode.item_id) for episode in episodes),
|
||||
return_exceptions=True,
|
||||
)
|
||||
items: list[PodcastEpisode] = []
|
||||
for episode, detail in zip(episodes, details, strict=True):
|
||||
if isinstance(detail, PodcastEpisode):
|
||||
items.append(detail)
|
||||
elif isinstance(
|
||||
detail,
|
||||
MediaNotFoundError
|
||||
| LoginFailed
|
||||
| ProviderUnavailableError
|
||||
| ResourceTemporarilyUnavailable
|
||||
| RetriesExhausted,
|
||||
):
|
||||
self.logger.debug("Could not retrieve details for %s: %s", episode.name, detail)
|
||||
items.append(episode)
|
||||
else:
|
||||
raise detail
|
||||
return items
|
||||
|
||||
async def browse(self, path: str) -> list[MediaItemType | BrowseFolder]:
|
||||
"""
|
||||
Browse this provider's items.
|
||||
|
||||
:param path: The browse path to resolve.
|
||||
"""
|
||||
item_path = path.split("://", 1)[1] if "://" in path else path
|
||||
|
||||
if not item_path:
|
||||
# root level - special folders followed by subscribed podcasts
|
||||
items: list[MediaItemType | BrowseFolder] = list(self._create_browse_folders())
|
||||
for podcast_data in await self._client.get_subscribed_podcasts():
|
||||
items.append(self._convert_podcast(podcast_data))
|
||||
return items
|
||||
|
||||
if item_path in SPECIAL_FOLDERS:
|
||||
return list(await self._get_special_folder_episodes(item_path))
|
||||
|
||||
return [episode async for episode in self.get_podcast_episodes(item_path)]
|
||||
|
||||
async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
|
||||
"""
|
||||
Get streamable URL and details for the given media item.
|
||||
|
||||
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
:param media_type: The media type of the item.
|
||||
"""
|
||||
_, episode_uuid = item_id.split(":", 1)
|
||||
episode_data = await self._client.get_episode_details(episode_uuid)
|
||||
|
||||
url = episode_data.get("url", "")
|
||||
if not url:
|
||||
raise MediaNotFoundError(f"No URL found for episode {item_id}")
|
||||
|
||||
return StreamDetails(
|
||||
item_id=item_id,
|
||||
provider=self.instance_id,
|
||||
audio_format=AudioFormat(
|
||||
content_type=ContentType.try_parse(episode_data.get("fileType", "audio/mpeg")),
|
||||
),
|
||||
media_type=MediaType.PODCAST_EPISODE,
|
||||
stream_type=StreamType.HTTP,
|
||||
path=url,
|
||||
duration=episode_data.get("duration"),
|
||||
can_seek=True,
|
||||
allow_seek=True,
|
||||
)
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def search(
|
||||
self, search_query: str, media_types: list[MediaType], limit: int = 5
|
||||
) -> SearchResults:
|
||||
"""
|
||||
Search for podcasts.
|
||||
|
||||
:param search_query: The search query.
|
||||
:param media_types: The media types to include in the search.
|
||||
:param limit: The maximum number of items to return per type.
|
||||
"""
|
||||
results = SearchResults()
|
||||
if media_types and MediaType.PODCAST not in media_types:
|
||||
return results
|
||||
podcasts = await self._client.search_podcasts(search_query)
|
||||
results.podcasts = [self._convert_podcast(podcast) for podcast in podcasts[:limit]]
|
||||
return results
|
||||
|
||||
@use_cache(3600)
|
||||
async def get_podcast_episode(self, prov_item_id: str) -> PodcastEpisode:
|
||||
"""
|
||||
Get full details for a podcast episode.
|
||||
|
||||
:param prov_item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
"""
|
||||
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
|
||||
episode_data, show_notes, podcast_name = await asyncio.gather(
|
||||
self._client.get_episode_details(episode_uuid),
|
||||
self._get_show_notes(podcast_uuid),
|
||||
self._get_podcast_name(podcast_uuid),
|
||||
)
|
||||
episode_item = self._convert_episode(
|
||||
episode_data, podcast_uuid, show_notes.get(episode_uuid), podcast_name
|
||||
)
|
||||
if episode_item is None:
|
||||
raise MediaNotFoundError(f"Episode {episode_uuid} not found in podcast {podcast_uuid}")
|
||||
|
||||
# the API sends explicit nulls for these fields, so a dict default is not enough
|
||||
played_up_to = episode_data.get("playedUpTo") or 0
|
||||
duration = episode_data.get("duration") or 0
|
||||
playing_status = episode_data.get("playingStatus", 1) # 1=unplayed, 2=in_progress, 3=played
|
||||
if duration > 0:
|
||||
episode_item.duration = duration
|
||||
completed = playing_status == 3 or (
|
||||
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
)
|
||||
episode_item.fully_played = completed
|
||||
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
|
||||
|
||||
return episode_item
|
||||
|
||||
async def get_resume_position(
|
||||
self, item_id: str, media_type: MediaType
|
||||
) -> tuple[bool, int, datetime | None]:
|
||||
"""
|
||||
Return the (fully_played, position_ms, timestamp) resume point for an episode.
|
||||
|
||||
PocketCasts does not expose a reliable last-played timestamp, so the timestamp
|
||||
is always None.
|
||||
|
||||
:param item_id: The episode item id (format: podcast_uuid:episode_uuid).
|
||||
:param media_type: The media type (should be PODCAST_EPISODE).
|
||||
"""
|
||||
_, episode_uuid = item_id.split(":", 1)
|
||||
|
||||
try:
|
||||
in_progress = await self._client.get_in_progress_episodes()
|
||||
except (ProviderUnavailableError, ResourceTemporarilyUnavailable, RetriesExhausted) as err:
|
||||
# resume is best-effort; a transient failure should not break playback
|
||||
LOGGER.warning("Could not fetch resume position for %s: %s", episode_uuid, err)
|
||||
return (False, 0, None)
|
||||
|
||||
for ep in in_progress:
|
||||
if ep.get("uuid") == episode_uuid:
|
||||
played_up_to = int(ep.get("playedUpTo") or 0) # seconds from API
|
||||
duration = int(ep.get("duration") or 0)
|
||||
fully_played = duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
LOGGER.debug(
|
||||
"Resume position for %s: %d ms (fully_played=%s)",
|
||||
episode_uuid,
|
||||
played_up_to * 1000,
|
||||
fully_played,
|
||||
)
|
||||
return (fully_played, played_up_to * 1000, None)
|
||||
|
||||
LOGGER.debug("No in-progress entry for %s; resuming from start", episode_uuid)
|
||||
return (False, 0, None)
|
||||
|
||||
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:
|
||||
"""
|
||||
Sync playback progress for a podcast episode back to Pocket Casts.
|
||||
|
||||
Called by the Queue controller when a track is played, stopped/skipped, and
|
||||
periodically while playing.
|
||||
|
||||
:param media_type: The media type of the played item.
|
||||
:param prov_item_id: The provider item id (format: podcast_uuid:episode_uuid).
|
||||
:param fully_played: Whether the episode was played to the end.
|
||||
:param position: Last known position in seconds.
|
||||
:param media_item: The full media item details.
|
||||
:param is_playing: Whether the episode is currently playing.
|
||||
"""
|
||||
if media_type != MediaType.PODCAST_EPISODE or not isinstance(media_item, PodcastEpisode):
|
||||
return
|
||||
podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
|
||||
|
||||
# MA reports fully_played=True when an episode is skipped/stopped, not only when it
|
||||
# truly ends, so confirm completion against the real position before marking it played.
|
||||
duration = media_item.duration or 0
|
||||
completed = fully_played and duration > 0 and position >= duration * FULLY_PLAYED_THRESHOLD
|
||||
if completed:
|
||||
self._announced_episodes.discard(episode_uuid)
|
||||
await self._client.mark_episode_played(podcast_uuid, episode_uuid)
|
||||
await self._client.remove_from_up_next(episode_uuid)
|
||||
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=True)
|
||||
elif position == 0 and not is_playing:
|
||||
# the user explicitly marked the episode as unplayed
|
||||
self._announced_episodes.discard(episode_uuid)
|
||||
await self._client.mark_episode_unplayed(podcast_uuid, episode_uuid)
|
||||
await self._client.archive_episode(podcast_uuid, episode_uuid, archive=False)
|
||||
else:
|
||||
# on_played fires every progress tick, so mirror the start to Up Next/history only
|
||||
# once per session - re-announcing each tick would re-bump Up Next and spam the API.
|
||||
# A resume within the same session is intentionally not re-announced.
|
||||
if is_playing and episode_uuid not in self._announced_episodes:
|
||||
self._announced_episodes.add(episode_uuid)
|
||||
await self._announce_playback_start(podcast_uuid, episode_uuid, media_item)
|
||||
await self._client.update_episode_progress(podcast_uuid, episode_uuid, position)
|
||||
|
||||
def _convert_podcast(self, podcast_data: dict[str, Any]) -> Podcast:
|
||||
"""
|
||||
Convert raw Pocket Casts podcast data to a Podcast object.
|
||||
|
||||
:param podcast_data: Raw podcast data from the subscribed-list or full-podcast endpoint.
|
||||
"""
|
||||
uuid = podcast_data["uuid"]
|
||||
return Podcast(
|
||||
item_id=uuid,
|
||||
provider=self.instance_id,
|
||||
name=podcast_data.get("title", ""),
|
||||
publisher=podcast_data.get("author"),
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=uuid,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
)
|
||||
},
|
||||
metadata=MediaItemMetadata(
|
||||
description=podcast_data.get("description"),
|
||||
images=UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=f"https://static.pocketcasts.com/discover/images/280/{uuid}.jpg",
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def _get_podcast_name(self, prov_podcast_id: str) -> str:
|
||||
"""Return a podcast's name, empty when it cannot be looked up."""
|
||||
# the podcast lookup is cached, so this is one call per podcast per day at most
|
||||
try:
|
||||
return (await self.get_podcast(prov_podcast_id)).name
|
||||
except (
|
||||
MediaNotFoundError,
|
||||
LoginFailed,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
) as err:
|
||||
self.logger.debug("Could not retrieve podcast name for %s: %s", prov_podcast_id, err)
|
||||
return ""
|
||||
|
||||
async def _get_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return show notes per episode uuid, empty when they cannot be read."""
|
||||
# show notes are supplementary, so a failure here must never break episode
|
||||
# resolution. The failure itself is not cached, so the next call tries again.
|
||||
try:
|
||||
return await self._fetch_show_notes(prov_podcast_id)
|
||||
except (
|
||||
LoginFailed,
|
||||
ProviderUnavailableError,
|
||||
ResourceTemporarilyUnavailable,
|
||||
RetriesExhausted,
|
||||
) as err:
|
||||
self.logger.debug("Could not retrieve show notes for %s: %s", prov_podcast_id, err)
|
||||
return {}
|
||||
|
||||
@use_cache(3600 * 24)
|
||||
async def _fetch_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return show notes per episode uuid for the given podcast."""
|
||||
return await self._client.get_show_notes(prov_podcast_id)
|
||||
|
||||
def _convert_episode(
|
||||
self,
|
||||
episode_data: dict[str, Any],
|
||||
podcast_uuid: str,
|
||||
show_notes: dict[str, Any] | None = None,
|
||||
podcast_name: str = "",
|
||||
position: int = 0,
|
||||
) -> PodcastEpisode | None:
|
||||
"""
|
||||
Convert episode data to a PodcastEpisode, or None when it carries no episode uuid.
|
||||
|
||||
:param position: The episode's listing position. Defaults to 0 (unknown).
|
||||
"""
|
||||
episode_uuid = episode_data.get("uuid")
|
||||
if not episode_uuid:
|
||||
return None
|
||||
|
||||
# this is fed by two endpoints with different field schemas: the full-podcast JSON
|
||||
# uses snake_case (file_type) while /user/episode uses camelCase (fileType,
|
||||
# episodeNumber). Neither carries the description or artwork, which is what the
|
||||
# separate show notes lookup is for.
|
||||
item_id = f"{podcast_uuid}:{episode_uuid}"
|
||||
file_type = episode_data.get("fileType") or episode_data.get("file_type", "audio/mpeg")
|
||||
episode_item = PodcastEpisode(
|
||||
item_id=item_id,
|
||||
provider=self.instance_id,
|
||||
name=episode_data.get("title", "Unknown Episode"),
|
||||
podcast=ItemMapping(
|
||||
media_type=MediaType.PODCAST,
|
||||
item_id=podcast_uuid,
|
||||
provider=self.instance_id,
|
||||
name=podcast_name,
|
||||
),
|
||||
position=position,
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=item_id,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
audio_format=AudioFormat(content_type=ContentType.try_parse(file_type)),
|
||||
url=episode_data.get("url", ""),
|
||||
)
|
||||
},
|
||||
)
|
||||
if episode_data.get("duration"):
|
||||
episode_item.duration = int(episode_data["duration"])
|
||||
if title := episode_data.get("title"):
|
||||
episode_item.metadata.label = title
|
||||
details = show_notes or {}
|
||||
if description := details.get("description"):
|
||||
episode_item.metadata.description = description
|
||||
# only about half the episodes have their own artwork, the rest keep the podcast cover
|
||||
image_url = details.get("image") or (
|
||||
f"https://static.pocketcasts.com/discover/images/280/{podcast_uuid}.jpg"
|
||||
)
|
||||
episode_item.metadata.images = UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=image_url,
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
return episode_item
|
||||
|
||||
def _enrich_episode_with_status(
|
||||
self,
|
||||
episode_item: PodcastEpisode,
|
||||
episode_data: dict[str, Any],
|
||||
in_progress_map: dict[str | None, dict[str, Any]],
|
||||
history_map: dict[str | None, dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Apply playback status to a PodcastEpisode from the in-progress/history data.
|
||||
|
||||
:param episode_item: The episode object to enrich in place.
|
||||
:param episode_data: Raw episode data dict from the API.
|
||||
:param in_progress_map: UUID-keyed map of in-progress episode data.
|
||||
:param history_map: UUID-keyed map of listen-history episode data.
|
||||
"""
|
||||
episode_uuid = episode_data.get("uuid")
|
||||
# history is "recently played", not "completed" - rely on the entry's real progress,
|
||||
# never on mere history membership. Both fields are always set so the library sync can
|
||||
# clear a stale completed/resume value (it only updates when both are non-None).
|
||||
status_data = in_progress_map.get(episode_uuid) or history_map.get(episode_uuid) or {}
|
||||
# feeds that omit a duration yield an explicit null rather than a missing key, so
|
||||
# coerce instead of relying on a dict default
|
||||
played_up_to = status_data.get("playedUpTo") or 0
|
||||
duration = status_data.get("duration") or episode_data.get("duration") or 0
|
||||
completed = status_data.get("playingStatus") == 3 or (
|
||||
duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
|
||||
)
|
||||
episode_item.fully_played = completed
|
||||
episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
|
||||
|
||||
async def _get_special_folder_episodes(self, folder_name: str) -> list[PodcastEpisode]:
|
||||
"""
|
||||
Get episodes for a special browse folder.
|
||||
|
||||
:param folder_name: Name of the special folder (up_next, new_releases, etc.)
|
||||
"""
|
||||
folder_getters = {
|
||||
"up_next": self._client.get_up_next_episodes,
|
||||
"new_releases": self._client.get_new_releases,
|
||||
"in_progress": self._client.get_in_progress_episodes,
|
||||
"starred": self._client.get_starred_episodes,
|
||||
"history": self._client.get_history,
|
||||
}
|
||||
episode_list = await folder_getters[folder_name]()
|
||||
|
||||
# (episode, podcast uuid, podcast name) per episode, the name empty when the folder
|
||||
# payload does not carry it
|
||||
resolved: list[tuple[dict[str, Any], str, str]] = []
|
||||
for episode_data in episode_list:
|
||||
# the podcast reference is a string on some endpoints and an object on others
|
||||
podcast_field = episode_data.get("podcast")
|
||||
podcast_uuid: str | None
|
||||
if isinstance(podcast_field, str):
|
||||
podcast_uuid = podcast_field
|
||||
elif isinstance(podcast_field, dict):
|
||||
podcast_uuid = podcast_field.get("uuid")
|
||||
else:
|
||||
podcast_uuid = episode_data.get("podcastUuid")
|
||||
|
||||
if not podcast_uuid:
|
||||
continue
|
||||
# these folders mix podcasts, so the name is not known up front. Take it from the
|
||||
# payload where that carries it, in either of the two shapes
|
||||
payload_name = podcast_field.get("title") if isinstance(podcast_field, dict) else None
|
||||
resolved.append(
|
||||
(
|
||||
episode_data,
|
||||
podcast_uuid,
|
||||
payload_name or episode_data.get("podcastTitle") or "",
|
||||
)
|
||||
)
|
||||
|
||||
# every remaining name costs a full-podcast fetch, so look them up once per podcast and
|
||||
# all at once: serialising them would stall the browse for as long as the folder is deep
|
||||
missing = list({uuid for _, uuid, name in resolved if not name})
|
||||
looked_up = await asyncio.gather(*(self._get_podcast_name(uuid) for uuid in missing))
|
||||
names = dict(zip(missing, looked_up, strict=True))
|
||||
|
||||
items: list[PodcastEpisode] = []
|
||||
for episode_data, podcast_uuid, podcast_name in resolved:
|
||||
if episode_item := self._convert_episode(
|
||||
episode_data,
|
||||
podcast_uuid,
|
||||
show_notes=None,
|
||||
podcast_name=podcast_name or names.get(podcast_uuid, ""),
|
||||
):
|
||||
items.append(episode_item)
|
||||
return items
|
||||
|
||||
def _create_browse_folders(self) -> list[BrowseFolder]:
|
||||
"""Create special browse folders for root level."""
|
||||
folders = [
|
||||
("up_next", "Up Next"),
|
||||
("new_releases", "New Releases"),
|
||||
("in_progress", "In Progress"),
|
||||
("starred", "Starred"),
|
||||
("history", "History"),
|
||||
]
|
||||
return [
|
||||
BrowseFolder(
|
||||
item_id=folder_id,
|
||||
provider=self.instance_id,
|
||||
path=f"{self.instance_id}://{folder_id}",
|
||||
name=name,
|
||||
image=MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=BROWSE_FOLDER_ICONS[folder_id],
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
),
|
||||
)
|
||||
for folder_id, name in folders
|
||||
]
|
||||
|
||||
def _get_up_next_playlist(self) -> Playlist:
|
||||
"""Return the playlist that mirrors the user's Up Next queue."""
|
||||
return Playlist(
|
||||
item_id=UP_NEXT_PLAYLIST_ID,
|
||||
provider=self.instance_id,
|
||||
name="Up Next",
|
||||
owner="Pocket Casts",
|
||||
is_editable=False,
|
||||
supported_mediatypes={MediaType.PODCAST_EPISODE},
|
||||
provider_mappings={
|
||||
ProviderMapping(
|
||||
item_id=UP_NEXT_PLAYLIST_ID,
|
||||
provider_domain=self.domain,
|
||||
provider_instance=self.instance_id,
|
||||
# every account has its own Up Next queue
|
||||
is_unique=True,
|
||||
)
|
||||
},
|
||||
metadata=MediaItemMetadata(
|
||||
images=UniqueList(
|
||||
[
|
||||
MediaItemImage(
|
||||
type=ImageType.THUMB,
|
||||
path=BROWSE_FOLDER_ICONS[UP_NEXT_PLAYLIST_ID],
|
||||
provider=self.instance_id,
|
||||
remotely_accessible=True,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def _announce_playback_start(
|
||||
self, podcast_uuid: str, episode_uuid: str, episode: PodcastEpisode
|
||||
) -> None:
|
||||
"""
|
||||
Mirror a playback start to Pocket Casts by adding the episode to Up Next and history.
|
||||
|
||||
:param podcast_uuid: The podcast UUID.
|
||||
:param episode_uuid: The episode UUID.
|
||||
:param episode: The episode that started playing.
|
||||
"""
|
||||
# source the url from the already-loaded item so no extra API call is needed; filtered
|
||||
# to our own mapping since merged library items can carry other providers' mappings
|
||||
url = next(
|
||||
(
|
||||
mapping.url
|
||||
for mapping in episode.provider_mappings
|
||||
if mapping.provider_instance == self.instance_id and mapping.url
|
||||
),
|
||||
"",
|
||||
)
|
||||
await self._client.play_now(
|
||||
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
|
||||
)
|
||||
await self._client.add_to_history(
|
||||
episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
|
||||
)
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:8.10.3-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
|
||||
image: redis:8.10.2-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
|
||||
@@ -53,7 +53,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: wiki
|
||||
image: gitea.renner.uno/konairius/wiki:build.1793006975 # {"$imagepolicy": "flux-system:wiki"}
|
||||
image: gitea.renner.uno/konairius/wiki:build.1792997373 # {"$imagepolicy": "flux-system:wiki"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -37,6 +37,7 @@ existing `flux-system` GitRepository whose Secret was created by
|
||||
| paperless-ngx | `2.x` | |
|
||||
| audiobookshelf | `2.x` | |
|
||||
| music-assistant | `2.x` | |
|
||||
| bgutil-pot-provider | `2.x` | Bare-semver filter (node flavor); major follows MA's unpinned PyPI plugin |
|
||||
| immich-server / immich-machine-learning | `2.x` | v-prefix filter; bump in lockstep |
|
||||
| home-assistant | `2026.x` | Calver — bump range each year |
|
||||
| matter-server | `8.x` | Pre-release filter |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: bgutil-pot-provider
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: docker.io/brainicism/bgutil-ytdlp-pot-provider
|
||||
interval: 1h
|
||||
---
|
||||
# Bare semver tags are the node flavor; -node/-deno/sha-* variants excluded.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: bgutil-pot-provider
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: bgutil-pot-provider
|
||||
filterTags:
|
||||
pattern: '^\d+\.\d+\.\d+$'
|
||||
policy:
|
||||
semver:
|
||||
range: "2.x"
|
||||
@@ -5,6 +5,7 @@ resources:
|
||||
- audiobookshelf.yaml
|
||||
- authentik-gate-sync.yaml
|
||||
- backup-verify.yaml
|
||||
- bgutil-pot-provider.yaml
|
||||
- ceph.yaml
|
||||
- cloudflare-lb-sync.yaml
|
||||
- cnpg-barman-plugin.yaml
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"apache/tika",
|
||||
"deluan/navidrome",
|
||||
"docker.gitea.com/gitea-mcp-server",
|
||||
"docker.io/brainicism/bgutil-ytdlp-pot-provider",
|
||||
"eclipse-mosquitto",
|
||||
"everythingsmarthome/everything-presence-mmwave-configurator",
|
||||
"ghcr.io/advplyr/audiobookshelf",
|
||||
|
||||
Reference in New Issue
Block a user