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; /// Posts "new stream recording" / "new clip" embeds to a Discord channel when items land in the library. public sealed class FeedService : IHostedService { private readonly ILibraryManager _library; private readonly DiscordClient _discord; private readonly IdentityResolver _identity; private readonly LibraryPaths _paths; private readonly ILogger _log; private readonly HashSet _announced = new(); public FeedService(ILibraryManager library, DiscordClient discord, IdentityResolver identity, LibraryPaths paths, ILogger 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; } }