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

View File

@@ -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 },
];
}

View File

@@ -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 },
];
}
}