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