π¬ MediaScope¶

MediaScope is a web application that scans your local media directory, detects movies and TV series, and helps you identify missing seasons or incomplete collections.
Introduction¶
The goal of MediaScope is to make it easier to manage large personal media libraries. Instead of manually checking folders one by one, MediaScope analyzes your directory structure, extracts information about films, series, seasons, and episodes, then compares your local collection against online metadata sources to highlight what may be missing.
This page covers installing and running the application as a self-contained web service.
Info
MediaScope is open-source and lives in this repository under src/
(Rust backend + React/TypeScript frontend), with the Podman packaging in
build/, quadlet/ and nginx/.
Features¶
- β‘ Event-driven, near-zero idle scanner with instant rescheduling and live SSE updates
- π Smart incremental scanning of SMB movie/series sources with metadata, posters, and missing-file cleanup
- π§© Missing-episode detection via TMDB: flags whole missing seasons and individual episodes
- πΌοΈ Dark-blue responsive library browser with movie/series walls, type-to-find by the English or the French title (TMDB's, accents ignored, offline too), and missing episode/season highlights
- ποΈ An animated poster wall behind the sign-in, activation and email-confirmation pages, on the platform's scene engine (
@platform/scene, mounted once byLoginBackdrop): it rests after a minute untouched and wakes on any input, stays still under reduced motion, and leaves out its fog, vignette and light beam on a touch screen; the card over it is frosted glass with MediaScope's own navy values, opaque under reduced transparency; the pages are always dark through the login theme (MEDIASCOPE_LOGIN_THEME, defaultdark;MEDIASCOPE_LOGIN_NIGHTserves adaytimesetting) - π Wishlist: every account asks, from its profile, for the films and series the library lacks (a name, and a year when several share it); each scan finds the wishes the library now holds β by the English or the French title, accents ignored β and mails each owner once; admins accept or refuse wishes on the Users page, and the weekly email lists the open ones
- π΄ Local-first PWA: the library with its posters, the viewed marks, resume positions and the wishlist live on the device, open instantly offline and sync back in the background; playback, downloads and scans need a connection (offline & sync)
- β¬οΈ Download a film, episode or whole series: streamed off the SMB share through a queued, pausable in-app download manager that keeps the screen awake while it transfers and keeps its queue across visits, resuming each file from the bytes on disk β committed in 128 MiB blocks, so a closed browser loses one block at most
- π Admin Statistics page: each data source's size and files, the last seven days' visitors, what each account downloaded and watched, and the live per-minute traffic of the last three hours
- π Secure accounts with roles, email onboarding, password reset, optional TOTP 2FA, and closing one's own account
How it works¶
The scanner sleeps until the next poll time. When it wakes, it lists each enabled SMB
source, filters to .mkv files, and records new or changed entries β decoding the
Matroska header for movies, parsing SxxEyy for episodes β while caching poster
thumbnails to disk and pruning anything that has vanished. PostgreSQL holds the
library metadata; posters are cached as files under the data directory and served by
the binary, so the database stays small.
π₯ Installation¶
This section describes how to install and run Podman services using systemd Quadlet, enabling containers to restart automatically. It allows the service to run in its own isolated rootless environment with a dedicated Linux user.
π Requirements¶
βοΈ Configuration¶
π Service Setup¶
Info
Please follow the following installation steps:
Define Service Variables
# define the service name
SERVICE_NAME=mediascope
Initialize Service Environment
# built-in bash safety and SERVICE_NAME validation
set -euo pipefail; [ -n "${SERVICE_NAME:-}" ] || { echo "SERVICE_NAME is empty"; exit 1; }
# automatically generate variables
SERVICE_HOME="/media/ssd/podman-users/${SERVICE_NAME}"
SERVICE_DIR="/media/ssd/podman/${SERVICE_NAME}"
SERVICE_USER="${SERVICE_NAME}_svc"
SERVICE_ADMIN="${SERVICE_NAME}_admins"
π Service Isolation¶
Configure Rootless Podman UID/GID Namespace Mappings
# allocate rootless Podman UID/GID mappings if missing
SUBID_SIZE=65536
allocate_subids_if_missing() {
local user="$1"
local start
if ! grep -q "^${user}:" /etc/subuid; then
start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subuid)"
sudo usermod --add-subuids "${start}-$((start + SUBID_SIZE - 1))" "$user"
fi
if ! grep -q "^${user}:" /etc/subgid; then
start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subgid)"
sudo usermod --add-subgids "${start}-$((start + SUBID_SIZE - 1))" "$user"
fi
}
allocate_subids_if_missing "${SERVICE_USER}"
# verify rootless Podman UID/GID namespace mappings exist
grep -q "^${SERVICE_USER}:" /etc/subuid || { echo "ERROR: missing subuid mappings for ${SERVICE_USER}"; exit 1; }
grep -q "^${SERVICE_USER}:" /etc/subgid || { echo "ERROR: missing subgid mappings for ${SERVICE_USER}"; exit 1; }
# validate that all subordinate ID ranges in a file are non-overlapping
validate_subid_ranges() {
local file="$1"
awk -F: '
{
start = $2
end = $2 + $3 - 1
for (i = 1; i <= count; i++) {
if (start <= ends[i] && end >= starts[i]) {
printf "ERROR: overlapping ranges in %s\n", FILENAME
printf " %s:%s:%s overlaps %s:%s:%s\n",
$1, $2, $3,
names[i], starts[i], sizes[i]
exit 1
}
}
count++
names[count] = $1
starts[count] = start
ends[count] = end
sizes[count] = $3
}
' "$file"
}
validate_subid_ranges /etc/subuid
validate_subid_ranges /etc/subgid
# configure the rootless storage driver BEFORE Podman first initializes its storage.
# fuse-overlayfs is a reliable rootless overlay backend and avoids kernel-version
# quirks of native rootless overlay on ARM/Armbian. It is optional with the default
# userns used here, but recommended; set it before the first storage init so no
# 'podman system reset' is needed later.
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers"
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/storage.conf" >/dev/null <<'EOF'
[storage]
driver = "overlay"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
EOF
# rebuild rootless Podman state to ensure existing containers/storage use current namespace mappings
# use a login shell (-i) so HOME points at the service user's home
# (storage now initializes with fuse-overlayfs; no 'podman system reset' needed)
sudo -iu "${SERVICE_USER}" podman system migrate
Configure Secure Service Directory Permissions
# Apply the admin/service ACL policy to one directory tree, deterministically.
# $1 = tree to treat
# $2 = (optional) a sub-path to skip entirely (e.g. a container data subtree)
#
# Why setfacl, not chmod: on an ACL'd tree, chmod edits only the mask, never group::,
# so stale execute bits survive. setfacl sets every entry explicitly (mask included),
# lowercase β the result is reproducible and no execute bit can resurface.
apply_acl_tree() {
local tree="$1"
local skip="${2:-}"
local prune=()
[ -n "$skip" ] && prune=( -path "$skip" -prune -o ) # skip this subtree if a 2nd arg is given
sudo find "$tree" "${prune[@]}" -exec chown "${SERVICE_USER}:${SERVICE_ADMIN}" {} + # service user owns; admin group is the owning group
# directories β access ACL (perms on the existing dirs)
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m g:${SERVICE_ADMIN}:rwx {} + # admin group: access existing dirs
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m u:${SERVICE_USER}:rwx {} + # service user: access existing dirs
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m u::rwx,g::rwx,o::-,m::rwx {} + # owner/group rwx, deny others, pin mask rwx
# directories β default ACL (inherited by NEW files/dirs created later)
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m g:${SERVICE_ADMIN}:rwx {} + # admin group: inherit access on new items
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u:${SERVICE_USER}:rwx {} + # service user: inherit access on new items
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u::rwx,g::rwx,o::- {} + # default owner/group/other for new items
# files β access ACL (rw only, never executable)
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m g:${SERVICE_ADMIN}:rw {} + # admin group: access existing files
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m u:${SERVICE_USER}:rw {} + # service user: access existing files
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m u::rw,g::rw,o::-,m::rw {} + # owner/group rw, deny others, pin mask rw
sudo find "$tree" "${prune[@]}" -type d -exec chmod g+s {} + # setgid: new items inherit the group (a MODE bit; ACLs can't set it)
}
# --- SERVICE_HOME: Podman's private runtime home ------------------------------
# Own the home + set setgid, then ACL ONLY the .config subtree (containers.conf,
# storage.conf, and the *.network / *.container quadlets you hand-edit) so admins
# can edit them sudo-less.
#
# Leave ~/.local (storage) and ~/.cache to Podman: ACLs on the overlay store break
# the runtime ("OCI permission denied" on the `merged` mount). Admin reaches them via sudo.
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_HOME}" # service user owns; admin group is the owning group
sudo chmod u=rwx,g=rwx,o=,g+s "${SERVICE_HOME}" # owner/admin access; deny others; inherit group on new items
apply_acl_tree "${SERVICE_HOME}/.config" # .config subtree: full sudo-less admin ACL treatment
# --- bind-mount data dirs: created + owned BEFORE the SERVICE_DIR ACL pass ---------
# Each container writes as a mapped subuid, so its data dir must be owned by that subuid
# (chown as root; only root crosses the id range). We create + own them FIRST so they
# neither inherit SERVICE_DIR's default ACL (inheritance happens at creation) nor get
# walked by apply_acl_tree (which prunes data/).
#
# No ACLs here: Postgres refuses a group-accessible PGDATA. Admin reaches them via sudo;
# back Postgres up with pg_dumpall, not a raw file copy.
SUBUID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subuid)"
SUBGID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subgid)"
# data subdir : in-container uid/gid it must be owned by
# Every entry is a bind mount in the quadlets: each MUST exist before the units
# start, or podman fails the service with "statfs <path>: no such file or directory".
data_dirs=(
"postgres-db:70" # /var/lib/postgresql β alpine postgres user (70)
"posters:1000" # /data/posters β mediascope poster thumbnail cache (image runs as USER mediascope, uid/gid 1000; the quadlet sets no User=)
"subtitles:1000" # /data/subtitles β mediascope extracted-subtitles store (same uid; written by the Settings subtitles scan and the zip import)
)
mkdir -p "${SERVICE_DIR}/data" # the data/ parent: plain, no ACL
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_DIR}/data" # owner/admin
sudo chmod 0750 "${SERVICE_DIR}/data" # children are set individually below
for entry in "${data_dirs[@]}"; do
sub="${entry%%:*}" # subdir name
ids="${entry#*:}" # "uid" or "uid:gid"
uid="${ids%%:*}" # uid
gid="${ids#*:}"; [ "$gid" = "$ids" ] && gid="$uid" # gid (defaults to uid)
dir="${SERVICE_DIR}/data/${sub}"
sudo mkdir -p "$dir"
sudo chown -R "$((SUBUID_BASE + uid - 1)):$((SUBGID_BASE + gid - 1))" "$dir" # map both to host subids
sudo chmod 0700 "$dir"
done
# --- SERVICE_DIR: application data, env files, configuration -------------------
# Full treatment so the admin group gets sudo-less access AND the service user keeps
# access even on admin-created files. data/ is EXCLUDED: those dirs are owned by the
# containers' subuids and need engine-specific modes (Postgres rejects a group/other-
# accessible PGDATA), so they are handled separately just below.
apply_acl_tree "${SERVICE_DIR}" "${SERVICE_DIR}/data" # treat everything EXCEPT data/
Configure Secure NGINX Static Assets Permissions
# Grant the NGINX worker user (www-data) read-only access to assets subtrees.
# $1 = service directory β traverse-only (x)
# $2..$n = assets subtrees served directly by NGINX (configs, icons, favicon, manifest, ...)
apply_acl_nginx() {
local tree="$1"; shift
local assets
sudo setfacl -m u:www-data:x "$tree" # traverse only: pass through, no listing, no reading
for assets in "$@"; do
# assets directories β enter + list, existing and future
sudo find "$assets" -type d -exec setfacl -m u:www-data:rx {} + # www-data: read existing dirs
sudo find "$assets" -type d -exec setfacl -d -m u:www-data:rx {} + # www-data: inherit read on new items
# assets files β www-data read-only, owner/admin rw, never executable.
# Entries AND mask are pinned explicitly: a bare `setfacl -m u:www-data:r`
# would recalculate the mask to the union of all entries, resurfacing the
# latent rwx inherited from the default ACL (files would show group rwx).
sudo find "$assets" -type f -exec setfacl \
-m u:www-data:r,u:${SERVICE_USER}:rw,g:${SERVICE_ADMIN}:rw,u::rw,g::rw,o::-,m::rw {} +
done
}
# --- SERVICE_DIR/nginx: served directly by NGINX -----------
# Created AFTER the apply_acl_tree pass so they inherit the default ACL + setgid
# group (service user/admin keep full access); www-data is then layered on top
# as a read-only named entry.
sudo mkdir -p "${SERVICE_DIR}/nginx"
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_DIR}/nginx"
apply_acl_nginx "${SERVICE_DIR}" "${SERVICE_DIR}/nginx"
π‘οΈ Rootless Podman Defaults¶
Configure rootless Podman per-user configuration directory
# configure rootless Podman defaults for this service user
# - store per-user Podman configuration under ~/.config/containers
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers/systemd"
Configure rootless Podman defaults
# configure rootless Podman defaults for this service user
# - use k8s-file logging for easier log rotation and inspection
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/containers.conf" >/dev/null <<EOF
[containers]
log_driver = "k8s-file"
[engine]
healthcheck_events=false
EOF
π§ Service Configuration¶
Setup MediaScope Parameters
Before deploying, you need to define a few environment variables that will be used throughout the setup process.
BASE_URL: public URL where the web service is accessibleHOST_PORT: external port used by NGINX to route traffic to the serviceADMIN_EMAIL: the default admin account (email only β there is no admin password in the env)- SMTP settings: required so the admin and users receive their set-password and password-reset links
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=10055
###################################################################################
# Postgres Configuration
###################################################################################
PG_VERSION=18-alpine
PG_DB=mediascope
PG_USER=mediascope
PG_PASSWORD="$(openssl rand -hex 32)"
###################################################################################
# MediaScope Configuration
###################################################################################
BASE_URL=https://mediascope.domain.fr
ADMIN_EMAIL=admin@domain.fr
SECRET_KEY="$(openssl rand -base64 48)"
###################################################################################
# SMTP Configuration
###################################################################################
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USER=mediascope@domain.fr
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=mediascope@domain.fr
Getting Your Gmail SMTP Password
To send emails via Gmail SMTP, you'll need to generate an App Password.
A special password used for third-party applications.
Visit myaccount.google.com/apppasswords to create one.
Use the generated password in the SMTP_PASSWORD field of your configuration.
Create the Environment file
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/postgres.env" >/dev/null <<EOF
###################################################################################
# Postgres Database Configuration
###################################################################################
POSTGRES_VERSION=${PG_VERSION}
POSTGRES_DB=${PG_DB}
POSTGRES_USER=${PG_USER}
POSTGRES_PASSWORD=${PG_PASSWORD}
EOF
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/mediascope.env" >/dev/null <<EOF
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=${HOST_PORT}
###################################################################################
# Base image versions (build)
###################################################################################
RUST_VERSION=1.98.0
NODE_VERSION=24
DEBIAN_TAG=trixie-slim
###################################################################################
# MediaScope Configuration
###################################################################################
MEDIASCOPE_BASE_URL=${BASE_URL}
MEDIASCOPE_COOKIE_SECURE=true
MEDIASCOPE_TIMEZONE=Europe/Paris
MEDIASCOPE_SESSION_TTL_DAYS=30
MEDIASCOPE_SECRET_KEY=${SECRET_KEY}
MEDIASCOPE_SCAN_TIME=17:00
MEDIASCOPE_DIGEST_DAY=fri
MEDIASCOPE_DIGEST_TIME=17:00
MEDIASCOPE_DB_MAX_CONNECTIONS=20
MEDIASCOPE_THUMBNAIL_WIDTH=475
MEDIASCOPE_DEVICE_LIMIT=3
###################################################################################
# TMDB Configuration
###################################################################################
# The API Read Access Token is NOT configured here: save it in the web UI under
# Settings -> TMDB. It is encrypted at rest in the database and can be rotated
# without touching this file or restarting anything.
#
# Pacing gap between TMDB calls; 50ms = the 20 req/s security cap (the floor).
TMDB_MIN_REQUEST_INTERVAL_MS=50
###################################################################################
# Default admin (seeded 'pending' on first start)
###################################################################################
MEDIASCOPE_ADMIN_EMAIL=${ADMIN_EMAIL}
###################################################################################
# SMTP (required for set-password and password-reset links)
###################################################################################
SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT}
SMTP_SECURITY=${SMTP_SECURITY}
SMTP_USER=${SMTP_USER}
SMTP_PASSWORD=${SMTP_PASSWORD}
SMTP_FROM=${SMTP_FROM}
EOF
Keep the .env files
All the secret informations will be stored in the .env files.
π₯ Firewall Egress Confinement¶
Why: the server container runs on the host network
mediascope-server uses Network=host in its quadlet. Rootless bridge traffic
passes through pasta, a single-threaded userspace network stack that capped
SMB reads from the NAS at ~14 MB/s (75 % of a CPU core) while the same NAS
delivers ~114 MB/s to a native host process β on the host network the kernel
moves the packets instead. PostgreSQL and tmdb stay on the internal bridge and
publish loopback-only ports for the server (127.0.0.1:15432 and
127.0.0.1:18091).
Joining the host network costs isolation: the container could otherwise reach every service on the host's loopback and the whole LAN. The rules below reclaim it β rootless container processes carry the service user's subuids, and iptables matches locally-generated packets by owning uid, so the container is allowed exactly its destinations and nothing else:
| Rule | Purpose |
|---|---|
ESTABLISHED,RELATED |
replies on inbound (NGINX) connections |
NAS :445 |
the SMB media shares |
127.0.0.1:15432 / :18091 |
its own PostgreSQL and tmdb services |
127.0.0.1:${HOST_PORT} |
the container's own healthcheck β curl runs inside the container; without this rule podman kills it as unhealthy |
DNS + SMTP port ${SMTP_PORT} (any destination β Gmail rotates its IPs, so pinning one would silently kill mail) |
set-password / reset / weekly-digest / wishlist mail β forgetting SMTP doesn't break the app visibly, it just silently stops sending mail |
final REJECT |
everything else from those uids is refused |
The other containers (postgres, tmdb) egress through pasta as the service user's real uid β outside the matched subuid range β so they are unaffected (that's also why the tmdb container needs no egress rule to reach the TMDB API over HTTPS).
Rule placement is critical
ufw's stock before.rules accepts all loopback traffic near the top
(-A ufw-before-output -o lo -j ACCEPT). The block must sit above that
line β the snippet below inserts it right after the # End required lines
marker. Inserted lower, the container's loopback packets are accepted before
the restrictions are consulted and the rules protect nothing. (ufw's CLI
cannot express per-uid rules; carrying them in before.rules is the
supported way.)
Install the ufw rules (idempotent)
# SMB host of the media shares (the NAS)
NAS_IP=192.168.1.50
# host-side subuid range of the service user (allocated in Service Isolation)
SUBUID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subuid)"
SUBUID_END=$((SUBUID_BASE + 65535))
# iptables resolves hostnames only once, at rule-load time β pin the current IP
RESOLVER_IP="$(awk '/^nameserver/ {print $2; exit}' /etc/resolv.conf)"
# render the allowlist (the marker comment doubles as the idempotence check)
tee /tmp/mediascope-ufw.rules >/dev/null <<EOF
# --- mediascope-server egress allowlist (host-network container) --------------
# Container processes carry ${SERVICE_USER}'s subuids; everything they are
# allowed to reach is listed here, then the rest is rejected.
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# SMB share on the NAS (the media library)
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -p tcp -d ${NAS_IP} --dport 445 -j ACCEPT
# loopback-published companions (postgres, tmdb) and the app's own healthcheck
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -p tcp -d 127.0.0.1 --dport 15432 -j ACCEPT
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -p tcp -d 127.0.0.1 --dport 18091 -j ACCEPT
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -p tcp -d 127.0.0.1 --dport ${HOST_PORT} -j ACCEPT
# DNS (resolver from /etc/resolv.conf)
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -p udp -d ${RESOLVER_IP} --dport 53 -j ACCEPT
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -p tcp -d ${RESOLVER_IP} --dport 53 -j ACCEPT
# SMTP submission (set-password / password-reset / weekly-digest / wishlist mail) β by port,
# not pinned to the relay's IP: cloud relays (Gmail) rotate IPs between lookups,
# and a pinned rule silently stops all outgoing mail on the next rotation.
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -p tcp --dport ${SMTP_PORT} -j ACCEPT
# everything else from the container: refused
-A ufw-before-output -m owner --uid-owner ${SUBUID_BASE}-${SUBUID_END} -j REJECT
# -------------------------------------------------------------------------------
EOF
# add the rules at the top of the chains if absent (idempotent)
sudo grep -q 'mediascope-server egress allowlist' /etc/ufw/before.rules \
|| sudo sed -i '/# End required lines/r /tmp/mediascope-ufw.rules' /etc/ufw/before.rules
# apply β do NOT use `ufw reload`: with chains already loaded it trips the
# half-loaded-chain bug ("ERROR: Could not load logging rules"). Flush + enable
# is the reliable sequence (the firewall is open for the second in between).
sudo /lib/ufw/ufw-init flush-all
sudo ufw enable
# confirm the block is live (before.rules content never shows in `ufw status`)
sudo iptables -S ufw-before-output | head -12 # the --uid-owner rules must be listed
Recovering ufw from a mixed state
A failed ufw enable β and ufw reload whenever chains are already loaded β
fails with the misleading message ERROR: Could not load logging rules and
can leave ufw's chains half-loaded in the kernel.
Flush the leftover chains, then start clean (keep your SSH session open and verify a second login afterwards β a fresh default-deny load is exactly when a missing allow rule bites):
sudo /lib/ufw/ufw-init flush-all # delete every half-loaded ufw chain
sudo ufw enable # clean start, persists across reboots
sudo ufw status verbose # must report: Status: active
π§© Quadlet Service¶
Install the Quadlet units
# the units live in the repository under quadlet/ β every .build unit already
# carries the platform build context:
# PodmanArgs=--build-context platform=/media/ssd/podman/platform
# link them into the service user's systemd directory (the generator and
# podmanctl read them there). Re-runnable: every existing link into quadlet/
# is dropped first β stale or dangling ones included β then the current set
# is linked, replacing a plain file of the same name if one is left over.
# find runs from / : GNU find returns to its starting directory when done, and a
# directory the service user cannot enter (the admin's home, mode 700) fails it β AFTER
# the delete, so a `set -e` shell would stop before relinking
(cd / && sudo -u "${SERVICE_USER}" find "${SERVICE_HOME}/.config/containers/systemd" -maxdepth 1 -type l -lname "${SERVICE_DIR}/quadlet/*" -delete)
sudo -u "${SERVICE_USER}" ln -sf "${SERVICE_DIR}"/quadlet/* "${SERVICE_HOME}/.config/containers/systemd/"
Symlinks, so a pull that edits a unit is live at the next
systemctl --user daemon-reload(podmanctl --updatereloads after its pull). A pull that adds, renames or removes a unit needs the two lines again, then the reload β quadlet changes are rare and deliberate.
Enable and Start Quadlet Services
# open an interactive shell as the service user
sudo -iu "${SERVICE_USER}"
# reload systemd user units
systemctl --user daemon-reload
# build the mediascope images (first install; podmanctl --update rebuilds afterwards)
systemctl --user start mediascope-build.service
systemctl --user start mediascope-tmdb-build.service
# start Podman Quadlet services
systemctl --user start mediascope-server.service
# verify service status
systemctl --user status mediascope-server.service
To follow the service logs in real time, run
sudo journalctl _UID=$(id -u ${SERVICE_USER}) -ffrom the Debian account.
π Deploy MediaScope¶
Install NGINX
NGINX needs to be installed, follow the NGINX section.
Configure NGINX
NGINX needs to be configured using a file in /etc/nginx/sites-enabled directory.
This configuration file specify the documentation path:
# Per-client rate-limit bucket for the auth endpoints (login is Argon2-costly; reset
# can spam mail). Must live in http{} context, so it sits outside the server{} block.
# $limit_key is the shared key from nginx.conf: the address for IPv4, the /64 prefix
# for IPv6 β keying on a full IPv6 address would let one subscriber prefix mint 2^64
# empty buckets and bypass the limit entirely.
limit_req_zone $limit_key zone=mediascope_auth:10m rate=10r/m;
# Per-client bucket for the liveness probe. Its own zone, NOT a share of the
# auth one: while offline the app probes /api/health every 15 s per tab, and at
# the auth zone's 10r/m that traffic ate the bucket a login needs β a household
# with a few tabs open could 429 its own sign-in. Generous on purpose (the app
# probes only while it believes it is offline); the point is a ceiling, not a brake.
limit_req_zone $limit_key zone=mediascope_health:10m rate=60r/m;
# Per-client connection buckets for the two platform SSE streams β long-lived by
# design (one per open tab), so they are capped per source IP rather than
# rate-limited. The app caps the total itself; these only stop one client from
# holding every worker connection.
limit_conn_zone $limit_key zone=mediascope_events:10m;
limit_conn_zone $limit_key zone=mediascope_sync_events:10m;
# The app container. Unlike the rest of the fleet it runs with Network=host and
# binds 127.0.0.1:10055 itself, so these connections are plain kernel loopback
# rather than a trip through rootless Podman's userspace forwarder. The upstream
# block still earns its place: without `keepalive` nginx opens β and immediately
# closes β a brand-new TCP connection for EVERY proxied request, and a library
# page is one poster per title on top of the SPA assets, so the socket churn is
# paid per image. `proxy_http_version 1.1` (already set in every location) and
# `proxy_set_header Connection ""` are what let a connection go back into this
# pool instead of being torn down.
upstream mediascope_app {
server 127.0.0.1:10055;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
server {
server_name mediascope.domain.fr;
# Security headers; `always` also covers the NGINX-served error pages. No
# Content-Security-Policy here: the app serves its own on every response
# (src/backend/src/web.rs, its inline bootstrap script allowed by sha256).
# Browsers enforce EVERY CSP header they receive, so a second one here could
# only drift from the app's: keep the app's the single source of truth.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# The app sends these four itself (platform-http's security headers, HSTS
# once its cookies are Secure): hidden here, so each reaches the browser once,
# with the values above. A browser keeps only the FIRST HSTS header it gets,
# and the app's (one year) came first.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
# Default ceiling for the whole vhost. Everything except the subtitles import
# and the sync batch posts small JSON, so 1 MB is generous β and it matters
# that this is the default: nginx buffers a request body to disk BEFORE opening
# the upstream connection, so a server-wide 4g would let an unauthenticated
# client spool 4 GB to client_body_temp against any path (`/`, `/api/me`,
# `/favicon.ico`) and fill the proxy host's disk long before the app returns
# 401. The import route and /api/sync raise it for themselves below.
client_max_body_size 1m;
# setup 404 error_page
error_page 404 /404.html;
include snippets/error-404.conf;
# show maintenance page when backend is down
error_page 502 503 504 /maintenance.html;
include snippets/error-maintenance.conf;
# The backend answers unknown /api/* paths with a JSON 404 and every other path
# with the SPA (200), so the only 404 that ever reaches us from upstream is an
# API error. proxy_intercept_errors is on globally, which would swap that JSON
# body for the HTML page and leave the UI parsing markup as JSON. NGINX's own
# errors are unaffected: the `return 404` below still serves the 404 page, and
# an unreachable container still gets the maintenance page (NGINX generates
# that 502 itself rather than proxying it).
proxy_intercept_errors off;
# The internal loopback media routes deliberately bypass sessions β their only
# intended caller is the app's own ffmpeg on 127.0.0.1, gated by a per-process
# token. They have no business being reachable from the internet: without this
# rule, a single disclosure of that token (a journal captured at RUST_LOG=debug,
# a support bundle, /proc/<pid>/cmdline) would hand an unauthenticated caller
# the whole media library over this vhost. `^~` beats the regex locations below.
location ^~ /api/internal/ {
return 404;
}
# The one route that uploads anything: an extractor archive holding a whole
# library's extracted subtitles (hundreds of MB). The app enforces the same
# ceiling itself (subs_import::IMPORT_MAX_BYTES), and admin-gates the route.
# `proxy_request_buffering off` streams it straight through instead of
# spooling the entire archive to the proxy's disk first.
location ^~ /api/subtitles/import/ {
client_max_body_size 4g;
proxy_request_buffering off;
proxy_pass http://mediascope_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
}
# Every unauthenticated auth endpoint, rate-limited before hitting the app:
# the credential routes plus the ones that peek at or consume a mailed token
# (activation/<token>, activate, confirm-email). A route left out of this list
# is a route with no limit at all β the zone only applies where a location
# names it: a public route the router gains is added here (contracts/auth.md).
# Regex location (takes precedence over `location /`), so it repeats the
# proxy setup; rejections give 429. `activation/<token>` carries the token in
# the path and is why the pattern has no trailing `$`.
location ~ ^/api/auth/(login|activate|forgot-password|confirm-email|activation/) {
limit_req zone=mediascope_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://mediascope_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# SSE liveness stream β long-lived by design, so it is capped per source IP
# and must not be buffered (buffering would swallow the pings and the app
# would call itself offline). 16: a normal browser's tab count with room for
# a reload overlapping the stream it replaces; under HTTP/2 nginx counts each
# concurrent request as a connection.
location = /api/events {
limit_conn mediascope_events 16;
limit_conn_status 429;
proxy_pass http://mediascope_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
proxy_buffering off;
proxy_read_timeout 1h;
}
# SSE change stream (the platform's sync contract) β the same long-lived,
# unbuffered treatment as the liveness stream (buffering would hold the
# `sync` events until the connection closed, which is never), but
# authenticated and on its own connection bucket.
location = /api/events/sync {
limit_conn mediascope_sync_events 8;
limit_conn_status 429;
proxy_pass http://mediascope_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
proxy_buffering off;
proxy_read_timeout 1h;
}
# The sync round trip (the library mirror + viewed marks and resume
# positions): up to 500 changes per collection in one JSON body, so the
# vhost's 1 MB ceiling is not enough here. A 413 would count as a failed
# round on the phone and keep its outbox waiting.
location = /api/sync {
client_max_body_size 8m;
proxy_pass http://mediascope_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# Liveness probe β public because the UI seeds its status dot with it, but
# every hit runs a DB query against the connection pool, so it is rate-limited:
# a flood would otherwise saturate the pool and stall real requests. The
# container healthcheck probes the same route with HealthOnFailure=kill, so a
# saturated pool means podman kills the container, Restart=on-failure brings it
# back into the same flood, and after StartLimitBurst cycles the unit stays down.
# Its own zone rather than the auth one it used to share β see that zone's
# comment: the app's own offline probing was spending the login budget.
location = /api/health {
limit_req zone=mediascope_health burst=20 nodelay;
limit_req_status 429;
proxy_pass http://mediascope_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# reverse proxy
location / {
proxy_pass http://mediascope_app;
# keep it HTTP/1.1
proxy_http_version 1.1;
# forwarded headers. proxy_params APPENDS to the client's own
# X-Forwarded-For, so a caller can prepend whatever hops it likes; NGINX is
# the edge here and there is no upstream chain worth keeping, so overwrite
# it with the real peer and the app can never be handed a forged hop.
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# server-sent events: live UI updates stream here
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
}
}
# restart nginx
sudo nginx -t && sudo service nginx restart
Replace
mediascope.domain.frby the name of your website, and10055byHOST_PORT.
Activate HTTPS
To activate HTTPS protocol, follow theΒ Let's Encrypt section.
First login β set the admin password
The admin account is created pending on first start (from MEDIASCOPE_ADMIN_EMAIL) and mailed its activation link (SMTP).
There is no admin password in mediascope.env. To activate it:
- Open the set-password link from the e-mail.
- Choose a password then sign in.
The link expired? On the login page, click "Forgot password?" and enter the admin address: a pending account is mailed a fresh activation link. Restarting the service mails one too.
Forgot a password later? The login page's "Forgot password?" link e-mails a reset link to any registered address. The confirmation is deliberately generic (it never reveals whether an e-mail exists) and rate-limited to one mail per address per minute; the link reuses the same set-password page and invalidates old sessions.
β¬οΈ Updating MediaScope¶
MediaScope deploys from a dev machine: ./deploy.ps1 builds its two production images β
mediascope and mediascope-tmdb, one deployment under one <app sha>.<platform sha> tag β
publishes them to the fleet registry, then pushes the commit: that order
is the whole invariant.
That push fires the bare repo's hook, which calls podmanctl --deploy mediascope <short-sha>:
it acknowledges the push, then deploys detached. Nothing is built here β
it pulls <registry>/mediascope:<app sha>.<platform sha>, checks its revision labels against the
commit just checked out and starts it; with nothing published it refuses before stopping
anything. Follow a deploy with sudo tail -f /var/log/mediascope-deploy.log.
π Deploying a change
From the app's clone on a dev machine, with the platform clone beside it:
git add -u && git commit -m "..."
./deploy.ps1
deploy.ps1 does everything in order: build both images β push them β git push. Never
push first β the hook would fire before the images exist and the deploy would be refused.
Both are built before either is pushed, so a failure never leaves half a deployment
published.
It refuses early rather than wasting a build when the tree is dirty or has diverged from the
server. With nothing left to push it asks the app's /api/version what production runs:
HEAD itself β it exits 3 (skipped); no answer β exit 3 marked unconfirmed, with the reason;
another commit β it publishes the image the server is missing and exits 4, naming the
podmanctl --deploy left to run there. After a push it waits until /api/version reports
the new commit (-NoWait skips the wait).
πͺ One-time setup β hook and clone wiring
The deploy entry point is central and installed once for the whole fleet β
see Podman β Push deploys for
podmanctl --deploy and its single sudoers rule. Only the two steps below
are per-repository.
# install into the bare repo's hooks/ β fires on every push
# ${SERVICE_NAME} expands now; the rest stays literal (\$β¦) for git to expand
sudo tee /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive >/dev/null <<EOF
#!/bin/sh
set -eu
DEPLOY_BRANCH=refs/heads/master
while read -r oldrev newrev refname; do
[ "\$refname" = "\$DEPLOY_BRANCH" ] || continue # only the deploy branch
sudo /usr/local/bin/podmanctl --deploy ${SERVICE_NAME} "\$(git rev-parse --short "\$newrev")" </dev/null
done
EOF
# normalize the hook ownership
sudo chown www-data:debian /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive
sudo chmod 0750 /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive
# git seeded hooks/ with executable *.sample files β inert, but strip the bit
sudo chmod -x /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/*.sample
The commit comes from the just-pushed ref (the bare repo already holds it) and only names the deploy in its log. Nothing else is baked in:
podmanctlderives the service directory, user, admin group, env file and log path from the name it is given.
# deploys pull non-interactively as root: pull from the co-located bare repo over a
# LOCAL path (the HTTPS origin needs a credential prompt + TTY, which a hook lacks)
git -C ${SERVICE_DIR} remote set-url origin /media/ssd/git/podman/${SERVICE_NAME}.git
# trust both repos for root so the root-run `git pull` doesn't refuse with
# "detected dubious ownership"; --system applies whatever HOME the hook runs with
sudo git config --system --add safe.directory ${SERVICE_DIR}
sudo git config --system --add safe.directory /media/ssd/git/podman/${SERVICE_NAME}.git
Without the local origin the pull dies with could not read Username for 'https://β¦'; without
safe.directoryit dies with detected dubious ownership. Either way the worker aborts beforepodmanctl --updateand leaves the service running on its previous build.
π§± Upgrading the base images
Every image the build starts FROM is pinned in mediascope.env β
rust:${RUST_VERSION}-slim-trixie, node:${NODE_VERSION}-alpine, debian:${DEBIAN_TAG},
alongside the stack's postgres:${POSTGRES_VERSION}. The .build units pass them as
--build-arg and build/Dockerfile carries matching ARG defaults, so nothing moves under
the fleet on its own. MediaScope builds on a dev machine, so the bases are pulled there;
here --upgrade still refreshes postgres:${POSTGRES_VERSION}. Refreshing is a
deliberate step:
podmanctl --list --check # UPDATE column shows when an upstream image moved
podmanctl --upgrade mediascope # pull bases + registry images, then rebuild + restart
The env files are gitignored: add a new key on the server before the push that needs it, or a quadlet expanding
${RUST_VERSION}against a missing key buildsrust:-slim-trixieand fails. A bumpedRUST_VERSIONinvalidates the whole Rust layer chain β toolchain, every dependency crate (a fresh cargo cache) β so that rebuild runs 15 min+ on the ARM host: schedule it, don't treat it as a routine deploy. Edit and push a pin first (build/Dockerfile,mediascope.env,postgres.env), then--upgrade.
β Platform conformance
MediaScope consumes the shared platform β the platform-* crates and @platform/* packages its
image is built from β so the platform's conformance suites must stay green for it. They run
on the local stack only, never against production (2026-10-01): from the shared-core
checkout, before every deploy,
./scripts/fleet-check.ps1 -Apps mediascope -UseVsClang
It starts
run-local.ps1 -Detach -ResetDevAdmin, runs the HTTP suite, the browser suite and this app's owne2e/through the stack's own nginx (https://127.0.0.1:8446), checks that nginx itself, then stops the stack. The sync protocol (/api/sync,/api/events/sync, the worker and the manifest) is checked without a credentialed round trip: its synced rows need the ids of real library items, and the tombstone step would erase a real mark. The whole fleet at once: Release & rollout β Fleet conformance.