Vanguard media stack: ingest, copyparty, Syncthing, normalize sweep, Authentik, Jellyfin + Vanguard Metadata plugin, MariaDB, Gitea, bot runtime

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 15:43:30 -05:00
commit c602a6ff52
66 changed files with 4229 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Pure logic (season policy, timestamp parsing). No Jellyfin dependency so it is unit-testable
without the ASP.NET shared runtime. Shipped alongside the plugin DLL. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Vanguard</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,79 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Jellyfin.Plugin.Vanguard.Seasons;
/// <summary>Finds the best "when was this recorded" for a media file.</summary>
public static partial class ItemTimestamp
{
public enum Source { Sidecar, Filename, FileTime }
public sealed record Result(DateTimeOffset Utc, Source Source);
/// <summary>
/// Precedence: clip.json / &lt;name&gt;.json sidecar "timestamp" (UTC ISO-8601, written by the clip bot)
/// -> MediaMTX filename yyyy-MM-dd_HH-mm-ss (local time of the server, i.e. <paramref name="tz"/>)
/// -> file last-write time.
/// </summary>
public static Result Resolve(string path, TimeZoneInfo tz)
{
var sidecar = TryReadSidecar(path);
if (sidecar is not null) return new Result(sidecar.Value, Source.Sidecar);
var fromName = TryParseFilename(Path.GetFileNameWithoutExtension(path), tz);
if (fromName is not null) return new Result(fromName.Value, Source.Filename);
var mtime = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.UtcNow;
return new Result(new DateTimeOffset(mtime, TimeSpan.Zero), Source.FileTime);
}
// MediaMTX 2026-10-29_21-03-11 Xbox Game Bar Game 2026-10-29 21-03-11
[GeneratedRegex(@"(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})[_ T](?<h>\d{2})-(?<mi>\d{2})-(?<s>\d{2})")]
private static partial Regex DashedName();
// ShadowPlay / NVIDIA App Game 2026.10.29 - 21.03.11.05.DVR
[GeneratedRegex(@"(?<y>\d{4})\.(?<mo>\d{2})\.(?<d>\d{2}) - (?<h>\d{2})\.(?<mi>\d{2})\.(?<s>\d{2})")]
private static partial Regex ShadowPlayName();
// Steam Game Recording clip_730_20261029_210311 (also plain 20261029_210311 / 20261029-210311)
[GeneratedRegex(@"(?<![\d])(?<y>20\d{2})(?<mo>\d{2})(?<d>\d{2})[_-](?<h>\d{2})(?<mi>\d{2})(?<s>\d{2})(?![\d])")]
private static partial Regex CompactName();
public static DateTimeOffset? TryParseFilename(string stem, TimeZoneInfo tz)
{
var m = DashedName().Match(stem);
if (!m.Success) m = ShadowPlayName().Match(stem);
if (!m.Success) m = CompactName().Match(stem);
if (!m.Success) return null;
try
{
var local = new DateTime(
int.Parse(m.Groups["y"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["mo"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["d"].Value, CultureInfo.InvariantCulture),
int.Parse(m.Groups["h"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["mi"].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups["s"].Value, CultureInfo.InvariantCulture),
DateTimeKind.Unspecified);
var offset = tz.GetUtcOffset(local);
return new DateTimeOffset(local, offset);
}
catch (ArgumentOutOfRangeException) { return null; }
}
private static DateTimeOffset? TryReadSidecar(string path)
{
var dir = Path.GetDirectoryName(path) ?? string.Empty;
var stem = Path.GetFileNameWithoutExtension(path);
foreach (var candidate in new[] { Path.Combine(dir, stem + ".json"), Path.Combine(dir, stem + ".clip.json") })
{
if (!File.Exists(candidate)) continue;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(candidate));
if (doc.RootElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String &&
DateTimeOffset.TryParse(ts.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
{
return parsed;
}
}
catch (JsonException) { /* ignore broken sidecar */ }
}
return null;
}
}

View File

@@ -0,0 +1,119 @@
using System.Globalization;
namespace Jellyfin.Plugin.Vanguard.Seasons;
/// <summary>Where an item sits in the rolling season scheme.</summary>
public sealed record SeasonSlot(int SeasonNumber, string SeasonName, int EpisodeNumber, DateOnly BroadcastDay, SeasonKind Kind);
public enum SeasonKind { Day, Week, Month, Year }
/// <summary>
/// Pure function: (item timestamp, now, settings) -> season/episode numbers and season name.
/// Rolling consolidation: items in the current ISO week are bucketed by broadcast day; in the
/// current month by ISO week; in the current year by month; older by year. Episode numbers are
/// MMDDHHMMSS of the local timestamp, so they never change when the season rolls.
/// </summary>
public static class SeasonPolicy
{
/// <summary>Week seasons are 1YYYYWW so they never collide with YYYYMM month seasons. Lengths: year 4, month 6, week 7, day 8.</summary>
public const int WeekBase = 1_000_000;
/// <summary>Recover kind and display name from a season number alone (used by the Season provider).</summary>
public static (SeasonKind Kind, string Name)? Describe(int seasonNumber, IReadOnlyDictionary<int, string>? nameOverrides = null)
{
(SeasonKind, string)? r = seasonNumber switch
{
>= 10_000_000 and <= 99_999_999 when TryDay(seasonNumber, out var d) => (SeasonKind.Day, d.ToString("dddd, MMM d", CultureInfo.InvariantCulture)),
>= WeekBase and < 10_000_000 => (SeasonKind.Week, $"Week {seasonNumber % 100}"),
>= 100_000 and <= 999_999 when seasonNumber % 100 is >= 1 and <= 12 => (SeasonKind.Month, new DateOnly(seasonNumber / 100, seasonNumber % 100, 1).ToString("MMMM yyyy", CultureInfo.InvariantCulture)),
>= 1000 and <= 9999 => (SeasonKind.Year, seasonNumber.ToString(CultureInfo.InvariantCulture)),
_ => null,
};
if (r is null) return null;
if (nameOverrides is not null && nameOverrides.TryGetValue(seasonNumber, out var over) && !string.IsNullOrWhiteSpace(over))
{
return (r.Value.Item1, over.Trim());
}
return r;
}
private static bool TryDay(int n, out DateOnly d)
{
try { d = new DateOnly(n / 10000, n / 100 % 100, n % 100); return true; }
catch (ArgumentOutOfRangeException) { d = default; return false; }
}
public static SeasonSlot Resolve(DateTimeOffset itemUtc, DateTimeOffset nowUtc, TimeZoneInfo tz, int rolloverHour, IReadOnlyDictionary<int, string>? nameOverrides = null)
{
var item = TimeZoneInfo.ConvertTime(itemUtc, tz);
var now = TimeZoneInfo.ConvertTime(nowUtc, tz);
var itemDay = BroadcastDay(item, rolloverHour);
var today = BroadcastDay(now, rolloverHour);
var (itemIsoYear, itemIsoWeek) = IsoWeek(itemDay);
var (todayIsoYear, todayIsoWeek) = IsoWeek(today);
int season; string name; SeasonKind kind;
if (itemIsoYear == todayIsoYear && itemIsoWeek == todayIsoWeek)
{
kind = SeasonKind.Day;
season = itemDay.Year * 10000 + itemDay.Month * 100 + itemDay.Day; // 20261030
name = itemDay.ToString("dddd, MMM d", CultureInfo.InvariantCulture); // Thursday, Oct 30
}
else if (itemDay.Year == today.Year && itemDay.Month == today.Month)
{
kind = SeasonKind.Week;
season = WeekBase + itemIsoYear * 100 + itemIsoWeek; // 1202644 (leading 1 keeps weeks distinct from months)
name = $"Week {itemIsoWeek}";
}
else if (itemDay.Year == today.Year)
{
kind = SeasonKind.Month;
season = itemDay.Year * 100 + itemDay.Month; // 202610
name = itemDay.ToString("MMMM yyyy", CultureInfo.InvariantCulture); // October 2026
}
else
{
kind = SeasonKind.Year;
season = itemDay.Year; // 2026
name = itemDay.Year.ToString(CultureInfo.InvariantCulture);
}
if (nameOverrides is not null && nameOverrides.TryGetValue(season, out var over) && !string.IsNullOrWhiteSpace(over))
{
name = over.Trim();
}
// MMDDHHMMSS of the local timestamp: 1030210311 -> fits int32 (max 1231235959 < 2147483647)
var episode = item.Month * 100000000 + item.Day * 1000000 + item.Hour * 10000 + item.Minute * 100 + item.Second;
return new SeasonSlot(season, name, episode, itemDay, kind);
}
/// <summary>Calendar day the item belongs to after shifting the day boundary to <paramref name="rolloverHour"/>.</summary>
public static DateOnly BroadcastDay(DateTimeOffset local, int rolloverHour) =>
DateOnly.FromDateTime(local.DateTime.AddHours(-rolloverHour));
public static (int IsoYear, int IsoWeek) IsoWeek(DateOnly d)
{
var dt = d.ToDateTime(TimeOnly.MinValue);
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
}
/// <summary>Parse "202611=Winter Scrims" lines into a map.</summary>
public static IReadOnlyDictionary<int, string> ParseOverrides(string? text)
{
var map = new Dictionary<int, string>();
foreach (var raw in (text ?? string.Empty).Split('\n'))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith('#')) continue;
var eq = line.IndexOf('=');
if (eq <= 0) continue;
if (int.TryParse(line[..eq].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var key))
{
map[key] = line[(eq + 1)..].Trim();
}
}
return map;
}
}