5
ytdlp web player
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

ytdlp-web-player

Deploys ytdlp_web_player, a web front end that fetches and transcodes videos on demand, as a stateless Deployment. The video cache and the tool cache are emptyDirs — there is no PersistentVolume. The only chart in this repo with a dependency: an optional oauth2-proxy subchart, off by default, because the application ships no authentication at all.

Image docker.io/matszwe02/ytdlp_web_player
Versions not listed on purpose — read the current pair off ytdlp-web-player/Chart.yaml, see Chart index
Workloads Deployment (1 replica); plus the oauth2-proxy subchart's own when enabled
Storage none — two emptyDirs (/data, /cache)
Routing two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled); ingress.clusterIssuer required only while the Ingress is on, ingress.domain while either mode is. The subchart has no Gateway support, so with the proxy owning the host the publishing object is its Ingress in both modes — see Routing
Dependencies oauth2-proxy 10.7.0 (https://oauth2-proxy.github.io/manifests), condition: oauth2-proxy.enabled, off by default
Secrets 1 × OnePasswordItem, and only with the proxy enabled
Install-test ci/ytdlp-web-player/ (test values only — no fixtures needed)

Prerequisites

  • cert-manager with the ClusterIssuer named in ingress.clusterIssuer, and DNS for ingress.domain pointing at the ingress controller.
  • Egress to the internet, including github.com — yt-dlp fetches a JavaScript challenge-solver library from there. Without egress the pod is Ready and every playback fails.
  • Only with the proxy enabled: an OIDC provider with a client application and a group/policy binding, the 1Password operator, and one 1Password item — see the fields below.
  • A default install needs no 1Password item at all.

Installation

The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.

helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update

helm install player jlab/ytdlp-web-player \
  --namespace ytdlp --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml — and this one is open to everyone who reaches the URL:

ingress:
  domain: player.example.com
  clusterIssuer: letsencrypt

With the proxy, which is what a reachable install should look like:

secrets:
  oauth2ProxySecretRef: op://vault/ytdlp-oauth2-proxy

ingress:
  enabled: false            # the proxy owns the host from here on

oauth2-proxy:
  enabled: true
  ingress:
    enabled: true
    className: nginx
    hosts: ["player.example.com"]
    tls:
      - secretName: tls-player-example-com
        hosts: ["player.example.com"]
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt
      nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
  config:
    configFile: |
      email_domains = ["*"]
      upstreams = ["http://{{ .Release.Name }}-ytdlp-web-player-service:80"]
      reverse_proxy = true
      session_cookie_minimal = true
      provider = "oidc"
      oidc_issuer_url = "https://sso.example.com/application/o/player/"
      redirect_url = "https://player.example.com/oauth2/callback"

helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — including the open-instance warning and the currently effective ephemeral-storage limit. Read it — it is the per-release version of this page.

What the 1Password item must contain

The chart renders a OnePasswordItem CR; the operator turns it into a Kubernetes Secret whose keys are the field names of the 1Password item. A missing field does not fail the install: the release goes through and the proxy pod then sits in CreateContainerConfigError, which looks like a broken chart.

Values key Kubernetes Secret Fields
secrets.oauth2ProxySecretRef <release>-ytdlp-web-player-oauth2-proxy-secret client-id (the OIDC client id), client-secret, cookie-secret (16, 24 or 32 bytes, URL-safe base64)

Generate the cookie secret as

openssl rand -base64 32 | tr '+/' '-_'

The tr is not cosmetic: oauth2-proxy decodes the value as URL-safe base64 first, so a standard-base64 value containing + or / fails to decode and the pod exits with cookie_secret must be 16, 24, or 32 bytes.

The CR is rendered only while the proxy is enabled and oauth2-proxy.config.existingSecret still names this chart's Secret. Point that value at a Secret you manage yourself and the chart creates nothing — your Secret then has to carry the same three keys.

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders every workload with replicas: 0 without uninstalling. Takes precedence over ytdlpWebPlayer.replicas. Does not reach the subchart — with the proxy on, set oauth2-proxy.replicaCount: 0 as well, or the proxy keeps running and answers with a gateway error.
global object unset Helm's conventional cross-chart values. Unused by this chart, kept open — and with a subchart present this is no longer hypothetical: whatever is set here reaches oauth2-proxy too.

secrets

Key Type Default Description
secrets.oauth2ProxySecretRef string null Required while oauth2-proxy.enabled is true and oauth2-proxy.config.existingSecret still names this chart's Secret. Unused otherwise.

ytdlpWebPlayer

Key Type Default Description
ytdlpWebPlayer.replicas int 1 Replica count; 0 is allowed. See More than one replica shares nothing.
ytdlpWebPlayer.securityContext.pod object non-root 20000:1000, fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault Merged onto the chart's pod hardening. See Documented deviation: the uid. The seccomp profile sits here so it covers every container including init containers.
ytdlpWebPlayer.securityContext.container object readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation Merged onto the chart's container hardening. Everything the app writes goes to the two emptyDirs.
ytdlpWebPlayer.livenessProbe object GET /:http, period 10s, timeout 5s, 3 failures The app exposes no health endpoint; / is Flask's index route and touches neither ffmpeg nor the network. See A Ready pod does not mean a video will play.
ytdlpWebPlayer.readinessProbe object GET /:http, period 10s, timeout 5s, 3 failures
ytdlpWebPlayer.startupProbe object GET /:http, period 5s, 30 failures (= 150s) Gates liveness and readiness. The app opened its port in about two seconds in k3d; the budget only has to cover a slow first process start.
ytdlpWebPlayer.env list [] Extra environment variables appended to DATA_PATH, PORT, HOME and XDG_CACHE_HOME. Rendered through tpl; value, secretKeyRef, configMapKeyRef forms. This is where the application is configured — see The app's twenty switches are not chart values.
ytdlpWebPlayer.ports list one entry: containerPort: 5000, protocol: TCP, name: http Rendered verbatim as the container's port list. The entry named http is load-bearing: its number becomes PORT, the Service target and every probe target. A list without it fails the render with that message.
ytdlpWebPlayer.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request.
ytdlpWebPlayer.resources.requests object cpu: 0.5, memory: 512Mi, ephemeralStorage: 2Gi The ephemeral-storage number is the interesting one, not memory — 2Gi request yields a 6Gi limit. See The video cache is an eviction risk.
ytdlpWebPlayer.resources.limits object unset An explicit limit always wins. No CPU limit on purpose, and this is the chart where it matters most: a CPU limit does not fail a request, it throttles ffmpeg, which arrives at the viewer as a stuttering stream with nothing in any log.

storage

Key Type Default Description
storage.dataSizeLimit string null Optional sizeLimit on the /data emptyDir. Null means the only ceiling is the container's ephemeral-storage limit. Hitting either has the same outcome — the kubelet evicts the pod — so this buys a clearer message, not a graceful failure.
storage.cacheSizeLimit string null Same for the /cache emptyDir (HOME and XDG_CACHE_HOME).

ingress

Key Type Default Description
ingress.enabled bool true Gates this chart's whole Ingress template. Mutually exclusive with httpRoute.enabled. It frees clusterIssuer; it frees domain only if httpRoute is off as well — this page used to say it frees both, which was true until the Gateway support landed and the route started reading the domain. What still holds is the reason: the app builds no absolute URLs and has no base-URL setting, so unlike searxng nothing else reads either value.
ingress.domain string null Required while either mode is enabled. Host — the Ingress's, and the HTTPRoute's while httpRoute.hostnames is empty.
ingress.clusterIssuer string null Required while the Ingress is enabled. cert-manager ClusterIssuer. Unused and unnecessary in Gateway mode.
ingress.className string nginx Ingress class. No counterpart in Gateway mode — the class sits on the Gateway.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode. Note which annotation this is not about: the 16k proxy-buffer-size this chart recommends belongs on the proxy's Ingress (oauth2-proxy.ingress.annotations), and the subchart renders an Ingress in either mode, so that one is unaffected.

TLS is stored in tls-<release>-ytdlp-web-player-ingress. In Gateway mode the chart renders neither the secret nor a certificate.

httpRoute

The Gateway-API alternative to the Ingress, off by default. Added with group 3 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4). Read Routing before switching it on — in the recommended configuration it changes nothing.

Key Type Default Description
httpRoute.enabled bool false Renders <release>-ytdlp-web-player-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values.
httpRoute.parentRefs list [] The Gateway(s) the route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases.
httpRoute.hostnames list [] Empty means [ingress.domain], so the hostname has a single source of truth. Set it to publish under a different or additional name — which is also how the application is given a host of its own beside the proxy's. The host guard reads this list too, so a hostname set here that the proxy's Ingress also claims is caught rather than silently published twice.

oauth2-proxy (subchart)

Everything under this key is passed to the oauth2-proxy chart, not read by this one — with three exceptions: enabled (the dependency condition) and ingress.enabled / ingress.hosts (the two-Ingress guard). The block stays fully open in values.schema.json: the chart owns neither the keys nor their meaning, and a closed copy would reject whatever upstream adds next release. Anything not listed here stays at the subchart's default.

Reaching the key from a template needs index .Values "oauth2-proxy" — .Values.oauth2-proxy parses as a subtraction. The condition: in Chart.yaml splits on dots only and needs no escaping.

Key Type Default Description
oauth2-proxy.enabled bool false The dependency condition. Off means nothing of the subchart renders and no proxy image is pulled — and that the instance has no authentication.
oauth2-proxy.ingress.enabled bool false With the proxy on, this is the Ingress that should be on and ingress.enabled the one that should be off.
oauth2-proxy.ingress.className string nginx Ingress class of the proxy's Ingress.
oauth2-proxy.ingress.hosts list unset (commented example) The single host the app would otherwise carry. Read by this chart's guard.
oauth2-proxy.ingress.tls list unset (commented example) The proxy owns its own TLS secret; this chart's ingress.clusterIssuer does not reach it.
oauth2-proxy.ingress.annotations object {} Where the cert-manager annotation and nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" belong.
oauth2-proxy.config.existingSecret string '{{ .Release.Name }}-ytdlp-web-player-oauth2-proxy-secret' The Secret with the three credentials. While it names this value, the chart renders the OnePasswordItem for it. The subchart runs the string through tpl, so .Release.Name resolves; .Chart.Name would not — inside the subchart that is oauth2-proxy, which is why the parent name is written out literally.
oauth2-proxy.config.configFile string email_domains = ["*"], upstreams = ["http://{{ .Release.Name }}-ytdlp-web-player-service:80"], reverse_proxy = true, session_cookie_minimal = true The proxy's whole configuration. Add your provider settings here. See Three load-bearing lines in configFile.
oauth2-proxy.alphaConfig.enabled bool false Leave this off. With it on the subchart generates a configuration without upstreams and ignores config.configFile silently — the login succeeds and the app answers 404. It is off in the subchart's defaults too; it is spelled out here because switching it on looks like an upgrade.

networkPolicy

Inbound 5000 only. Outbound DNS plus TCP 80 and 443 to any destination — fetching media is the application.

Value Type Default Meaning
networkPolicy.enabled bool true Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured.
networkPolicy.allowedNamespaces list [] Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it.
networkPolicy.probeSources list [] CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready.
networkPolicy.ingressController object kube-system / app.kubernetes.io/name: rke2-traefik The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed.
networkPolicy.egress.extraPorts list [] Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off.

Operating notes

Without the proxy, this release is open to everyone who knows the URL

The application has no authentication of any kind and the chart adds none while oauth2-proxy.enabled is false. An anonymous visitor does not just read a page: they can make this pod fetch and transcode arbitrary videos from the internet — your egress, your CPU, your IP address in the logs of whatever site is being pulled from, and your ephemeral storage. Assume it is found: hosts get scanned, and certificate transparency logs publish every name cert-manager requests, so "nobody knows the URL" has a half-life of hours. NOTES.txt prints this warning whenever an Ingress is on and the proxy is off.

Routing: Ingress or Gateway API

Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.

Read this before switching httpRoute on, because in the recommended configuration it does nothing. The oauth2-proxy subchart has no Gateway-API support and this chart cannot give it one — upstream's chart is consumed as it comes (what the dependency costs), and it renders an Ingress or nothing. So with the proxy in the data path and owning the host, the object that publishes the release is the subchart's Ingress in both of this chart's modes. Turning httpRoute.enabled on there changes nothing about how the host is served.

This chart's own route is for the other two cases:

  • the proxy switched off entirely (oauth2-proxy.enabled: false), so the chart publishes the application itself;
  • the application on a host of its own beside the proxy's host — allowed, and meaning exactly what it says: an unauthenticated way past the proxy.
Rule Why
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths two data paths onto one Service under one hostname is a conflict at DNS level already. ingress.enabled defaults to true here, so turning Gateway mode on means setting ingress.enabled: false in the same values file. A migration runs over a second hostname
httpRoute.parentRefs must not be empty — the render aborts a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer

Who owns the host — and in Gateway mode nothing else will tell you

The proxy sits in the data path, so with oauth2-proxy.enabled: true the Ingress belongs to the proxy and this chart's own routing object should be off; the app is then reachable only through its Service.

Leaving both on for the same host is refused by the chart, and the reason to refuse it is much stronger in Gateway mode:

  • In Ingress mode ignoring it would fail loudly anyway: ingress-nginx's admission webhook rejects the second Ingress, which leaves a Fleet bundle in ErrApplied until forceSyncGeneration is bumped. Annoying, but reported.
  • In Gateway mode the webhook never fires. It compares Ingress objects with Ingress objects, and an HTTPRoute is a different kind — so nothing rejects anything, both objects stay valid, and one hostname is served over two data paths that are not equivalent: the proxy's Ingress goes through authentication, this chart's HTTPRoute goes straight to the application. Which one a client reaches depends on what DNS resolves to. That is an authentication bypass that comes and goes with a DNS record, and no Kubernetes object reports it.

The guard is the helper ytdlp-web-player.published (ingress.enabled OR httpRoute.enabled), and it exists in that form because of exactly this: before the Gateway support it asked ingress.enabled, which is false by construction in Gateway mode — so the guard would have gone silent precisely where it matters most. It reads httpRoute.hostnames as well as ingress.domain, and the Gateway-mode abort carries its own message naming the bypass. Called from the Deployment, the one template that always renders, so it cannot be skipped by scaling to zero or by helm template -s.

Leaving this chart's routing object on for a different host stays allowed and still means an unauthenticated way past the proxy; NOTES.txt warns about it in both modes. And in Gateway mode that is the safe half — it is the same-host case that is dangerous, which is why the chart refuses it rather than warning.

Nothing was measured on a live Ingress of this chart, because there is none

Checked 2026-10-12: the release runs in namespace jlab-player with both Deployments Ready (jlab-player-ytdlp-web-player-service and jlab-player-oauth2-proxy), and not one of the cluster's 23 Ingress objects is in that namespace — neither this chart's nor the subchart's.

What publishes it instead could not be determined. The workspace's read-only identity may not list httproutes or secrets, so neither a hand-written HTTPRoute beside the release nor the release's own Helm values are readable from here. Recorded as the open question it is rather than guessed at — and worth resolving before anybody plans a mode switch for this host, since the answer decides whether the switch is a no-op, a change of publisher, or a bypass.

What the dependency costs

A dependency here is admissible, not free (style guide §14). The bar it clears: the proxy would otherwise be a second Helm release that only exists to serve this one, upstream is the right owner of it, and it is optional and off by default. The price, all of it:

  • The subchart is invisible to everything downstream. Its version is inside our packaged .tgz, so anything watching Helm release versions — Fleet's fleet.yaml included — sees only our chart version. A new oauth2-proxy release, a security fix included, reaches a cluster only when a new ytdlp-web-player version is published. Nothing reports the gap. Renovate's built-in helmv3 manager opens the PR (no customManagers entry needed) and ci/renovate-bump-chart.sh bumps the chart version that ships it — since Gitea #128 at the subchart's own level, so an oauth2-proxy minor makes a chart minor here; that chain is the entire safety net. The subchart version is independent of appVersion, which is exactly why the level is taken from Renovate's update type rather than from comparing appVersion.
  • Upstream's chart is consumed as it comes. We do not patch its templates, do not re-harden its workload and do not re-implement its values. Its resource limits, probes and security context follow its defaults, not this repo's; set them under oauth2-proxy.resources / .securityContext / .livenessProbe if you want this repo's shape.
  • This repo's mechanisms stop at the subchart boundary. checksum/config, the 1Password auto-restart annotation and scaleDown reach our templates only.
  • Every pipeline needs a network fetch first. helm lint, package, template and install all exit 1 while charts/ is unfilled, so ci/helm-dependencies.sh runs before each — including the resource-quantity render test that covers every chart on every PR. A third-party Helm repository's bad day can therefore redden a PR that touched nothing related. charts/*.tgz and Chart.lock are not committed, which is why the pipelines run helm dependency update rather than build.
  • The subchart itself has a conditional dependency on redis-ha (off by default), which is why helm dependency update here pulls two repositories.

Three load-bearing lines in configFile

Do not remove these while trimming the default:

Line What goes wrong without it
reverse_proxy = true The proxy considers every request plain http and builds its redirect back to http:// — behind a TLS-terminating Ingress that is a loop the moment a login completes.
session_cookie_minimal = true id_token and access_token go into the session cookie; a user with a longer group list pushes it past the browsers' 4 kB limit, the browser drops it, and the request comes back as HTTP 502 after a successful login, with [AuthSuccess] in the proxy's log. Incompatible with pass_access_token, set_authorization_header and pass_authorization_header — switch any of those on together with it and the proxy refuses to start. This app reads no identity headers at all.
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" (on the proxy's Ingress) The other half of the same 4 kB trap: the response carrying the session cookie does not fit ingress-nginx's default 4k proxy buffer, and the browser gets a 502 after a successful login.

upstreams is written in config.configFile rather than in the structured config.upstreams for one reason: the subchart emits config.upstreams with toJson and without tpl, so a {{ .Release.Name }} there ends up in the manifest verbatim. config.configFile is passed through tpl. A wrong upstream is not a render error — it is a 404 after a successful login, in production.

The provider is the only gate, and it is wider than it looks

The default email_domains = ["*"] admits every identity the provider hands over. Which people that is, is decided entirely by the group or policy binding on the provider application, and this app has no second login behind the proxy. A forgotten or too-wide binding is not "one more user", it is every account the provider knows. Check that binding when you set this up.

Repointing the proxy secret does not restart it

The OnePasswordItem is this chart's, but it is consumed by the subchart's pod template, which this chart cannot annotate — and Helm never sees a Secret's content anyway (style guide §9):

kubectl -n <namespace> rollout restart deployment/<release>-oauth2-proxy

The app's own Deployment carries no checksum/config, and nothing is missing: it consumes no ConfigMap and no Secret — it is configured purely through environment variables, which are part of the pod template already, so a changed value rolls the pods by itself.

A Ready pod does not mean a video will play

All three probes request /. That is the only cheap path the app has, and it keeps a slow upstream site from restarting the pod. The price is that egress problems are invisible to Kubernetes: with DNS broken or outbound traffic blocked, / still answers 200, the rollout goes green, and every playback fails. A failing fetch shows up as a yt-dlp error line in the container log, never as a probe failure:

kubectl -n <namespace> logs deployment/<release>-ytdlp-web-player-deployment

The video cache is an eviction risk, not a disk

DATA_PATH is /data, backed by an emptyDir. Nothing survives a restart, a node drain, an eviction or a helm upgrade; every cached video is fetched again on demand. That is the intended shape — the app expires files itself after MAX_VIDEO_AGE — but do not treat it as storage.

An emptyDir counts against the container's ephemeral-storage limit. Going over it is not an application error: the kubelet evicts the whole pod, the reason sits on the pod rather than in any log, and everything in flight goes with it. Raising the limit is the wrong first move; the knobs that decide how much lands there are in ytdlpWebPlayer.env:

Variable Upstream default What it decides
MAX_VIDEO_DURATION 36000 Ten hours — one enormous single file
MAX_VIDEO_AGE 3600 Seconds a file survives after the last access
MAX_PROCESSES 5 How many concurrent ffmpeg/yt-dlp runs can arrive at once

/tmp is not mounted, /cache is — both measured

Two runs of the image at uid 20000 with a read-only root filesystem, driven through metadata extraction, HLS transcoding, sprite generation and audio extraction:

  • /tmp is never written to. Not once. The usual "read-only rootfs, so mount an emptyDir on /tmp" is dead weight for this image and deliberately absent.
  • $HOME/.cache is written to. Without it the log carries ten OSError: [Errno 30] Read-only file system: '/.cache' lines. Both bundled tools use it: deno for its module cache, yt-dlp for the JavaScript challenge-solver library it fetches from github.com. The failure is a warning, not an error — and what it costs is silent: that library is re-downloaded on every single extraction, which turns a github.com outage, or an egress rule that does not expect it, into broken playback with a warning nobody read.

HOME and XDG_CACHE_HOME are set to /cache rather than relying on a mount at /.cache, so the path does not depend on which uid the pod ends up running as.

yt-dlp does not update itself here

On start and periodically the app runs pip install --upgrade yt-dlp into its own site-packages. With readOnlyRootFilesystem: true that write cannot happen; the app catches the failure and carries on with the version baked into the image (verified — it starts, serves and transcodes normally). That is the right trade in a cluster, but know the consequence: when a video site changes its extraction and playback breaks, waiting does not fix it. The fix arrives as a new app image, i.e. a new appVersion of this chart. There is no in-place update path.

The app's twenty switches are not chart values

The upstream offers around twenty environment switches (APP_TITLE, THEME_COLOR, GENERATE_SPRITE_BELOW, AMOLED_BG, MAX_VIDEO_AGE, MAX_VIDEO_DURATION, DEFAULT_QUALITY, MAX_QUALITY, AUTOPLAY, MIN_LIVE_BUFFER, ALWAYS_TRANSCODE, DISABLE_TRANSCODING, MAX_PROCESSES, AUTOSKIP_SB_SEGMENTS, COOKIES_ONLY_ON_FAILURE, PLAYLIST_SUPPORT, AUTO_BG_PLAYBACK, AUDIO_VISUALIZER, PROXY as of v1.2.0). The chart mirrors none of them: a chart that rebuilds every option is a lie one upstream release later, and every one would have to be kept in values.schema.json too. src/example.env upstream is the authoritative list.

Do not set DATA_PATH, PORT, HOME or XDG_CACHE_HOME in ytdlpWebPlayer.env. The entries are appended, and Kubernetes applies the last entry of the list — a duplicate silently wins and points the app away from its writable volume, off the port the Service and all three probes use, or its caches back onto the read-only root filesystem.

Documented deviation: the uid

The upstream image carries no USER instruction and runs as root by default (python:3.13-alpine, apk add ffmpeg deno, no USER). That is a default, not a requirement: measured, the container runs as an arbitrary uid with readOnlyRootFilesystem: true and the full hardening baseline, serving the UI and completing a real transcode. The chart therefore pins a non-root uid rather than following the image, and the deviation is about the number only — no part of the hardening baseline is relaxed. 20000 is arbitrary on purpose: nothing in the image belongs to it, /app is world-readable and read-only, and no PersistentVolume ties data to the number.

More than one replica shares nothing

Each pod keeps its own /data emptyDir, so a video fetched by one is invisible to the other, and without session affinity the HLS segment requests of a single playback are spread across both — which looks like a stuttering or failing stream rather than an error. Pin clients to a pod at the ingress, or stay at one replica.

Not measured

HLS playback through the proxy. Rendering and wiring are verified; a long stream through proxy and ingress is not. Three places to look first if playback stalls where a direct ingress works: proxy-buffering in ingress-nginx, its 60-second proxy-read/send timeouts, and the proxy's own upstream timeout. Browser extension and PWA are unverified too — an expired session answers with a redirect to the provider, not a 401, and a fetch() sees only an opaque redirect.

The oauth2-proxy subchart is not covered by this policy

This is the sharpest case of a general limit, so it is worth stating rather than inferring: the policy selects this chart's workload by its app label. The oauth2-proxy subchart renders its own Deployment with its own labels and ships no policy of its own.

So with the proxy enabled, the pod that actually faces the Ingress is unrestricted in both directions. Nothing in this chart can change that — an upstream chart is consumed as it comes — so do not read "this chart has a NetworkPolicy" as "this release is covered".

The probed port is open inside the cluster by default

networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.

It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.

Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.

The policy is only a boundary where the CNI enforces one

The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.

Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.

Upgrades and backup

  • Nothing to back up. No PVC, no database, no persisted state; both volumes are caches refilled from the network. A rollback carries no data risk.
  • Raising the subchart version is the only path a proxy update has. Keep that line moving — see What the dependency costs.
  • An app-version bump is the only way a yt-dlp fix reaches the cluster.

What this chart deliberately does not do

No cookies.txt: videos that require a login on the source site do not work, and there is no value to make them work. No PersistentVolume. No mirror of the upstream's environment switches as chart values.

CI

ci/ytdlp-web-player/ holds only test-values.yaml — the app has no secrets, no PVC and no external dependency to fake, so there is no fixtures.yaml.

The proxy stays off in CI, and that is not an oversight. Two independent reasons, either one sufficient: enabling it would test somebody else's chart (what this repo owns is the wiring, and wiring is a render-time property checked by rendering, not by installing); and it could not pass anyway, because oauth2-proxy fetches its provider's OIDC discovery document at startup and never becomes Ready without one. There is no provider in the CI cluster, and standing one up is a test of the fixture, not of the chart.

Because the chart declares a dependency, ci/helm-dependencies.sh runs before lint, package, render and install — see CI/CD.

The Gateway mode is held at render level, by ci/httproute/ytdlp-web-player.yaml plus ci/run-httproute-match-test.sh, which runs over every chart on every PR (CI/CD). The overlay switches this chart into Gateway mode; 2 assertions — parentRefs not empty, and the backendRefs entry resolving to the application Service of the same render by port number, because backendRefs takes a PortNumber and cannot name the port http the way the Ingress does. That number also has to agree with ytdlp-web-player.appServiceAddress, which is what the proxy gets as its upstream — three places, and the overlay holds two of them together.

The subchart stays disabled in the overlay too, and deliberately rather than lazily: it has no Gateway-API support, so switching it on would add a second object this test does not look at, while the thing worth exercising in Gateway mode is this chart's own route. The interesting interaction — one hostname claimed by the proxy's Ingress and by this chart's HTTPRoute, which the admission webhook cannot catch because the kinds differ — is a render-failure path: the chart refuses it. This test only runs renders it expects to succeed, so that guard was exercised by hand instead, in both modes, including the collision reached through httpRoute.hostnames rather than through the ingress.domain fallback, and with only the Deployment rendered.

What that does not cover: that the hostname answers (no Gateway controller runs in CI, so no route reaches Accepted: True); that authentication is in front of the application — with the proxy off, that route is the unauthenticated path, which is correct for a fixture and is exactly what NOTES.txt warns about for a real installation; and anything about how this release is published in the real cluster, since it has no live Ingress to compare against. Nothing in CI reads NOTES.txt either: helm template does not emit it and no script under ci/ reads it, so its text carries review-only assurance.

Renovate

Two blocks watch this chart. The built-in helmv3 manager reads the dependencies: block and opens the subchart PR (labelled subchart); no customManagers entry is needed or would help, because the repository URL and the version sit in one file. A customManagers block tracks appVersion against docker.io/matszwe02/ytdlp_web_player with an anchored versioning regex: the registry carries four versioned tags and the two floating ones, latest and stable, both of which move — stable was republished at the same minute as v1.2.0. The anchors requiring a v prefix and three numeric parts are what keeps them out of the value that selects the image. The v itself stays outside every capture group: it carries no ordering, and a capture group is a claim about how a value is compared. See Renovate.

See also