Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6e0e44b6a | ||
|
|
39b7fc7fc3 | ||
|
|
33dd968a25 | ||
|
|
d8813a68b7 | ||
|
|
9dcaf42f96 | ||
|
|
d033503fc3 | ||
|
|
66c56288a7 | ||
|
|
2b309dec92 | ||
|
|
396f839567 | ||
|
|
2e412e6448 | ||
|
|
7f61ad0ce9 | ||
|
|
6503161146 | ||
|
|
01133ca106 | ||
|
|
ce5be4d067 | ||
|
|
150e8229fd | ||
|
|
0247b3dfd5 | ||
|
|
9b7a64e48c | ||
|
|
aee26df4b8 | ||
|
|
0bf2cd309e | ||
|
|
73d85b4aa8 | ||
|
|
b78cacb6c4 | ||
|
|
b753ba4776 | ||
|
|
84c0561146 | ||
|
|
be4e605d65 | ||
|
|
10cb1c2152 | ||
|
|
ae8ce26a01 | ||
|
|
1f1125c49c | ||
|
|
1467596309 | ||
|
|
8ef130da2e | ||
|
|
2e677483db | ||
|
|
532b853929 | ||
|
|
af7103fd75 | ||
|
|
1984b01e5d | ||
|
|
e02ad7639b | ||
|
|
80f5310653 | ||
|
|
9895b7f4f4 | ||
|
|
c380c7c2b8 | ||
|
|
0f86537bb1 | ||
|
|
31459dac6d | ||
|
|
57c96a9a62 | ||
|
|
ffd24865ee |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -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.
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
- 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"
|
||||
|
||||
# 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"
|
||||
|
||||
- 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"
|
||||
|
||||
- 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: Read the effective sshd settings
|
||||
ansible.builtin.command: sshd -T
|
||||
changed_when: false
|
||||
register: ssh_ca_effective
|
||||
|
||||
# Keyword case in sshd -T output differs between OpenSSH versions (mixed case
|
||||
# on 10.6), so the lines are compared lowercased.
|
||||
- name: Check sshd uses the CA, principals and revocation list
|
||||
vars:
|
||||
ssh_ca_effective_lines: "{{ ssh_ca_effective.stdout_lines | map('lower') | list }}"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'trustedusercakeys /etc/ssh/trusted_user_ca_keys' in ssh_ca_effective_lines"
|
||||
- "'authorizedprincipalsfile /etc/ssh/auth_principals/%u' in ssh_ca_effective_lines"
|
||||
- "'revokedkeys /etc/ssh/revoked_keys' in ssh_ca_effective_lines"
|
||||
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
|
||||
|
||||
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian. sshd -T
|
||||
# only parses the files, so the running daemon is reloaded on every run; a
|
||||
# reload keeps open sessions.
|
||||
- name: Read the service list
|
||||
ansible.builtin.service_facts:
|
||||
|
||||
- name: Reload sshd so the running daemon uses the CA config
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ 'sshd.service' if 'sshd.service' in ansible_facts.services else 'ssh.service' }}"
|
||||
state: reloaded
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gotenberg
|
||||
image: gotenberg/gotenberg:8.38.1 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
image: gotenberg/gotenberg:8.39.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
args:
|
||||
- "gotenberg"
|
||||
- "--chromium-disable-javascript=true"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -4,7 +4,7 @@ metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: docker.gitea.com/gitea-mcp-server@sha256:ccdb5660ecdf54c5e480f10cc8120ba2bf150b582cea8a1974de2e59883582d3
|
||||
image: docker.gitea.com/gitea-mcp-server
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"matchPackageNames": [
|
||||
"apache/tika",
|
||||
"deluan/navidrome",
|
||||
"docker.gitea.com/gitea-mcp-server",
|
||||
"eclipse-mosquitto",
|
||||
"everythingsmarthome/everything-presence-mmwave-configurator",
|
||||
"ghcr.io/advplyr/audiobookshelf",
|
||||
|
||||
Reference in New Issue
Block a user