Streams published flat as <user>/<Title>__<ts>.mp4 so each recording is its own Jellyfin episode; plugin 0.1.3 parses the title from the name

Jellyfin merges several videos in one non-season folder into a single episode with versions,
which collapsed a night of helmet cams into one entry. Recording still lands in <Title>/.live/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 03:42:53 -05:00
parent d28c2335d0
commit e02accb855
6 changed files with 20 additions and 12 deletions

View File

@@ -253,7 +253,9 @@ Pulls everything older than 15 days off the server, including the hidden `.src/`
## Layout on the server ## Layout on the server
/srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty) /srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty)
/srv/media/streams/<user>/<Title>/2025-10-10_21-03-11.mp4 finished stream, flat MP4 published by the sweep /srv/media/streams/<user>/<Title>__<ts>.mp4 finished stream, one flat MP4 per recording, directly in
the streamer's folder (several files inside one title
folder would be ONE Jellyfin episode with "versions")
/srv/media/clips/<user>/clip.mp4 clip (synced as-is, or converted; raw removed) /srv/media/clips/<user>/clip.mp4 clip (synced as-is, or converted; raw removed)
/srv/media/clips/<user>/.stlocal Syncthing ignore list maintained by the sweep /srv/media/clips/<user>/.stlocal Syncthing ignore list maintained by the sweep
/srv/media/clips/<user>/.src/clip.mkv raw source, only for clips that did NOT arrive via Syncthing /srv/media/clips/<user>/.src/clip.mkv raw source, only for clips that did NOT arrive via Syncthing
@@ -272,7 +274,7 @@ upscaled; aspect kept).
| input | action | | input | action |
|-------|--------| |-------|--------|
| already compliant | nothing | | already compliant | nothing |
| MediaMTX recording in `.live/` (fragmented MP4) | once idle 3 min: lossless remux to a flat MP4 in the title folder, `.live` source removed | | MediaMTX recording in `<Title>/.live/` (fragmented MP4) | once idle 3 min: lossless remux to `<user>/<Title>__<ts>.mp4`, `.live` source and empty title folder removed |
| in a Syncthing folder (`.stfolder` marker) | compliant / index-only: untouched; otherwise convert → `name.mp4`, add raw to `.stlocal`, delete raw (one copy) | | in a Syncthing folder (`.stfolder` marker) | compliant / index-only: untouched; otherwise convert → `name.mp4`, add raw to `.stlocal`, delete raw (one copy) |
| HEVC / 10-bit / VFR / MKV / >1440p / >60fps / non-AAC | full-res re-encode (x264 veryfast CRF 18) → `name.mp4`; source kept in `.src/` | | HEVC / 10-bit / VFR / MKV / >1440p / >60fps / non-AAC | full-res re-encode (x264 veryfast CRF 18) → `name.mp4`; source kept in `.src/` |
| ffmpeg fails or duration mismatch | `name.normalize-failed.log` written, file skipped until that log is deleted | | ffmpeg fails or duration mismatch | `name.normalize-failed.log` written, file skipped until that log is deleted |

View File

@@ -11,7 +11,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Continuous | OBS streams record into a hidden `streams/<user>/<Title>/.live/` folder, invisible to Jellyfin and copyparty until finished | MediaMTX | | Continuous | OBS streams record into a hidden `streams/<user>/<Title>/.live/` folder, invisible to Jellyfin and copyparty until finished | MediaMTX |
| Continuous | Gamers' clip folders mirror onto the server (video files only, their structure untouched, receive-only) | Syncthing | | Continuous | Gamers' clip folders mirror onto the server (video files only, their structure untouched, receive-only) | Syncthing |
| Continuous | New recordings and clips are announced in a Discord channel with the streamer's name and a frame grab | Jellyfin plugin (needs bot token + channel in its config) | | Continuous | New recordings and clips are announced in a Discord channel with the streamer's name and a frame grab | Jellyfin plugin (needs bot token + channel in its config) |
| Every 5 min | Finished files are checked. Streams idle ≥3 min: lossless remux out of `.live/` into the title folder as a flat, seekable MP4 (this is when they appear in Jellyfin/copyparty and the Discord notice fires). Non-compliant clips: converted to the editing standard, raw removed, one copy per clip | normalize sweep (systemd timer) | | Every 5 min | Finished files are checked. Streams idle ≥3 min: lossless remux out of `<Title>/.live/` to `<user>/<Title>__<ts>.mp4`, one file per recording (this is when they appear in Jellyfin/copyparty and the Discord notice fires). Non-compliant clips: converted to the editing standard, raw removed, one copy per clip | normalize sweep (systemd timer) |
| Every 5 min | Discord scheduled events snapshotted into a history so recordings can be matched to their session | Jellyfin plugin | | Every 5 min | Discord scheduled events snapshotted into a history so recordings can be matched to their session | Jellyfin plugin |
| Every 30 min + startup | Every non-admin Jellyfin user set to direct-play-only (no hardware encoder on this box) | Jellyfin plugin | | Every 30 min + startup | Every non-admin Jellyfin user set to direct-play-only (no hardware encoder on this box) | Jellyfin plugin |
| Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin | | Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin |
@@ -53,7 +53,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Caddy | TLS + routing for all hostnames | live | | Caddy | TLS + routing for all hostnames | live |
| Authentik (+Postgres, Redis) | Discord login, groups, guild-membership policy | live | | Authentik (+Postgres, Redis) | Discord login, groups, guild-membership policy | live |
| Jellyfin | watch page, SSO via Authentik, direct play only | live | | Jellyfin | watch page, SSO via Authentik, direct play only | live |
| Vanguard Metadata plugin | seasons by tag, Discord names/avatars/events, feed, playback policy | **0.1.0 live, 0.1.2 built** (policy task, "new recording" feed wording) — install pending | | Vanguard Metadata plugin | seasons by tag, Discord names/avatars/events, feed, playback policy | **0.1.3 built** (flat `<Title>__<ts>` names, policy task, feed wording) — install pending; server has 0.1.0 or 0.1.2 |
| MariaDB | databases for the bot and Gitea; public :3306, root local-only | live | | MariaDB | databases for the bot and Gitea; public :3306, root local-only | live |
| Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin | | Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin |
| act_runner | CI jobs in Docker | **not registered** | | act_runner | CI jobs in Docker | **not registered** |
@@ -83,6 +83,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
## Verified behaviours worth remembering ## Verified behaviours worth remembering
- **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only. - **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only.
- Caddy runs in a container: anything it serves from disk must be a mount (`/srv/live` was missing → 404). - Caddy runs in a container: anything it serves from disk must be a mount (`/srv/live` was missing → 404).
- Several videos inside one non-season folder = ONE Jellyfin episode with "versions". Streams are therefore published flat as `<Title>__<ts>.mp4` in the streamer's folder (plugin 0.1.3 parses the title from the name). Four or five helmet cams a night each get their own episode.
- The sweep's VFR check used a gawk-only function; Debian's mawk made it pass everything silently. Portable now. - The sweep's VFR check used a gawk-only function; Debian's mawk made it pass everything silently. Portable now.
- A deploy block that is only half-run leaves old binaries in `/usr/local/bin`; `grep -c` the installed script before blaming logic. - A deploy block that is only half-run leaves old binaries in `/usr/local/bin`; `grep -c` the installed script before blaming logic.
- MediaMTX stamps recording filenames in its container clock: without `TZ` it is UTC and the plugin reads names as Eastern (4 h off). `TZ=America/New_York` is set on the container. - MediaMTX stamps recording filenames in its container clock: without `TZ` it is UTC and the plugin reads names as Eastern (4 h off). `TZ=America/New_York` is set on the container.

View File

@@ -6,8 +6,9 @@
# - anything else (HEVC, 10-bit, VFR, MKV, >1440p, >60fps): full-res re-encode -> name.mp4, # - anything else (HEVC, 10-bit, VFR, MKV, >1440p, >60fps): full-res re-encode -> name.mp4,
# raw source kept in a hidden .src/ subfolder next to it (Jellyfin/copyparty hide dot-dirs) # raw source kept in a hidden .src/ subfolder next to it (Jellyfin/copyparty hide dot-dirs)
# - streams/<user>/<Title>/.live/<ts>.mp4 (MediaMTX writes here, hidden from Jellyfin/copyparty while live): # - streams/<user>/<Title>/.live/<ts>.mp4 (MediaMTX writes here, hidden from Jellyfin/copyparty while live):
# when finished -> flat MP4 written to the parent folder, the .live source deleted. One copy, and nothing # when finished -> flat MP4 published as streams/<user>/<Title>__<ts>.mp4 (one file per recording directly
# indexes a recording in progress. # in the streamer's folder: Jellyfin would merge several files inside one title folder into ONE episode
# with "versions"), the .live source deleted, the empty title folder removed.
# - Syncthing folders (marker dir .stfolder in the folder root, receive-only): compliant files are left # - Syncthing folders (marker dir .stfolder in the folder root, receive-only): compliant files are left
# alone (zero copies); a non-compliant file is converted to name.mp4 (name.h264.mp4 if the raw is # alone (zero copies); a non-compliant file is converted to name.mp4 (name.h264.mp4 if the raw is
# already .mp4), the raw's name is appended to .stlocal (an #include'd ignore list) so Syncthing # already .mp4), the raw's name is appended to .stlocal (an #include'd ignore list) so Syncthing
@@ -74,7 +75,8 @@ while IFS= read -r -d '' src; do
live=0 live=0
if [[ $(basename "$(dirname "$src")") == .live ]]; then if [[ $(basename "$(dirname "$src")") == .live ]]; then
live=1 live=1
base="$(dirname "$(dirname "$src")")/$(basename "${src%.*}")" titledir="$(dirname "$(dirname "$src")")" # streams/<user>/<Title>
base="$(dirname "$titledir")/$(basename "$titledir")__$(basename "${src%.*}")" # streams/<user>/<Title>__<ts>
fi fi
synced=0; stroot="" synced=0; stroot=""
d="$(dirname "$src")" d="$(dirname "$src")"
@@ -122,7 +124,7 @@ while IFS= read -r -d '' src; do
if (( rc == 0 )) && [[ -n $d1 && -n $d2 ]] && (( d2 >= d1 - 2 && d2 <= d1 + 2 )); then if (( rc == 0 )) && [[ -n $d1 && -n $d2 ]] && (( d2 >= d1 - 2 && d2 <= d1 + 2 )); then
if (( live )); then if (( live )); then
mv -f "$tmp" "$out"; chown 1000:1000 "$out" 2>/dev/null mv -f "$tmp" "$out"; chown 1000:1000 "$out" 2>/dev/null
rm -f "$src"; rmdir "$(dirname "$src")" 2>/dev/null || true rm -f "$src"; rmdir "$(dirname "$src")" "$titledir" 2>/dev/null || true # drop .live and the empty title folder
log "done $out"; continue log "done $out"; continue
elif (( synced )); then elif (( synced )); then
rel_src="${src#"$stroot"/}"; rel_out="${out#"$stroot"/}" rel_src="${src#"$stroot"/}"; rel_out="${out#"$stroot"/}"

View File

@@ -2,8 +2,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Vanguard</RootNamespace> <RootNamespace>Jellyfin.Plugin.Vanguard</RootNamespace>
<AssemblyVersion>0.1.2.0</AssemblyVersion> <AssemblyVersion>0.1.3.0</AssemblyVersion>
<FileVersion>0.1.2.0</FileVersion> <FileVersion>0.1.3.0</FileVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>

View File

@@ -26,6 +26,8 @@ public sealed class LibraryPaths
var rel = full[(root.Length + 1)..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); var rel = full[(root.Length + 1)..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
if (rel.Length < 2) return null; if (rel.Length < 2) return null;
var stem = Path.GetFileNameWithoutExtension(rel[^1]); var stem = Path.GetFileNameWithoutExtension(rel[^1]);
return rel.Length >= 3 ? new Parts(rel[0], rel[^2], stem) : new Parts(rel[0], null, stem); if (rel.Length >= 3) return new Parts(rel[0], rel[^2], stem); // legacy: <user>/<Title>/<ts>.mp4
var sep = stem.IndexOf("__", StringComparison.Ordinal); // flat: <user>/<Title>__<ts>.mp4
return sep > 0 ? new Parts(rel[0], stem[..sep], stem[(sep + 2)..]) : new Parts(rel[0], null, stem);
} }
} }

View File

@@ -1,6 +1,6 @@
name: "Vanguard Metadata" name: "Vanguard Metadata"
guid: "d00ebd97-f987-4678-8089-c8f0f8a6ab3e" guid: "d00ebd97-f987-4678-8089-c8f0f8a6ab3e"
version: "0.1.2.0" version: "0.1.3.0"
targetAbi: "12.4.0.0" targetAbi: "12.4.0.0"
framework: "net10.0" framework: "net10.0"
overview: "Season/episode metadata for gameplay recordings from timestamps, Discord and Authentik." overview: "Season/episode metadata for gameplay recordings from timestamps, Discord and Authentik."
@@ -14,6 +14,7 @@ artifacts:
- "Jellyfin.Plugin.Vanguard.dll" - "Jellyfin.Plugin.Vanguard.dll"
- "Jellyfin.Plugin.Vanguard.Core.dll" - "Jellyfin.Plugin.Vanguard.Core.dll"
changelog: > changelog: >
0.1.3.0 flat stream files <Title>__<ts>.mp4 (title parsed from the name)
0.1.2.0 feed says "New recording" (streams are published only after they finish) 0.1.2.0 feed says "New recording" (streams are published only after they finish)
0.1.1.0 playback-policy enforcement task (direct-play-only for all users) 0.1.1.0 playback-policy enforcement task (direct-play-only for all users)
0.1.0.0 initial skeleton 0.1.0.0 initial skeleton