using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Plugin.Placard.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Placard; /// Scheduled task that (re)generates a labeled Primary image for each library. public class PlacardTask : IScheduledTask { private static readonly BaseItemKind[] SourceTypes = { BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicArtist, BaseItemKind.BoxSet, BaseItemKind.MusicAlbum }; private readonly ILibraryManager _libraryManager; private readonly IProviderManager _providerManager; private readonly ILogger _logger; public PlacardTask(ILibraryManager libraryManager, IProviderManager providerManager, ILogger logger) { _libraryManager = libraryManager; _providerManager = providerManager; _logger = logger; } public string Name => "Generate Placard library cards"; public string Key => "PlacardGenerate"; public string Description => "Bake each library's name onto a representative backdrop."; public string Category => "Placard"; public IEnumerable GetDefaultTriggers() { yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(3).Ticks }; } public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { var config = Plugin.Instance!.Configuration; if (!config.Enabled) { _logger.LogInformation("Placard is disabled; skipping run."); return; } var folders = _libraryManager.GetUserRootFolder().Children .OfType() .ToList(); var pins = ParsePins(config.PinnedSources); _logger.LogInformation("Placard: processing {Count} libraries ({Pins} pinned)", folders.Count, pins.Count); int done = 0; foreach (var folder in folders) { cancellationToken.ThrowIfCancellationRequested(); try { await ProcessFolderAsync(folder, config, pins, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Placard: failed for library {Name}", folder.Name); } progress.Report(100.0 * ++done / Math.Max(folders.Count, 1)); } } private static ItemImageInfo? Backdrop(BaseItem item) => item.GetImages(ImageType.Backdrop).FirstOrDefault(); private static Dictionary ParsePins(string raw) { var pins = new Dictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(raw)) { return pins; } foreach (var line in raw.Split('\n')) { var trimmed = line.Trim(); var eq = trimmed.IndexOf('=', StringComparison.Ordinal); if (eq > 0) { pins[trimmed[..eq].Trim()] = trimmed[(eq + 1)..].Trim(); } } return pins; } private static (ItemSortBy, SortOrder)[] OrderFor(SourceRule rule) => rule switch { SourceRule.Newest => new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, SourceRule.Random => new[] { (ItemSortBy.Random, SortOrder.Ascending) }, _ => new[] { (ItemSortBy.CommunityRating, SortOrder.Descending) } }; private BaseItem? PickSource(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary pins) { // A pinned title wins, when it exists and has a backdrop. if (pins.TryGetValue(folder.Name, out var pinned) && !string.IsNullOrWhiteSpace(pinned)) { var candidates = _libraryManager.GetItemList(new InternalItemsQuery { Parent = folder, Recursive = true, SearchTerm = pinned, IncludeItemTypes = SourceTypes, Limit = 15 }).Where(i => Backdrop(i) is not null).ToList(); var match = candidates.FirstOrDefault(i => string.Equals(i.Name, pinned, StringComparison.OrdinalIgnoreCase)) ?? candidates.FirstOrDefault(); if (match is not null) { return match; } _logger.LogWarning("Placard: pinned title '{Pin}' not found in {Name}; using rule", pinned, folder.Name); } return _libraryManager.GetItemList(new InternalItemsQuery { Parent = folder, Recursive = true, IncludeItemTypes = SourceTypes, OrderBy = OrderFor(config.Source), Limit = 60 }).FirstOrDefault(i => Backdrop(i) is not null); } private async Task ProcessFolderAsync(CollectionFolder folder, PluginConfiguration config, IReadOnlyDictionary pins, CancellationToken cancellationToken) { var source = PickSource(folder, config, pins); var backdropPath = source is null ? null : Backdrop(source)?.Path; if (source is null || string.IsNullOrEmpty(backdropPath) || !File.Exists(backdropPath)) { _logger.LogWarning("Placard: no usable backdrop found for {Name}", folder.Name); return; } _logger.LogInformation("Placard: {Library} <- backdrop of {Item}", folder.Name, source.Name); var bytes = CardRenderer.Render(backdropPath, folder.Name, config.ScrimOpacity, config.FontHeightPercent); using var stream = new MemoryStream(bytes); await _providerManager .SaveImage(folder, stream, "image/jpeg", ImageType.Primary, null, cancellationToken) .ConfigureAwait(false); await folder.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false); } }