π¬ 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/, nginx/ and scripts/.
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
- πΌοΈ Dark-blue responsive library browser with movie/series walls, filters, and missing episode/season highlights
- π Secure accounts with roles, email onboarding, password reset, and optional TOTP 2FA
How it works¶
in-process channel (instant reschedule) Web UI βββΊ axum API βββΊ Scanner βββΊ SMB shares (.mkv + posters) β² β (sleeps to β βββββ SSE βββββ next poll) βΌ PostgreSQL (movies / series / episodes)
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
data_dirs=(
"postgres-db:70" # /var/lib/postgresql/data β alpine postgres user (70)
"posters:1000" # /data β mediascope posters directory (pinned User=1000:1000)
)
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 -hex 64)"
###################################################################################
# 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}
###################################################################################
# Build Version
###################################################################################
GIT_COMMIT=
###################################################################################
# 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_INTERVAL_SECONDS=86400
###################################################################################
# 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.
βοΈ Create MediaScope build¶
Create MediaScope build Dockerfile
# setup of Dockerfile
mkdir -p "${SERVICE_DIR}/build"
tee "${SERVICE_DIR}/build/Dockerfile" > /dev/null <<'EOF'
# Single multi-stage Dockerfile, built as FOUR separately-tagged images by four
# Quadlet `.build` units that each select a stage with `Target=` + `ImageTag=`:
#
# mediascope-planner.build Target=planner β localhost/mediascope-planner:latest
# mediascope-frontend.build Target=frontend β localhost/mediascope-frontend:latest
# mediascope-backend.build Target=backend β localhost/mediascope-backend:latest
# mediascope.build Target=runtime β localhost/mediascope:latest
#
# Every expensive cache lives inside a TAGGED image, so they all survive the
# `podman image prune` the runtime build runs: the backend stage KEEPS its Rust
# toolchain + target/ (cargo-chef's cooked deps), and the planner stage is tagged
# so its recipe layer isn't reclaimed as dangling. The prune only reclaims old
# replaced image versions.
#############################################
##### Shared Rust base with cargo-chef
#############################################
FROM docker.io/library/rust:1-slim AS chef
# build-essential + pkg-config + libssl-dev: native-tls links OpenSSL on Linux.
# libsmbclient-dev + clang/libclang: pavao binds to libsmbclient via bindgen.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential pkg-config libssl-dev libsmbclient-dev clang libclang-dev \
&& rm -rf /var/lib/apt/lists/* \
&& cargo install cargo-chef --locked
WORKDIR /app
#############################################
##### Planner β capture the dependency recipe (cheap; no compilation).
##### Only the manifests are copied, so this layer + recipe.json are cached
##### unless Cargo.toml/Cargo.lock change. Tagged as localhost/mediascope-planner
##### so the runtime build's prune can't reclaim it.
#############################################
FROM chef AS planner
COPY src/backend/Cargo.toml src/backend/Cargo.lock ./
RUN cargo chef prepare --recipe-path recipe.json
#############################################
##### Backend β cook deps (cached), then build the app.
##### Tagged image; retains the toolchain + target/ so the cook cache persists.
#############################################
FROM chef AS backend
# Compile the compile-time-checked sqlx queries against the committed offline
# cache (src/backend/.sqlx), so the build needs no live database.
ENV SQLX_OFFLINE=true
# Cooks ONLY the dependencies β cached unless Cargo.toml/Cargo.lock change.
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Copies the source AND the .sqlx/ offline query cache (kept in the build context;
# see .dockerignore) so the query! macros verify without a database.
COPY src/backend/ .
RUN cargo build --release
#############################################
##### Frontend β build the React SPA (npm deps cached separately from source)
#############################################
FROM docker.io/library/node:24-alpine AS frontend
WORKDIR /app
COPY src/frontend/package.json src/frontend/package-lock.json* ./
RUN npm ci || npm install
COPY src/frontend/ ./
RUN npm run build
#############################################
##### Runtime β slim final image
#############################################
FROM docker.io/library/debian:trixie-slim AS runtime
# libsmbclient: pavao's runtime dependency (read media files over SMB);
# libssl3: native-tls (OpenSSL) runtime; ca-certificates: TLS roots for SMTP;
# tini: PID 1 reaping; curl: healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates libssl3 libsmbclient tini curl \
&& rm -rf /var/lib/apt/lists/*
# Runs as a fixed non-root uid so the host data dir can be pre-chowned to its subuid.
RUN useradd --system --uid 1000 --create-home --home-dir /home/mediascope mediascope
COPY --from=backend /app/target/release/mediascope /usr/local/bin/mediascope
# --chown: the SPA assets must be readable by the runtime uid 1000.
COPY --chown=mediascope:mediascope --from=frontend /app/dist /app/static
# /data holds the cached poster thumbnails (bind-mounted volume).
ENV MEDIASCOPE_STATIC_DIR=/app/static \
MEDIASCOPE_DATA_DIR=/data \
MEDIASCOPE_BIND=0.0.0.0:8080
EXPOSE 8080
USER mediascope
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["mediascope"]
EOF
π§© Quadlet Service¶
Create Network Podman Quadlet
# podman quadlet: create network
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/systemd/mediascope.network" >/dev/null <<EOF
[Unit]
Description=mediascope shared network (app <-> postgres)
[Network]
# A dedicated podman network so the server container reaches the database by
# name (mediascope-postgres). Podman enables DNS on user networks by default.
NetworkName=mediascope
[Install]
WantedBy=default.target
EOF
Create Postgres Podman Quadlet
# podman quadlet: create PostgreSQL
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/systemd/mediascope-postgres.container" >/dev/null <<'EOF'
[Unit]
Description=mediascope PostgreSQL database
Requires=mediascope-network.service
After=mediascope-network.service
[Container]
EnvironmentFile=/media/ssd/podman/mediascope/postgres.env
Image=docker.io/library/postgres:${POSTGRES_VERSION}
ContainerName=mediascope-postgres
Network=mediascope.network
# run as the postgres uid/gid (alpine = 70). The image starts its entrypoint as root
# then drops to 70 before creating PGDATA, so we pin the uid here. The data dir is
# pre-chowned to 70's mapped subuid in the permissions step, so a plain :rw mount works.
User=70:70
Environment=PGDATA=/var/lib/postgresql/data/pgdata
# plain bind mount (data dir already owned by 70's subuid). Host data is owned by that
# subuid; the debian admin still reaches it via the SERVICE_DIR ACLs / sudo.
Volume=/media/ssd/podman/mediascope/data/postgres-db:/var/lib/postgresql/data:rw
HealthCmd=pg_isready -U $POSTGRES_USER -d $POSTGRES_DB
HealthStartPeriod=30s
HealthInterval=60s
HealthTimeout=5s
HealthRetries=3
HealthOnFailure=kill
[Service]
EnvironmentFile=/media/ssd/podman/mediascope/postgres.env
UMask=0007
Restart=always
RestartSec=30
TimeoutStartSec=180
[Install]
WantedBy=default.target
EOF
UMask=0007should be configured for each Podman Quadlet service. It removes all permissions forotherswhile preserving read/write access for the service owner and admin group on newly created files and directories.
Create mediascope-planner.build Podman Quadlet
# podman quadlet: create mediascope-planner.build
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/systemd/mediascope-planner.build" >/dev/null <<'EOF'
[Unit]
Description=Build mediascope planner image (cargo-chef dependency recipe)
[Build]
ImageTag=localhost/mediascope-planner:latest
# Build only the `planner` stage. Tagging it keeps its recipe layer out of the
# `dangling` set, so the runtime build's `podman image prune` can't reclaim it.
# The stage copies only Cargo.toml/Cargo.lock, so this is a cache hit unless
# dependencies change.
# (If your podman is too old for Target=, use: PodmanArgs=--target planner)
Target=planner
File=/media/ssd/podman/mediascope/build/Dockerfile
SetWorkingDirectory=/media/ssd/podman/mediascope
[Service]
EnvironmentFile=/media/ssd/podman/mediascope/mediascope.env
EOF
Create mediascope-backend.build Podman Quadlet
# podman quadlet: create mediascope-backend.build
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/systemd/mediascope-backend.build" >/dev/null <<'EOF'
[Unit]
Description=Build mediascope backend image (Rust, cargo-chef cached)
# The planner image is built (and TAGGED) first so its recipe layer is a cache
# hit here and survives the runtime build's prune.
Requires=mediascope-planner-build.service
After=mediascope-planner-build.service
[Build]
ImageTag=localhost/mediascope-backend:latest
# Build only the `backend` stage. This tagged image keeps the toolchain +
# target/, so cargo-chef's cooked dependency cache survives `podman image prune`.
# Compiles with SQLX_OFFLINE=true (set in the Dockerfile) against the committed
# .sqlx/ cache, so no database is needed at build time.
# (If your podman is too old for Target=, use: PodmanArgs=--target backend)
Target=backend
File=/media/ssd/podman/mediascope/build/Dockerfile
SetWorkingDirectory=/media/ssd/podman/mediascope
[Service]
EnvironmentFile=/media/ssd/podman/mediascope/mediascope.env
EOF
Create mediascope-frontend.build Podman Quadlet
# podman quadlet: create mediascope-frontend.build
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/systemd/mediascope-frontend.build" >/dev/null <<'EOF'
[Unit]
Description=Build mediascope frontend image (React SPA)
[Build]
ImageTag=localhost/mediascope-frontend:latest
# Build only the `frontend` stage of the shared Dockerfile.
# (If your podman is too old for Target=, use: PodmanArgs=--target frontend)
Target=frontend
File=/media/ssd/podman/mediascope/build/Dockerfile
SetWorkingDirectory=/media/ssd/podman/mediascope
[Service]
EnvironmentFile=/media/ssd/podman/mediascope/mediascope.env
EOF
Create mediascope.build Podman Quadlet
# podman quadlet: create mediascope.build
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/systemd/mediascope.build" >/dev/null <<'EOF'
[Unit]
Description=Build mediascope runtime image
# The phase images are built first so their layer caches are warm + tagged. The
# planner image is pulled in transitively (backend-build requires it).
Requires=mediascope-frontend-build.service mediascope-backend-build.service
After=mediascope-frontend-build.service mediascope-backend-build.service
[Build]
ImageTag=localhost/mediascope:latest
# Build the slim `runtime` stage; it COPYs the binary + SPA from the two phase
# stages (cache hits, since those were just built).
Target=runtime
File=/media/ssd/podman/mediascope/build/Dockerfile
SetWorkingDirectory=/media/ssd/podman/mediascope
[Service]
EnvironmentFile=/media/ssd/podman/mediascope/mediascope.env
# Safe to prune here: every expensive cache (cargo-chef recipe + cooked deps,
# npm) lives inside a TAGGED phase image, so this only reclaims old image versions.
ExecStartPost=-/usr/bin/podman image prune -f
EOF
Create Server Podman Quadlet
# podman quadlet: create mediascope server
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/systemd/mediascope-server.container" >/dev/null <<'EOF'
[Unit]
Description=mediascope server
Requires=mediascope-build.service mediascope-postgres.service
After=mediascope-build.service mediascope-postgres.service
[Container]
EnvironmentFile=/media/ssd/podman/mediascope/mediascope.env
Image=mediascope.build
ContainerName=mediascope-server
Network=mediascope.network
# Attach the container's stdout/stderr to this systemd unit so the binary's
# `tracing` lines land in the unit journal. Trade-off: with passthrough,
# `podman logs mediascope-server` no longer works β read via the journal.
LogDriver=passthrough
Environment=MEDIASCOPE_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@mediascope-postgres:5432/${POSTGRES_DB}
# No User= needed: the image declares USER mediascope (1000). The data dir is
# pre-chowned to 1000's mapped subuid, so a plain :rw mount works.
# /data holds the cached poster thumbnails.
Volume=/media/ssd/podman/mediascope/data/posters:/data:rw
PublishPort=127.0.0.1:${HOST_PORT}:8080
HealthCmd=curl -fsS -o /dev/null http://localhost:8080/api/alive
HealthStartPeriod=180s
HealthInterval=60s
HealthTimeout=3s
HealthRetries=3
HealthOnFailure=kill
[Service]
EnvironmentFile=/media/ssd/podman/mediascope/mediascope.env
EnvironmentFile=/media/ssd/podman/mediascope/postgres.env
UMask=0007
SuccessExitStatus=143
Restart=always
RestartSec=30
TimeoutStartSec=900
# wait until Postgres reports healthy before starting mediascope
ExecStartPre=/bin/sh -c 'until podman healthcheck run mediascope-postgres >/dev/null 2>&1; do sleep 5; done'
[Install]
WantedBy=default.target
EOF
UMask=0007: removes all permissions forotherswhile preserving read/write access for the service owner and admin group on newly created files and directoriesSuccessExitStatus=143: treat a SIGTERM-style exit (143) as a clean stop, not a failure
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
# 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:
server {
server_name mediascope.domain.fr;
# 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;
# reverse proxy
location / {
proxy_pass http://127.0.0.1:10055;
# keep it HTTP/1.1
proxy_http_version 1.1;
# forwarded headers
include proxy_params;
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, and10070byHOST_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).
There is no admin password in mediascope.env. To activate it:
- Open the site and, on the login page, click "First run? Send admin setup link".
- Open the set-password link, from the e-mail (SMTP), or from the server log.
- Choose a password then sign in.
This button is first-run only, the login page hides it as soon as an admin account is active.
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 on push. The bare repo's post-receive hook calls the deploy
wrapper, which stamps the pushed commit into mediascope.env, acknowledges the push
right away, and then runs podmanctl --update mediascope (stop β git pull β rebuild β
restart) detached in the background. The rebuild takes 10 min+ β far longer than the
HTTP push stays open β so the heavy work is handed to a worker that outlives the push, and
git push returns in seconds with a short status. podmanctl no longer stamps
GIT_COMMIT itself β the wrapper owns that, so the About panel matches the deployed commit.
A flock in the worker serialises deploys: if you push again while a build is still
running, the second deploy waits for the first to finish, then redeploys whatever master
points at by then β concurrent git pull / image builds can never overlap, and the server
always converges on the latest pushed commit. Follow a running deploy with
sudo tail -f /var/log/mediascope-deploy.log.
One-time setup β deploy wrapper, hook and sudoers
# the deploy wrapper bakes in the instantiated service dir β guard it exists first
[ -d "${SERVICE_DIR}" ] || { echo "missing ${SERVICE_DIR}"; exit 1; }
# (a) the wrapper β fast + synchronous: stamp the commit, ack the push, detach the build.
# ${SERVICE_DIR}/${SERVICE_NAME} expand now; the rest stays literal (\$β¦).
sudo tee /usr/local/bin/mediascope-deploy >/dev/null <<EOF
#!/bin/sh
set -eu
ENV_FILE=${SERVICE_DIR}/mediascope.env
LOG=/var/log/${SERVICE_NAME}-deploy.log
commit=\${1:-}
case "\$commit" in ''|*[!0-9a-f]*) echo "usage: mediascope-deploy <short-sha>" >&2; exit 2 ;; esac
[ -f "\$ENV_FILE" ] && grep -q '^GIT_COMMIT=' "\$ENV_FILE" || { echo "mediascope-deploy: no GIT_COMMIT= in \$ENV_FILE" >&2; exit 1; }
# fast + synchronous: stamp the commit and stream a short ack back over the push
sed -i "s/^GIT_COMMIT=.*/GIT_COMMIT=\$commit/" "\$ENV_FILE"
echo "mediascope: GIT_COMMIT -> \$commit"
echo "mediascope: starting 'podmanctl --update ${SERVICE_NAME}' in the background (~10 min)"
# slow + detached: a 10 min rebuild must NOT hold the HTTP push open (it trips the
# git/proxy timeout). Hand it to the worker in a NEW session with its std fds off
# the push connection, so 'git push' returns now.
setsid /usr/local/bin/${SERVICE_NAME}-deploy-run "\$commit" </dev/null >>"\$LOG" 2>&1 &
echo "mediascope: push accepted - deploy running in background"
echo "mediascope: follow it with -> sudo tail -f \$LOG"
EOF
sudo chmod 0755 /usr/local/bin/mediascope-deploy
# (b) the worker β slow + detached: serialise deploys with flock, then rebuild.
sudo tee /usr/local/bin/${SERVICE_NAME}-deploy-run >/dev/null <<EOF
#!/bin/sh
set -eu
LOCK=/run/lock/${SERVICE_NAME}-deploy.lock
SERVICE_NAME=${SERVICE_NAME}
commit=\${1:-unknown}
# serialise deploys: fd 9 holds an exclusive lock for the whole worker. A second
# push that lands mid-build BLOCKS at flock, then runs once we finish β so it is
# never dropped; it redeploys whatever master points at by then.
exec 9>"\$LOCK"
echo "[\$(date -Is)] queued: \$commit"
flock 9
echo "[\$(date -Is)] running: podmanctl --update \$SERVICE_NAME (commit \$commit)"
podmanctl --update "\$SERVICE_NAME"
echo "[\$(date -Is)] done: \$commit"
EOF
sudo chmod 0755 /usr/local/bin/${SERVICE_NAME}-deploy-run
The hook's
sudoruns the wrapper as root: it stampsGIT_COMMIT(the hex-only check keeps the wildcard sudoers rule safe β nosedinjection), prints a short ack that streams back over the push, thensetsid-detaches the worker so the long rebuild outlives the HTTP connection. The worker'sflockserialises deploys; its output (pluspodmanctl's) lands in/var/log/mediascope-deploy.log. No extra sudoers entry is needed β the wrapper is already root when it launches the worker.
# install into the bare repo's hooks/ β fires on every push
sudo tee /media/ssd/git/podman/mediascope.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/mediascope-deploy "$(git rev-parse --short "$newrev")" </dev/null
done
EOF
# normalize the hook ownership
sudo chown www-data:debian /media/ssd/git/podman/mediascope.git/hooks/post-receive
sudo chmod 0750 /media/ssd/git/podman/mediascope.git/hooks/post-receive
The commit comes from the just-pushed ref (the bare repo already holds it), so it is stamped before the instantiated repo pulls β the env always matches the code the restart runs.
# let the git/web user run ONLY the deploy wrapper as root, no password
sudo tee /etc/sudoers.d/mediascope-deploy >/dev/null <<'EOF'
www-data ALL=(root) NOPASSWD: /usr/local/bin/mediascope-deploy *
EOF
sudo chmod 0440 /etc/sudoers.d/mediascope-deploy
sudo visudo -cf /etc/sudoers.d/mediascope-deploy
www-datais the user your HTTPS git backend runs the hook as β replace it if yours differs (e.g. if you push over SSH asdebian).
# 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/mediascope.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/mediascope.git
Without the local origin the pull dies with could not read Username for 'https://β¦'; without
safe.directoryit dies with detected dubious ownership. Either waypodmanctl --updateaborts and leaves the service stopped.
Deploy a new version
# from your working clone β the push triggers the whole deploy
git push origin master
The push returns in seconds with a short status (
GIT_COMMIT -> β¦, deploy started); the stop β git pull β rebuild β restart then runs detached in the background. Watch it withsudo tail -f /var/log/mediascope-deploy.log; the About panel shows the newGIT_COMMITonce the restart completes.A manual
podmanctl --update mediascopestill works but no longer refreshesGIT_COMMITβ runsudo mediascope-deploy <short-sha>(or just push) to update it.Changing a task's CRON, steps, variables or pool in the UI takes effect immediately. The scheduler reloads in memory, no restart needed.