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 ? $"📼 New recording from {name}: {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;
}
}