Skip to content

Media Servers (Jellyfin & Navidrome)

Linux Tuning

/etc/network/interfaces

Download/etc/sysctl.d/99-network-performance.conf
# /etc/sysctl.d/99-network-performance.conf
# Kernel-level network performance tuning for maximum throughput

# === Core Network Buffer Sizes ===
net.core.rmem_max = 134217728          # 128MB receive buffer
net.core.wmem_max = 134217728          # 128MB send buffer
net.core.rmem_default = 16777216       # 16MB default receive
net.core.wmem_default = 16777216       # 16MB default send
net.core.optmem_max = 40960            # Ancillary buffer size

# === TCP Buffer Tuning ===
net.ipv4.tcp_rmem = 4096 87380 134217728    # TCP receive buffer (min/default/max)
net.ipv4.tcp_wmem = 4096 65536 134217728    # TCP send buffer (min/default/max)
net.ipv4.tcp_mem = 16777216 16777216 16777216  # TCP memory pages

# === Queue and Backlog Sizes ===
net.core.netdev_max_backlog = 250000   # Packets queued on INPUT side
net.core.netdev_budget = 600           # Packets per NAPI poll
net.core.netdev_budget_usecs = 8000    # Time limit per NAPI poll (microseconds)
net.core.somaxconn = 65535             # Max listen() backlog
net.ipv4.tcp_max_syn_backlog = 8192    # SYN backlog

# === TCP Performance Optimizations ===
net.ipv4.tcp_congestion_control = bbr  # Use BBR congestion control
net.core.default_qdisc = fq            # Fair queue scheduler (optimal for BBR)
net.ipv4.tcp_window_scaling = 1        # Enable window scaling
net.ipv4.tcp_timestamps = 1            # Enable timestamps
net.ipv4.tcp_sack = 1                  # Selective acknowledgment
net.ipv4.tcp_fack = 1                  # Forward acknowledgment
net.ipv4.tcp_low_latency = 1           # Optimize for latency
net.ipv4.tcp_slow_start_after_idle = 0 # Don't reduce cwnd after idle
net.ipv4.tcp_tw_reuse = 1              # Reuse TIME_WAIT sockets
net.ipv4.tcp_fin_timeout = 15          # Faster FIN timeout
net.ipv4.tcp_keepalive_time = 300      # Keepalive probe frequency (seconds)
net.ipv4.tcp_keepalive_probes = 5      # Keepalive probe count
net.ipv4.tcp_keepalive_intvl = 15      # Keepalive probe interval (seconds)
net.ipv4.tcp_max_tw_buckets = 2000000  # Max TIME_WAIT sockets
net.ipv4.tcp_fastopen = 3              # Enable TCP Fast Open (client+server)
net.ipv4.tcp_mtu_probing = 1           # Enable MTU probing
net.ipv4.tcp_no_metrics_save = 1       # Don't cache connection metrics
net.ipv4.tcp_moderate_rcvbuf = 1       # Auto-tune receive buffer

# === UDP Buffer Tuning ===
net.ipv4.udp_rmem_min = 8192
net.ipv4.udp_wmem_min = 8192

# === Connection Tracking ===
net.netfilter.nf_conntrack_max = 2000000
net.nf_conntrack_max = 2000000
net.netfilter.nf_conntrack_tcp_timeout_established = 1200

# === IPv4 General ===
net.ipv4.ip_forward = 0                # Disable if not routing
net.ipv4.ip_local_port_range = 1024 65535  # Expanded port range

# === Security ===
net.ipv4.tcp_syncookies = 1            # SYN flood protection
net.ipv4.conf.all.rp_filter = 1        # Reverse path filtering
net.ipv4.conf.default.rp_filter = 1

# === Core System Limits ===
fs.file-max = 2097152                  # Max file descriptors
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 524288

# === Virtual Memory ===
vm.swappiness = 10                     # Reduce swap usage
vm.dirty_ratio = 15                    # Start background writeback at 15%
vm.dirty_background_ratio = 5          # Background writeback at 5%
vm.vfs_cache_pressure = 50             # Retain more inode/dentry cache
vm.max_map_count=262144

# === RPS/RFS ===
net.core.rps_sock_flow_entries = 32768

/etc/fstab

command to reload sysctl.d without restarting

sysctl --system

command brick to generate swapfile

sudo fallocate -l 40G /swapfile && \
sudo chmod 600 /swapfile && \
sudo mkswap /swapfile && \
sudo swapon /swapfile && \
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Nvidia NVENC Patching

Nvidia is an asshole company and artifically limit the amount of running NVENC streams on consumer GPUs. Here's a super cool project that unfucks this: https://github.com/keylase/nvidia-patch

Live Network Filesystem on Linux

For some reason this doesn't exist? I wrote a program to fix it: https://github.com/alchemyyy/smb_changenotify_to_inotify_translator

You NEED this.

Jellyfin

Installation (Linux)

curl https://repo.jellyfin.org/install-debuntu.sh | sudo bash
I use Jellarr to make sure my configurations are saved. I have a bat file for easy deployment here (requires git and Node 20+):

Downloadrun_jellarr.bat
@echo off
cd /d "%~dp0"

if not exist "jellarr" (
    git clone https://github.com/venkyr77/jellarr.git jellarr
    cd jellarr
    npm install
) else (
    cd jellarr
)

npm run dev -- --configFile "%~dp0jellarr_config.yml"

Plugins

  • Webhook
  • Playback Reporting
  • TVDB
  • Meilisearch
  • https://github.com/arnesacnussem/jellyfin-plugin-meilisearch
  • installer script for debian (does everything besides install the jellyfin plugin, you dont even have to configure the plugin)
#!/usr/bin/env bash
set -euo pipefail

# ============================================================================
# Meilisearch setup for Jellyfin (bare Debian)
# ----------------------------------------------------------------------------
# Installs Meilisearch, creates a dedicated service user, generates a master
# key, writes a config file + systemd unit, and drops a Jellyfin environment
# override so the jellyfin-plugin-meilisearch plugin auto-discovers the
# instance via MEILI_URL / MEILI_MASTER_KEY.
# ============================================================================

# ── Tunables (override via environment before running) ──────────────────────
MEILI_BIND="${MEILI_BIND:-127.0.0.1:7700}"          # listen address
MEILI_DB_PATH="${MEILI_DB_PATH:-/var/lib/meilisearch/data}"
MEILI_DUMP_DIR="${MEILI_DUMP_DIR:-/var/lib/meilisearch/dumps}"
MEILI_SNAP_DIR="${MEILI_SNAP_DIR:-/var/lib/meilisearch/snapshots}"
MEILI_CONFIG="/etc/meilisearch.toml"
JELLYFIN_ENV_DIR="/etc/jellyfin"
JELLYFIN_ENV_FILE="${JELLYFIN_ENV_DIR}/meilisearch.env"
# If you already have a master key you want to reuse, export MEILI_MASTER_KEY
# before running this script. Otherwise one will be generated.

# ── Preflight ───────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
  echo "ERROR: Please run this script as root (or with sudo)." >&2
  exit 1
fi

echo "==> Updating package lists…"
apt-get update -qq

echo "==> Installing prerequisites…"
apt-get install -y -qq curl openssl >/dev/null

# ── Install Meilisearch via official APT repo ───────────────────────────────
if command -v meilisearch &>/dev/null; then
  echo "==> Meilisearch binary already present: $(meilisearch --version 2>&1 | head -1)"
else
  echo "==> Adding Meilisearch APT repository…"
  echo "deb [trusted=yes] https://apt.fury.io/meilisearch/ /" \
    | tee /etc/apt/sources.list.d/fury.list >/dev/null
  apt-get update -qq
  echo "==> Installing Meilisearch…"
  apt-get install -y -qq meilisearch >/dev/null
  echo "    Installed: $(meilisearch --version 2>&1 | head -1)"
fi

# ── Create service user ────────────────────────────────────────────────────
if id meilisearch &>/dev/null; then
  echo "==> System user 'meilisearch' already exists."
else
  echo "==> Creating system user 'meilisearch'…"
  useradd -r -s /usr/sbin/nologin -d /var/lib/meilisearch -m meilisearch
fi

# ── Data directories ───────────────────────────────────────────────────────
echo "==> Setting up data directories…"
mkdir -p "$MEILI_DB_PATH" "$MEILI_DUMP_DIR" "$MEILI_SNAP_DIR"
chown -R meilisearch:meilisearch /var/lib/meilisearch
chmod 750 /var/lib/meilisearch

# ── Master key ─────────────────────────────────────────────────────────────
if [[ -z "${MEILI_MASTER_KEY:-}" ]]; then
  MEILI_MASTER_KEY="$(openssl rand -hex 24)"
  echo "==> Generated master key (save this somewhere safe):"
  echo "    $MEILI_MASTER_KEY"
else
  echo "==> Using provided MEILI_MASTER_KEY."
fi

# ── Config file ────────────────────────────────────────────────────────────
echo "==> Writing config to ${MEILI_CONFIG}…"
cat > "$MEILI_CONFIG" <<TOML
# /etc/meilisearch.toml — managed by setup script
env = "production"
master_key = "${MEILI_MASTER_KEY}"
http_addr = "${MEILI_BIND}"
db_path = "${MEILI_DB_PATH}"
dump_dir = "${MEILI_DUMP_DIR}"
snapshot_dir = "${MEILI_SNAP_DIR}"
# Uncomment to enable scheduled snapshots (every 24 h by default):
# schedule_snapshot = true
TOML
chmod 640 "$MEILI_CONFIG"
chown root:meilisearch "$MEILI_CONFIG"

# ── Systemd service ────────────────────────────────────────────────────────
echo "==> Installing systemd service…"
cat > /etc/systemd/system/meilisearch.service <<UNIT
[Unit]
Description=Meilisearch search engine
After=network.target

[Service]
Type=simple
User=meilisearch
Group=meilisearch
WorkingDirectory=/var/lib/meilisearch
ExecStart=/usr/bin/meilisearch --config-file-path ${MEILI_CONFIG}
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload
systemctl enable --now meilisearch
echo "==> Meilisearch service started."

# ── Wait for readiness ─────────────────────────────────────────────────────
echo -n "==> Waiting for Meilisearch to become ready"
for i in $(seq 1 30); do
  if curl -sf "http://${MEILI_BIND}/health" >/dev/null 2>&1; then
    echo " OK"
    break
  fi
  echo -n "."
  sleep 1
done

# Quick health check
HEALTH="$(curl -sf "http://${MEILI_BIND}/health" || true)"
if echo "$HEALTH" | grep -q "available"; then
  echo "==> Meilisearch is healthy."
else
  echo "WARNING: Could not confirm Meilisearch health. Check 'journalctl -u meilisearch'." >&2
fi

# ── Jellyfin environment override ──────────────────────────────────────────
# The jellyfin-plugin-meilisearch plugin reads MEILI_URL and MEILI_MASTER_KEY
# from the Jellyfin process environment. The cleanest way to inject them on a
# bare-metal install is a systemd drop-in.
echo "==> Configuring Jellyfin environment for the Meilisearch plugin…"

JELLYFIN_SERVICE="jellyfin.service"
DROPIN_DIR="/etc/systemd/system/${JELLYFIN_SERVICE}.d"
mkdir -p "$DROPIN_DIR"

cat > "${DROPIN_DIR}/meilisearch.conf" <<DROPIN
[Service]
Environment="MEILI_URL=http://${MEILI_BIND}"
Environment="MEILI_MASTER_KEY=${MEILI_MASTER_KEY}"
DROPIN

systemctl daemon-reload

# Restart Jellyfin only if it is already running
if systemctl is-active --quiet "$JELLYFIN_SERVICE" 2>/dev/null; then
  echo "==> Restarting Jellyfin to pick up new environment…"
  systemctl restart "$JELLYFIN_SERVICE"
else
  echo "==> Jellyfin is not currently running; it will pick up the env vars on next start."
fi

# ── Summary ────────────────────────────────────────────────────────────────
cat <<EOF

============================================================
  Setup complete!
============================================================

  Meilisearch
    URL  : http://${MEILI_BIND}
    Key  : ${MEILI_MASTER_KEY}
    Config : ${MEILI_CONFIG}
    Data   : ${MEILI_DB_PATH}
    Service: systemctl status meilisearch

  Jellyfin integration
    Drop-in: ${DROPIN_DIR}/meilisearch.conf
    The plugin will auto-detect the instance via
    MEILI_URL and MEILI_MASTER_KEY env vars.

  Next steps
    1. Install the plugin in Jellyfin using this repo URL:
       https://raw.githubusercontent.com/arnesacnussem/jellyfin-plugin-meilisearch/refs/heads/master/manifest.json
    2. Restart Jellyfin if you haven't already.
    3. Open the plugin settings in Jellyfin and click Save —
       verify the status shows "ok".

EOF

My Customizations

ATOW I don't know when these changes, if any, will actually get merged into Jellyfin. So, here's what I've done and how to set it up.

https://github.com/alchemyyy/jellyfin

place these script outside of the respective jellyfin repo clone folders and run it.

Downloadbuild-jellyfin-debian.bat
@echo off
setlocal

set REPO_DIR=%~dp0jellyfin
set OUTPUT_DIR=%~dp0jellyfin-build-output

echo Building Jellyfin Server for Debian (linux-x64)...
echo.

cd /d "%REPO_DIR%"

dotnet publish Jellyfin.Server/Jellyfin.Server.csproj ^
    -c Release ^
    -f net10.0 ^
    -r linux-x64 ^
    --self-contained true ^
    -o "%OUTPUT_DIR%"

if %ERRORLEVEL% NEQ 0 (
    echo.
    echo BUILD FAILED
    pause
    exit /b 1
)

echo.
echo Build complete. Output: %OUTPUT_DIR%
echo Transfer this folder to your Debian server and run with: ./jellyfin --webdir /path/to/jellyfin-web/dist
pause
Downloadbuild-jellyfin-web.bat
@echo off
cd /d "%~dp0jellyfin-web"
echo Installing dependencies...
call npm ci --engine-strict=false
echo Building local hls.js...
cd /d "%~dp0jellyfin-web\hls.js"
call npm run build
echo Copying local hls.js build over node_modules...
copy /Y "dist\hls.js" "%~dp0jellyfin-web\node_modules\hls.js\dist\hls.js"
copy /Y "dist\hls.js.map" "%~dp0jellyfin-web\node_modules\hls.js\dist\hls.js.map"
cd /d "%~dp0jellyfin-web"
echo Building jellyfin-web (production)...
call npm run build:production
echo Done. Output is in the dist folder.
pause

(you must clone hls.js and put dump it into the clone repo root)

to install web: zip up the "dist" folder and copy onto the jellyfin machine. then dump it into /usr/share/jellyfin-web/ the server gets built to "dist" too, dump it into usr/lib/jellyfin/ on the jellyfin machine.

This script re-does the permissions from that custom server build, and fixes possibly broken symlinks. Run on the jellyfin machine AFTER swapping the server out.

Downloadjellyfin_server_build_swapper_helper.sh
chmod -R 777 /usr/lib/jellyfin/
ln -sf /usr/lib/jellyfin/jellyfin /usr/bin/jellyfin
ln -s /usr/lib/x86_64-linux-gnu/libjemalloc.so.2 /usr/lib/jellyfin/libjemalloc.so

atow I don't have these settings definable in the UI. So, AFTER creating your libraries, run this script on the jellyfin machine.

Downloadjellyfin_crosslibraryseriesmergeenableonall.sh
#!/bin/bash
# Enables EnableCrossLibrarySeriesMerging on all Jellyfin libraries.
# Usage: sudo bash enable-cross-library-merging.sh

JELLYFIN_ROOT="/var/lib/jellyfin/root/default"

if [ ! -d "$JELLYFIN_ROOT" ]; then
    echo "Jellyfin root not found at $JELLYFIN_ROOT"
    exit 1
fi

count=0
for opts in "$JELLYFIN_ROOT"/*/options.xml; do
    [ -f "$opts" ] || continue
    lib="$(basename "$(dirname "$opts")")"

    if grep -q '<EnableCrossLibrarySeriesMerging>true</EnableCrossLibrarySeriesMerging>' "$opts"; then
        echo "[$lib] already enabled"
    elif grep -q '<EnableCrossLibrarySeriesMerging>' "$opts"; then
        sed -i 's|<EnableCrossLibrarySeriesMerging>false</EnableCrossLibrarySeriesMerging>|<EnableCrossLibrarySeriesMerging>true</EnableCrossLibrarySeriesMerging>|' "$opts"
        echo "[$lib] updated false -> true"
        ((count++))
    else
        sed -i 's|</LibraryOptions>|  <EnableCrossLibrarySeriesMerging>true</EnableCrossLibrarySeriesMerging>\n</LibraryOptions>|' "$opts"
        echo "[$lib] inserted"
        ((count++))
    fi
done

echo ""
echo "Updated $count libraries. Restart Jellyfin and rescan libraries to regenerate keys."
echo "  sudo systemctl restart jellyfin"

Jellyfin-Server

Features

  1. Aspect Ratio Computation from Trickplay Data** (26901ca9) Server-side counterpart to the web UI feature — computes the actual aspect ratio of content by analyzing trickplay images for black bars.

  2. Cross-Library Series Joining (a340180d) Joins series across multiple libraries (not just across folders within a single library) and deduplicates cross-library series in search results.

Jellyfin-Web

Features

  1. Detected Aspect Ratio via Trickplay Analysis (563dbcea) Adds a "Detected aspect ratio" option that uses trickplay image black bar analysis to automatically determine and apply the correct aspect ratio during playback. (Went through a couple revert/re-apply cycles before landing.)

  2. Mixed Libraries Show Tall Cards (e54fddf9) Makes mixed-content libraries display tall (portrait) cards instead of the default layout.

Fixes

  1. HLS.js Tuning for FFmpeg Segment Gaps (bf1c253f) Adjusts hls.js config (stretchShortVideoTrack, nudgeMaxRetry: 10, maxBufferHole: 0.5) to prevent micro-stalls and fatal errors caused by audio/video frame boundary mismatches in FFmpeg-muxed HLS streams — especially during long playback sessions.

  2. CSP-safe HLS.js Worker Loading (07805ffc) Loads the hls.js transmuxer worker from a file URL instead of an inline blob URL, avoiding internalException errors on origins with strict Content Security Policy.

  3. Image Loading During Fast Scrolling (6957b66d) Stops aggressively unloading images that scroll out of view, and adds retry logic for failed image preloads so thumbnails don't stay grey permanently.

  4. Notification Permission Check (70604623) Checks Notification.permission before calling showNotification() to prevent uncaught TypeError when permission hasn't been granted.

CSS Customziations

Dump this into branding->css textbox in the admin dashboard

Downloadjellyfin.css
.homePage .emby-scrollbuttons {
    display: none;
}

.homePage .section0 .itemsContainer {
    display: grid !important;
    grid-template-columns: repeat(auto-fill, min(max(15%, 270px), 650px));
    justify-content: center;
    margin: 0 auto;
    gap: 1em;
}

.homePage .section0 .itemsContainer .card {
    width: 100% !important;
    height: auto !important;
}

.homePage .section0 .itemsContainer .card .cardBox,
.homePage .section0 .itemsContainer .card .cardScalable {
    width: 100% !important;
    padding-bottom: 56.25% !important;
    height: 0 !important;
    position: relative !important;
}

.homeSectionsContainer {
    display: flex;
    flex-direction: column;
}

.homeSectionsContainer .section0 {
    order: 0 !important;
}
.homeSectionsContainer .section1 {
    order: 1 !important;
}
.homeSectionsContainer .section6 {
    order: 2 !important;
}
.homeSectionsContainer .section4 {
    order: 3 !important;
}
.homeSectionsContainer .section2 {
    order: 4 !important;
}
.homeSectionsContainer .section3 {
    order: 5 !important;
}
.homeSectionsContainer .section5 {
    order: 6 !important;
}

.section6 {
    display: flex;
    flex-direction: column;
}
.section6 .verticalSection:nth-child(5) {
    order: 1 !important;
} /* Television */
.section6 .verticalSection:nth-child(4) {
    order: 2 !important;
} /* Movies */
.section6 .verticalSection:nth-child(2) {
    order: 3 !important;
} /* Anime */
.section6 .verticalSection:nth-child(1) {
    order: 4 !important;
} /* Animated */
.section6 .verticalSection:nth-child(3) {
    order: 5 !important;
} /* Cartoons */

.sectionTitleContainer-cards {
    padding-top: 0 !important;
}

.homePage .section1 .overflowBackdropCard,
.homePage .section5 .overflowBackdropCard {
    max-width: max(15%, 270px) !important;
}

.homePage .section6 .card {
    width: min(max(8vw, 120px), 200px) !important;
}

.homePage .padded-left,
.homePage .emby-scroller {
    padding-left: 1% !important;
}

.homePage .section0 .padded-left,
.homePage .section0 .emby-scroller {
    padding-left: 0 !important;
    padding-right: 0 !important;
}

.homePage .section0 > .sectionTitle {
    font-size: 0 !important;
    line-height: 0 !important;
    display: block !important;
    text-align: center !important;
}

#.homePage .section0 > .sectionTitle::after {
#    content: "Libraries";
#    font-size: 1.35rem;
#    line-height: normal;
#}

REDACTED

Linux tuning

nano /etc/default/jellyfin

# comment out the MALLOC_TRIM_THRESHOLD i.e. disable it

# Tweaks
MALLOC_ARENA_MAX="2"
DOTNET_gcServer = "1"
DOTNET_GCConserveMemory = "9"
DOTNET_GCHeapHardLimitPercent = "0x5a"
DOTNET_GCHighMemPercent=48
NVIDIA_DRIVER_CAPABILITIES=all
NVIDIA_VISIBLE_DEVICES=all

Important Config Notes

  • Enable audio downmix boost, use AC4 as the algorithm
  • Query what codecs your specific GPU supports
  • Trickplay: use keyframes
  • Networking: Known Proxies: 127.0.0.1.
    • This makes it so caddy is seen as a known proxy, which makes all traffic use X-Forwarded-For, which makes it so users IPs are resolved instead of the cloudflare IP they're connecting with.
  • Only use TVDB and TMDB as metadata services. Use OpenSubtitles as subtitle service, pay for a bit of VIP if you need a ton of API for a bit.
  • Always save all metadata types to library folders

My Libraries

  • #AutoAnime
  • #AutoMovies
  • #AutoTV
  • Animated
  • Anime
  • Cartoons
  • Movies
  • Television

Jellyfin WebSocket "Token is required" — EnableLegacyAuthorization

Problem: Every WebSocket connection to Jellyfin fails immediately. Browser console spams:

WebSocket connection to 'ws://<host>:8096/socket?api_key=...' failed:
web socket closed
nulling out web socket

Server-side logs show:

[ERR] Jellyfin.Api.Middleware.ExceptionMiddleware: Error processing request: Token is required. URL GET /socket.

All HTTP API requests work fine — only WebSocket is broken.

Root cause: The bundled jellyfin-apiclient (v1.11.0, deprecated) authenticates WebSocket connections by passing api_key=<token> as a query parameter. Starting in Jellyfin 10.11, the server introduced EnableLegacyAuthorization as an opt-in flag to disable deprecated auth methods (query string api_key, old header formats). In 10.12 this defaults to false, and in 10.13 legacy auth will be removed entirely.

When EnableLegacyAuthorization is false, the server ignores the api_key query parameter on the /socket endpoint, sees no valid token, and rejects the upgrade with "Token is required." The WebSocket never establishes.

Fix: Edit /etc/jellyfin/system.xml:

<EnableLegacyAuthorization>true</EnableLegacyAuthorization>
Then restart: sudo systemctl restart jellyfin

This is a stopgap — the real fix is for the web client to send auth via the Authorization header instead of query string, but the deprecated jellyfin-apiclient library doesn't support that for WebSocket connections.

Relevant links: - PR #13306 — Add option to disable deprecated legacy authorization — the PR that introduced EnableLegacyAuthorization - Issue #15962 — Default EnableLegacyAuthorization=false breaks auth in 10.12 - Issue #12990 — API Authentication documentation incorrect - Jellyfin API Authorization gist — detailed explanation of the auth deprecation timeline

Music server. Best there is, flawless.

Clients

I use feishin as a desktop app.

I use subtracks on android.

Installation

Installer (auto grabs latest github linux amd64 release):

Downloadnavidrome_install_debian.sh
#!/usr/bin/env bash
set -euo pipefail

# === Navidrome Installer for Debian-based Systems ===

if [[ $EUID -ne 0 ]]; then
    echo "This script must be run as root."
    exit 1
fi

# --- Prompt for user and group ---
read -rp "Enter the Linux user to run Navidrome as: " ND_USER
read -rp "Enter the Linux group to run Navidrome as: " ND_GROUP

# Create group if it doesn't exist
if ! getent group "$ND_GROUP" > /dev/null 2>&1; then
    echo "Group '$ND_GROUP' does not exist. Creating..."
    groupadd "$ND_GROUP"
fi

# Create user if it doesn't exist
if ! id -u "$ND_USER" > /dev/null 2>&1; then
    echo "User '$ND_USER' does not exist. Creating..."
    useradd -r -g "$ND_GROUP" -s /usr/sbin/nologin -M "$ND_USER"
fi

# --- Install dependencies ---
apt update
apt upgrade -y
apt install -y ffmpeg jq curl wget

# --- Create directories ---
install -d -o "$ND_USER" -g "$ND_GROUP" /opt/navidrome
install -d -o "$ND_USER" -g "$ND_GROUP" /var/lib/navidrome

# --- Download latest Navidrome ---
LATEST_VERSION=$(curl -s https://api.github.com/repos/navidrome/navidrome/releases/latest | jq -r '.tag_name' | sed 's/^v//')
echo "Downloading Navidrome v${LATEST_VERSION}..."
wget "https://github.com/navidrome/navidrome/releases/download/v${LATEST_VERSION}/navidrome_${LATEST_VERSION}_linux_amd64.tar.gz" -O /tmp/navidrome.tar.gz

tar -xzf /tmp/navidrome.tar.gz -C /opt/navidrome/
chown -R "$ND_USER":"$ND_GROUP" /opt/navidrome
rm /tmp/navidrome.tar.gz

# --- Write navidrome.toml ---
cat > /var/lib/navidrome/navidrome.toml << 'TOML'
MusicFolder = "/shares/media/Music"

Scanner.Schedule = "0"
Scanner.WatcherWait = "1s"
Scanner.ScanOnStartup = true
Scanner.FollowSymlinks = true
Scanner.PurgeMissing = "always"

DevExternalScanner = true
# NFS share is configured with multithreading
# Otherwise, beware this setting.
DevScannerThreads = 8
DevSelectiveWatcher = true
DevOptimizeDB = true

EnableArtworkPrecache = true
ImageCacheSize = "8GB"
TranscodingCacheSize = "8GB"
RecentlyAddedByModTime = true

#LastFM.ApiKey = ""
#LastFM.Secret = ""
#Spotify.ID = ""
#Spotify.Secret = ""

# === SHARING & AUTH ===
# EnableSharing = true
# AuthWindowLength = "0s"
# AuthRequestLimit = 11111
TOML

chown "$ND_USER":"$ND_GROUP" /var/lib/navidrome/navidrome.toml

# --- Write systemd service ---
cat > /etc/systemd/system/navidrome.service << EOF
[Unit]
Description=Navidrome Music Server and Streamer compatible with Subsonic/Airsonic
After=remote-fs.target network.target
AssertPathExists=/var/lib/navidrome

[Install]
WantedBy=multi-user.target

[Service]
User=${ND_USER}
Group=${ND_GROUP}
Type=simple
ExecStart=/opt/navidrome/navidrome --configfile "/var/lib/navidrome/navidrome.toml"
WorkingDirectory=/var/lib/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure

DevicePolicy=closed
NoNewPrivileges=yes
PrivateTmp=yes
PrivateUsers=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
ReadWritePaths=/var/lib/navidrome

ProtectSystem=full
EOF

# --- Enable and start ---
systemctl daemon-reload
systemctl enable navidrome.service
systemctl start navidrome.service

echo ""
echo "Navidrome v${LATEST_VERSION} installed and running."
echo "  User/Group: ${ND_USER}:${ND_GROUP}"
echo "  Config:     /var/lib/navidrome/navidrome.toml"
echo "  Service:    systemctl status navidrome"
echo "  Web UI:     http://<your-ip>:4533"

Tricks

Clear Playlists:

sudo systemctl stop navidrome
sqlite3 /var/lib/navidrome/navidrome.db "DELETE FROM playlist_tracks; DELETE FROM playlist;"
sudo systemctl start navidrome