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:
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"/}"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user