# 13 — Optimization Audit (Read-Only)

> Status: **read-only audit**, executed 2026-05-08 against
> `https://arrflix.s8n.ru` (Jellyfin 10.10.3 on nullstone). Scope: scan
> for performance, capacity, reliability, and ops-hygiene risks. **No
> fixes applied. No state mutated. No container restarts.**

Audit ran ~25 minutes wall. Inputs: Jellyfin REST API (auth
`X-Emby-Token: 76858153…f8b1`), `docker exec jellyfin`, `docker logs
{traefik,jellyfin} --since 1h/6h/24h`, host `free`, `df`, `uptime`,
`nvidia-smi`, on-disk Jellyfin XML configs.

---

## Executive summary

1. **Host is under serious memory pressure right now.** `uptime` shows
   load average **11.40 / 9.59 / 6.19** on a 12-core box, **6.8 GiB of
   swap is in use** (out of 24 GiB), and `/home` is **90 % full
   (40 GiB free of 399 GiB)**. Jellyfin itself is fine
   (522 MiB / 31 GiB cap, no restarts), but the host it lives on is
   loaded enough that any media ingest at scale will start swap-thrashing.
   This is the single biggest risk to playback latency.
2. **GPU transcode is dead and confirmed dead.** `nvidia-smi` fails on
   host, `lsmod | grep nvidia` returns empty, `/dev/nvidia*` does not
   exist. `EnableHardwareEncoding=true` and `HardwareAccelerationType=none`
   in `encoding.xml` is harmless but misleading — the toggle is on, but
   the type selector is `none`, so every transcode goes through ffmpeg
   software path. Two HLS segment requests this hour returned **499**
   (client cancelled mid-transcode at 6.4 s and 2.9 s wall) — that is
   the playback-stalls signature.
3. **OpenSubtitles plugin is logging an error per file probed during
   library scan** (102 errors in last 6 h) because `Username` and
   `Password` are empty in the plugin XML. Every Scan Media Library run
   tries Open Subtitles, fails on auth, logs an `ERR`, retries on the
   next file. This is pure log noise + wasted RTT, not data loss, but
   it bloats `/config/log` and obscures real warnings.
4. **Transcode throttling is OFF and `MaxMuxingQueueSize` is 2048** —
   on a CPU-only deploy that means a stalled client with high-bitrate
   AV1/HEVC source will keep ffmpeg burning a full core for up to
   `SegmentKeepSeconds=720`s after the client gives up. `EnableThrottling`
   should be on for a CPU deploy; this would have prevented the 499s
   seen above.
5. **No automated backup of `/home/docker/jellyfin/config/`.** The
   Cineplex CSS, the 5 user accounts + permissions, the library
   metadata, and the Open Subtitles plugin install all live in one
   unprotected directory tree. The repo's `snapshots/` only captures the
   pre-ElegantFin migration baseline; nothing on disk is being rotated
   off-host.

---

## Findings table

Severity legend: **R** = red (acute, fix this week), **Y** = yellow
(deferred fix, document risk), **G** = green (audited, healthy, no
action). Effort: **S** ≤ 30 min, **M** half-day, **L** > 1 day.

| # | Category | Severity | Evidence | Recommendation | Effort |
|---|---|:-:|---|---|:-:|
| 01 | Host capacity | **R** | `uptime` load 11.40 / 9.59 / 6.19 on 12 cores; swap 6.8 GiB used / 24 GiB; `/home` 90 % full | Identify swap hog (likely not Jellyfin — only 522 MiB RSS); reclaim space on `/home`; budget media additions against the 40 GiB headroom | M |
| 02 | GPU transcode | **R** | `nvidia-smi` fails, no `/dev/nvidia*`, `lsmod` no nvidia mod; `HardwareAccelerationType=none` | Reinstall nvidia driver on nullstone host; once `nvidia-smi` works, add device reservation block to compose and flip `HardwareAccelerationType` to `nvenc` | L |
| 03 | Transcode throttling | **R** | `EnableThrottling=false`, `ThrottleDelaySeconds=180`, `MaxMuxingQueueSize=2048`, **two 499 client-cancels** logged (6 439 ms / 2 890 ms) | Enable `EnableThrottling=true` and `EnableSegmentDeletion=true` for CPU-only era — caps wasted ffmpeg CPU after client disconnect | S |
| 04 | OpenSubtitles auth | **R** | `Username`/`Password` empty in `Jellyfin.Plugin.OpenSubtitles.xml`; **102** `Error downloading subtitles from Open Subtitles` lines / 6 h | Set creds via UI, OR disable the provider on both libraries (`EnableInternetProviders=false` already; subtitle search still runs). Doc 03-subtitles.md already calls this out as pending | S |
| 05 | Cache trash budget | **Y** | `EnableSegmentDeletion=false`, `SegmentKeepSeconds=720`; `/cache/transcodes` only 20 K right now (no live stream), but a 4K HEVC→h264 session will fill GiBs and not auto-prune | Enable `EnableSegmentDeletion=true` (default 720 s keep is fine) — pairs with finding 03 | S |
| 06 | Backup posture | **R** | `/home/docker/jellyfin/config/` (104 MB) has no off-host rotation; `snapshots/` in repo only holds pre-ElegantFin baseline | Add a weekly `tar.zst` of `/config/` (excluding `log/`, `cache/`) to NAS or git-backed snapshot dir | M |
| 07 | Disk pressure | **Y** | `/home` 90 % full, 40 GiB free of 399 GiB; `/home/user/media` only 189 files | Cap on media growth: at current free space + episode bitrate budget user has ~3–4 more series before disk fills | M |
| 08 | DB WAL ratio | **Y** | `library.db`=3.3 MB, `library.db-wal`=4.4 MB (WAL > main, uncheckpointed). `Optimize database` last ran 2026-05-08T00:58 (OK) but a fresh scan completed 03:16 left WAL fat | Either trigger a manual `Optimize database` post-scan, or shorten its schedule to "after every full scan". WAL > main is normal during/after a scan but should checkpoint on idle | S |
| 09 | Custom CSS bloat | **Y** | `CustomCss` in `branding.xml` is **25 225 bytes**, 17 `!important`, sole `@import` is `MRunkehl/cineplex@v1.0.6` (jsDelivr) | jsDelivr import adds 1 round-trip + ~50 KB on every cold cache load. Inline the import for offline-resilience and one-fewer DNS hop. Also doc 11 already flags this as the wrong theme (Cineplex, not NeutralFin) — resolve theme race first | M |
| 10 | SPA shim cost | **G** | `web-overrides/index.html` 58 KB; runs **2× MutationObserver** + **1× setInterval(1000ms)** with `lockTitle/lockFavicon/nukeSettings`; cost ~1 ms per tick | Acceptable for a single-tab branding shim; would be a problem only on background tabs at scale. No action | — |
| 11 | Service worker | **G** | `/web/serviceworker.js` 768 bytes, last modified 2024-11-19 (Jellyfin 10.10.3 ship date), serves with `cache-control: no-store` (HTTPS, etag set). Notification-only SW (per doc 10) | No action — it is small and not caching `index.html` so cannot pin stale branding | — |
| 12 | Metrics endpoint | **G** | `EnableMetrics=false` | Off is correct for a single-server box. No action | — |
| 13 | Slow-response warning | **Y** | `EnableSlowResponseWarning=true`, threshold **500 ms**. Two transcoding 499s above 2.8 s would normally trigger this warning, but I see 0 `slow` lines in 1 h logs | Either Jellyfin's slow log only fires on synchronous request handlers (not HLS segment GETs), or warning suppressed by another setting. Worth confirming threshold semantics | S |
| 14 | Library scan concurrency | **Y** | `LibraryScanFanoutConcurrency=0`, `LibraryMetadataRefreshConcurrency=0`, `ParallelImageEncodingLimit=0` (all defaults — auto = `ProcessorCount`) | On a 12-core box already at load 11+, `0` (= 12) for all three is aggressive. Cap each at 4–6 to leave headroom for Forgejo/Traefik/etc | S |
| 15 | Realtime monitor | **Y** | Both libraries have `EnableRealtimeMonitor=true`; only 189 files; `LibraryMonitorDelay=60` | Fine for current size, but inotify watches grow with file count. Re-evaluate at 10 k+ files | — |
| 16 | Trickplay / chapter previews | **G** | `EnableTrickplayImageExtraction=false`, `ExtractChapterImagesDuringLibraryScan=false`, `EnableChapterImageExtraction=false`, `ExtractTrickplayImagesDuringLibraryScan=false` (all libs) | Disabled on both libraries — saves significant CPU. No action. (Note: scheduled task `Generate Trickplay Images` still ran 02:00 — check it is a no-op when libs say no) | — |
| 17 | Photos library | **G** | `EnablePhotos=false` on both | Correct for a movies/TV deploy. No action | — |
| 18 | Plugin set | **G** | 6 plugins active (AudioDB, MusicBrainz, OMDb, OpenSubtitles, StudioImages, TMDb). `Username/Password` empty for OMDb (= no key, falls back to anon rate limit) and TMDb (`TmdbApiKey` empty — falls back to bundled key) | Both tolerated. AudioDB + MusicBrainz unused (no music libs) but cost zero idle. Consider removing for minimalism, not perf | — |
| 19 | Admin user policy | **R** | `s8n` admin has `EnableRemoteControlOfOtherUsers=true`, `EnableContentDeletion=true` (correct for admin) but **also `IsHidden=true`** | Hidden admin is non-standard; usually a hidden admin is reserved for automation. If `s8n` is the operator's daily account, `IsHidden=false` is the convention. Low risk, just unusual | S |
| 20 | Non-admin policies | **Y** | All 4 non-admin users (`5`, `guest`, `house`, `marco`) have `EnableContentDownloading=true`, `EnableMediaConversion=true`, `EnableLiveTvManagement=true`, `EnableSharedDeviceControl=true`, `IsHidden=true` | LiveTvManagement on accounts with no Live TV is dead weight, no harm. ContentDownloading + MediaConversion let any user kick off transcodes — a foot-gun on a CPU-only host. Review desired stance | S |
| 21 | Login disclaimer leak | **G** | `LoginDisclaimer` = "Welcome to ARRFLIX - Private invite only service" | Public-facing string is intentional per doc 09. No action | — |
| 22 | Public WAN exposure | **Y** | `EnableRemoteAccess=true`, `no-guest@file` middleware **dropped** in compose (per doc 09 §1.2). 24 h log: 270 LAN reqs, **59 reqs from 157.143.84.87, 1 from 82.31.156.86** | Doc 09 confirms this is intentional. The 157.143.84.87 hits are bot-style asset-prober 404s — harmless but confirms the service is internet-reachable. No action; re-verify rate limit / fail2ban once router port-forward is active | — |
| 23 | Splashscreen size | **Y** | `/config/data/splashscreen.png` is **3.0 MB** | A splash image of 3 MB is large for a PNG; lossless re-encode or downscale to ≤500 KB; saves on first-paint over WAN | S |
| 24 | Log rotation | **G** | `LogFileRetentionDays=3`; `/config/log` 1.3 MB; rotation working | No action | — |
| 25 | Splashscreen flag | **Y** | `SplashscreenEnabled=true` in `branding.xml` | Intentional for branding, no action — pairs with finding 23 (just shrink the file) | — |
| 26 | Cache breakdown | **G** | `/cache/images` 15 MB (entire cache 15 MB); `/config/metadata` 92 MB; `/config/data` 12 MB; `/config/plugins` 128 KB | Healthy small footprint. No action | — |
| 27 | Forgejo log noise | **Y** | Traefik logs show `forgejo@docker` returning **401** for `s8n/ARRFLIX.git/info/refs?service=git-receive-pack` 8× / hour from 192.168.0.10 | Out of scope for this deploy but indicates a stale `git push` retry loop on onyx — surfaces here only because we're scanning traefik logs. Mention to operator separately | — |
| 28 | Path substitutions | **G** | `system.xml` empty `<PathSubstitutions />` and `<CorsHosts />` | Correct (no NFS/SMB indirection, no cross-origin clients). No action | — |
| 29 | LiveTV residue | **G** | `DisableLiveTvChannelUserDataName=true`; no Live TV configured; per-user `EnableLiveTvAccess=true` is dead weight | Cosmetic; no perf cost. No action | — |
| 30 | Container restart count | **G** | `docker inspect` `RestartCount=0`, `Status=running`, `StartedAt=2026-05-08T02:13:01` (~2 h uptime, healthy) | No action. (Boot was at 02:13, suggests the compose was applied for doc-09 WAN flip and ran clean since) | — |
| 31 | Network XML hygiene | **Y** | `KnownProxies` empty, `LocalNetworkSubnets` empty, `LocalNetworkAddresses` empty | Jellyfin can't tell the Traefik 172.20.0.0/16 docker net from random WAN — every external IP is logged as remote, which inflates Jellyfin's geoIP/session bookkeeping. Set `KnownProxies=172.20.0.0/16` and `LocalNetworkSubnets=192.168.0.0/24` | S |
| 32 | TLS cert | **G** | LE cert valid `2026-05-08 → 2026-08-06` (89 days remaining), issued by R13, Gandi DNS-01 resolver, in `acme.json` | Healthy. No action | — |
| 33 | Request-rate posture | **G** | 81 req / hour total via traefik; 62 of those are `jellyfin@docker`. Top src 192.168.0.10 (LAN, the operator), then 157.143.84.87 (asset-prober 404s) | Low rate. No action — re-evaluate if WAN exposure draws more traffic | — |
| 34 | Idle session count | **G** | `/Sessions` returns 2 idle (s8n + guest) on 192.168.0.10; no playback in flight at audit time | No action | — |
| 35 | Item counts | **G** | 2 movies, 6 series, 169 episodes; matches `find /media -type f` (189 files, accounting for non-video extras) | Library scan is healthy; counts converged | — |

---

## Recommended fix order (top 5 by impact-per-effort)

1. **Finding 03 — enable transcode throttling + segment deletion.**
   *Effort: S (two checkboxes in Playback settings).* Closes the
   highest-cost behaviour we have evidence of (the 499 ms wall events).
   Saves CPU cycles per stalled client.
2. **Finding 04 — set OpenSubtitles credentials, OR disable
   provider.** *Effort: S.* Removes 102 ERR/6 h of log spam, fixes
   subtitle download, immediately restores log signal.
3. **Finding 31 — populate `KnownProxies` + `LocalNetworkSubnets` in
   `network.xml`.** *Effort: S.* Restores accurate session origin
   reporting; needed before any rate-limiting or fail2ban work post-WAN.
4. **Finding 14 — cap `LibraryScanFanoutConcurrency`,
   `LibraryMetadataRefreshConcurrency`, `ParallelImageEncodingLimit`
   to 4–6.** *Effort: S.* Stops a future scan piling on top of the
   existing host load (currently 11.4).
5. **Finding 06 — automate `/config/` backup.** *Effort: M.* Single
   highest-blast-radius risk: a corrupt `library.db` or a `branding.xml`
   regression and you've lost the user accounts AND the theme work in
   one go. A weekly `tar.zst` to NAS closes this.

GPU re-enable (finding 02) would unlock more wins but is **L** effort
and lives outside Jellyfin (host driver work). Throttling (#03) is the
right CPU-era patch until then.

---

## Out of scope (audited and found healthy)

- **Service worker** (`/web/serviceworker.js`, 768 B, notification-only,
  not caching index.html — finding 11).
- **Container restart count** (0 — finding 30).
- **TLS cert chain** (89 days valid — finding 32).
- **Trickplay / chapter / photo extraction** (all disabled — findings
  16, 17).
- **Log rotation** (3-day retention working, 1.3 MB /config/log —
  finding 24).
- **Cache directory growth** (15 MB total, healthy — finding 26).
- **Plugin set** (6 plugins, all idle-cheap — finding 18).
- **Idle session footprint** (2 idle web sessions, no playback in
  flight — finding 34).
- **Item count convergence** (Items/Counts matches filesystem —
  finding 35).
- **Path substitution / CORS hygiene** (empty as expected — finding 28).
- **Login disclaimer string** (per-doc-09 intentional public-facing
  text — finding 21).

---

## Appendix — raw evidence

### Host

```
uptime: 04:18:55 up 4 days,  4:36,  3 users,  load average: 11.40, 9.59, 6.19
nproc:  12
free -h: total 31Gi, used 9.2Gi, free 5.8Gi, swap used 6.8Gi / 24Gi
df -h /home: 399G total, 339G used, 40G avail (90 % full)
```

### Container

```
docker stats jellyfin (no-stream):
CPU 0.01 %, MEM 521.5 MiB / 31.27 GiB (1.63 %), PIDS 24, NET 83.8 MB / 361 MB
docker inspect: Restarts=0, Started=2026-05-08T02:13:01Z, Status=running
```

### GPU

```
nvidia-smi: NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver
lsmod | grep nvidia: (no matches)
ls /dev/nvidia*: No such file or directory
encoding.xml: HardwareAccelerationType=none, EnableHardwareEncoding=true
```

### Disk

```
/config        104 M   (data 12M, metadata 92M, log 1.3M, plugins 128K)
/cache          15 M   (images 15M, transcodes 20K, fontconfig 36K, omdb 84K)
/home/docker/jellyfin: not visible (sudo blocked); inferred from container view
```

### Database

```
jellyfin.db        208 K  (WAL 473 K, SHM 32 K)
library.db        3.3 M  (WAL 4.4 M, SHM 32 K)  <- WAL > main
keyframes/             16 K
splashscreen.png      3.0 M
```

### Traefik (last 1 h)

```
total log lines:           279
jellyfin@docker requests:   62
status 499 (client cancel):  2 (HLS segments, 6439 ms + 2890 ms)
status 5xx:                  0
top source IPs (jellyfin):
  82.31.156.86  123  (own WAN egress, hairpin)
  82.131.116.123  122 (external — likely friend / scanner)
  192.168.0.10   13  (operator LAN)
  173.244.58.11   2  (cloud scanner)
  35.203.85.72    1  (Google security scan)
```

### Jellyfin (last 6 h)

```
"Error downloading subtitles from Open Subtitles": 102
"slow" / "throttl" matches: 1 (false positive, no real slow-warn)
Container restart events: 0
```

### TLS

```
Subject:  CN=arrflix.s8n.ru
Issuer:   C=US, O=Let's Encrypt, CN=R13
Valid:    2026-05-08 00:58:11 GMT  →  2026-08-06 00:58:10 GMT  (89 d)
Resolver: letsencrypt (Gandi DNS-01)
```

### Service worker

```
URL:           https://arrflix.s8n.ru/web/serviceworker.js
HTTP:          200, content-type text/javascript
Size:          768 bytes
Last-Modified: Tue, 19 Nov 2024 03:43:48 GMT (Jellyfin 10.10.3 ship)
Headers:       HSTS preload + nosniff + frame=SAMEORIGIN + xss-protection
```

### CSS / branding

```
/Branding/Configuration:
  CustomCss bytes:      25 225
  !important rules:         17
  sole @import:         https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@v1.0.6/cineplex.css
  LoginDisclaimer:      "Welcome to ARRFLIX - Private invite only service"
  SplashscreenEnabled:  True
on disk:
  /config/config/branding.xml  25 584 bytes
```

### SPA shim

```
/opt/docker/jellyfin/web-overrides/index.html  58 725 bytes
MutationObserver count:  2  (one head/title-favicon, one body/nukeSettings)
setInterval count:       1  (1000 ms — relocks title + favicon + nukeSettings)
```

### Users

```
# users: 5
admin (s8n):       IsHidden=true, EnableRemoteControlOfOtherUsers=true, EnableContentDeletion=true
non-admin (5, guest, house, marco):  IsHidden=true, EnableContentDownloading=true,
                                     EnableMediaConversion=true, EnableLiveTvManagement=true
```

### Plugins

```
AudioDB         10.10.3.0  Active
MusicBrainz     10.10.3.0  Active   RateLimit=1, ReplaceArtistName=false
OMDb            10.10.3.0  Active   CastAndCrew=false
Open Subtitles  20.0.0.0   Active   Username/Password empty, CredentialsInvalid=false
Studio Images   10.10.3.0  Active
TMDb            10.10.3.0  Active   TmdbApiKey empty
```

### Library options (both libs)

```
EnableRealtimeMonitor                  = True
ExtractChapterImagesDuringLibraryScan  = False
EnableTrickplayImageExtraction         = False
EnablePhotos                           = False
SaveLocalMetadata                      = False
EnableInternetProviders                = False
SkipSubtitlesIfAudioTrackMatches       = True
SaveSubtitlesWithMedia                 = True
ExtractTrickplayImagesDuringLibraryScan= False
```

### Network XML

```
EnableHttps=false (TLS handled by Traefik) | EnableUPnP=false | EnableRemoteAccess=true
KnownProxies=(empty)  LocalNetworkSubnets=(empty)  LocalNetworkAddresses=(empty)
IgnoreVirtualInterfaces=true  VirtualInterfaceNames=[veth]
EnablePublishedServerUriByRequest=false
```

### System config — performance knobs

```
LogFileRetentionDays            =  3
EnableMetrics                   =  False
EnableSlowResponseWarning       =  True   (threshold 500 ms)
RemoteClientBitrateLimit        =  0      (no cap)
LibraryScanFanoutConcurrency    =  0      (auto = ProcessorCount = 12)
LibraryMetadataRefreshConcurrency = 0     (auto = ProcessorCount = 12)
ParallelImageEncodingLimit      =  0      (auto = ProcessorCount = 12)
EnableNormalizedItemByNameIds   =  True   (correct for 10.10.x)
QuickConnectAvailable           =  False
EnableCaseSensitiveItemIds      =  True
EnableFolderView                =  False
EnableGroupingIntoCollections   =  False
IsStartupWizardCompleted        =  True
ChapterImageResolution          =  (default)
DummyChapterDuration            =  (default)
ImageExtractionTimeoutMs        =  (default)
LibraryMonitorDelay             =  60
LibraryUpdateDuration           =  30
ActivityLogRetentionDays        =  (default)
```

### Encoding config — full dump

```
EncodingThreadCount             = -1     (auto)
EnableAudioVbr                  = False
MaxMuxingQueueSize              = 2048
EnableThrottling                = False  ← finding 03
ThrottleDelaySeconds            = 180
EnableSegmentDeletion           = False  ← finding 05
SegmentKeepSeconds              = 720
HardwareAccelerationType        = none   ← finding 02
EncoderAppPathDisplay           = /usr/lib/jellyfin-ffmpeg/ffmpeg
VaapiDevice                     = /dev/dri/renderD128 (no Intel iGPU on host)
H264Crf                         = 23
H265Crf                         = 28
EncoderPreset                   = (nil)
EnableHardwareEncoding          = True   (no-op while type=none)
AllowHevcEncoding               = False
AllowAv1Encoding                = False
EnableSubtitleExtraction        = True
HardwareDecodingCodecs          = [h264, vc1]
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = [mkv]
PreferSystemNativeHwDecoder     = True
EnableEnhancedNvdecDecoder      = True   (no-op while no nvidia)
```

### Scheduled tasks

```
Audio Normalization              Idle  Completed  2026-05-08T00:58
Clean Cache Directory            Idle  Completed  2026-05-08T00:58
Clean Log Directory              Idle  Completed  2026-05-08T00:58
Clean Transcode Directory        Idle  Completed  2026-05-08T02:13
Download missing subtitles       Idle  Completed  2026-05-08T00:58
Extract Chapter Images           Idle  Completed  2026-05-08T01:00
Generate Trickplay Images        Idle  Completed  2026-05-08T02:00  (no-op?)
Optimize database                Idle  Completed  2026-05-08T00:58
Refresh People                   Idle  Completed  2026-05-08T00:58
Scan Media Library               Idle  Completed  2026-05-08T03:16
Update Plugins                   Idle  Completed  2026-05-08T02:13
```

---

## Sign-off

- Audit: 2026-05-08, read-only, ~25 min wall.
- No fixes applied. No state mutated. No container restart.
- Next audit due: **2026-08-08** (quarterly, before LE cert renewal
  window opens at 2026-08-06).
