freesailarr: MediathekArr as the tenth container, off by default (3.1.0+up1.0.0)

MediathekArr fakes a Usenet indexer towards Prowlarr and a SABnzbd downloader
towards Sonarr/Radarr and fetches from the ARD and ZDF media libraries. Both
consumers already share gluetun's network namespace, so they reach it over
127.0.0.1 - no service, no DNS, no ingress detour. Purely additive: the flag
defaults to false and a default render is byte-identical to 3.0.5 (verified
against the previous chart for both the default values and the CI values).

The four things that fail silently without them:

- A START GATE on gluetun. MediathekArr fetches its ruleset once at start and
  never retries; started before the tunnel it answers every Newznab search with
  0 results while the container is up, Ready and quiet. Kubernetes cannot order
  two regular containers and an initContainer would deadlock, so the wait sits
  in front of the image entrypoint: poll gluetun's health server on
  127.0.0.1:9999 and exec the entrypoint afterwards; on timeout the container
  exits rather than starting blind, because a crash loop is visible and an empty
  indexer is not. The startup probe budget (300s) deliberately outlasts the gate
  (180s).
- incomplete/ AND complete/ ON THE SHARED DOWNLOADS CLAIM, not in an emptyDir.
  An emptyDir counts against the container's ephemeral-storage limit, and one
  large download would have the kubelet evict the whole pod with all ten
  containers - the trap ytdlp-web-player documents.
- THE COMPLETE PATH SONARR SEES. The claim is mounted at /downloads exactly as
  sonarr/radarr mount it, and the chart sets DOWNLOAD_COMPLETE_PATH itself, so
  the SABnzbd complete_dir it reports is the same absolute path on both sides.
- TWO PORTS: 5007 (SABnzbd role and UI, primary) and 5008 (Newznab role for
  Prowlarr), both declared to gluetun's firewall and both on one Service. The
  SABnzbd API is at /download/api - /api on 5007 answers with a misleading
  Newznab validation error.

Further details:

- Image pinned by digest (tag + digest): "latest" is a 2024 build, "beta"
  moves. Single-arch linux/amd64. The built-in helm-values manager cannot
  follow that pair, so renovate.json5 gets the chart's first customManagers
  block; verified by dry-run with two fixtures, since a matching and a broken
  rule look identical while the pin is current.
- Hardened as measured: non-root, uid/gid from puid/pgid,
  readOnlyRootFilesystem: true, all capabilities dropped, no PUID/PGID.
- Probes on measured 200 endpoints (there is no health endpoint): readiness and
  startup on /download/, liveness as an exec covering BOTH ports, because one
  container runs two processes and a wedged Newznab half is exactly what makes
  searches come back empty.
- No ingress by default: the UI links its assets with root-absolute paths, so it
  can only be served at the root of a host; on the shared domain that root
  belongs to seerr. A sub-path fails the render instead of producing a broken
  UI.
- values.yaml and NOTES.txt document the geo MARKER PROCEDURE (not a table of
  series), the escape route of a German exit and its price for the whole stack,
  the fail_message: null trap, and the HLS question as an open question.
- CI: mediathekarr enabled in ci/freesailarr/test-values.yaml, install timeout
  raised to 1200s for the extra image and the gate.

Fixes #245
This commit is contained in:
jklein committed 2026-09-08 23:20:16 +02:00
1 parent f49bfa4af8
commit 78d7020dab
11 files changed
+717 -49

No files matched your search

+261 -4
View File
@@ -4,10 +4,11 @@
# the Wireguard tunnel.
#
# Image pinning (documented deviation from the Chart.AppVersion standard):
# NO image tag comes from appVersion. All nine images, gluetun included, are
# NO image tag comes from appVersion. All ten images, gluetun included, are
# pinned here via <app>.image.repository / <app>.image.tag, because the stack
# combines nine independently versioned upstream projects and none of them is
# "the" one app version.
# combines ten independently versioned upstream projects and none of them is
# "the" one app version. mediathekarr additionally carries an image.digest -
# see the reasoning in its own block at the end of this file.
#
# That is also what puts every image under Renovate: the built-in helm-values
# manager reads image.repository/image.tag pairs out of this file and proposes
@@ -84,7 +85,7 @@ storage:
# Media libraries as pre-existing PVCs (shared with the separately deployed
# Jellyfin) - the chart mounts but never creates them. Each claim is required
# as soon as an app that mounts it is enabled:
# downloads: qbittorrent, radarr, sonarr
# downloads: qbittorrent, radarr, sonarr, mediathekarr
# movies: radarr, bazarr
# tv: sonarr, bazarr
#
@@ -874,3 +875,259 @@ bazarr:
- FOWNER
- SETGID
- SETUID
# MediathekArr: the tenth container, and the only one that is OFF by default. It fakes a Usenet indexer towards Prowlarr and a SABnzbd downloader
# towards Sonarr/Radarr, and fetches the content over plain HTTP from the ARD
# and ZDF media libraries. Both consumers already sit in this pod and share
# gluetun's network namespace, so they reach it over 127.0.0.1 - no service, no
# DNS, no ingress detour.
#
# Off by default because it is additive and nobody's existing release should
# grow a container on an upgrade: enabling it creates a new
# volumeClaimTemplate, which a StatefulSet cannot take without being recreated
# (see the NOTES and the volumeClaimTemplates comment in the StatefulSet).
#
# ----------------------------------------------------------------------------
# THE GEO CAVEAT, AND HOW TO CHECK IT YOURSELF
# ----------------------------------------------------------------------------
# The tunnel leaves through Zurich by this chart's default, and ARD and ZDF
# block parts of their catalogue outside Germany. Every container hangs in
# gluetun's namespace; there is no bypass for a single container.
#
# Measured, 480 URLs across 12 channels: 104 of 473 loadable URLs were blocked
# through Zurich (22.0%), none of them on the German side. The spread is very
# uneven - ONE 97.5%, ZDFneo 47.5%, ZDFinfo 37.5%, Das Erste 20.5%, ZDF 10.3%,
# BR 0% - so a single number tells you little about the show you actually want.
#
# THE PROCEDURE, NOT A TABLE OF SERIES. A list of titles is stale in half a
# year; the marker in the CDN path is not. Over two measurement rounds it
# predicted the outcome perfectly - 584 of 584 cases in the full survey, not a
# single misclassification. Look at the media URL of an entry and read the
# path:
#
# /de/, /geo/de/, /deutschlandweit/, geoavailability_D -> blocked outside DE
# /weltweit/ -> open
# /none/ (ZDF) -> open
# /dach/, deChAt (WDR variant) -> open, Switzerland
# is part of DACH
#
# The obvious rule of thumb "own production = available worldwide" is MEASURABLY
# WRONG and is named here so nobody re-invents it: an ARD own production was
# blocked, a licensed series was open. Only the marker counts.
#
# THE ESCAPE ROUTE AND ITS PRICE. The exit is a deployment decision, not a chart
# property: set gluetun.vpn.serverCountries/serverCities to a German exit and
# the blocks disappear. The price is that this moves the WHOLE stack, all ten
# containers - qBittorrent included - into that jurisdiction and onto those
# servers. There is no per-container exit, and this chart deliberately does not
# pretend otherwise.
#
# ----------------------------------------------------------------------------
# THE fail_message: null TRAP - READ THIS BEFORE DEBUGGING A FAILED DOWNLOAD
# ----------------------------------------------------------------------------
# MediathekArr never checks the HTTP status code. Measured A/B with an identical
# NZB: direct (DE) -> status Completed, 94,235,525 bytes, fail_message null;
# through Zurich -> status Failed, 0 bytes, fail_message ALSO null.
#
# On a geo-blocked fetch it writes the ~476-byte HTML error page out as .mp4 and
# logs "Download completed". Only mkvmerge then refuses the file (exit code 2)
# and flips the status to Failed. Two consequences:
#
# 1. Sonarr is told "failed" without a reason. A geo block looks exactly like
# a broken server, an expired URL or a full disk - and the container log
# does not mention the 403 either.
# 2. That protection is an accident, not error handling. It rests entirely on
# mkvmerge rejecting an HTML page. Any geo response mkvmerge would accept
# is reported as Completed, and then a broken file walks into the library.
# It did not happen in either measurement round (Akamai consistently
# returns HTML), but that is not something to rely on.
#
# ----------------------------------------------------------------------------
# OPEN QUESTION, EXPLICITLY UNMEASURED: HLS
# ----------------------------------------------------------------------------
# Part of the catalogue is served as an HLS playlist (application/x-mpegURL)
# instead of a progressive MP4. The image carries an ffmpeg-based M3U8 path
# (log lines "Starting M3U8 download" / "M3U8 download completed", and ffmpeg is
# present in the image), so the code is there - but whether such an entry
# actually completes end to end has NOT been measured. If it does not, those
# entries fail regardless of any geo block. Stated as an open question on
# purpose: no assumption in either direction.
mediathekarr:
enabled: false
# Pinned BY DIGEST, unlike every other image in this chart, because the tags
# are unusable: "latest" is from 2024-11-12 (= v0.2.5) and the upstream README
# points at "beta", which is a moving tag. tag + digest together keep the
# human-readable line and the immutable reference.
#
# The image is SINGLE-ARCH, linux/amd64 only - there is no arm64 variant. The
# target cluster is amd64 throughout, so this is a note, not a problem; on a
# mixed cluster the pod would need a nodeSelector this chart does not offer.
#
# Renovate: the built-in helm-values manager cannot follow this pair (the tag
# carries no version), so renovate.json5 has a customManagers block that
# matches exactly these three lines and proposes digest updates for the beta
# tag. Renaming or reordering the keys silently switches that off - the rule
# would still be valid, it just would not match any more (#158).
image:
repository: docker.io/pcjones/mediathekarr
tag: beta
digest: sha256:3ed7e1487810e1519301bd56a0c420e260e428295b029a47806c0397e4a52a33
# TWO ports, not one, and the upstream documentation only mentions the first:
# 5007 MediathekArrDownloader - the SABnzbd role and the web UI
# 5008 MediathekArrServer - the Newznab role Prowlarr talks to
# The SABnzbd API lives under /download/api, NOT under /api: a request to
# /api on 5007 answers with a Newznab validation error and sends you looking
# in the wrong place. Both ports are declared to gluetun's firewall
# automatically (see the firewallInputPorts helper).
#
# No ingress by default: MediathekArr's UI links its assets with root-absolute
# paths (/download/css/..., measured), so it can only be served at the ROOT of
# a host - a sub-path is broken even behind a prefix-stripping rewrite,
# because the browser then requests the assets at the host root. On the shared
# domain the root belongs to seerr, so enabling this ingress there needs
# either seerr switched off or a host of its own here. Both consumers reach
# MediathekArr over localhost and need no ingress at all; the UI is only
# needed to run the setup wizard, for which a port-forward is enough.
ingress:
enabled: false
host: null
# Read only in the shared-domain mode. "/" is the only value that works;
# anything else fails the render rather than producing a broken UI.
path: /
# clusterIssuer: null
# className: null
# annotations: {}
storage:
# Size of the MediathekArr config volume (/app/config, a small JSON).
size: 1Gi
# THE START GATE - the sharpest point of this container, and the one whose
# failure mode looks green.
#
# If MediathekArr starts before the tunnel is up, its ruleset fetch fails once
# and is never retried: the instance then answered every Newznab search with 0
# results for over eleven minutes, while the container was up, Ready and
# logged nothing wrong. A green pod proves nothing here. With the start
# delayed until gluetun reports healthy, the ruleset loads and the search
# matches an instance running with direct egress (the search itself is not
# geo-blocked - 56 hits through either exit).
#
# Kubernetes cannot express "start after that container": gluetun is a regular
# container, and an initContainer waiting for it would deadlock. So the gate
# lives in this container's command - it polls gluetun's health server on
# 127.0.0.1:9999 (the same endpoint gluetun's own probes use, 200 only while
# the tunnel works) and only then execs the image entrypoint.
#
# On timeout the container EXITS instead of starting anyway. That is
# deliberate: a crash-looping container is visible, a silently empty indexer
# is not.
#
# Keep startupProbe budget > timeoutSeconds + app start, or the kubelet kills
# the container while the gate is still waiting. Defaults: 180s gate,
# 5s * 60 = 300s startup budget.
startGate:
enabled: true
# Poll interval and total budget for gluetun becoming healthy. gluetun's own
# startup budget is 150s, so 180s covers a normal handshake with room.
intervalSeconds: 3
timeoutSeconds: 180
# Extra pause after gluetun reports healthy, before the app starts. NOT a
# measured requirement - gluetun's 200 is the actual condition - but a cheap
# buffer for DNS and routing to settle. 0 disables it.
settleSeconds: 10
# WHERE THE DOWNLOADS GO. Both directories live on the SHARED downloads claim
# (media.downloads), mounted at /downloads exactly like sonarr, radarr and
# qbittorrent mount it, under this sub-directory:
#
# /downloads/<directory>/incomplete work in progress
# /downloads/<directory>/complete finished, plus one sub-dir per category
#
# Two reasons, both of them the kind that fails silently:
#
# 1. complete/ MUST be visible to Sonarr/Radarr under the SAME absolute
# path. The chart hands that path to MediathekArr
# (DOWNLOAD_COMPLETE_PATH), which reports it to Sonarr as the SABnzbd
# complete_dir. Every container here mounts the claim at /downloads, so
# the paths are identical by construction - a deviation imports nothing,
# without an error.
# 2. incomplete/ must NOT be an emptyDir. An emptyDir counts against the
# container's ephemeral-storage limit, and one large download makes the
# kubelet evict the WHOLE pod - all ten containers. Same trap as the one
# documented in ytdlp-web-player.
#
# Do not set DOWNLOAD_COMPLETE_PATH or DOWNLOAD_INCOMPLETE_PATH in the env
# block below: the chart always sets both, and a second entry of the same name
# is not a defined state (see README, "Environment variables").
downloads:
directory: mediathekarr
# Extra environment variables, appended to the ones the chart always sets.
# Useful ones the image reads: CATEGORIES, MAX_PARALLEL_DOWNLOADS,
# MEDIATHEKARR_API_BASE_URL.
env: []
# Probes on measured 200 endpoints - MediathekArr has NO health endpoint at
# all (/health, /healthz, /api/health, /download/health, /status and /ping all
# answer 404).
#
# Liveness is an exec on purpose: one container runs TWO processes, and the
# kubelet allows one probe of each kind. The Newznab half can wedge while the
# downloader half still answers, and that is exactly the failure that makes
# searches come back empty. The command checks both ports and restarts the
# container when either stops answering. (The entrypoint already exits when
# one of the two processes CRASHES - the exec probe covers the case where it
# is alive but not serving.)
#
# Readiness and startup use the downloader port: that is the one the Service
# and any ingress point at, and /download/ answers 200 without a query string.
livenessProbe:
exec:
command:
- /bin/bash
- -c
- >-
wget -q -T 5 -O /dev/null "http://127.0.0.1:5008/api?t=caps" &&
wget -q -T 5 -O /dev/null "http://127.0.0.1:5007/download/api?mode=version&output=json&apikey=x"
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /download/
port: 5007
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness AND has to outlast the start gate
# (see startGate above): 5s * 60 = 300s against a 180s gate.
startupProbe:
httpGet:
path: /download/
port: 5007
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 60
resources:
limitFactor: 3
# ephemeral-storage stays small on purpose: downloads land on the media
# claim, not on the container filesystem (see downloads above).
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# Measured, including a real download and the mkvmerge remux: the container
# runs fully hardened - non-root, uid/gid from puid/pgid,
# readOnlyRootFilesystem: true - as long as its writable paths are volumes.
# Those are /app/config (the config claim) and the two download directories on
# the media claim; the image's built-in default /data/mediathek is never
# touched, because the chart overrides both paths by environment (measured:
# read-only root, no /data/mediathek mount, no error).
# PUID/PGID are NOT needed (the image's gosu path is only taken when they are
# set), and the
# commented-out non-root block in its Dockerfile is outdated for the beta.
# runAsUser/runAsGroup come from the global puid/pgid values in the template -
# do not set them here.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL