using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Floatplane.Channel; /// /// Every Jellyfin client lists a channel folder sorted by name by default, and the web /// client stores a sort choice per folder. To get release order without touching every /// folder, each video's ForcedSortName is set to its release time reversed /// ((10^13 - unixMs), zero-padded), so "Name ascending" = newest first. /// Runs a few seconds after a listing (the items only exist in the database after /// Jellyfin has synced them) and during the scheduled refresh. /// public class SortNameUpdater { private const long Epoch = 10_000_000_000_000; // > any unix ms this side of year 2286 private readonly ILibraryManager _libraryManager; private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); private int _scheduled; public SortNameUpdater(ILibraryManager libraryManager, ILogger logger) { _libraryManager = libraryManager; _logger = logger; } /// Sort name for a release date: newest first under ascending name order. public static string SortNameFor(DateTime? releaseDate) { var ms = releaseDate.HasValue ? new DateTimeOffset(DateTime.SpecifyKind(releaseDate.Value, DateTimeKind.Utc)).ToUnixTimeMilliseconds() : 0; return (Epoch - 1 - Math.Clamp(ms, 0, Epoch - 1)).ToString("D13", CultureInfo.InvariantCulture); } /// Schedules one pass shortly after a listing (coalesces bursts of calls). public void ScheduleSoon() { if (Interlocked.CompareExchange(ref _scheduled, 1, 0) != 0) { return; } _ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); Interlocked.Exchange(ref _scheduled, 0); await UpdateAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { Interlocked.Exchange(ref _scheduled, 0); _logger.LogWarning(ex, "Sort-name update failed"); } }); } /// Sets the forced sort name on every Floatplane video item that lacks the right one. public async Task UpdateAsync(CancellationToken ct) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { var channelIds = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Channel], Name = "Floatplane" }).Select(c => c.Id).ToArray(); if (channelIds.Length == 0) { return 0; } var items = _libraryManager.GetItemList(new InternalItemsQuery { ChannelIds = channelIds, IncludeItemTypes = [BaseItemKind.Video], Recursive = true }); var changed = new List(); foreach (var item in items) { if (FloatplaneChannel.PostIdOf(item.ExternalId) is null) { continue; } var wanted = SortNameFor(item.PremiereDate ?? item.DateCreated); if (!string.Equals(item.ForcedSortName, wanted, StringComparison.Ordinal)) { item.ForcedSortName = wanted; changed.Add(item); } } if (changed.Count > 0) { await _libraryManager.UpdateItemsAsync(changed, changed[0].GetParent(), ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false); _logger.LogInformation("Set release-order sort names on {Count} Floatplane videos", changed.Count); } return changed.Count; } finally { _gate.Release(); } } }