v0.2: All videos folder, shared watched state, release-order sorting, official icon, OAuth plumbing, docs
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- per-creator "All videos" folder (release order across sub-channels); sub-channel copies use cpost: ids and UserDataMirror keeps played/resume state in sync - SortNameUpdater forces release-order sort names so every client lists newest first by default - official Floatplane icon as channel image (Primary only — a channel Thumb hid video thumbnails in Continue Watching) and as 16:9 plugin thumbnail; one-time image replacement on upgrade - OAuth device-flow login (FloatplaneAuthService + settings UI); needs a Floatplane client id; Turnstile verified domain-bound so password login inside Jellyfin is impossible - generic install.sh (linuxserver / official image / native), thumb.png in package + manifest - README rewritten for a general audience, doc/usage.md with screenshots, 60 tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
120
Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs
Normal file
120
Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>ForcedSortName</c> is set to its release time reversed
|
||||
/// (<c>(10^13 - unixMs)</c>, 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.
|
||||
/// </summary>
|
||||
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<SortNameUpdater> _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private int _scheduled;
|
||||
|
||||
public SortNameUpdater(ILibraryManager libraryManager, ILogger<SortNameUpdater> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Sort name for a release date: newest first under ascending name order.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Schedules one pass shortly after a listing (coalesces bursts of calls).</summary>
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Sets the forced sort name on every Floatplane video item that lacks the right one.</summary>
|
||||
public async Task<int> 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<BaseItem>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user