Vanguard media stack: ingest, copyparty, Syncthing, normalize sweep, Authentik, Jellyfin + Vanguard Metadata plugin, MariaDB, Gitea, bot runtime
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user