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
298 lines
15 KiB
Plaintext
298 lines
15 KiB
Plaintext
{{- $name := printf "%s-%s" .Release.Name .Chart.Name -}}
|
|
{{- $replicas := include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) -}}
|
|
{{- /* The effective (null-safe) startup probe, so the budget below matches the
|
|
container even when the block has been erased (#99). */ -}}
|
|
{{- $startup := fromYaml (include "freesailarr.probeFor" (dict "root" $ "app" "gluetun" "kind" "startupProbe" "given" .Values.gluetun.startupProbe)) -}}
|
|
{{- $budget := mul (int $startup.periodSeconds) (int $startup.failureThreshold) -}}
|
|
{{- $ingressApps := include "freesailarr.ingressApps" . | fromJsonArray -}}
|
|
{{- $portApps := include "freesailarr.portApps" . | fromJsonArray -}}
|
|
{{- $sharedHost := .Values.ingress.host | default "" -}}
|
|
{{- $shared := include "freesailarr.sharedIngressApps" . | fromJsonArray -}}
|
|
{{- $sharedNames := list -}}
|
|
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
|
|
freesailarr — release {{ .Release.Name }} in namespace {{ .Release.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
|
|
the Wireguard tunnel is actually working. So "pod not Ready" almost always
|
|
means "no tunnel", not "app broken". The startup budget is {{ $budget }}s before
|
|
Kubernetes starts killing the container:
|
|
|
|
kubectl -n {{ .Release.Namespace }} logs {{ $name }}-sfs-0 -c gluetun -f
|
|
|
|
While gluetun is down its firewall keeps blocking every app container's
|
|
egress. That is the kill switch doing its job — the apps stay up and simply
|
|
reach nothing.
|
|
|
|
THE 1PASSWORD ITEM AND THE TWO FIELDS IT MUST CONTAIN
|
|
secrets.gluetunSecretRef -> {{ .Values.secrets.gluetunSecretRef | default "(not set)" }}
|
|
wireguard-private-key
|
|
wireguard-addresses
|
|
|
|
A missing field does not fail the install: gluetun starts with the variable
|
|
empty and never completes a handshake.
|
|
|
|
kubectl -n {{ .Release.Namespace }} get onepassworditem {{ $name }}-gluetun-secret
|
|
{{- if not .Values.gluetun.vpn.firewallOutboundSubnets }}
|
|
|
|
SET gluetun.vpn.firewallOutboundSubnets — IT IS EMPTY RIGHT NOW
|
|
Without it the gluetun firewall blocks cluster-internal egress as well:
|
|
lookups against cluster DNS and seerr reaching an in-cluster Jellyfin both
|
|
fail, in a way that looks like an application bug. Set it to the cluster pod
|
|
and service CIDRs, e.g. "10.42.0.0/16,10.43.0.0/16" on k3s.
|
|
{{- end }}
|
|
|
|
ACCESS
|
|
{{- if and .Values.ingress.enabled $ingressApps }}
|
|
{{- range $shared }}
|
|
{{ .name }}: https://{{ $sharedHost }}{{ if ne .path "/" }}{{ .path }}{{ end }}
|
|
{{- end }}
|
|
{{- range $ingressApps }}
|
|
{{- $cfg := (index $.Values .name).ingress }}
|
|
{{- if not (has .name $sharedNames) }}
|
|
{{ .name }}: https://{{ $cfg.host }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- if $shared }}
|
|
|
|
These apps share one host, one Ingress and one certificate.
|
|
{{- if has "bazarr" $sharedNames }}
|
|
|
|
BAZARR NEEDS ONE MANUAL STEP UNDER A SUB-PATH
|
|
Bazarr reads its base path only from its own configuration - it has no
|
|
environment variable for it, so the chart cannot set it. Open bazarr,
|
|
go to Settings -> General -> Base URL, enter
|
|
|
|
{{ include "freesailarr.appPath" (dict "root" $ "app" "bazarr") }}
|
|
|
|
and restart the pod. Until then bazarr answers only at the root and the
|
|
sub-path shows a broken UI. radarr, sonarr and prowlarr need nothing -
|
|
the chart sets their UrlBase itself.
|
|
{{- end }}
|
|
{{- if has "qbittorrent" $sharedNames }}
|
|
|
|
QBITTORRENT SITS BEHIND A PREFIX-STRIPPING REWRITE
|
|
It has no base path of its own, so it gets a second Ingress object on the
|
|
same host whose rewrite cuts the prefix off. Two things there look exactly
|
|
like a login failure and send you looking in the wrong place: its session
|
|
cookie is marked "Secure" (over plain HTTP the browser never sends it back
|
|
and every click bounces to the login form), and it bans the client IP for a
|
|
while after a few failed attempts. If it does not work at all, set
|
|
qbittorrent.ingress.host and it is back on a host of its own - no chart
|
|
change needed.
|
|
{{- end }}
|
|
{{- if not (has "seerr" $sharedNames) }}
|
|
|
|
NOTHING SERVES THE ROOT OF {{ $sharedHost }}
|
|
seerr is the app that sits at "/" and it is not on the shared host, so
|
|
https://{{ $sharedHost }}/ answers 404. The sub-paths are unaffected.
|
|
{{- end }}
|
|
{{- end }}
|
|
|
|
Every app listed with a host of its own has its OWN Ingress and its own TLS
|
|
secret. While cert-manager is still issuing, the browser warns:
|
|
|
|
kubectl -n {{ .Release.Namespace }} get certificate
|
|
{{- else }}
|
|
No Ingress is rendered{{ if not .Values.ingress.enabled }} (ingress.enabled is false){{ end }}. Reach an app through its own service:
|
|
{{- range $portApps }}
|
|
|
|
kubectl -n {{ $.Release.Namespace }} port-forward svc/{{ $name }}-{{ .name }}-service {{ .port }}:{{ .port }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- if .Values.qbittorrent.enabled }}
|
|
|
|
qBittorrent's WebUI rejects a Host header whose port differs from its own
|
|
({{ "8080" }}) with a plain 401. A port-forward on a different local port therefore
|
|
looks like a login failure — forward 8080:8080, or go through the ingress.
|
|
{{- if .Values.qbittorrent.savePath }}
|
|
|
|
QBITTORRENT DOWNLOADS GO TO {{ .Values.qbittorrent.savePath }}
|
|
The chart seeds Session\DefaultSavePath into qBittorrent's config, because
|
|
its built-in default is /config/Downloads — the config volume{{ with .Values.qbittorrent.storage.size }} ({{ . }}){{ end }}, which
|
|
fills within minutes and puts EVERY torrent into "errored" while a large,
|
|
empty media volume sits next to it. The failure looks like a network or VPN
|
|
problem; only "df" shows what it is.
|
|
|
|
The value is seeded ONLY when the key is absent, never replaced. So if you
|
|
changed the path in the WebUI, it stays yours and this value does nothing —
|
|
check which one is actually in effect:
|
|
|
|
kubectl -n {{ .Release.Namespace }} exec {{ $name }}-sfs-0 -c qbittorrent -- \
|
|
grep SavePath /config/qBittorrent/qBittorrent.conf
|
|
|
|
If that shows a path under /config, downloads are landing on the config
|
|
volume: fix it under Options -> Downloads -> Default Save Path (the running
|
|
app owns the file; editing it under the app is overwritten on exit).
|
|
{{- else }}
|
|
|
|
QBITTORRENT HAS NO SAVE PATH SET (qbittorrent.savePath is empty)
|
|
It therefore falls back to /config/Downloads — the small config volume. That
|
|
fills within minutes and puts every torrent into "errored". Either set
|
|
qbittorrent.savePath or set Options -> Downloads -> Default Save Path in the
|
|
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
|
|
containers share gluetun's network namespace, so the services differ only in
|
|
their port. They give you stable per-app DNS names, NOT network separation
|
|
between the apps.
|
|
|
|
TOGGLING AN APP OFF AND ON LATER NEEDS ONE MANUAL STEP
|
|
The config volumes come from volumeClaimTemplates, and those are immutable.
|
|
Changing any <app>.enabled after the first install makes the upgrade fail
|
|
until the StatefulSet is recreated — the data survives:
|
|
|
|
kubectl -n {{ .Release.Namespace }} delete statefulset {{ $name }}-sfs --cascade=orphan
|
|
|
|
THE MEDIA CLAIMS ARE YOURS, THE CONFIG VOLUMES ARE THE CHART'S
|
|
media.<category>.existingClaim is mounted, never created, and is meant to be
|
|
shared with your Jellyfin. One claim can serve all three categories via
|
|
media.<category>.subPath.
|
|
|
|
Measured, so you do not plan around something that does not happen: Radarr
|
|
and Sonarr do NOT hardlink between the categories, not even on one claim.
|
|
Kubernetes makes every volumeMount its own bind mount and link() across two
|
|
mount points fails with EXDEV regardless of subPath, so an import copies.
|
|
|
|
HARDLINKS NEED ONE MOUNT, NOT ONE CLAIM
|
|
This is the point where the obvious conclusion is wrong, so it is spelled
|
|
out: putting downloads on the same claim as movies and tv, as a third
|
|
subPath, does NOT produce hardlinks. It buys the layout, nothing else.
|
|
|
|
The kernel compares the VFSMOUNT in do_linkat, not the superblock, and two
|
|
bind mounts of the same filesystem are two vfsmounts — so link() across
|
|
them is EXDEV even though it is one volume. Measured on Longhorn/NFS: both
|
|
mount points report st_dev 0:56, the mount table shows two mount IDs on
|
|
that same device, a cross-mount link() fails, a link() inside one mount
|
|
succeeds with link count 2.
|
|
|
|
Note the trap in that measurement: "stat" reports the SAME device number
|
|
on both sides. Every intuition says a hardlink must work there. It does
|
|
not, and the device number is exactly what makes this look like a bug in
|
|
the app instead of a property of the mount namespace.
|
|
|
|
The layout that WOULD hardlink is one claim with exactly ONE mount and the
|
|
categories as sub-directories inside it — the "single /data mount" the *arr
|
|
guides recommend. This chart cannot express that today: it mounts each
|
|
category separately. Naming the possibility, not giving instructions —
|
|
there are no values for it.
|
|
|
|
The containers write as {{ .Values.puid }}:{{ .Values.pgid }}. The claim's root directory has to be
|
|
writable for them, and a subPath directory the kubelet creates inherits that
|
|
root's mode — so fix the ownership before the first start. The pod will not
|
|
do it for you: fsGroupChangePolicy is OnRootMismatch so a large library is
|
|
not re-chowned on every start, and on an RWX claim a driver like Longhorn
|
|
(fsGroupPolicy ReadWriteOnceWithFSType) ignores fsGroup altogether.
|
|
{{- if eq $replicas "0" }}
|
|
|
|
SCALED TO ZERO
|
|
{{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0. PVCs, secrets,
|
|
services{{ if .Values.ingress.enabled }} and ingresses{{ end }} stay in place; set it back and upgrade to bring the
|
|
stack up again.
|
|
{{- else if ne $replicas "1" }}
|
|
|
|
MORE THAN ONE REPLICA IS NOT MEANINGFUL
|
|
replicas is {{ $replicas }}. Each replica would run its own isolated stack on its own
|
|
config volumes, all of them fighting over the same media claims and the same
|
|
VPN credentials.
|
|
{{- end }}
|