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:
4
jellyfin-plugin-vanguard/.gitignore
vendored
Normal file
4
jellyfin-plugin-vanguard/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
dist/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
|
||||
<PackageReference Include="xunit" Version="2.*" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Jellyfin.Plugin.Vanguard.Core/Jellyfin.Plugin.Vanguard.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,115 @@
|
||||
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Tests;
|
||||
|
||||
public class SeasonPolicyTests
|
||||
{
|
||||
private static readonly TimeZoneInfo NY = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
|
||||
private static DateTimeOffset Ny(int y, int mo, int d, int h, int mi, int s = 0)
|
||||
{
|
||||
var local = new DateTime(y, mo, d, h, mi, s, DateTimeKind.Unspecified);
|
||||
return new DateTimeOffset(local, NY.GetUtcOffset(local));
|
||||
}
|
||||
|
||||
// "now" = Thursday 2026-10-30 12:00 Eastern (ISO week 44)
|
||||
private static readonly DateTimeOffset Now = Ny(2026, 10, 30, 12, 0);
|
||||
|
||||
[Fact]
|
||||
public void SameWeek_IsDaySeason()
|
||||
{
|
||||
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 29, 21, 3, 11), Now, NY, 4);
|
||||
Assert.Equal(SeasonKind.Day, slot.Kind);
|
||||
Assert.Equal(20261029, slot.SeasonNumber);
|
||||
Assert.Equal("Thursday, Oct 29", slot.SeasonName);
|
||||
Assert.Equal(1029210311, slot.EpisodeNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterMidnight_BelongsToPreviousBroadcastDay()
|
||||
{
|
||||
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 30, 1, 15), Now, NY, 4);
|
||||
Assert.Equal(new DateOnly(2026, 10, 29), slot.BroadcastDay);
|
||||
Assert.Equal(20261029, slot.SeasonNumber);
|
||||
Assert.Equal(1030011500, slot.EpisodeNumber); // episode number keeps the real clock time
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EarlierInMonth_IsWeekSeason()
|
||||
{
|
||||
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 14, 20, 0), Now, NY, 4); // ISO week 42
|
||||
Assert.Equal(SeasonKind.Week, slot.Kind);
|
||||
Assert.Equal(1202642, slot.SeasonNumber);
|
||||
Assert.Equal("Week 42", slot.SeasonName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EarlierInYear_IsMonthSeason()
|
||||
{
|
||||
var slot = SeasonPolicy.Resolve(Ny(2026, 7, 4, 20, 0), Now, NY, 4);
|
||||
Assert.Equal(SeasonKind.Month, slot.Kind);
|
||||
Assert.Equal(202607, slot.SeasonNumber);
|
||||
Assert.Equal("July 2026", slot.SeasonName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviousYear_IsYearSeason()
|
||||
{
|
||||
var slot = SeasonPolicy.Resolve(Ny(2025, 12, 31, 23, 0), Now, NY, 4);
|
||||
Assert.Equal(SeasonKind.Year, slot.Kind);
|
||||
Assert.Equal(2025, slot.SeasonNumber);
|
||||
Assert.Equal("2025", slot.SeasonName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EpisodeNumber_IsStableAcrossRolls()
|
||||
{
|
||||
var ts = Ny(2026, 10, 29, 21, 3, 11);
|
||||
var e1 = SeasonPolicy.Resolve(ts, Now, NY, 4).EpisodeNumber;
|
||||
var e2 = SeasonPolicy.Resolve(ts, Ny(2026, 11, 20, 12, 0), NY, 4).EpisodeNumber; // weeks later: week bucket? no -> month bucket
|
||||
var e3 = SeasonPolicy.Resolve(ts, Ny(2027, 3, 1, 12, 0), NY, 4).EpisodeNumber; // next year -> year bucket
|
||||
Assert.Equal(e1, e2);
|
||||
Assert.Equal(e1, e3);
|
||||
Assert.Equal(202610, SeasonPolicy.Resolve(ts, Ny(2026, 11, 20, 12, 0), NY, 4).SeasonNumber);
|
||||
Assert.Equal(2026, SeasonPolicy.Resolve(ts, Ny(2027, 3, 1, 12, 0), NY, 4).SeasonNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_ReplaceGeneratedName()
|
||||
{
|
||||
var over = SeasonPolicy.ParseOverrides("# comment\n202610 = Fall Training Block\nbad line\n");
|
||||
var slot = SeasonPolicy.Resolve(Ny(2026, 10, 14, 20, 0), Ny(2026, 12, 1, 12, 0), NY, 4, over);
|
||||
Assert.Equal("Fall Training Block", slot.SeasonName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DstTransition_UsesCorrectOffset()
|
||||
{
|
||||
// 2026-11-01 02:30 local does not exist twice in a way that breaks us; 01:30 is ambiguous: must not throw
|
||||
var slot = SeasonPolicy.Resolve(Ny(2026, 11, 1, 1, 30), Ny(2026, 11, 2, 12, 0), NY, 4);
|
||||
Assert.Equal(new DateOnly(2026, 10, 31), slot.BroadcastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_RecoversKindAndName()
|
||||
{
|
||||
Assert.Equal((SeasonKind.Day, "Thursday, Oct 29"), SeasonPolicy.Describe(20261029));
|
||||
Assert.Equal((SeasonKind.Week, "Week 42"), SeasonPolicy.Describe(1202642));
|
||||
Assert.Equal((SeasonKind.Month, "October 2026"), SeasonPolicy.Describe(202610));
|
||||
Assert.Equal((SeasonKind.Year, "2025"), SeasonPolicy.Describe(2025));
|
||||
Assert.Null(SeasonPolicy.Describe(7));
|
||||
Assert.Equal("Fall Block", SeasonPolicy.Describe(202610, new Dictionary<int, string> { [202610] = "Fall Block" })!.Value.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filename_ParsesMediaMtxPattern()
|
||||
{
|
||||
var ts = ItemTimestamp.TryParseFilename("2026-10-29_21-03-11", NY);
|
||||
Assert.NotNull(ts);
|
||||
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ts!.Value);
|
||||
Assert.Null(ItemTimestamp.TryParseFilename("clip-final-v2", NY));
|
||||
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ItemTimestamp.TryParseFilename("Overwatch 2 2026.10.29 - 21.03.11.05.DVR", NY));
|
||||
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ItemTimestamp.TryParseFilename("Halo Infinite 2026-10-29 21-03-11", NY));
|
||||
Assert.Equal(Ny(2026, 10, 29, 21, 3, 11), ItemTimestamp.TryParseFilename("clip_730_20261029_210311", NY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Configuration;
|
||||
|
||||
/// <summary>Plugin settings (edited on the plugin's config page).</summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
// --- Discord -------------------------------------------------------------------------
|
||||
public string DiscordBotToken { get; set; } = string.Empty;
|
||||
public string DiscordGuildId { get; set; } = string.Empty;
|
||||
|
||||
// --- Discord feed (new stream / new clip notices) ---------------------------------------
|
||||
/// <summary>Channel to post notices in (same guild). Empty = feed off.</summary>
|
||||
public string NotifyChannelId { get; set; } = string.Empty;
|
||||
public bool NotifyNewStreams { get; set; } = true;
|
||||
public bool NotifyNewClips { get; set; } = true;
|
||||
/// <summary>Public Jellyfin URL used in links, e.g. https://watch.example.com</summary>
|
||||
public string PublicUrl { get; set; } = string.Empty;
|
||||
/// <summary>Library names that count as streams / clips (case-insensitive substring match on the library name or path).</summary>
|
||||
public string StreamsLibraryHint { get; set; } = "stream";
|
||||
public string ClipsLibraryHint { get; set; } = "clip";
|
||||
|
||||
// --- Playback policy (no hardware encoder on this box) --------------------------------------
|
||||
/// <summary>Set direct-play-only on all users automatically (new SSO users included). Turn off when the server can transcode.</summary>
|
||||
public bool EnforceDirectPlayOnly { get; set; } = true;
|
||||
public bool EnforceOnAdminsToo { get; set; } = false;
|
||||
|
||||
// --- Authentik (streamer username -> Discord user id via the OAuth source connection) --
|
||||
public string AuthentikUrl { get; set; } = "https://auth.example.com";
|
||||
public string AuthentikApiToken { get; set; } = string.Empty;
|
||||
|
||||
// --- Time / seasons ---------------------------------------------------------------------
|
||||
public string TimeZoneId { get; set; } = "America/New_York";
|
||||
/// <summary>Hour (0-23) at which the "broadcast day" rolls over. 4 = a 01:00 clip belongs to the previous evening.</summary>
|
||||
public int BroadcastDayRolloverHour { get; set; } = 4;
|
||||
/// <summary>Season name overrides: "202611=Winter Scrims Block", one per line.</summary>
|
||||
public string SeasonNameOverrides { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Prefer the overlapping Discord scheduled event's name as the episode title; fallback is the dock title (folder name).</summary>
|
||||
public bool EpisodeTitleFromEvent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Folder name -> identity, one per line: "techy=123456789012345678" (Discord user id) or
|
||||
/// "techy=authentik:someuser" (Authentik username). Folders not listed are looked up in Authentik
|
||||
/// by username = folder name.
|
||||
/// </summary>
|
||||
public string FolderIdentityMap { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hours of slack when matching a recording to a Discord scheduled event (before start / after end).</summary>
|
||||
public double EventMatchSlackHours { get; set; } = 1.0;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Vanguard Metadata</title></head>
|
||||
<body>
|
||||
<div id="VanguardConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
|
||||
<div data-role="content"><div class="content-primary">
|
||||
<form id="VanguardConfigForm">
|
||||
<h2>Discord</h2>
|
||||
<div class="inputContainer"><input is="emby-input" id="DiscordBotToken" type="password" label="Bot token" /></div>
|
||||
<div class="inputContainer"><input is="emby-input" id="DiscordGuildId" type="text" label="Server (guild) ID" /></div>
|
||||
<h2>Discord feed</h2>
|
||||
<div class="inputContainer"><input is="emby-input" id="NotifyChannelId" type="text" label="Channel ID for new stream / clip notices (empty = off)" /></div>
|
||||
<div class="inputContainer"><input is="emby-input" id="PublicUrl" type="text" label="Public Jellyfin URL for links (https://watch.example.com)" /></div>
|
||||
<div class="checkboxContainer"><label><input is="emby-checkbox" id="NotifyNewStreams" type="checkbox" /><span>Announce new stream recordings</span></label></div>
|
||||
<div class="checkboxContainer"><label><input is="emby-checkbox" id="NotifyNewClips" type="checkbox" /><span>Announce new clips</span></label></div>
|
||||
<div class="inputContainer"><input is="emby-input" id="StreamsLibraryHint" type="text" label="Streams library name contains" /></div>
|
||||
<div class="inputContainer"><input is="emby-input" id="ClipsLibraryHint" type="text" label="Clips library name contains" /></div>
|
||||
<h2>Playback policy</h2>
|
||||
<div class="checkboxContainer"><label><input is="emby-checkbox" id="EnforceDirectPlayOnly" type="checkbox" /><span>Enforce direct-play-only (no video transcoding) on all users, every 30 min — turn off once the server has a hardware encoder</span></label></div>
|
||||
<div class="checkboxContainer"><label><input is="emby-checkbox" id="EnforceOnAdminsToo" type="checkbox" /><span>Apply to administrators too</span></label></div>
|
||||
<h2>Authentik</h2>
|
||||
<div class="inputContainer"><input is="emby-input" id="AuthentikUrl" type="text" label="Authentik URL" /></div>
|
||||
<div class="inputContainer"><input is="emby-input" id="AuthentikApiToken" type="password" label="API token (read-only service account)" /></div>
|
||||
<div class="inputContainer"><textarea is="emby-textarea" id="FolderIdentityMap" rows="4" label="Folder → identity (folder=discordId or folder=authentik:username, one per line; default: Authentik username = folder)"></textarea></div>
|
||||
<h2>Seasons</h2>
|
||||
<div class="inputContainer"><input is="emby-input" id="TimeZoneId" type="text" label="Time zone (IANA)" /></div>
|
||||
<div class="inputContainer"><input is="emby-input" id="BroadcastDayRolloverHour" type="number" min="0" max="23" label="Broadcast day rolls over at hour" /></div>
|
||||
<div class="inputContainer"><textarea is="emby-textarea" id="SeasonNameOverrides" rows="4" label="Season name overrides (seasonNumber=Name, one per line)"></textarea></div>
|
||||
<div class="checkboxContainer"><label><input is="emby-checkbox" id="EpisodeTitleFromEvent" type="checkbox" /><span>Episode title from the overlapping Discord event (fallback: dock title)</span></label></div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
|
||||
</form>
|
||||
</div></div>
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var pluginId = "d00ebd97-f987-4678-8089-c8f0f8a6ab3e";
|
||||
var fields = ["DiscordBotToken","DiscordGuildId","AuthentikUrl","AuthentikApiToken","TimeZoneId","BroadcastDayRolloverHour","SeasonNameOverrides","FolderIdentityMap","NotifyChannelId","PublicUrl","StreamsLibraryHint","ClipsLibraryHint"];
|
||||
var checks = ["EpisodeTitleFromEvent","NotifyNewStreams","NotifyNewClips","EnforceDirectPlayOnly","EnforceOnAdminsToo"];
|
||||
document.querySelector('#VanguardConfigPage').addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (c) {
|
||||
fields.forEach(function (f) { document.querySelector('#' + f).value = c[f] == null ? '' : c[f]; });
|
||||
checks.forEach(function (f) { document.querySelector('#' + f).checked = !!c[f]; });
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
document.querySelector('#VanguardConfigForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault(); Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (c) {
|
||||
fields.forEach(function (f) { c[f] = document.querySelector('#' + f).value; });
|
||||
c.BroadcastDayRolloverHour = parseInt(c.BroadcastDayRolloverHour, 10) || 4;
|
||||
checks.forEach(function (f) { c[f] = document.querySelector('#' + f).checked; });
|
||||
ApiClient.updatePluginConfiguration(pluginId, c).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Vanguard</RootNamespace>
|
||||
<AssemblyVersion>0.1.1.0</AssemblyVersion>
|
||||
<FileVersion>0.1.1.0</FileVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.4.0" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.4.0" ExcludeAssets="runtime" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Jellyfin.Plugin.Vanguard.Core/Jellyfin.Plugin.Vanguard.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
33
jellyfin-plugin-vanguard/Jellyfin.Plugin.Vanguard/Plugin.cs
Normal file
33
jellyfin-plugin-vanguard/Jellyfin.Plugin.Vanguard/Plugin.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard;
|
||||
|
||||
/// <summary>Vanguard Metadata plugin entry point.</summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public override string Name => "Vanguard Metadata";
|
||||
|
||||
public override Guid Id => Guid.Parse("d00ebd97-f987-4678-8089-c8f0f8a6ab3e");
|
||||
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
public IEnumerable<PluginPageInfo> GetPages() =>
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||
using Jellyfin.Plugin.Vanguard.Services;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Providers;
|
||||
|
||||
/// <summary>Season/episode numbers from the timestamp; title from the Discord event or the dock title.</summary>
|
||||
public sealed class VanguardEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder
|
||||
{
|
||||
private readonly LibraryPaths _paths;
|
||||
private readonly EventStore _store;
|
||||
private readonly IdentityResolver _identity;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly ILogger<VanguardEpisodeProvider> _log;
|
||||
|
||||
public VanguardEpisodeProvider(LibraryPaths paths, EventStore store, IdentityResolver identity, IHttpClientFactory http, ILogger<VanguardEpisodeProvider> log)
|
||||
{ _paths = paths; _store = store; _identity = identity; _http = http; _log = log; }
|
||||
|
||||
public string Name => "Vanguard Metadata";
|
||||
public int Order => 0;
|
||||
|
||||
private static PluginConfiguration Cfg => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
public static TimeZoneInfo Tz(PluginConfiguration cfg)
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(cfg.TimeZoneId); }
|
||||
catch (TimeZoneNotFoundException) { return TimeZoneInfo.Utc; }
|
||||
}
|
||||
|
||||
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken ct)
|
||||
{
|
||||
var result = new MetadataResult<Episode>();
|
||||
if (string.IsNullOrEmpty(info.Path)) return result;
|
||||
var cfg = Cfg;
|
||||
var tz = Tz(cfg);
|
||||
var when = ItemTimestamp.Resolve(info.Path, tz);
|
||||
var slot = SeasonPolicy.Resolve(when.Utc, DateTimeOffset.UtcNow, tz, cfg.BroadcastDayRolloverHour, SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides));
|
||||
var parts = _paths.Split(info.Path);
|
||||
|
||||
var ev = _store.FindEventAt(when.Utc, TimeSpan.FromHours(cfg.EventMatchSlackHours), TimeSpan.FromHours(4));
|
||||
var local = TimeZoneInfo.ConvertTime(when.Utc, tz);
|
||||
var dockTitle = parts?.Title ?? parts?.Stem ?? Path.GetFileNameWithoutExtension(info.Path);
|
||||
var title = cfg.EpisodeTitleFromEvent && ev is not null ? ev.Name : dockTitle;
|
||||
|
||||
var who = parts is null ? null : await _identity.ResolveFolderAsync(parts.SeriesFolder, ct).ConfigureAwait(false);
|
||||
var lines = new List<string>();
|
||||
if (ev is not null && !string.IsNullOrWhiteSpace(ev.Description)) lines.Add(ev.Description!.Trim());
|
||||
lines.Add($"Recorded {local:dddd, MMMM d yyyy 'at' h:mm tt} by {who?.DisplayName ?? parts?.SeriesFolder ?? "unknown"}.");
|
||||
if (ev is not null && title != dockTitle && !string.IsNullOrWhiteSpace(dockTitle)) lines.Add($"Stream title: {dockTitle}");
|
||||
|
||||
result.Item = new Episode
|
||||
{
|
||||
Name = $"{title} — {local:MMM d, h:mm tt}",
|
||||
IndexNumber = slot.EpisodeNumber,
|
||||
ParentIndexNumber = slot.SeasonNumber,
|
||||
PremiereDate = when.Utc.UtcDateTime,
|
||||
ProductionYear = local.Year,
|
||||
Overview = string.Join("\n\n", lines),
|
||||
};
|
||||
result.HasMetadata = true;
|
||||
_log.LogDebug("Episode {Path}: season {Season} ({Name}) episode {Ep} from {Src}", info.Path, slot.SeasonNumber, slot.SeasonName, slot.EpisodeNumber, when.Source);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken ct) =>
|
||||
Task.FromResult<IEnumerable<RemoteSearchResult>>([]);
|
||||
|
||||
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Providers;
|
||||
|
||||
/// <summary>Names a season from its number: "Thursday, Oct 29", "Week 44", "October 2026", "2026" or an override.</summary>
|
||||
public sealed class VanguardSeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo>, IHasOrder
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
public VanguardSeasonProvider(IHttpClientFactory http) { _http = http; }
|
||||
|
||||
public string Name => "Vanguard Metadata";
|
||||
public int Order => 0;
|
||||
|
||||
public Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken ct)
|
||||
{
|
||||
var result = new MetadataResult<Season>();
|
||||
if (!info.IndexNumber.HasValue) return Task.FromResult(result);
|
||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
var d = SeasonPolicy.Describe(info.IndexNumber.Value, SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides));
|
||||
if (d is null) return Task.FromResult(result);
|
||||
result.Item = new Season { Name = d.Value.Name, IndexNumber = info.IndexNumber };
|
||||
result.HasMetadata = true;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken ct) =>
|
||||
Task.FromResult<IEnumerable<RemoteSearchResult>>([]);
|
||||
|
||||
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Jellyfin.Plugin.Vanguard.Services;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Providers;
|
||||
|
||||
/// <summary>A show is a streamer/submitter folder: display name and overview from Discord.</summary>
|
||||
public sealed class VanguardSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder
|
||||
{
|
||||
private readonly IdentityResolver _identity;
|
||||
private readonly IHttpClientFactory _http;
|
||||
public VanguardSeriesProvider(IdentityResolver identity, IHttpClientFactory http) { _identity = identity; _http = http; }
|
||||
|
||||
public string Name => "Vanguard Metadata";
|
||||
public int Order => 0;
|
||||
|
||||
public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken ct)
|
||||
{
|
||||
var result = new MetadataResult<Series>();
|
||||
var folder = string.IsNullOrEmpty(info.Path) ? info.Name : Path.GetFileName(info.Path.TrimEnd(Path.DirectorySeparatorChar));
|
||||
if (string.IsNullOrEmpty(folder)) return result;
|
||||
var m = await _identity.ResolveFolderAsync(folder, ct).ConfigureAwait(false);
|
||||
result.Item = new Series
|
||||
{
|
||||
Name = m?.DisplayName ?? folder,
|
||||
Overview = m is null ? $"Recordings from {folder}." : $"Recordings from {m.DisplayName} (@{m.Username} on Discord).",
|
||||
Status = SeriesStatus.Continuing,
|
||||
};
|
||||
result.HasMetadata = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken ct) =>
|
||||
Task.FromResult<IEnumerable<RemoteSearchResult>>([]);
|
||||
|
||||
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||
}
|
||||
|
||||
/// <summary>Series poster = the Discord avatar (guild-specific if set).</summary>
|
||||
public sealed class VanguardSeriesImageProvider : IRemoteImageProvider, IHasOrder
|
||||
{
|
||||
private readonly IdentityResolver _identity;
|
||||
private readonly IHttpClientFactory _http;
|
||||
public VanguardSeriesImageProvider(IdentityResolver identity, IHttpClientFactory http) { _identity = identity; _http = http; }
|
||||
|
||||
public string Name => "Vanguard Metadata";
|
||||
public int Order => 0;
|
||||
|
||||
public bool Supports(BaseItem item) => item is Series;
|
||||
public IEnumerable<ImageType> GetSupportedImages(BaseItem item) => [ImageType.Primary, ImageType.Thumb];
|
||||
|
||||
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken ct)
|
||||
{
|
||||
var folder = Path.GetFileName(item.Path?.TrimEnd(Path.DirectorySeparatorChar) ?? string.Empty);
|
||||
if (string.IsNullOrEmpty(folder)) return [];
|
||||
var m = await _identity.ResolveFolderAsync(folder, ct).ConfigureAwait(false);
|
||||
if (m?.AvatarUrl is null) return [];
|
||||
return
|
||||
[
|
||||
new RemoteImageInfo { ProviderName = Name, Type = ImageType.Primary, Url = m.AvatarUrl, Width = 512, Height = 512 },
|
||||
new RemoteImageInfo { ProviderName = Name, Type = ImageType.Thumb, Url = m.AvatarUrl, Width = 512, Height = 512 },
|
||||
];
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken ct) =>
|
||||
_http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Jellyfin.Plugin.Vanguard.Services;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard;
|
||||
|
||||
/// <summary>Registers the plugin's services with Jellyfin's DI container.</summary>
|
||||
public sealed class ServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost)
|
||||
{
|
||||
services.AddSingleton<DiscordClient>();
|
||||
services.AddSingleton<AuthentikClient>();
|
||||
services.AddSingleton<LibraryPaths>();
|
||||
services.AddSingleton<IdentityResolver>();
|
||||
services.AddHostedService<FeedService>();
|
||||
services.AddSingleton(sp => new EventStore(Plugin.Instance?.DataFolderPath ?? Path.Combine(Path.GetTempPath(), "vanguard")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Resolves an Authentik username to the Discord user id stored on their OAuth source connection.</summary>
|
||||
public sealed class AuthentikClient
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly ILogger<AuthentikClient> _log;
|
||||
|
||||
public AuthentikClient(IHttpClientFactory http, ILogger<AuthentikClient> log) { _http = http; _log = log; }
|
||||
|
||||
public async Task<string?> GetDiscordIdForUsernameAsync(string baseUrl, string token, string username, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(token)) return null;
|
||||
var c = _http.CreateClient(NamedClient.Default);
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var b = baseUrl.TrimEnd('/');
|
||||
try
|
||||
{
|
||||
using var u = await c.GetAsync($"{b}/api/v3/core/users/?username={Uri.EscapeDataString(username)}", ct).ConfigureAwait(false);
|
||||
if (!u.IsSuccessStatusCode) { _log.LogWarning("Authentik users lookup {User}: {Status}", username, u.StatusCode); return null; }
|
||||
using var ud = JsonDocument.Parse(await u.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
var results = ud.RootElement.GetProperty("results");
|
||||
if (results.GetArrayLength() == 0) return null;
|
||||
var pk = results[0].GetProperty("pk").GetInt32();
|
||||
|
||||
using var cres = await c.GetAsync($"{b}/api/v3/sources/user_connections/oauth/?user={pk}", ct).ConfigureAwait(false);
|
||||
if (!cres.IsSuccessStatusCode) { _log.LogWarning("Authentik connections lookup {User}: {Status}", username, cres.StatusCode); return null; }
|
||||
using var cd = JsonDocument.Parse(await cres.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
foreach (var conn in cd.RootElement.GetProperty("results").EnumerateArray())
|
||||
{
|
||||
var slug = conn.TryGetProperty("source", out var src) && src.ValueKind == JsonValueKind.Object && src.TryGetProperty("slug", out var sl) ? sl.GetString() : null;
|
||||
var ident = conn.TryGetProperty("identifier", out var idp) ? idp.GetString() : null;
|
||||
if (!string.IsNullOrEmpty(ident) && (slug is null || slug.Contains("discord", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return ident;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
||||
{
|
||||
_log.LogWarning(ex, "Authentik lookup failed for {User}", username);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
public sealed record DiscordMember(string UserId, string Username, string DisplayName, string? AvatarUrl);
|
||||
public sealed record DiscordEvent(string Id, string Name, string? Description, DateTimeOffset Start, DateTimeOffset? End, string? Status, DateTimeOffset SeenAt);
|
||||
|
||||
/// <summary>Thin REST client for the Discord bot API (no gateway). Uses the same application Authentik uses; add a bot user to it.</summary>
|
||||
public sealed class DiscordClient
|
||||
{
|
||||
private const string Api = "https://discord.com/api/v10";
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly ILogger<DiscordClient> _log;
|
||||
|
||||
public DiscordClient(IHttpClientFactory http, ILogger<DiscordClient> log) { _http = http; _log = log; }
|
||||
|
||||
private HttpClient Client(string token)
|
||||
{
|
||||
var c = _http.CreateClient(NamedClient.Default);
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bot", token);
|
||||
return c;
|
||||
}
|
||||
|
||||
public async Task<DiscordMember?> GetGuildMemberAsync(string token, string guildId, string userId, CancellationToken ct)
|
||||
{
|
||||
using var resp = await Client(token).GetAsync($"{Api}/guilds/{guildId}/members/{userId}", ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_log.LogWarning("Discord member lookup {User} failed: {Status}", userId, resp.StatusCode);
|
||||
return null;
|
||||
}
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
var root = doc.RootElement;
|
||||
var user = root.GetProperty("user");
|
||||
var username = user.GetProperty("username").GetString() ?? userId;
|
||||
var global = user.TryGetProperty("global_name", out var g) && g.ValueKind == JsonValueKind.String ? g.GetString() : null;
|
||||
var nick = root.TryGetProperty("nick", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null;
|
||||
var guildAvatar = root.TryGetProperty("avatar", out var ga) && ga.ValueKind == JsonValueKind.String ? ga.GetString() : null;
|
||||
var userAvatar = user.TryGetProperty("avatar", out var ua) && ua.ValueKind == JsonValueKind.String ? ua.GetString() : null;
|
||||
string? avatarUrl = guildAvatar is not null
|
||||
? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{guildAvatar}.png?size=512"
|
||||
: userAvatar is not null ? $"https://cdn.discordapp.com/avatars/{userId}/{userAvatar}.png?size=512"
|
||||
: $"https://cdn.discordapp.com/embed/avatars/{(ulong.TryParse(userId, out var id) ? (id >> 22) % 6 : 0)}.png";
|
||||
return new DiscordMember(userId, username, nick ?? global ?? username, avatarUrl);
|
||||
}
|
||||
|
||||
/// <summary>Post an embed to a channel. <paramref name="fields"/> are (name, value) pairs.</summary>
|
||||
public async Task<bool> PostEmbedAsync(string token, string channelId, string title, string? description, string? url, string? thumbnailUrl, IEnumerable<(string Name, string Value)> fields, int color, CancellationToken ct, byte[]? imageBytes = null, string imageName = "thumb.jpg")
|
||||
{
|
||||
var embed = new Dictionary<string, object?>
|
||||
{
|
||||
["title"] = title.Length > 256 ? title[..256] : title,
|
||||
["description"] = description is { Length: > 0 } ? (description.Length > 2000 ? description[..2000] : description) : null,
|
||||
["url"] = url,
|
||||
["color"] = color,
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToString("o"),
|
||||
["fields"] = fields.Select(f => new { name = f.Name, value = f.Value, inline = true }).ToArray(),
|
||||
};
|
||||
if (thumbnailUrl is not null) embed["thumbnail"] = new { url = thumbnailUrl };
|
||||
if (imageBytes is { Length: > 0 }) embed["image"] = new { url = $"attachment://{imageName}" }; // big picture = the frame grab
|
||||
var payload = JsonSerializer.Serialize(new { embeds = new[] { embed } }, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
|
||||
HttpContent content;
|
||||
if (imageBytes is { Length: > 0 })
|
||||
{
|
||||
var mp = new MultipartFormDataContent();
|
||||
mp.Add(new StringContent(payload, System.Text.Encoding.UTF8, "application/json"), "payload_json");
|
||||
var file = new ByteArrayContent(imageBytes);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(imageName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : "image/jpeg");
|
||||
mp.Add(file, "files[0]", imageName);
|
||||
content = mp;
|
||||
}
|
||||
else
|
||||
{
|
||||
content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||
}
|
||||
using var _ = content;
|
||||
using var resp = await Client(token).PostAsync($"{Api}/channels/{channelId}/messages", content, ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode) _log.LogWarning("Discord post to {Channel} failed: {Status} {Body}", channelId, resp.StatusCode, await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscordEvent>> GetScheduledEventsAsync(string token, string guildId, CancellationToken ct)
|
||||
{
|
||||
using var resp = await Client(token).GetAsync($"{Api}/guilds/{guildId}/scheduled-events", ct).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_log.LogWarning("Discord scheduled-events failed: {Status}", resp.StatusCode);
|
||||
return [];
|
||||
}
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
|
||||
var list = new List<DiscordEvent>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var e in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var start = e.GetProperty("scheduled_start_time").GetDateTimeOffset();
|
||||
DateTimeOffset? end = e.TryGetProperty("scheduled_end_time", out var et) && et.ValueKind == JsonValueKind.String ? et.GetDateTimeOffset() : null;
|
||||
list.Add(new DiscordEvent(
|
||||
e.GetProperty("id").GetString()!,
|
||||
e.GetProperty("name").GetString() ?? "(event)",
|
||||
e.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null,
|
||||
start, end,
|
||||
e.TryGetProperty("status", out var st) ? st.ToString() : null,
|
||||
now));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent history of Discord scheduled events (Discord only returns upcoming/active ones, so we keep
|
||||
/// everything we have ever seen) plus an in-memory member cache.
|
||||
/// </summary>
|
||||
public sealed class EventStore
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly string _path;
|
||||
private Dictionary<string, DiscordEvent> _events = new();
|
||||
private readonly Dictionary<string, (DiscordMember Member, DateTimeOffset At)> _members = new();
|
||||
private readonly Dictionary<string, (string? DiscordId, DateTimeOffset At)> _identities = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public EventStore(string dataFolder)
|
||||
{
|
||||
Directory.CreateDirectory(dataFolder);
|
||||
_path = Path.Combine(dataFolder, "events.json");
|
||||
Load();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<DiscordEvent>>(File.ReadAllText(_path)) ?? [];
|
||||
_events = list.ToDictionary(e => e.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException) { _events = new(); }
|
||||
}
|
||||
|
||||
public void Merge(IEnumerable<DiscordEvent> fresh)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var e in fresh) _events[e.Id] = e;
|
||||
var tmp = _path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(_events.Values.OrderBy(e => e.Start).ToList(), new JsonSerializerOptions { WriteIndented = true }));
|
||||
File.Move(tmp, _path, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Event whose [start - slack, end + slack] window contains <paramref name="at"/>; closest start wins.</summary>
|
||||
public DiscordEvent? FindEventAt(DateTimeOffset at, TimeSpan slack, TimeSpan defaultDuration)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _events.Values
|
||||
.Where(e => at >= e.Start - slack && at <= (e.End ?? e.Start + defaultDuration) + slack)
|
||||
.OrderBy(e => Math.Abs((e.Start - at).Ticks))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get { lock (_lock) return _events.Count; } }
|
||||
|
||||
public DiscordMember? GetCachedMember(string discordId, TimeSpan maxAge)
|
||||
{
|
||||
lock (_lock) return _members.TryGetValue(discordId, out var m) && DateTimeOffset.UtcNow - m.At < maxAge ? m.Member : null;
|
||||
}
|
||||
public void CacheMember(DiscordMember m) { lock (_lock) _members[m.UserId] = (m, DateTimeOffset.UtcNow); }
|
||||
|
||||
public bool TryGetCachedIdentity(string folder, TimeSpan maxAge, out string? discordId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_identities.TryGetValue(folder, out var v) && DateTimeOffset.UtcNow - v.At < maxAge) { discordId = v.DiscordId; return true; }
|
||||
discordId = null; return false;
|
||||
}
|
||||
}
|
||||
public void CacheIdentity(string folder, string? discordId) { lock (_lock) _identities[folder] = (discordId, DateTimeOffset.UtcNow); }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Jellyfin.Plugin.Vanguard.Providers;
|
||||
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Posts "new stream recording" / "new clip" embeds to a Discord channel when items land in the library.</summary>
|
||||
public sealed class FeedService : IHostedService
|
||||
{
|
||||
private readonly ILibraryManager _library;
|
||||
private readonly DiscordClient _discord;
|
||||
private readonly IdentityResolver _identity;
|
||||
private readonly LibraryPaths _paths;
|
||||
private readonly ILogger<FeedService> _log;
|
||||
private readonly HashSet<Guid> _announced = new();
|
||||
|
||||
public FeedService(ILibraryManager library, DiscordClient discord, IdentityResolver identity, LibraryPaths paths, ILogger<FeedService> log)
|
||||
{ _library = library; _discord = discord; _identity = identity; _paths = paths; _log = log; }
|
||||
|
||||
public Task StartAsync(CancellationToken ct) { _library.ItemAdded += OnItemAdded; return Task.CompletedTask; }
|
||||
public Task StopAsync(CancellationToken ct) { _library.ItemAdded -= OnItemAdded; return Task.CompletedTask; }
|
||||
|
||||
private void OnItemAdded(object? sender, ItemChangeEventArgs e)
|
||||
{
|
||||
if (e.Item is not Episode ep || string.IsNullOrEmpty(ep.Path)) return;
|
||||
_ = Task.Run(() => AnnounceAsync(ep));
|
||||
}
|
||||
|
||||
private async Task AnnounceAsync(Episode ep)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
if (string.IsNullOrWhiteSpace(cfg.NotifyChannelId) || string.IsNullOrWhiteSpace(cfg.DiscordBotToken)) return;
|
||||
lock (_announced) { if (!_announced.Add(ep.Id)) return; }
|
||||
|
||||
var kind = Classify(ep.Path, cfg);
|
||||
if (kind == Kind.Other) return;
|
||||
if (kind == Kind.Stream && !cfg.NotifyNewStreams) return;
|
||||
if (kind == Kind.Clip && !cfg.NotifyNewClips) return;
|
||||
|
||||
var parts = _paths.Split(ep.Path);
|
||||
var who = parts is null ? null : await _identity.ResolveFolderAsync(parts.SeriesFolder, CancellationToken.None).ConfigureAwait(false);
|
||||
var tz = VanguardEpisodeProvider.Tz(cfg);
|
||||
var when = TimeZoneInfo.ConvertTime(ItemTimestamp.Resolve(ep.Path, tz).Utc, tz);
|
||||
var title = parts?.Title ?? parts?.Stem ?? Path.GetFileNameWithoutExtension(ep.Path);
|
||||
var link = string.IsNullOrWhiteSpace(cfg.PublicUrl) ? null : $"{cfg.PublicUrl.TrimEnd('/')}/web/#/details?id={ep.Id:N}";
|
||||
var name = who?.DisplayName ?? parts?.SeriesFolder ?? "someone";
|
||||
|
||||
var heading = kind == Kind.Stream ? $"🔴 {name} is recording: {title}" : $"🎬 New clip from {name}: {title}";
|
||||
var fields = new List<(string, string)> { ("When", when.ToString("ddd MMM d, h:mm tt", CultureInfo.InvariantCulture)) };
|
||||
if (parts?.Title is not null && kind == Kind.Clip) fields.Add(("Folder", parts.Title));
|
||||
|
||||
// Jellyfin's Screen Grabber extracts the episode's primary image during the refresh that follows
|
||||
// ItemAdded; wait a little for it so the embed can carry the frame. Falls back to avatar only.
|
||||
var (bytes, imgName) = await WaitForPrimaryImageAsync(ep.Id, TimeSpan.FromSeconds(90)).ConfigureAwait(false);
|
||||
await _discord.PostEmbedAsync(cfg.DiscordBotToken, cfg.NotifyChannelId, heading, null, link, who?.AvatarUrl, fields,
|
||||
kind == Kind.Stream ? 0xE74C3C : 0x3D8BFD, CancellationToken.None, bytes, imgName).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Feed announce failed for {Path}", ep.Path); }
|
||||
}
|
||||
|
||||
private async Task<(byte[]? Bytes, string Name)> WaitForPrimaryImageAsync(Guid id, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (_library.GetItemById(id) is BaseItem fresh && fresh.HasImage(ImageType.Primary))
|
||||
{
|
||||
var path = fresh.GetImagePath(ImageType.Primary, 0);
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
{
|
||||
try { return (await File.ReadAllBytesAsync(path).ConfigureAwait(false), "thumb" + Path.GetExtension(path)); }
|
||||
catch (IOException) { /* still being written; retry */ }
|
||||
}
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
}
|
||||
return (null, "thumb.jpg");
|
||||
}
|
||||
|
||||
private enum Kind { Stream, Clip, Other }
|
||||
|
||||
private Kind Classify(string path, PluginConfiguration cfg)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
foreach (var vf in _library.GetVirtualFolders())
|
||||
{
|
||||
var hit = (vf.Locations ?? []).Any(l => full.StartsWith(Path.GetFullPath(l).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.Ordinal));
|
||||
if (!hit) continue;
|
||||
var hay = (vf.Name ?? string.Empty) + " " + string.Join(' ', vf.Locations ?? []);
|
||||
if (!string.IsNullOrWhiteSpace(cfg.StreamsLibraryHint) && hay.Contains(cfg.StreamsLibraryHint, StringComparison.OrdinalIgnoreCase)) return Kind.Stream;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.ClipsLibraryHint) && hay.Contains(cfg.ClipsLibraryHint, StringComparison.OrdinalIgnoreCase)) return Kind.Clip;
|
||||
}
|
||||
return Kind.Other;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Folder name (streamer / submitter) -> Discord member, via the config map or Authentik.</summary>
|
||||
public sealed class IdentityResolver
|
||||
{
|
||||
private static readonly TimeSpan MemberTtl = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan IdentityTtl = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly DiscordClient _discord;
|
||||
private readonly AuthentikClient _authentik;
|
||||
private readonly EventStore _store;
|
||||
private readonly ILogger<IdentityResolver> _log;
|
||||
|
||||
public IdentityResolver(DiscordClient discord, AuthentikClient authentik, EventStore store, ILogger<IdentityResolver> log)
|
||||
{ _discord = discord; _authentik = authentik; _store = store; _log = log; }
|
||||
|
||||
private static PluginConfiguration Cfg => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
public async Task<DiscordMember?> ResolveFolderAsync(string folderName, CancellationToken ct)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (string.IsNullOrWhiteSpace(cfg.DiscordBotToken) || string.IsNullOrWhiteSpace(cfg.DiscordGuildId)) return null;
|
||||
|
||||
if (!_store.TryGetCachedIdentity(folderName, IdentityTtl, out var discordId))
|
||||
{
|
||||
discordId = await LookupDiscordIdAsync(cfg, folderName, ct).ConfigureAwait(false);
|
||||
_store.CacheIdentity(folderName, discordId);
|
||||
}
|
||||
if (discordId is null) return null;
|
||||
|
||||
var cached = _store.GetCachedMember(discordId, MemberTtl);
|
||||
if (cached is not null) return cached;
|
||||
var m = await _discord.GetGuildMemberAsync(cfg.DiscordBotToken, cfg.DiscordGuildId, discordId, ct).ConfigureAwait(false);
|
||||
if (m is not null) _store.CacheMember(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
private async Task<string?> LookupDiscordIdAsync(PluginConfiguration cfg, string folder, CancellationToken ct)
|
||||
{
|
||||
string authentikUser = folder;
|
||||
foreach (var raw in (cfg.FolderIdentityMap ?? string.Empty).Split('\n'))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
var eq = line.IndexOf('=');
|
||||
if (eq <= 0 || !line[..eq].Trim().Equals(folder, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var val = line[(eq + 1)..].Trim();
|
||||
if (val.StartsWith("authentik:", StringComparison.OrdinalIgnoreCase)) { authentikUser = val["authentik:".Length..].Trim(); break; }
|
||||
if (val.All(char.IsDigit) && val.Length >= 15) return val; // a Discord snowflake
|
||||
}
|
||||
var id = await _authentik.GetDiscordIdForUsernameAsync(cfg.AuthentikUrl, cfg.AuthentikApiToken, authentikUser, ct).ConfigureAwait(false);
|
||||
if (id is null) _log.LogInformation("No Discord identity for folder {Folder} (authentik user {User})", folder, authentikUser);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Services;
|
||||
|
||||
/// <summary>Splits a media path into (series folder, dock title or null, file stem) relative to its library root.</summary>
|
||||
public sealed class LibraryPaths
|
||||
{
|
||||
private readonly ILibraryManager _library;
|
||||
public LibraryPaths(ILibraryManager library) { _library = library; }
|
||||
|
||||
public sealed record Parts(string SeriesFolder, string? Title, string Stem);
|
||||
|
||||
public Parts? Split(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
string? root = null;
|
||||
foreach (var vf in _library.GetVirtualFolders())
|
||||
{
|
||||
foreach (var loc in vf.Locations ?? [])
|
||||
{
|
||||
var l = Path.GetFullPath(loc).TrimEnd(Path.DirectorySeparatorChar);
|
||||
if (full.StartsWith(l + Path.DirectorySeparatorChar, StringComparison.Ordinal) && (root is null || l.Length > root.Length)) root = l;
|
||||
}
|
||||
}
|
||||
if (root is null) return null;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Jellyfin.Plugin.Vanguard.Services;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Tasks;
|
||||
|
||||
/// <summary>Every 5 minutes: pull the guild's scheduled events into the persistent history.</summary>
|
||||
public sealed class DiscordSnapshotTask : IScheduledTask
|
||||
{
|
||||
private readonly DiscordClient _discord;
|
||||
private readonly EventStore _store;
|
||||
private readonly ILogger<DiscordSnapshotTask> _log;
|
||||
public DiscordSnapshotTask(DiscordClient discord, EventStore store, ILogger<DiscordSnapshotTask> log) { _discord = discord; _store = store; _log = log; }
|
||||
|
||||
public string Name => "Vanguard: snapshot Discord events";
|
||||
public string Key => "VanguardDiscordSnapshot";
|
||||
public string Description => "Keeps a history of Discord scheduled events so recordings can be matched to them later.";
|
||||
public string Category => "Vanguard";
|
||||
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken ct)
|
||||
{
|
||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
if (string.IsNullOrWhiteSpace(cfg.DiscordBotToken) || string.IsNullOrWhiteSpace(cfg.DiscordGuildId)) { _log.LogInformation("Discord not configured; skipping"); return; }
|
||||
var events = await _discord.GetScheduledEventsAsync(cfg.DiscordBotToken, cfg.DiscordGuildId, ct).ConfigureAwait(false);
|
||||
_store.Merge(events);
|
||||
_log.LogInformation("Discord snapshot: {Fresh} current events, {Total} in history", events.Count, _store.Count);
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() =>
|
||||
[
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromMinutes(5).Ticks },
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces a direct-play-only policy on every non-administrator user (this box has no hardware encoder).
|
||||
/// Runs at startup and every 30 minutes so users created by the SSO plugin are covered automatically.
|
||||
/// Turn off in the plugin config when the server gets Quick Sync.
|
||||
/// </summary>
|
||||
public sealed class PlaybackPolicyTask : IScheduledTask
|
||||
{
|
||||
private readonly IUserManager _users;
|
||||
private readonly ILogger<PlaybackPolicyTask> _log;
|
||||
public PlaybackPolicyTask(IUserManager users, ILogger<PlaybackPolicyTask> log) { _users = users; _log = log; }
|
||||
|
||||
public string Name => "Vanguard: enforce playback policy";
|
||||
public string Key => "VanguardPlaybackPolicy";
|
||||
public string Description => "Sets direct-play-only (no video transcoding) on all non-admin users, including new SSO users.";
|
||||
public string Category => "Vanguard";
|
||||
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken ct)
|
||||
{
|
||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
if (!cfg.EnforceDirectPlayOnly) { _log.LogDebug("playback policy enforcement is off"); return; }
|
||||
|
||||
int changed = 0;
|
||||
var all = _users.GetUsers().ToList();
|
||||
foreach (var u in all)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (u.HasPermission(PermissionKind.IsAdministrator) && !cfg.EnforceOnAdminsToo) continue;
|
||||
bool dirty = false;
|
||||
dirty |= Set(u, PermissionKind.EnableVideoPlaybackTranscoding, false);
|
||||
dirty |= Set(u, PermissionKind.EnableAudioPlaybackTranscoding, false);
|
||||
dirty |= Set(u, PermissionKind.EnablePlaybackRemuxing, true); // container remux is cheap and keeps odd clients working
|
||||
if (dirty) { await _users.UpdateUserAsync(u).ConfigureAwait(false); changed++; }
|
||||
}
|
||||
_log.LogInformation("playback policy: {Changed} of {Total} users updated", changed, all.Count);
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private static bool Set(Jellyfin.Database.Implementations.Entities.User u, PermissionKind kind, bool value)
|
||||
{
|
||||
if (u.HasPermission(kind) == value) return false;
|
||||
u.SetPermission(kind, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() =>
|
||||
[
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger },
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromMinutes(30).Ticks },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Vanguard.Configuration;
|
||||
using Jellyfin.Plugin.Vanguard.Providers;
|
||||
using Jellyfin.Plugin.Vanguard.Seasons;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Vanguard.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Nightly after the broadcast-day rollover: re-bucket every episode (day -> week -> month -> year),
|
||||
/// rename seasons, and refresh series so virtual seasons appear/disappear. Paths never change, so
|
||||
/// item ids and watched state are kept.
|
||||
/// </summary>
|
||||
public sealed class SeasonRollTask : IScheduledTask
|
||||
{
|
||||
private readonly ILibraryManager _library;
|
||||
private readonly IFileSystem _fs;
|
||||
private readonly ILogger<SeasonRollTask> _log;
|
||||
public SeasonRollTask(ILibraryManager library, IFileSystem fs, ILogger<SeasonRollTask> log) { _library = library; _fs = fs; _log = log; }
|
||||
|
||||
public string Name => "Vanguard: roll seasons";
|
||||
public string Key => "VanguardSeasonRoll";
|
||||
public string Description => "Moves recordings between day/week/month/year seasons as time passes and fixes season names.";
|
||||
public string Category => "Vanguard";
|
||||
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken ct)
|
||||
{
|
||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
var tz = VanguardEpisodeProvider.Tz(cfg);
|
||||
var overrides = SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var episodes = _library.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Episode], Recursive = true, IsVirtualItem = false });
|
||||
var touchedSeries = new HashSet<Guid>();
|
||||
int moved = 0, i = 0;
|
||||
foreach (var item in episodes.OfType<Episode>())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
progress.Report(80.0 * ++i / Math.Max(1, episodes.Count));
|
||||
if (string.IsNullOrEmpty(item.Path)) continue;
|
||||
var when = ItemTimestamp.Resolve(item.Path, tz);
|
||||
var slot = SeasonPolicy.Resolve(when.Utc, now, tz, cfg.BroadcastDayRolloverHour, overrides);
|
||||
if (item.ParentIndexNumber == slot.SeasonNumber && item.IndexNumber == slot.EpisodeNumber) continue;
|
||||
item.ParentIndexNumber = slot.SeasonNumber;
|
||||
item.IndexNumber = slot.EpisodeNumber;
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false);
|
||||
if (item.SeriesId != Guid.Empty) touchedSeries.Add(item.SeriesId);
|
||||
moved++;
|
||||
}
|
||||
|
||||
// let each touched series re-evaluate its (virtual) seasons, then name every season from its number
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fs)) { MetadataRefreshMode = MetadataRefreshMode.Default, ImageRefreshMode = MetadataRefreshMode.None };
|
||||
foreach (var sid in touchedSeries)
|
||||
{
|
||||
if (_library.GetItemById(sid) is Series s) await s.RefreshMetadata(options, ct).ConfigureAwait(false);
|
||||
}
|
||||
var seasons = _library.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Season], Recursive = true });
|
||||
int renamed = 0;
|
||||
foreach (var s in seasons.OfType<Season>())
|
||||
{
|
||||
if (!s.IndexNumber.HasValue) continue;
|
||||
var d = SeasonPolicy.Describe(s.IndexNumber.Value, overrides);
|
||||
if (d is null || string.Equals(s.Name, d.Value.Name, StringComparison.Ordinal)) continue;
|
||||
s.Name = d.Value.Name;
|
||||
await s.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false);
|
||||
renamed++;
|
||||
}
|
||||
_log.LogInformation("Season roll: {Moved} episodes re-bucketed, {Renamed} seasons renamed, {Series} series refreshed", moved, renamed, touchedSeries.Count);
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
var cfg = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
var hour = Math.Clamp(cfg.BroadcastDayRolloverHour, 0, 23);
|
||||
return
|
||||
[
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(hour).Add(TimeSpan.FromMinutes(10)).Ticks },
|
||||
];
|
||||
}
|
||||
}
|
||||
60
jellyfin-plugin-vanguard/README.md
Normal file
60
jellyfin-plugin-vanguard/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Vanguard Metadata — Jellyfin plugin
|
||||
|
||||
Metadata provider for gameplay recordings. Each streamer/submitter folder is a show; season and
|
||||
episode numbers come from the recording timestamp under a rolling policy; names, avatars and episode
|
||||
titles come from Discord via Authentik's identity link; new recordings and clips are announced in a
|
||||
Discord channel.
|
||||
|
||||
## Layout
|
||||
- `Jellyfin.Plugin.Vanguard.Core/` — pure logic: `SeasonPolicy` (rolling buckets, naming, overrides),
|
||||
`ItemTimestamp` (sidecar → filename patterns → mtime). No Jellyfin dependency. Unit-tested.
|
||||
- `Jellyfin.Plugin.Vanguard/` — the plugin: providers (Series, Series images, Season, Episode),
|
||||
services (Discord REST, Authentik REST, event store, identity resolver, feed), scheduled tasks
|
||||
(Discord snapshot every 5 min; season roll nightly after the rollover hour), config page.
|
||||
- `Jellyfin.Plugin.Vanguard.Tests/` — xunit.
|
||||
|
||||
## Season scheme
|
||||
| age of item | season # | season name |
|
||||
|---|---|---|
|
||||
| current ISO week | `YYYYMMDD` | "Thursday, Oct 29" |
|
||||
| current month | `1YYYYWW` | "Week 44" |
|
||||
| current year | `YYYYMM` | "October 2026" |
|
||||
| older | `YYYY` | "2026" |
|
||||
|
||||
Episode # = `MMDDHHMMSS` of the local timestamp — never changes when the season rolls.
|
||||
Broadcast day rolls at the configured hour (default 04:00). Overrides: `seasonNumber=Name`, one per line.
|
||||
|
||||
## Timestamp sources, in order
|
||||
1. `<name>.json` / `<name>.clip.json` sidecar with `"timestamp"` (UTC ISO-8601) — written by the clip bot
|
||||
2. filename: MediaMTX `2026-10-29_21-03-11`, Game Bar `2026-10-29 21-03-11`,
|
||||
ShadowPlay `2026.10.29 - 21.03.11`, Steam `20261029_210311`
|
||||
3. file modification time (Syncthing preserves the gamer's mtime)
|
||||
|
||||
## Build / package
|
||||
./package.sh # tests + Release build → dist/VanguardMetadata_<ver>.zip + manifest.json
|
||||
Requires .NET 10 SDK. Bump `version` in `build.yaml` for a release.
|
||||
|
||||
## Install on the server (manual, v0.1)
|
||||
scp dist/VanguardMetadata_0.1.0.0.zip debian@HOST:/tmp/
|
||||
ssh debian@HOST 'sudo -u "#1000" mkdir -p "/srv/appdata/jellyfin/config/plugins/Vanguard Metadata_0.1.0.0" && sudo -u "#1000" python3 -m zipfile -e /tmp/VanguardMetadata_0.1.0.0.zip "/srv/appdata/jellyfin/config/plugins/Vanguard Metadata_0.1.0.0/" && cd ~/vanguard-stream && docker compose restart jellyfin'
|
||||
Later: serve `dist/` at `https://HOST/plugins/` (Caddy) and add it as a repository in Jellyfin so updates
|
||||
come through the Catalog.
|
||||
|
||||
## Runs with an empty config
|
||||
Seasons, episode numbers, timestamps and the nightly roll need nothing external. Without a Discord bot
|
||||
token the show is named after its folder, has no poster, and episode titles are the dock title; without
|
||||
Authentik no folder→Discord mapping is attempted; without a feed channel nothing is posted. Add the
|
||||
credentials later and re-run "roll seasons" / refresh the libraries to pick up names and avatars.
|
||||
|
||||
## Configure (Dashboard → Plugins → Vanguard Metadata)
|
||||
- Discord: bot token (add a **Bot** to the existing Authentik Discord application; enable the Server
|
||||
Members intent; invite it to the server), guild ID, feed channel ID, public Jellyfin URL.
|
||||
- Authentik: URL + API token of a read-only service account (used to map folder → Discord user).
|
||||
- Folder → identity overrides if a streamer's folder name isn't their Authentik username.
|
||||
- Libraries (Dashboard → Libraries → each of streams/clips): content type Shows; metadata downloaders:
|
||||
**only** "Vanguard Metadata" for Series, Season and Episode; image fetchers: Vanguard Metadata (Series)
|
||||
and **Screen Grabber** (Episode) — the grab is what the Discord embed attaches.
|
||||
|
||||
## Tasks (Dashboard → Scheduled Tasks → Vanguard)
|
||||
- "snapshot Discord events": every 5 min + startup. Run it once after configuring.
|
||||
- "roll seasons": daily at rollover+10 min. Run it once after the first library scan.
|
||||
18
jellyfin-plugin-vanguard/build.yaml
Normal file
18
jellyfin-plugin-vanguard/build.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
name: "Vanguard Metadata"
|
||||
guid: "d00ebd97-f987-4678-8089-c8f0f8a6ab3e"
|
||||
version: "0.1.1.0"
|
||||
targetAbi: "12.4.0.0"
|
||||
framework: "net10.0"
|
||||
overview: "Season/episode metadata for gameplay recordings from timestamps, Discord and Authentik."
|
||||
description: >
|
||||
Treats each streamer/submitter folder as a show. Season and episode numbers come from the
|
||||
recording timestamp under a rolling policy (weekday -> week -> month -> year). Names, avatars and
|
||||
episode titles come from Discord (members, scheduled events) via Authentik's identity link.
|
||||
category: "Metadata"
|
||||
owner: "techy"
|
||||
artifacts:
|
||||
- "Jellyfin.Plugin.Vanguard.dll"
|
||||
- "Jellyfin.Plugin.Vanguard.Core.dll"
|
||||
changelog: >
|
||||
0.1.1.0 playback-policy enforcement task (direct-play-only for all users)
|
||||
0.1.0.0 initial skeleton
|
||||
65
jellyfin-plugin-vanguard/package.sh
Executable file
65
jellyfin-plugin-vanguard/package.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Build Release and produce dist/VanguardMetadata_<version>.zip (DLLs + meta.json) ready to drop into
|
||||
# Jellyfin's plugins folder, plus dist/manifest.json for serving as a plugin repository.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
command -v yq >/dev/null 2>&1 || true
|
||||
get() { grep -E "^$1:" build.yaml | head -1 | sed -E 's/^[a-zA-Z]+: *"?([^"]*)"?$/\1/'; }
|
||||
NAME=$(get name); GUID=$(get guid); VERSION=$(get version); ABI=$(get targetAbi); OVERVIEW=$(get overview); OWNER=$(get owner); CATEGORY=$(get category)
|
||||
DESC=$(python3 -c "import yaml,sys; print(yaml.safe_load(open('build.yaml'))['description'].strip())")
|
||||
CHANGELOG=$(python3 -c "import yaml,sys; print(yaml.safe_load(open('build.yaml'))['changelog'].strip())")
|
||||
|
||||
dotnet test Jellyfin.Plugin.Vanguard.Tests --nologo -v q
|
||||
dotnet build Jellyfin.Plugin.Vanguard -c Release --nologo -v q
|
||||
|
||||
OUT=Jellyfin.Plugin.Vanguard/bin/Release/net10.0
|
||||
STAGE=dist/stage; rm -rf dist; mkdir -p "$STAGE"
|
||||
cp "$OUT/Jellyfin.Plugin.Vanguard.dll" "$OUT/Jellyfin.Plugin.Vanguard.Core.dll" "$STAGE/"
|
||||
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
cat > "$STAGE/meta.json" <<JSON
|
||||
{
|
||||
"category": "$CATEGORY",
|
||||
"changelog": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$CHANGELOG"),
|
||||
"description": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$DESC"),
|
||||
"guid": "$GUID",
|
||||
"name": "$NAME",
|
||||
"overview": "$OVERVIEW",
|
||||
"owner": "$OWNER",
|
||||
"targetAbi": "$ABI",
|
||||
"timestamp": "$TS",
|
||||
"version": "$VERSION",
|
||||
"status": "Active",
|
||||
"autoUpdate": true,
|
||||
"imagePath": ""
|
||||
}
|
||||
JSON
|
||||
ZIP="dist/VanguardMetadata_${VERSION}.zip"
|
||||
( cd "$STAGE" && zip -q -r "../../$ZIP" . )
|
||||
SUM=$(md5sum "$ZIP" | cut -d' ' -f1)
|
||||
# repository manifest (serve dist/ at https://HOST/plugins/ and add that URL + manifest.json in Jellyfin)
|
||||
cat > dist/manifest.json <<JSON
|
||||
[
|
||||
{
|
||||
"guid": "$GUID",
|
||||
"name": "$NAME",
|
||||
"description": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$DESC"),
|
||||
"overview": "$OVERVIEW",
|
||||
"owner": "$OWNER",
|
||||
"category": "$CATEGORY",
|
||||
"imageUrl": "",
|
||||
"versions": [
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"changelog": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$CHANGELOG"),
|
||||
"targetAbi": "$ABI",
|
||||
"sourceUrl": "https://PLUGIN_HOST/plugins/VanguardMetadata_${VERSION}.zip",
|
||||
"checksum": "$SUM",
|
||||
"timestamp": "$TS"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
JSON
|
||||
rm -rf "$STAGE"
|
||||
echo "built: $ZIP (md5 $SUM)"
|
||||
echo "install (manual): python3 -m zipfile -e $ZIP \"/srv/appdata/jellyfin/config/plugins/${NAME}_${VERSION}/\" (as uid 1000; Debian has no unzip) then restart jellyfin"
|
||||
Reference in New Issue
Block a user