using System.Globalization; using Jellyfin.Plugin.Vanguard.Configuration; using Jellyfin.Plugin.Vanguard.Seasons; using Jellyfin.Plugin.Vanguard.Services; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Vanguard.Providers; /// Season/episode numbers from the timestamp; title from the Discord event or the dock title. public sealed class VanguardEpisodeProvider : IRemoteMetadataProvider, IHasOrder { private readonly LibraryPaths _paths; private readonly EventStore _store; private readonly IdentityResolver _identity; private readonly IHttpClientFactory _http; private readonly ILogger _log; public VanguardEpisodeProvider(LibraryPaths paths, EventStore store, IdentityResolver identity, IHttpClientFactory http, ILogger log) { _paths = paths; _store = store; _identity = identity; _http = http; _log = log; } public string Name => "Vanguard Metadata"; public int Order => 0; private static PluginConfiguration Cfg => Plugin.Instance?.Configuration ?? new PluginConfiguration(); public static TimeZoneInfo Tz(PluginConfiguration cfg) { try { return TimeZoneInfo.FindSystemTimeZoneById(cfg.TimeZoneId); } catch (TimeZoneNotFoundException) { return TimeZoneInfo.Utc; } } public async Task> GetMetadata(EpisodeInfo info, CancellationToken ct) { var result = new MetadataResult(); if (string.IsNullOrEmpty(info.Path)) return result; var cfg = Cfg; var tz = Tz(cfg); var when = ItemTimestamp.Resolve(info.Path, tz); var slot = SeasonPolicy.Resolve(when.Utc, DateTimeOffset.UtcNow, tz, cfg.BroadcastDayRolloverHour, SeasonPolicy.ParseOverrides(cfg.SeasonNameOverrides)); var parts = _paths.Split(info.Path); var ev = _store.FindEventAt(when.Utc, TimeSpan.FromHours(cfg.EventMatchSlackHours), TimeSpan.FromHours(4)); var local = TimeZoneInfo.ConvertTime(when.Utc, tz); var dockTitle = parts?.Title ?? parts?.Stem ?? Path.GetFileNameWithoutExtension(info.Path); var title = cfg.EpisodeTitleFromEvent && ev is not null ? ev.Name : dockTitle; var who = parts is null ? null : await _identity.ResolveFolderAsync(parts.SeriesFolder, ct).ConfigureAwait(false); var lines = new List(); if (ev is not null && !string.IsNullOrWhiteSpace(ev.Description)) lines.Add(ev.Description!.Trim()); lines.Add($"Recorded {local:dddd, MMMM d yyyy 'at' h:mm tt} by {who?.DisplayName ?? parts?.SeriesFolder ?? "unknown"}."); if (ev is not null && title != dockTitle && !string.IsNullOrWhiteSpace(dockTitle)) lines.Add($"Stream title: {dockTitle}"); result.Item = new Episode { Name = $"{title} — {local:MMM d, h:mm tt}", IndexNumber = slot.EpisodeNumber, ParentIndexNumber = slot.SeasonNumber, PremiereDate = when.Utc.UtcDateTime, ProductionYear = local.Year, Overview = string.Join("\n\n", lines), }; result.HasMetadata = true; _log.LogDebug("Episode {Path}: season {Season} ({Name}) episode {Ep} from {Src}", info.Path, slot.SeasonNumber, slot.SeasonName, slot.EpisodeNumber, when.Source); return result; } public Task> GetSearchResults(EpisodeInfo searchInfo, CancellationToken ct) => Task.FromResult>([]); public Task GetImageResponse(string url, CancellationToken ct) => _http.CreateClient(MediaBrowser.Common.Net.NamedClient.Default).GetAsync(url, ct); }