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:
@@ -50,6 +50,9 @@ public class CatalogService
|
||||
|
||||
public DateTimeOffset LastRefresh => _creatorsFetched;
|
||||
|
||||
/// <summary>"oauth", "cookie" or "none".</summary>
|
||||
public string SessionKind => _api.SessionKind;
|
||||
|
||||
/// <summary>Session state as last observed (null = not checked yet).</summary>
|
||||
public string? SessionState { get; private set; }
|
||||
|
||||
@@ -83,7 +86,16 @@ public class CatalogService
|
||||
var done = 0;
|
||||
foreach (var creator in creators)
|
||||
{
|
||||
var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List<string> { string.Empty };
|
||||
var channelIds = creator.Channels.Select(c => c.Id).ToList();
|
||||
try
|
||||
{
|
||||
await GetAllPostsAsync(creator.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not warm all-videos list for {Creator}", creator.Urlname);
|
||||
}
|
||||
|
||||
foreach (var channelId in channelIds)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -240,6 +252,22 @@ public class CatalogService
|
||||
return posts;
|
||||
}
|
||||
|
||||
/// <summary>Newest video posts across all of a creator's channels (the "All videos" folder).</summary>
|
||||
public async Task<IReadOnlyList<PostModel>> GetAllPostsAsync(string creatorId, CancellationToken ct)
|
||||
{
|
||||
var key = creatorId + "|*";
|
||||
var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes));
|
||||
if (_posts.TryGetValue(key, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < ttl)
|
||||
{
|
||||
return cached.Posts;
|
||||
}
|
||||
|
||||
var max = Math.Clamp(_config().MaxPostsAllVideos, 20, 5000);
|
||||
var posts = await FetchPostsAsync(creatorId, string.Empty, max, ct).ConfigureAwait(false);
|
||||
_posts[key] = new CachedPosts(posts, DateTimeOffset.UtcNow);
|
||||
return posts;
|
||||
}
|
||||
|
||||
/// <summary>Pages through /content/creator until <paramref name="max"/> posts or the end.</summary>
|
||||
public async Task<IReadOnlyList<PostModel>> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null)
|
||||
{
|
||||
|
||||
@@ -32,18 +32,22 @@ namespace Jellyfin.Plugin.Floatplane.Channel;
|
||||
public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia
|
||||
{
|
||||
private const string CreatorPrefix = "creator:";
|
||||
private const string AllPrefix = "all:";
|
||||
private const string ChannelPrefix = "channel:";
|
||||
private const string PostPrefix = "post:";
|
||||
private const string ChannelPostPrefix = "cpost:";
|
||||
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly SortNameUpdater _sortNames;
|
||||
private readonly ILogger<FloatplaneChannel> _logger;
|
||||
private readonly Func<PluginConfiguration> _config;
|
||||
|
||||
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger<FloatplaneChannel> logger)
|
||||
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, SortNameUpdater sortNames, ILogger<FloatplaneChannel> logger)
|
||||
{
|
||||
_catalog = catalog;
|
||||
_appHost = appHost;
|
||||
_sortNames = sortNames;
|
||||
_logger = logger;
|
||||
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
}
|
||||
@@ -82,7 +86,9 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb];
|
||||
// Primary only: a channel Thumb would be inherited by every video card in "Continue
|
||||
// watching" (the web client prefers parent thumbs there), hiding the video thumbnails.
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||
@@ -111,6 +117,10 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
{
|
||||
items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (folderId.StartsWith(AllPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
items = await GetAllVideoItemsAsync(folderId[AllPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var rest = folderId[ChannelPrefix.Length..];
|
||||
@@ -125,6 +135,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
items = new List<ChannelItemInfo>();
|
||||
}
|
||||
|
||||
if (items.Any(i => i.Type == ChannelItemType.Media))
|
||||
{
|
||||
// Jellyfin creates the DB entities after this returns; fix their sort names then.
|
||||
_sortNames.ScheduleSoon();
|
||||
}
|
||||
|
||||
items = ApplySort(items, query);
|
||||
var total = items.Count;
|
||||
if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
|
||||
@@ -164,12 +180,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!id.StartsWith(PostPrefix, StringComparison.Ordinal))
|
||||
var postId = PostIdOf(id);
|
||||
if (postId is null)
|
||||
{
|
||||
return Array.Empty<MediaSourceInfo>();
|
||||
}
|
||||
|
||||
var postId = id[PostPrefix.Length..];
|
||||
PostModel post;
|
||||
try
|
||||
{
|
||||
@@ -259,10 +275,23 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
|
||||
if (creator.Channels.Count == 0)
|
||||
{
|
||||
return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false);
|
||||
return await GetAllVideoItemsAsync(creator.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return creator.Channels.Select(ch => new ChannelItemInfo
|
||||
var items = new List<ChannelItemInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = AllPrefix + creator.Id,
|
||||
Name = "All videos",
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
Overview = "Every video from " + creator.Title + " across all its channels, newest first.",
|
||||
ImageUrl = creator.Card?.BestPath(1200) ?? creator.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600),
|
||||
HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home"
|
||||
}
|
||||
};
|
||||
items.AddRange(creator.Channels.Select(ch => new ChannelItemInfo
|
||||
{
|
||||
Id = ChannelPrefix + creator.Id + ":" + ch.Id,
|
||||
Name = ch.Title,
|
||||
@@ -271,25 +300,62 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
|
||||
Overview = ch.About,
|
||||
ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600),
|
||||
HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname
|
||||
}).ToList();
|
||||
}));
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>"All videos": every video post of the creator, newest first (canonical <c>post:</c> ids).</summary>
|
||||
private async Task<List<ChannelItemInfo>> GetAllVideoItemsAsync(string creatorId, CancellationToken ct)
|
||||
{
|
||||
var creator = _catalog.FindCreator(creatorId);
|
||||
var posts = await _catalog.GetAllPostsAsync(creatorId, ct).ConfigureAwait(false);
|
||||
var markLocked = _catalog.HasSession;
|
||||
return posts.Select(p => ToItem(p, creator, markLocked)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Sub-channel folder: the same posts under <c>cpost:</c> ids (one parent per item in Jellyfin).</summary>
|
||||
private async Task<List<ChannelItemInfo>> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct)
|
||||
{
|
||||
var creator = _catalog.FindCreator(creatorId);
|
||||
var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false);
|
||||
// Anonymous listings flag everything as inaccessible; only mark locks when logged in.
|
||||
var markLocked = _catalog.HasSession;
|
||||
return posts.Select(p => ToItem(p, creator, markLocked)).ToList();
|
||||
return posts.Select(p => ToItem(p, creator, markLocked, ChannelPostPrefix + channelId + ":")).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the Floatplane post id from a channel item id (<c>post:<id></c> or
|
||||
/// <c>cpost:<channelId>:<id></c>); null for folders / foreign ids.
|
||||
/// </summary>
|
||||
internal static string? PostIdOf(string? externalId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (externalId.StartsWith(PostPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return externalId[PostPrefix.Length..];
|
||||
}
|
||||
|
||||
if (externalId.StartsWith(ChannelPostPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var rest = externalId[ChannelPostPrefix.Length..];
|
||||
var sep = rest.IndexOf(':', StringComparison.Ordinal);
|
||||
return sep > 0 && sep < rest.Length - 1 ? rest[(sep + 1)..] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Maps a post to a channel item. Internal for tests.</summary>
|
||||
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true)
|
||||
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true, string idPrefix = PostPrefix)
|
||||
{
|
||||
var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title;
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = PostPrefix + post.Id,
|
||||
Id = idPrefix + post.Id,
|
||||
Name = name,
|
||||
Type = ChannelItemType.Media,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
156
Jellyfin.Plugin.Floatplane/Channel/UserDataMirror.cs
Normal file
156
Jellyfin.Plugin.Floatplane/Channel/UserDataMirror.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// A post is listed twice — under "All videos" (<c>post:…</c>) and under its sub-channel
|
||||
/// (<c>cpost:…</c>) — because Jellyfin gives a channel item exactly one parent. Jellyfin
|
||||
/// therefore keeps two sets of user data; this service copies played / resume state from
|
||||
/// one copy to the other whenever it changes (pattern borrowed from chaosflix-jellyfin).
|
||||
/// </summary>
|
||||
public sealed class UserDataMirror : IHostedService
|
||||
{
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<UserDataMirror> _logger;
|
||||
|
||||
// UserDataSaved is raised from inside SaveUserData, so our own mirrored save comes
|
||||
// back as an event; the copies being written are tracked here while it runs.
|
||||
private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new();
|
||||
|
||||
public UserDataMirror(IUserDataManager userDataManager, ILibraryManager libraryManager, IUserManager userManager, ILogger<UserDataMirror> logger)
|
||||
{
|
||||
_userDataManager = userDataManager;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved += OnUserDataSaved;
|
||||
_logger.LogDebug("Floatplane user-data mirror active");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Mirror(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not mirror watch state of {Item}", e.Item?.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private void Mirror(UserDataSaveEventArgs e)
|
||||
{
|
||||
var item = e.Item;
|
||||
var source = e.UserData;
|
||||
if (item is null || source is null || !IsWatchState(e.SaveReason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var postId = FloatplaneChannel.PostIdOf(item.ExternalId);
|
||||
if (postId is null || _mirroring.ContainsKey((e.UserId, item.Id)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(e.UserId);
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var sibling in Siblings(item, postId))
|
||||
{
|
||||
var target = _userDataManager.GetUserData(user, sibling);
|
||||
if (target is null || !Differs(source, target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
target.Played = source.Played;
|
||||
target.PlaybackPositionTicks = source.PlaybackPositionTicks;
|
||||
target.PlayCount = source.PlayCount;
|
||||
target.LastPlayedDate = source.LastPlayedDate;
|
||||
|
||||
if (!_mirroring.TryAdd((e.UserId, sibling.Id), 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_userDataManager.SaveUserData(user, sibling, target, UserDataSaveReason.UpdateUserData, CancellationToken.None);
|
||||
_logger.LogDebug("Mirrored watch state of {Post} to its other copy", postId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mirroring.TryRemove((e.UserId, sibling.Id), out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWatchState(UserDataSaveReason reason) => reason is
|
||||
UserDataSaveReason.PlaybackStart or
|
||||
UserDataSaveReason.PlaybackProgress or
|
||||
UserDataSaveReason.PlaybackFinished or
|
||||
UserDataSaveReason.TogglePlayed or
|
||||
UserDataSaveReason.UpdateUserData;
|
||||
|
||||
private static bool Differs(UserItemData source, UserItemData target) =>
|
||||
source.Played != target.Played ||
|
||||
source.PlaybackPositionTicks != target.PlaybackPositionTicks ||
|
||||
source.PlayCount != target.PlayCount ||
|
||||
source.LastPlayedDate != target.LastPlayedDate;
|
||||
|
||||
private IEnumerable<BaseItem> Siblings(BaseItem item, string postId)
|
||||
{
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
Name = item.Name,
|
||||
ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty<Guid>() : new[] { item.ChannelId }
|
||||
};
|
||||
|
||||
IReadOnlyList<BaseItem> candidates;
|
||||
try
|
||||
{
|
||||
candidates = _libraryManager.GetItemList(query);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not look up the other copy of {Item}", item.Name);
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
return candidates
|
||||
.Where(c => !c.Id.Equals(item.Id) && string.Equals(FloatplaneChannel.PostIdOf(c.ExternalId), postId, StringComparison.Ordinal))
|
||||
// Through the library cache so the instance the server serves gets the new data too.
|
||||
.Select(c => _libraryManager.GetItemById(c.Id) ?? c)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user