Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 9dcaf42f96 fix(ansible): start the SSH CA rollout on proxy-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
claude-ws is decommissioned. proxy-1 resolves by name, answers on 22,
and has proxy-2 as its twin, so the public edge survives a bad run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:50:58 +01:00
claude-botandClaude Opus 5.5 66c56288a7 feat(ansible): hosts trust the OpenBao SSH user CA (phase 2)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
New role ssh-ca with play configure-ssh-ca.yml (serial: 1) and pipeline
deploy-ssh-ca. Hosts in the new [ssh_ca] group get:

- TrustedUserCAKeys from OpenBao's public_key endpoint, fetched on the
  controller at run time (no copy in git).
- AuthorizedPrincipalsFile: k.renner for root/ladmin (whichever
  exists), claude-bot for Claude's own claude-bot account.
- RevokedKeys, always deployed: sshd refuses all key logins when the
  file is set but missing.
- claude-bot sudo: read-only journalctl/systemctl status/ss/sshd -T,
  restart/reload only for ssh_ca_claude_sudo_units; NOEXEC against
  pager shell escapes; visudo-validated.

The drop-in is tested with sshd -t and removed again on failure; sshd -T
must show all three settings afterwards. authorized_keys is untouched,
so this only adds a login path. [ssh_ca] starts with claude-ws only.

ssh-sign-owner.hcl now also records the mount, CA and owner role
commands (review on #1466).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:47:48 +01:00
k.renner 2b309dec92 Merge pull request 'feat(openbao): SSH client CA roles (SSH CA phase 1)' (#1466) from ssh-ca-phase1 into main
Reviewed-on: #1466
2026-10-27 08:42:24 +00:00
k.renner 396f839567 Merge pull request 'chore(ci): bump ci-claude image to 2.1.311' (#1464) from ci-image/claude-2.1.311 into main
Reviewed-on: #1464
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 08:42:06 +00:00
claude-botandClaude Opus 5.5 2e412e6448 feat(openbao): SSH client CA roles for owner and claude-bot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 1 of the SSH CA. The ssh-client-signer mount and its ed25519 CA
were set up by hand (root token); this records the roles and the
claude-bot signing policy.

- role owner: principal k.renner, 6-month certs. The mount was enabled
  with -max-lease-ttl=4380h; the 768h system default would cap it.
- role claude: principal claude-bot, 1h certs, pty only.
- policy ssh-sign-claude for the claude-bot userpass user, next to its
  existing claude-bot-write.

Verified: a YubiKey sk-ssh-ed25519 key signed on role owner yields a
sk-ssh-ed25519-cert-v01 cert, principal k.renner, valid to 2027-04-27.
CA fingerprint SHA256:xEhuqxqFVZ/ig6KpAtHOhLbwsIhyWpzXhvDcjIfM/Js,
served unauthenticated at /v1/ssh-client-signer/public_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:39:19 +01:00
security-bot 7f61ad0ce9 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:50 +00:00
security-bot 6503161146 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:23:47 +00:00
security-bot 01133ca106 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:46 +00:00
security-bot ce5be4d067 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807
Updates managed by image-automation-controller.
2026-10-27 08:17:10 +00:00
security-bot 150e8229fd chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1793088058
Updates managed by image-automation-controller.
2026-10-27 08:07:03 +00:00
security-bot 0247b3dfd5 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:06:48 +00:00
claude-bot 9b7a64e48c chore(ci): bump ci-claude image to 2.1.311
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-27 07:53:48 +00:00
k.renner aee26df4b8 Merge pull request 'fix(wiki): wiki-sync retries while the wiki pod restarts' (#1462) from alert-fix/issue-1461 into main
Reviewed-on: #1462
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 07:53:21 +00:00
k.renner 0bf2cd309e Merge pull request 'feat(openbao): OIDC login via Authentik (SSH CA phase 0)' (#1463) from worktree-openbao-oidc-authentik into main
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-edge-mqtt-bridge Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1463
2026-10-27 07:52:50 +00:00
claude-bot 73d85b4aa8 Merge remote-tracking branch 'origin/main' into worktree-openbao-oidc-authentik
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
# Conflicts:
#	kubernetes/apps/authentik/blueprints-configmap.yaml
2026-10-27 08:45:30 +01:00
claude-botandClaude Opus 5.5 b78cacb6c4 feat(openbao): OIDC login via Authentik for SSH cert signing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Phase 0 of the OpenBao SSH CA: humans log in to OpenBao with
Authentik instead of the root token.

- Authentik blueprint: group OpenBao Admins (k.renner), confidential
  OAuth2 provider openbao (CLI + UI callbacks), application, group
  binding. The client secret is generated by Authentik and only goes
  into auth/oidc/config.
- openbao-authentik-egress: auth.renner.uno resolves to the Traefik
  VIP in-cluster, which the existing world egress does not cover.
- Policy ssh-sign-owner with the manual bao commands for auth/oidc;
  its sign path is enabled in phase 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:44:15 +01:00
security-bot b753ba4776 chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793086240
Updates managed by image-automation-controller.
2026-10-27 07:37:00 +00:00
k.renner 84c0561146 Merge pull request 'feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413' (#1460) from claude/authentik-2026.8.4 into main
Reviewed-on: #1460
2026-10-27 07:28:39 +00:00
hermes-bot be4e605d65 fix(wiki): wiki-sync retry window covers the full 120 s
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Six retries at a fixed 10 s delay stopped after about 60 s, so
--retry-max-time 120 never bound. Twelve retries let the 120 s cap
be the limit, covering a Recreate rollout slowed by an image pull.

Retrying on 5xx is safe: POST /api/v1/sync is serialized by
SyncCoordinator and re-syncs the same state, the same as a webhook
landing mid-sync. --retry-max-time 120 keeps a long sync that fails
late from being repeated.
2026-10-27 07:27:46 +00:00
hermes-bot 10cb1c2152 fix(wiki): wiki-sync retries while the wiki pod restarts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The wiki Deployment uses Recreate, so during an image rollout the Service
has no ready endpoint for ~10 s. wiki-sync-29884710 started 06:30:00Z, 4 s
before the new pod was Ready, failed with curl (7) and fired KubeJobFailed.
Retry up to 6 times, 10 s apart, on connection refused too, bounded to
120 s from the first attempt so a long sync that fails is not repeated.

Refs #1461
2026-10-27 07:26:13 +00:00
claude-botandClaude Opus 5.5 ae8ce26a01 feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Chart, server/worker image and LDAP outpost image move together, as
the chart pin requires. Images built from authentik-samba-bridge
3c8f413 (PR #8), pipeline 43.

Supersedes #1347.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:39:59 +01:00
security-bot 1f1125c49c chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793081757
Updates managed by image-automation-controller.
2026-10-27 06:26:58 +00:00
security-bot 1467596309 chore(image-automation): bump valkey/valkey:9.1.3-alpine
Updates managed by image-automation-controller.
2026-10-27 02:50:38 +00:00
security-bot 8ef130da2e chore(image-automation): bump redis:8.10.3-alpine
Updates managed by image-automation-controller.
2026-10-26 17:50:32 +00:00
security-bot 2e677483db chore(image-automation): bump redis:7.4.12-alpine
Updates managed by image-automation-controller.
2026-10-26 17:46:58 +00:00
k.renner 532b853929 Merge pull request 'fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN' (#1459) from fix/ma-sendspin-mdns-net1 into main
Reviewed-on: #1459
2026-10-26 14:34:12 +00:00
claude-botandClaude Opus 5.5 af7103fd75 fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
aiosendspin starts its zeroconf on [bind_ip], or on InterfaceChoice.Default
when bind_ip is 0.0.0.0. Default egresses via the pod's main-table default
route (eth0); net1 routes sit in the sbr table. MA therefore never sent
_sendspin._tcp queries onto v30iot and only rediscovered Voice PEs when they
announced on their own, leaving them disconnected for hours after every MA
restart (e.g. 2026-10-26 13:56: 3 of 4 still offline at 15:05).

The reconcile initContainer now writes bind_ip = the live net1 address it
already computes for publish_ip. The address is read from the interface on
every boot, so the Sendspin no-bind-fallback failure (stale literal IP)
cannot recur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:12:32 +01:00
k.renner 1984b01e5d Merge pull request 'fix(music-assistant): mark Up Next episodes played from the playlist' (#1458) from claude/ma-pocketcasts-up-next-mark-played into main
Reviewed-on: #1458
2026-10-26 12:53:42 +00:00
claude-botandClaude Opus 5.5 e02ad7639b fix(music-assistant): mark Up Next episodes played from the playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Up Next payload carries no episode duration. Marking an episode played
in the UI reports the duration as the position, so the provider received 0
and took it for an explicit "mark unplayed": the episode stayed in Up Next
and was flagged unplayed.

The Up Next playlist now takes duration and playback status from the
(cached, throttled) episode details, falling back to the bare entry when
the details cannot be fetched.

Tested against the 2.10.7 source: 21/21 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:51:27 +01:00
k.renner 80f5310653 Merge pull request 'feat(music-assistant): Pocket Casts Up Next as a library playlist' (#1457) from claude/ma-pocketcasts-up-next-playlist into main
Reviewed-on: #1457
2026-10-26 12:30:42 +00:00
claude-botandClaude Opus 5.5 9895b7f4f4 feat(music-assistant): Pocket Casts Up Next as a library playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Overlays the Pocket Casts provider with a patched __init__.py that adds
ProviderFeature.LIBRARY_PLAYLISTS and a read-only "Up Next" playlist of
podcast episodes, fetched live from /up_next/list in queue order.

An initContainer copies the image's provider into an emptyDir and swaps
in the patch only when the stock file's sha256 matches the version it was
written against (identical in 2.10.7 and 2.10.8). A later image bump that
changes the provider keeps the stock provider instead of breaking it.

Temporary until the change is upstreamed to music-assistant/server.
Tested against the 2.10.7 source: 18/18 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:27:44 +01:00
k.renner c380c7c2b8 Merge pull request 'revert(music-assistant): remove PO token server' (#1456) from revert/ma-po-token-server into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1456
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 10:18:28 +00:00
claude-botandClaude Opus 5.5 0f86537bb1 revert(music-assistant): remove PO token server
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Reverts #1451. The YouTube Music provider is not kept; the PO token
server and its image automation go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:11 +01:00
security-bot 31459dac6d chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793006975
Updates managed by image-automation-controller.
2026-10-26 09:36:48 +00:00
k.renner 57c96a9a62 Merge pull request 'fix(image-automation): unpin the gitea-mcp ImageRepository' (#1452) from claude/gitea-mcp-imagerepo into main
Reviewed-on: #1452
2026-10-26 09:28:43 +00:00
claude-botandClaude Opus 5.5 ffd24865ee fix(image-automation): unpin the gitea-mcp ImageRepository
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Renovate #1439 pinned a digest into the ImageRepository's spec.image,
which image-reflector rejects ("should not include a tag"), so Flux
stopped tracking gitea-mcp-server. The image was missing from the
renovate.json list of images Flux image automation already bumps; add
it there so Renovate leaves it alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:11:42 +01:00
43 changed files with 1271 additions and 186 deletions
+4 -4
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-yaml-citations.sh
+34
View File
@@ -0,0 +1,34 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/ssh-ca/**"
- "ansible/plays/configure-ssh-ca.yml"
- "ansible/inventory/pve_hosts.yml"
- ".woodpecker/deploy-ssh-ca.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-ssh-ca"'
timeout: 30
steps:
- name: deploy-ssh-ca
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/configure-ssh-ca.yml -v
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.310
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+6
View File
@@ -69,3 +69,9 @@ edge_host
# home, so it must NOT be used as ansible_host.
[claude_workstation]
claude-ws ansible_host=10.3.40.78 ansible_user=root
# Hosts that trust the OpenBao SSH user CA (plays/configure-ssh-ca.yml).
# Rolled out one host at a time: add the next host after the previous one
# was verified.
[ssh_ca]
proxy-1.renner.uno
+15
View File
@@ -0,0 +1,15 @@
---
# Trust the OpenBao SSH user CA (ssh-client-signer) on the hosts in [ssh_ca]:
# owner certificates (principal k.renner) for root/ladmin, Claude's 1h
# certificates (principal claude-bot) for its own claude-bot account with
# limited sudo. Existing authorized_keys stay untouched.
#
# serial: 1 so a host whose sshd rejects the change stops the run before the
# next host is touched.
- name: Configure the SSH user CA
hosts: ssh_ca
become: true
gather_facts: true
serial: 1
roles:
- ssh-ca
+18
View File
@@ -0,0 +1,18 @@
---
# SSH user CA (OpenBao ssh-client-signer). Hosts trust certificates signed by
# it; the public key is fetched from OpenBao on the controller at run time.
ssh_ca_public_key_url: "https://openbao.renner.uno/v1/ssh-client-signer/public_key"
# Owner certificates (role owner) carry this principal. It is accepted for
# whichever admin login the distro has; users that do not exist are skipped.
ssh_ca_owner_principal: k.renner
ssh_ca_owner_users:
- root
- ladmin
# Claude's own account. Certificates (role claude, 1h) carry the principal.
ssh_ca_claude_user: claude-bot
ssh_ca_claude_principal: claude-bot
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
ssh_ca_claude_sudo_units: []
+2
View File
@@ -0,0 +1,2 @@
# Public keys whose certificates sshd must reject (one per line, OpenSSH format).
# Revoking a key also revokes every certificate issued for it.
+6
View File
@@ -0,0 +1,6 @@
---
- name: Reload sshd
ansible.builtin.service:
name: "{{ 'sshd' if ansible_facts['os_family'] | default('') == 'RedHat' else 'ssh' }}"
state: reloaded
listen: "reload sshd"
+130
View File
@@ -0,0 +1,130 @@
---
- name: Fetch the SSH CA public key from OpenBao
ansible.builtin.uri:
url: "{{ ssh_ca_public_key_url }}"
return_content: true
delegate_to: localhost
become: false
run_once: true
register: ssh_ca_key
- name: Check the fetched CA key is an SSH public key
ansible.builtin.assert:
that:
- ssh_ca_key.content is match('^ssh-ed25519 AAAA')
fail_msg: "Unexpected CA key from {{ ssh_ca_public_key_url }}"
run_once: true
- name: Install the trusted user CA key
ansible.builtin.copy:
content: "{{ ssh_ca_key.content | trim }}\n"
dest: /etc/ssh/trusted_user_ca_keys
owner: root
group: root
mode: "0644"
notify: reload sshd
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
- name: Install the revoked keys list
ansible.builtin.copy:
src: revoked_keys
dest: /etc/ssh/revoked_keys
owner: root
group: root
mode: "0644"
notify: reload sshd
- name: Create the Claude account
ansible.builtin.user:
name: "{{ ssh_ca_claude_user }}"
shell: /bin/bash
create_home: true
password: "!"
- name: Look up the owner admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_owner_lookup
- name: Create the principals directory
ansible.builtin.file:
path: /etc/ssh/auth_principals
state: directory
owner: root
group: root
mode: "0755"
- name: Accept the owner principal for the existing admin logins
ansible.builtin.copy:
content: "{{ ssh_ca_owner_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ item.item }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
content: "{{ ssh_ca_claude_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0644"
- name: Install the Claude sudo rules
ansible.builtin.template:
src: sudoers-claude.j2
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
# A broken drop-in only bites on the next sshd restart, so it is tested here
# and removed again if the full config does not parse.
- name: Configure sshd for the user CA
block:
- name: Install the sshd drop-in
ansible.builtin.template:
src: ssh-ca.conf.j2
dest: /etc/ssh/sshd_config.d/50-ssh-ca.conf
owner: root
group: root
mode: "0644"
notify: reload sshd
- name: Test the full sshd config
ansible.builtin.command: sshd -t
changed_when: false
rescue:
- name: Remove the drop-in that broke sshd -t
ansible.builtin.file:
path: /etc/ssh/sshd_config.d/50-ssh-ca.conf
state: absent
- name: Fail the host
ansible.builtin.fail:
msg: "sshd -t failed with the SSH CA drop-in; it was removed again"
- name: Reload sshd now
ansible.builtin.meta: flush_handlers
- name: Read the effective sshd settings
ansible.builtin.command: sshd -T
changed_when: false
register: ssh_ca_effective
- name: Check sshd uses the CA, principals and revocation list
ansible.builtin.assert:
that:
- "'trustedusercakeys /etc/ssh/trusted_user_ca_keys' in ssh_ca_effective.stdout_lines"
- "'authorizedprincipalsfile /etc/ssh/auth_principals/%u' in ssh_ca_effective.stdout_lines"
- "'revokedkeys /etc/ssh/revoked_keys' in ssh_ca_effective.stdout_lines"
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
@@ -0,0 +1,4 @@
# {{ ansible_managed }}
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
RevokedKeys /etc/ssh/revoked_keys
@@ -0,0 +1,6 @@
# {{ ansible_managed }}
# NOEXEC: journalctl/systemctl open a pager, which must not spawn a shell.
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/journalctl, /usr/bin/journalctl *, /usr/bin/systemctl status, /usr/bin/systemctl status *, /usr/bin/systemctl list-units, /usr/bin/systemctl list-units *, /usr/bin/ss -tulpn, /usr/sbin/sshd -T
{% for unit in ssh_ca_claude_sudo_units %}
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
{% endfor %}
@@ -706,7 +706,7 @@ data:
# return entries instead of short-circuiting. Built from
# the samba-bridge repo at
# gitea.renner.uno/konairius/authentik-samba-bridge .
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.3-9dd92dd
container_image: gitea.renner.uno/konairius/authentik-samba-bridge-ldap-outpost:2026.8.4-3c8f413
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
@@ -1093,6 +1093,72 @@ data:
negate: false
failure_result: false
# OpenBao human login (auth/oidc, role owner). Confidential client:
# OpenBao's OIDC method requires a secret. Authentik generates it; it
# lives only in OpenBao's auth/oidc/config, never in git.
- id: openbao-admins-group
model: authentik_core.group
identifiers:
name: OpenBao Admins
state: present
attrs:
is_superuser: false
# Members may log in to OpenBao via OIDC. Add/remove members HERE.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: openbao-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: OpenBao
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: confidential
client_id: openbao
# See the Matrix (MAS) provider: a blueprint-made provider starts
# with no grant types.
grant_types:
- authorization_code
- refresh_token
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
redirect_uris:
# bao login -method=oidc (CLI listener).
- url: http://localhost:8250/oidc/callback
matching_mode: strict
- url: https://openbao.renner.uno/ui/vault/auth/oidc/oidc/callback
matching_mode: strict
# The slug is the issuer path: https://auth.renner.uno/application/o/openbao/
- model: authentik_core.application
identifiers:
slug: openbao
state: present
attrs:
name: OpenBao
slug: openbao
provider: !KeyOf openbao-provider
meta_launch_url: https://openbao.renner.uno
meta_description: "OpenBao secrets"
# Only the OpenBao Admins group passes.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, openbao]]
group: !KeyOf openbao-admins-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# OpenProject, decommissioned. Application first: Application.provider
# is on_delete=PROTECT. Its policy binding cascades with it.
- model: authentik_core.application
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-25-1458 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+2 -2
View File
@@ -20,7 +20,7 @@ spec:
# Pinned to match the samba-bridge fork's app image (server.Dockerfile
# AUTHENTIK_VERSION). Bump in lockstep with the bridge rebuild — a
# floating range drifts the chart ahead of the pinned app image.
version: "2026.8.3"
version: "2026.8.4"
sourceRef:
kind: HelmRepository
name: authentik
@@ -58,7 +58,7 @@ spec:
# 2. Pinning means the deployed binary is traceable to a
# specific repo commit. Bump the tag whenever a new build
# is published from gitea.renner.uno/konairius/authentik-samba-bridge .
tag: "2026.8.3-9dd92dd"
tag: "2026.8.4-3c8f413"
envFrom:
# SAMBA_BRIDGE_DOMAIN_SID / _ENABLED_GROUP / _UID_OFFSET
# consumed by the bridge module at Django app startup. Missing
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-25-1504 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: sync
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/python
- /opt/hermes-skills/gitea-issues/scripts/sync_webhook.py
+10 -10
View File
@@ -51,7 +51,7 @@ spec:
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -75,7 +75,7 @@ spec:
mountPath: /opt/data
# WebSocket keepalive ping interval in seconds (default 20).
- name: config-ws-ping
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -99,7 +99,7 @@ spec:
mountPath: /opt/data
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
- name: skills-config
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -130,7 +130,7 @@ spec:
# - alert-monitor: hourly; the script wakes the agent only for
# untriaged alerts.
- name: cron-jobs
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal: a broken jobs.json or a changed cron CLI after an image
# bump logs a warning instead of keeping Hermes from starting.
command:
@@ -187,7 +187,7 @@ spec:
readOnly: true
# Home Assistant MCP server; token resolved from HA_TOKEN at connect, writes need approval.
- name: config-mcp-homeassistant
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -214,7 +214,7 @@ spec:
# skills, the Home Assistant MCP and the tea login are re-applied on
# every start. default gets the kanban toolset to hand cards to them.
- name: experts
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a broken expert logs a warning.
command:
- sh
@@ -305,7 +305,7 @@ spec:
# Gitea issues -> kanban (skills/gitea-issues): route script, webhook route on default,
# completion shell hook plus its allowlist entry on every profile.
- name: gitea-issues
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Non-fatal like cron-jobs: a failure logs a warning.
command:
- sh
@@ -368,7 +368,7 @@ spec:
readOnly: true
# Multiplexed default reads platform credentials only from .env (rationale: commit message).
- name: default-env
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
# Fatal on a writer failure: without these Matrix and the API server stay down.
command:
- /opt/hermes/.venv/bin/python
@@ -428,7 +428,7 @@ spec:
# tea login hermes-bot, written from the mounted token into an
# emptyDir at the agent's ~/.config/tea.
- name: tea-login
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
@@ -457,7 +457,7 @@ spec:
readOnly: true
containers:
- name: hermes
image: gitea.renner.uno/konairius/hermes:build.1792940452 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1793088058 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
+1 -1
View File
@@ -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.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
image: redis:7.4.12-alpine # {"$imagepolicy": "flux-system:redis-immich"}
args:
- redis-server
- --save
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-25-1459 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -21,10 +21,13 @@ 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 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.
- 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.
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 -
@@ -91,15 +94,15 @@ data:
.setdefault("values", {})
)
old_pub, old_bind = values.get("publish_ip"), values.get("bind_ip")
if (old_pub, old_bind) == (ip, "0.0.0.0"):
if (old_pub, old_bind) == (ip, ip):
print("[reconcile] publish_ip/bind_ip already correct; no change")
return 0
values["publish_ip"] = ip
values["bind_ip"] = "0.0.0.0"
values["bind_ip"] = ip
print(
f"[reconcile] publish_ip {old_pub!r} -> {ip!r}, "
f"bind_ip {old_bind!r} -> '0.0.0.0'"
f"bind_ip {old_bind!r} -> {ip!r}"
)
tmp = SETTINGS + ".reconcile.tmp"
@@ -44,7 +44,9 @@ 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).
# 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.
# 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
@@ -60,6 +62,30 @@ 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"}
@@ -81,6 +107,8 @@ spec:
volumeMounts:
- name: data
mountPath: /data
- name: pocketcasts-overlay
mountPath: /app/venv/lib/python3.14/site-packages/music_assistant/providers/pocketcasts
livenessProbe:
httpGet:
path: /
@@ -125,6 +153,11 @@ 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,6 +5,12 @@ 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. 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.
# 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.
#
# 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
@@ -1,114 +0,0 @@
# 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
@@ -0,0 +1,817 @@
"""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
)
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.10.2-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.3-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-26-0647 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
# Valkey major version tracks upstream's compose.yml (9.x). Sidekiq
# is the only consumer; a major bump is a separate migration.
- name: valkey
image: valkey/valkey:9.1.2-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
image: valkey/valkey:9.1.3-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
args:
- valkey-server
- --save
+1 -1
View File
@@ -72,7 +72,7 @@ spec:
command:
- sh
- -c
- 'curl -fsS --max-time 1700 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/sync'
- 'curl -fsS --retry 12 --retry-delay 10 --retry-connrefused --retry-max-time 120 --max-time 1700 -X POST -H "X-Api-Key: $API_KEY" http://wiki.ai.svc.cluster.local:8080/api/v1/sync'
env:
- name: API_KEY
valueFrom:
+1 -1
View File
@@ -53,7 +53,7 @@ spec:
type: RuntimeDefault
containers:
- name: wiki
image: gitea.renner.uno/konairius/wiki:build.1792997373 # {"$imagepolicy": "flux-system:wiki"}
image: gitea.renner.uno/konairius/wiki:build.1793086240 # {"$imagepolicy": "flux-system:wiki"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -37,7 +37,6 @@ 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 |
@@ -1,23 +0,0 @@
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,7 +5,6 @@ resources:
- audiobookshelf.yaml
- authentik-gate-sync.yaml
- backup-verify.yaml
- bgutil-pot-provider.yaml
- ceph.yaml
- cloudflare-lb-sync.yaml
- cnpg-barman-plugin.yaml
@@ -102,6 +102,30 @@ spec:
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
---
# OpenBao server → Authentik (auth.renner.uno) for the auth/oidc method:
# discovery, token exchange and JWKS. auth.renner.uno resolves to the Traefik
# VIP; Cilium applies the DNAT before egress policy, so the rule matches the
# Traefik pods, which `world` does not cover (same path as mas-authentik-egress).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: openbao-authentik-egress
namespace: openbao
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: openbao
component: server
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "8443"
protocol: TCP
---
# Egress complement to home-assistant-rotator-ingress (home-assistant-ns.yaml).
# The openbao-ns default-deny grants TCP egress only to openbao, kube-dns, the
# apiserver/host, and `world`. The Class-B rotators that call EXTERNAL APIs
@@ -0,0 +1,10 @@
# OpenBao policy: ssh-sign-claude
# Sign claude-bot's SSH public key on ssh-client-signer role claude (1h certs). Attached to userpass user claude-bot. NOT applied by Flux.
#
# bao write ssh-client-signer/roles/claude @kubernetes/infrastructure/openbao/ssh-ca/role-claude.json
# bao policy write ssh-sign-claude kubernetes/infrastructure/openbao/policies/ssh-sign-claude.hcl
# bao write auth/userpass/users/claude-bot token_policies=claude-bot-write,ssh-sign-claude
path "ssh-client-signer/sign/claude" {
capabilities = ["update"]
}
@@ -0,0 +1,19 @@
# OpenBao policy: ssh-sign-owner
# Sign the owner's SSH public keys on ssh-client-signer role owner. Bound to auth/oidc role owner. NOT applied by Flux.
#
# bao secrets enable -path=ssh-client-signer -max-lease-ttl=4380h ssh # 768h default would cap the 6-month certs
# bao write ssh-client-signer/config/ca generate_signing_key=true key_type=ed25519
# bao write ssh-client-signer/roles/owner @kubernetes/infrastructure/openbao/ssh-ca/role-owner.json
# bao policy write ssh-sign-owner kubernetes/infrastructure/openbao/policies/ssh-sign-owner.hcl
# bao auth enable oidc
# bao write auth/oidc/config oidc_discovery_url=https://auth.renner.uno/application/o/openbao/ \
# oidc_client_id=openbao oidc_client_secret=<from Authentik> default_role=owner
# bao write auth/oidc/role/owner role_type=oidc user_claim=preferred_username \
# groups_claim=groups oidc_scopes=profile,email bound_claims='{"groups":["OpenBao Admins"]}' \
# allowed_redirect_uris=http://localhost:8250/oidc/callback \
# allowed_redirect_uris=https://openbao.renner.uno/ui/vault/auth/oidc/oidc/callback \
# token_policies=ssh-sign-owner token_ttl=1h
path "ssh-client-signer/sign/owner" {
capabilities = ["update"]
}
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -133,7 +133,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -195,7 +195,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -0,0 +1,12 @@
{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "claude-bot",
"default_user": "claude-bot",
"allowed_extensions": "permit-pty",
"default_extensions": {
"permit-pty": ""
},
"ttl": "1h",
"max_ttl": "1h"
}
@@ -0,0 +1,14 @@
{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "k.renner",
"default_user": "k.renner",
"allowed_extensions": "permit-pty,permit-port-forwarding,permit-agent-forwarding,permit-user-rc",
"default_extensions": {
"permit-pty": "",
"permit-port-forwarding": "",
"permit-user-rc": ""
},
"ttl": "4380h",
"max_ttl": "4380h"
}
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: backup-verify
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-25 # {"$imagepolicy": "flux-system:backup-verify"}
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-27 # {"$imagepolicy": "flux-system:backup-verify"}
command: ["bash", "/script/backup-verify.sh"]
env:
- name: HOME
-1
View File
@@ -32,7 +32,6 @@
"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",