Jellyfin indexed in-progress fragmented recordings (no duration -> seek jumped to next episode). MediaMTX now writes under <Title>/.live/; the sweep remuxes finished files into the title folder and removes the source. Feed says 'New recording' since it fires after publish. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
105 lines
5.5 KiB
C#
105 lines
5.5 KiB
C#
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 ? $"📼 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;
|
|
}
|
|
}
|