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,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);
}

View File

@@ -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);
}

View File

@@ -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);
}