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
/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>/.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
@@ -272,7 +274,7 @@ upscaled; aspect kept).
| input | action |
|-------|--------|
| 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) |
| 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 |

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 | 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) |
| 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 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 |
@@ -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 |
| Authentik (+Postgres, Redis) | Discord login, groups, guild-membership policy | 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 |
| Gitea | git hosting on MariaDB, Actions enabled; holds this repo | live, local admin |
| 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
- **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).
- 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.
- 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.

View File

@@ -6,8 +6,9 @@
# - 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)
# - 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
# indexes a recording in progress.
# when finished -> flat MP4 published as streams/<user>/<Title>__<ts>.mp4 (one file per recording directly
# 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
# 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
@@ -74,7 +75,8 @@ while IFS= read -r -d '' src; do
live=0
if [[ $(basename "$(dirname "$src")") == .live ]]; then
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
synced=0; stroot=""
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 (( live )); then
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
elif (( synced )); then
rel_src="${src#"$stroot"/}"; rel_out="${out#"$stroot"/}"

View File

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

View File

@@ -26,6 +26,8 @@ public sealed class LibraryPaths
var rel = full[(root.Length + 1)..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
if (rel.Length < 2) return null;
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"
guid: "d00ebd97-f987-4678-8089-c8f0f8a6ab3e"
version: "0.1.2.0"
version: "0.1.3.0"
targetAbi: "12.4.0.0"
framework: "net10.0"
overview: "Season/episode metadata for gameplay recordings from timestamps, Discord and Authentik."
@@ -14,6 +14,7 @@ artifacts:
- "Jellyfin.Plugin.Vanguard.dll"
- "Jellyfin.Plugin.Vanguard.Core.dll"
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.1.0 playback-policy enforcement task (direct-play-only for all users)
0.1.0.0 initial skeleton