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

+12 -10
View File
@@ -1,23 +1,25 @@
apiVersion: v2
name: freesailarr
description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher and request front end in one pod, all reaching the internet only through the tunnel
description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher, request front end and an optional public-broadcaster indexer in one pod, all reaching the internet only through the tunnel
# No icon on purpose: the stack has no single upstream, and every other chart's
# icon is its one project's logo. Picking any of the nine here would suggest
# icon is its one project's logo. Picking any of the ten here would suggest
# the same false "main app" the versioning deliberately avoids. The field is
# optional, so it is omitted rather than filled with an arbitrary choice.
type: application
version: 3.0.5+up1.0.0
version: 3.1.0+up1.0.0
# The chart's own version marker, freely chosen and tied to no upstream. The
# stack bundles nine independently versioned projects, so there is no single
# "app version" to track here.
# stack bundles ten independently versioned projects (nine of them on by
# default), so there is no single "app version" to track here.
#
# It is NOT the source of any image tag: all nine images are pinned in
# values.yaml, where Renovate's helm-values manager proposes them. Nothing
# rewrites this field automatically - the chart has no app-version block in
# renovate.json5 and is not in the change-app-version.yml choice list. Raise it
# by hand when you want to mark a new edition of the chart as a whole.
# It is NOT the source of any image tag: all ten images are pinned in
# values.yaml, where Renovate's helm-values manager proposes them - except
# mediathekarr, which is pinned by digest and has its own customManagers block
# in renovate.json5, because its tags carry no version. Nothing rewrites this
# field automatically - the chart has no app-version block in renovate.json5
# and is not in the change-app-version.yml choice list. Raise it by hand when
# you want to mark a new edition of the chart as a whole.
appVersion: "1.0.0"
maintainers:
+96 -1
View File
@@ -11,7 +11,7 @@
{{- $sharedNames := list -}}
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
freesailarr — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
Nine containers in ONE pod, sharing gluetun's network namespace.
{{ if .Values.mediathekarr.enabled }}Ten{{ else }}Nine{{ end }} containers in ONE pod, sharing gluetun's network namespace.
NOTHING IS READY UNTIL THE TUNNEL IS UP
gluetun's readiness probe is its health server, which answers 200 only while
@@ -134,6 +134,101 @@ QBITTORRENT HAS NO SAVE PATH SET (qbittorrent.savePath is empty)
WebUI before starting any download.
{{- end }}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
{{- $dir := .Values.mediathekarr.downloads.directory }}
{{- $gate := .Values.mediathekarr.startGate }}
{{- $mediaStartup := fromYaml (include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "startupProbe" "given" .Values.mediathekarr.startupProbe)) }}
MEDIATHEKARR: TWO PORTS, AND THE SABNZBD API IS NOT AT /api
Every container shares one network namespace, so both consumers reach it over
127.0.0.1 — no service name, no DNS, no ingress:
Prowlarr -> Newznab indexer, http://127.0.0.1:5008
Sonarr/Radarr -> SABnzbd host 127.0.0.1, port 5007, API path /download/api
A request to /api on 5007 answers with a Newznab validation error and sends
you looking at the wrong half of the app — the upstream documentation only
mentions port 5007 at all.
Sonarr and Radarr must import from
/downloads/{{ $dir }}/complete (one sub-directory per category below it)
which is exactly the path the chart hands MediathekArr and which it reports
back as the SABnzbd complete_dir. Any deviation — a remote path mapping, a
different mount — imports NOTHING, without an error.
The UI (setup wizard and download queue) has no ingress{{ if and .Values.mediathekarr.ingress.enabled .Values.mediathekarr.ingress.host }}, other than the one
you gave it on {{ .Values.mediathekarr.ingress.host }}{{ end }}:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $name }}-mediathekarr-service 5007:5007
{{- if $gate.enabled }}
IT DELIBERATELY STARTS LATE, AND THAT IS THE POINT
The container waits (up to {{ $gate.timeoutSeconds }}s) for gluetun to report healthy before
it starts MediathekArr{{ if gt (int $gate.settleSeconds) 0 }}, plus {{ $gate.settleSeconds }}s of settling time{{ end }}. Started before the tunnel,
MediathekArr fails its one ruleset fetch, never retries it, and then answers
EVERY search with 0 results — container up, Ready, nothing in the log. That
is why a green pod proves nothing here and why the gate exits instead of
starting anyway on timeout: a crash-looping container is visible, a silently
empty indexer is not.
kubectl -n {{ .Release.Namespace }} logs {{ $name }}-sfs-0 -c mediathekarr | head -3
The startup probe has to outlast the gate ({{ mul (int $mediaStartup.periodSeconds) (int $mediaStartup.failureThreshold) }}s budget against a {{ $gate.timeoutSeconds }}s gate),
otherwise the kubelet kills the container while it is still waiting.
{{- else }}
THE START GATE IS OFF (mediathekarr.startGate.enabled: false)
MediathekArr may then start before the Wireguard tunnel is up. Its ruleset
fetch fails once, is never retried, and the indexer answers every search
with 0 results afterwards — measured over eleven minutes, with the container
up, Ready and no error anywhere. Nothing reports this state. Check a search
by hand after every restart, or switch the gate back on.
{{- end }}
A FAILED MEDIATHEKARR DOWNLOAD NEVER SAYS WHY
MediathekArr does not check the HTTP status code. 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 and the status flips to
Failed — with fail_message: null. Measured A/B with one identical NZB:
direct 94,235,525 bytes and Completed, through a foreign exit 0 bytes and
Failed, fail_message null in BOTH cases.
So a geo block is indistinguishable from a broken server, an expired URL or a
full disk, in Sonarr and in the container log alike. And the protection is an
accident: it rests entirely on mkvmerge rejecting an HTML page. Anything a
geo endpoint returns that mkvmerge accepts is reported as Completed and walks
into your library.
WHETHER A SHOW WORKS THROUGH YOUR EXIT: READ THE MARKER, NOT A LIST
The tunnel currently exits {{ with .Values.gluetun.vpn.serverCities }}in {{ . }}{{ else }}{{ with .Values.gluetun.vpn.serverCountries }}in {{ . }}{{ else }}wherever gluetun picks{{ end }}{{ end }}. ARD and ZDF block parts of their
catalogue outside Germany, and every container hangs in gluetun's namespace —
there is no per-container exit.
A table of series goes stale; the marker in the media URL does not. It
predicted the outcome in 584 of 584 measured cases:
/de/, /geo/de/, /deutschlandweit/, geoavailability_D -> blocked outside DE
/weltweit/ -> open
/none/ (ZDF) -> open
/dach/, deChAt (WDR variant) -> open
The rule of thumb "own production = available worldwide" is MEASURABLY WRONG
(an ARD own production was blocked, a licensed series was open). Only the
marker counts.
The escape route is one value — gluetun.vpn.serverCountries/serverCities set
to a German exit — and its price is that it moves the WHOLE stack, all ten
containers, qBittorrent included, into that jurisdiction and onto those
servers. That is a deployment decision, not a chart setting to flip lightly.
OPEN, EXPLICITLY UNMEASURED: HLS ENTRIES
Part of the catalogue is served as an HLS playlist instead of a progressive
MP4. The image carries an ffmpeg-based M3U8 path, but whether such an entry
completes end to end has not been measured. If it does not, those entries fail
regardless of any geo block — and, per the paragraph above, without a reason.
{{- end }}
EVERY SERVICE POINTS AT THE SAME POD IP
One ClusterIP service per app{{ if .Values.recyclarr.enabled }} (recyclarr has none — no port in daemon mode){{ end }}, but all
+74 -14
View File
@@ -135,7 +135,7 @@ implementation lives in template-chart.
{{/*
Render additive env entries (the repo-wide <app>.env convention: tpl-rendered,
supporting the value, secretKeyRef and configMapKeyRef forms). Shared as a
helper here because this chart has nine containers, each with its own env block.
helper here because this chart has ten containers, each with its own env block.
Usage:
{{- include "freesailarr.env" (dict "root" $ "env" .Values.<app>.env) | nindent 12 }}
@@ -176,6 +176,14 @@ recyclarr is deliberately absent: in daemon mode it serves no port, so it gets
no container port, no service and no ingress. flaresolverr is present - it
listens on 8191 and therefore gets a service - but it has no ingress block in
values and is never exposed from outside the cluster.
"port" is the app's PRIMARY port: the one its service is named after and the one
an ingress points at. mediathekarr is the only app with a second one, because it
runs two processes in one container - the SABnzbd role on 5007 (the UI, hence
the primary) and the Newznab role Prowlarr consumes on 5008. Optional
"extraPorts" entries ({name, port}) are additional ports of the SAME service and
are declared to gluetun's firewall alongside the primary; they never take part
in the ingress, which addresses a service port by name.
*/}}
{{- define "freesailarr.portApps" -}}
{{- $apps := list -}}
@@ -186,7 +194,8 @@ values and is never exposed from outside the cluster.
(dict "name" "radarr" "port" 7878)
(dict "name" "sonarr" "port" 8989)
(dict "name" "seerr" "port" 5055)
(dict "name" "bazarr" "port" 6767) -}}
(dict "name" "bazarr" "port" 6767)
(dict "name" "mediathekarr" "port" 5007 "extraPorts" (list (dict "name" "ma-newznab" "port" 5008))) -}}
{{- if (index $.Values $app.name).enabled -}}
{{- $apps = append $apps $app -}}
{{- end -}}
@@ -214,8 +223,9 @@ ingress block with enabled: true. Used by the ingress template.
{{/*
The sub-path an app is served under in the shared-domain mode, e.g. "/sonarr".
Null-safe by construction: an absent or erased <app>.ingress.path falls back to
"/<app>", and to "/" for seerr, which has no base-path support at all and can
therefore only live at the root.
"/<app>", and to "/" for the two apps that can only live at the root - seerr,
which has no base-path support at all, and mediathekarr, whose UI links its
assets with root-absolute paths (see the guard in sharedIngressApps).
Input: dict "root" $ "app" <app name>.
*/}}
@@ -226,7 +236,7 @@ Input: dict "root" $ "app" <app name>.
{{- $path = $config.path -}}
{{- end -}}
{{- if or (kindIs "invalid" $path) (eq (toString $path) "") -}}
{{- if eq .app "seerr" -}}
{{- if has .app (list "seerr" "mediathekarr") -}}
{{- $path = "/" -}}
{{- else -}}
{{- $path = printf "/%s" .app -}}
@@ -250,9 +260,12 @@ Two guards live here rather than in the ingress template, so that every
consumer - the ingress objects, the UrlBase env vars and NOTES.txt - sees the
same validated list:
1. seerr under any path other than "/". seerr has no base-path support
(upstream request open since May 2022), so a chart that rendered it under
a sub-path would produce something that provably cannot work.
1. seerr or mediathekarr under any path other than "/". seerr has no base-path
support (upstream request open since May 2022) and MediathekArr's UI links
its assets with root-absolute paths, so a chart that rendered either under
a sub-path would produce something that provably cannot work. Both at "/"
on one host is caught by guard 2 below - one of them then needs a host of
its own.
2. Two apps claiming the same path on the shared host. Only paths on the
shared host can collide; apps with their own host are on a different host
and are therefore not part of this check.
@@ -269,11 +282,14 @@ same validated list:
{{- $config := (index $root.Values $app.name).ingress -}}
{{- if not ($config.host | default "") -}}
{{- $path := include "freesailarr.appPath" (dict "root" $root "app" $app.name) -}}
{{- if and (eq $app.name "seerr") (ne $path "/") -}}
{{- fail (printf "seerr.ingress.path is %q, but seerr has no base-path support and can only be served at \"/\" (https://github.com/Fallenbagel/jellyseerr/issues/97). Either leave seerr.ingress.path at \"/\" or give seerr its own seerr.ingress.host." $path) -}}
{{- $rootOnly := dict
"seerr" "seerr has no base-path support (https://github.com/Fallenbagel/jellyseerr/issues/97)"
"mediathekarr" "MediathekArr links its UI assets with root-absolute paths (/download/css/..., measured), so a sub-path stays broken even behind a prefix-stripping rewrite - the browser then asks for those assets at the host root" -}}
{{- if and (hasKey $rootOnly $app.name) (ne $path "/") -}}
{{- fail (printf "%s.ingress.path is %q, but %s and it can therefore only be served at \"/\". Either leave %s.ingress.path at \"/\" or give it its own %s.ingress.host." $app.name $path (index $rootOnly $app.name) $app.name $app.name) -}}
{{- end -}}
{{- if hasKey $seen $path -}}
{{- fail (printf "%s.ingress.path and %s.ingress.path are both %q on the shared host %q - every app under ingress.host needs its own path" (index $seen $path) $app.name $path $host) -}}
{{- fail (printf "%s.ingress.path and %s.ingress.path are both %q on the shared host %q - every app under ingress.host needs its own path. Where neither can move (seerr and mediathekarr can only be served at \"/\"), give one of them its own <app>.ingress.host." (index $seen $path) $app.name $path $host) -}}
{{- end -}}
{{- $_ := set $seen $path $app.name -}}
{{- $apps = append $apps (dict "name" $app.name "port" $app.port "path" $path) -}}
@@ -356,6 +372,9 @@ alive. It is not what opens the ports today.
{{- $ports := list -}}
{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
{{- $ports = append $ports (int $app.port) -}}
{{- range $extra := $app.extraPorts | default list -}}
{{- $ports = append $ports (int $extra.port) -}}
{{- end -}}
{{- end -}}
{{- $ports = append $ports 9999 -}}
{{- join "," $ports -}}
@@ -366,7 +385,7 @@ Whether a media category is mounted at all, i.e. whether at least one app that
mounts it is enabled. "true" when it is, the empty string when it is not (an
empty string is falsy in a template "if", so the result can be used directly).
downloads: qbittorrent, radarr, sonarr
downloads: qbittorrent, radarr, sonarr, mediathekarr
movies: radarr, bazarr
tv: sonarr, bazarr
@@ -375,7 +394,7 @@ Input: dict "root" $ "category" <"downloads"|"movies"|"tv">.
{{- define "freesailarr.mediaCategoryEnabled" -}}
{{- $v := .root.Values -}}
{{- if eq .category "downloads" -}}
{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled -}}true{{- end -}}
{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled $v.mediathekarr.enabled -}}true{{- end -}}
{{- else if eq .category "movies" -}}
{{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}}
{{- else if eq .category "tv" -}}
@@ -885,6 +904,47 @@ bazarr:
- FOWNER
- SETGID
- SETUID
mediathekarr:
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
startupProbe:
httpGet:
path: /download/
port: 5007
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 60
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
securityContext:
runAsUser: {{ .Values.puid }}
runAsGroup: {{ .Values.pgid }}
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
configInit:
resources:
limitFactor: 3
@@ -905,7 +965,7 @@ configInit:
{{/*
The effective pod security context. Separate from the per-container table
because it is the one block shared by all nine containers.
because it is the one block shared by all containers of the pod.
*/}}
{{- define "freesailarr.podSecurityContext" -}}
{{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}}
@@ -21,5 +21,16 @@ spec:
port: {{ int $app.port }}
targetPort: {{ $app.name }}
name: {{ $app.name }}
{{- /* An app with more than one port keeps them in ONE service rather than
getting a second object: they belong to the same container and the
same pod IP, and only the primary port is ever addressed by an
ingress. mediathekarr is the only such app today - 5007 (SABnzbd
role, primary) and 5008 (Newznab role). */}}
{{- range $extra := $app.extraPorts | default list }}
- protocol: TCP
port: {{ int $extra.port }}
targetPort: {{ $extra.name }}
name: {{ $extra.name }}
{{- end }}
type: ClusterIP
{{- end }}
+110 -3
View File
@@ -521,6 +521,109 @@ spec:
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
# MediathekArr, the tenth container and the only one off by default: a fake
# Usenet indexer for Prowlarr (Newznab, port 5008) and a fake SABnzbd
# for Sonarr/Radarr (port 5007), fetching from the ARD and ZDF media
# libraries. Both consumers sit in this pod and reach it over
# 127.0.0.1, so it needs neither a service nor DNS to do its job.
- name: mediathekarr
# Tag AND digest: the tag stays readable, the digest is what is
# actually pulled. "beta" moves, and "latest" is a 2024 build - see
# the values comment.
image: "{{ .Values.mediathekarr.image.repository }}:{{ .Values.mediathekarr.image.tag }}@{{ required "mediathekarr.image.digest is required - the tag alone is a moving reference" .Values.mediathekarr.image.digest }}"
imagePullPolicy: IfNotPresent
{{- if .Values.mediathekarr.startGate.enabled }}
# THE START GATE. Kubernetes cannot order two regular containers, and
# an initContainer waiting for gluetun would deadlock (gluetun only
# starts once the init containers are done). So the wait happens here,
# in front of the image entrypoint: poll gluetun's health server -
# 200 only while the tunnel actually works - and exec the entrypoint
# afterwards.
#
# Why this is not optional decoration: MediathekArr fetches its
# ruleset once at start and never retries. Started before the tunnel,
# it answered every Newznab search with 0 results for over eleven
# minutes - container up, Ready, nothing in the log. The failure case
# looks exactly like the healthy one, which is why the gate exits
# instead of starting anyway when the timeout passes: a crash-looping
# container is visible, a silently empty indexer is not.
#
# wget comes with the (Debian) image, bash is its entrypoint's own
# interpreter - no extra tooling and no shell built into the chart.
command:
- /bin/bash
- -c
- |
set -eu
deadline=$(( SECONDS + {{ int .Values.mediathekarr.startGate.timeoutSeconds }} ))
echo "mediathekarr: waiting for gluetun to report healthy (127.0.0.1:9999)"
until wget -q -T 5 -O /dev/null http://127.0.0.1:9999/; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "mediathekarr: gluetun did not report healthy within {{ int .Values.mediathekarr.startGate.timeoutSeconds }}s - exiting instead of starting without a tunnel, because a MediathekArr started too early answers every search with 0 results and reports nothing" >&2
exit 1
fi
sleep {{ int .Values.mediathekarr.startGate.intervalSeconds }}
done
echo "mediathekarr: gluetun healthy after ${SECONDS}s"
{{- if gt (int .Values.mediathekarr.startGate.settleSeconds) 0 }}
sleep {{ int .Values.mediathekarr.startGate.settleSeconds }}
{{- end }}
exec /app/docker_start.sh
{{- end }}
env:
- name: TZ
value: {{ .Values.timezone | quote }}
{{- /* Both download paths are set by the chart, not left to the
image: complete/ has to be visible to Sonarr/Radarr under
the SAME absolute path (they are handed this value as the
SABnzbd complete_dir, and a deviation imports nothing
without an error), and incomplete/ has to live on the media
claim rather than in an emptyDir, which would count against
the container's ephemeral-storage limit and let one large
download evict the whole pod. Setting them also keeps the
image's built-in /data/mediathek default out of play, which
the read-only root filesystem does not provide. */}}
- name: DOWNLOAD_INCOMPLETE_PATH
value: {{ printf "/downloads/%s/incomplete" .Values.mediathekarr.downloads.directory | quote }}
- name: DOWNLOAD_COMPLETE_PATH
value: {{ printf "/downloads/%s/complete" .Values.mediathekarr.downloads.directory | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.mediathekarr.env) | nindent 12 }}
ports:
- containerPort: 5007
protocol: TCP
name: mediathekarr
- containerPort: 5008
protocol: TCP
name: ma-newznab
livenessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "livenessProbe" "given" .Values.mediathekarr.livenessProbe) | nindent 12 }}
readinessProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "readinessProbe" "given" .Values.mediathekarr.readinessProbe) | nindent 12 }}
startupProbe:
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "startupProbe" "given" .Values.mediathekarr.startupProbe) | nindent 12 }}
resources:
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.resources) | nindent 12 }}
securityContext:
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.securityContext) | nindent 12 }}
volumeMounts:
- mountPath: /app/config
name: mediathekarr-config
{{- /* The same claim, the same mount path as sonarr/radarr - that
is what makes the complete_dir path identical on both
sides. */}}
- mountPath: /downloads
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
# Not a measured requirement: the container ran through with a
# read-only root and without a writable /tmp. Kept as cheap
# insurance for the .NET runtime, which is the kind of consumer that
# reaches for /tmp on a code path nobody exercised yet.
- mountPath: /tmp
name: mediathekarr-tmp
{{- end }}
restartPolicy: Always
volumes:
# The tun device gluetun needs to create the Wireguard tunnel.
@@ -542,11 +645,11 @@ spec:
setup with three separate claims still renders byte-identically to
2.0.1 and the diff itself proves the change is inert there.
*/}}
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }}
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled .Values.mediathekarr.enabled }}
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }}
- name: media-downloads
persistentVolumeClaim:
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr, sonarr or mediathekarr is enabled" .Values.media.downloads.existingClaim }}
{{- end }}
{{- end }}
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
@@ -579,12 +682,16 @@ spec:
- name: seerr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.mediathekarr.enabled }}
- name: mediathekarr-tmp
emptyDir: {}
{{- end }}
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
# are immutable - toggling an app's enabled flag later requires recreating
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
# before upgrading; existing PVCs and their data survive).
volumeClaimTemplates:
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" }}
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" "mediathekarr" }}
{{- $config := index $.Values $app }}
{{- if or (eq $app "gluetun") $config.enabled }}
- metadata:
+91
View File
@@ -535,6 +535,76 @@
"$ref": "#/definitions/containerSecurityContext"
}
}
},
"mediathekarr": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"image": {
"$ref": "#/definitions/pinnedImage"
},
"ingress": {
"$ref": "#/definitions/appIngress"
},
"storage": {
"$ref": "#/definitions/appStorage"
},
"startGate": {
"description": "Delays the container until gluetun reports healthy. NOT null-safe on purpose and not merged through the fallback helper: this block decides whether the gate is rendered at all, and an erased block would silently produce a container that starts before the tunnel - the failure this chart writes three paragraphs about, and one that looks green.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"intervalSeconds": {
"type": "integer",
"minimum": 1
},
"timeoutSeconds": {
"description": "Total wait before the container exits instead of starting without a tunnel. Keep the startup probe's budget (periodSeconds x failureThreshold) above it, or the kubelet kills the container while the gate is still waiting.",
"type": "integer",
"minimum": 1
},
"settleSeconds": {
"type": "integer",
"minimum": 0
}
}
},
"downloads": {
"type": "object",
"additionalProperties": false,
"properties": {
"directory": {
"description": "Sub-directory on the shared downloads claim holding incomplete/ and complete/. The chart derives DOWNLOAD_INCOMPLETE_PATH and DOWNLOAD_COMPLETE_PATH from it; complete/ is what Sonarr/Radarr are told to import from, under the identical absolute path.",
"type": "string",
"minLength": 1
}
}
},
"env": {
"$ref": "#/definitions/env"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"resources": {
"$ref": "#/definitions/resources"
},
"securityContext": {
"$ref": "#/definitions/containerSecurityContext"
}
}
}
},
"definitions": {
@@ -746,6 +816,27 @@
"object",
"null"
]
},
"pinnedImage": {
"description": "Like image, plus the digest mediathekarr is pinned by: its tags are unusable (\"latest\" is a 2024 build, \"beta\" moves), so the tag stays as the readable label and the digest is what is actually pulled. Deliberately a separate definition instead of adding an optional digest to image: on every other image in this chart a digest key would be a value the templates never read - exactly the silent no-op the schema exists to reject (#68).",
"type": "object",
"additionalProperties": false,
"properties": {
"repository": {
"type": "string"
},
"tag": {
"type": [
"string",
"number"
]
},
"digest": {
"description": "Image digest, sha256:<64 hex>. Required - the template refuses to render without it.",
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
}
}
}
}
}
+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