Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Api;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// Turns the weighted recommendations media.ccc.de publishes for a talk into the
|
||||
/// "More like this" row every Jellyfin client already shows.
|
||||
///
|
||||
/// The plugin has always fetched them and could already list them behind a folder id
|
||||
/// of <c>related-<guid></c>, but nothing ever handed out such a folder, so the
|
||||
/// feature was unreachable (#72). Answering here instead of emitting folders is what
|
||||
/// keeps it from making the duplicate problem worse: a channel item is stored under
|
||||
/// exactly one parent, so every folder of related talks would add another library
|
||||
/// item for a talk that already exists (#54, #15). This adds none.
|
||||
/// </summary>
|
||||
public class ChaosflixSimilarItems : ILocalSimilarItemsProvider
|
||||
{
|
||||
/// <summary>How many related talks are offered when the caller names no limit.</summary>
|
||||
internal const int DefaultLimit = 12;
|
||||
|
||||
private readonly CccApiClient _apiClient;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<ChaosflixSimilarItems> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixSimilarItems"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixSimilarItems(
|
||||
CccApiClient apiClient, ILibraryManager libraryManager, ILogger<ChaosflixSimilarItems> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => ChaosflixChannel.ChannelName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Gets how long Jellyfin may reuse an answer. The recommendations change when
|
||||
/// media.ccc.de recomputes them, which is far slower than people browse.
|
||||
/// </summary>
|
||||
public TimeSpan? CacheDuration => TimeSpan.FromHours(6);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(Type itemType) => typeof(Video).IsAssignableFrom(itemType);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
|
||||
BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
|
||||
if (eventGuid == null)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (cccEvent?.Related == null || cccEvent.Related.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
var stored = StoredTalks(item.ChannelId);
|
||||
var excluded = query.ExcludeItemIds?.ToHashSet() ?? new HashSet<Guid>();
|
||||
|
||||
var similar = cccEvent.Related
|
||||
.OrderByDescending(r => r.Weight)
|
||||
.Select(r => stored.GetValueOrDefault(r.EventGuid))
|
||||
.OfType<BaseItem>()
|
||||
.Where(i => i.Id != item.Id && !excluded.Contains(i.Id))
|
||||
.Take(query.Limit ?? DefaultLimit)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Chaosflix: {Stored} of {Related} related talks of {Title} are in the library",
|
||||
similar.Count,
|
||||
cccEvent.Related.Count,
|
||||
item.Name);
|
||||
|
||||
return similar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The talks of this channel that Jellyfin has stored, by CCC event guid. Only a
|
||||
/// talk somebody has browsed to — or that the sync walked in (#8) — is in here at
|
||||
/// all; a recommendation pointing anywhere else is simply left out, because a
|
||||
/// similar-items provider can only answer with items that exist.
|
||||
/// </summary>
|
||||
private Dictionary<string, BaseItem> StoredTalks(Guid channelId)
|
||||
{
|
||||
var talks = new Dictionary<string, BaseItem>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var stored in _libraryManager.GetItemList(new InternalItemsQuery { ChannelIds = [channelId] }))
|
||||
{
|
||||
var guid = ChaosflixChannel.EventGuidOf(stored.ExternalId);
|
||||
if (guid == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A talk is its own library item in every folder that lists it, so the same
|
||||
// guid turns up several times. The conference copy is the canonical one.
|
||||
if (!talks.ContainsKey(guid) || ChaosflixChannel.IsConferenceCopy(stored.ExternalId))
|
||||
{
|
||||
talks[guid] = stored;
|
||||
}
|
||||
}
|
||||
|
||||
return talks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Api;
|
||||
using Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Channels;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task that refreshes the CCC API cache for the conferences the user follows
|
||||
/// and then walks them once, so Jellyfin stores their talks as library items. Entries are
|
||||
/// replaced one at a time, so cached event details and resolved CDN redirects survive a run.
|
||||
///
|
||||
/// The walk is what makes a talk findable by name: Jellyfin creates a channel item the
|
||||
/// first time a client asks for the folder holding it and never before (measured — neither
|
||||
/// its own channel refresh nor a library scan descends into channel content), so without
|
||||
/// this a talk is only searchable once somebody has clicked their way to it (#8).
|
||||
/// </summary>
|
||||
public class ChaosflixSyncTask : IScheduledTask
|
||||
{
|
||||
/// <summary>
|
||||
/// Interval between runs. <see cref="CccApiClient.ConferenceCacheTtl"/> outlives it, so the
|
||||
/// data this task caches is still there when the next run becomes due.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan SyncInterval = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>
|
||||
/// Identifier the server files this task under; the status endpoint looks it up by it.
|
||||
/// </summary>
|
||||
public const string TaskKey = "ChaosflixSync";
|
||||
|
||||
/// <summary>
|
||||
/// How many conferences are refreshed and walked when the user has named none. The
|
||||
/// whole catalogue is several hundred conferences, and expanding every one of them
|
||||
/// four times a day would be a poor way to treat an API the CCC runs on donated time.
|
||||
/// Naming conferences in the settings lifts this: then exactly those are walked (#86).
|
||||
/// </summary>
|
||||
internal const int DefaultConferenceCount = 20;
|
||||
|
||||
private readonly CccApiClient _apiClient;
|
||||
private readonly IChannelManager _channelManager;
|
||||
private readonly ILogger<ChaosflixSyncTask> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixSyncTask"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixSyncTask(
|
||||
CccApiClient apiClient, IChannelManager channelManager, ILogger<ChaosflixSyncTask> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_channelManager = channelManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Chaosflix: Sync CCC Media";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => TaskKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Refreshes the conference and talk cache from media.ccc.de";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "Chaosflix";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||
IntervalTicks = SyncInterval.Ticks
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Chaosflix sync: starting cache refresh");
|
||||
progress.Report(0);
|
||||
|
||||
var selected = await RefreshConferencesAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||
await IngestAsync(selected, progress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private async Task<List<CccConference>> RefreshConferencesAsync(
|
||||
IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var all = await _apiClient.RefreshConferencesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Chaosflix sync: found {Count} conferences", all.Count);
|
||||
progress.Report(5);
|
||||
|
||||
var filter = Plugin.Instance?.Configuration.ConferenceFilter;
|
||||
var selected = ChaosflixChannel.SelectConferences(all, filter)
|
||||
.Where(c => c.EventLastReleasedAt != null)
|
||||
.OrderByDescending(c => c.EventLastReleasedAt)
|
||||
.ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
selected = selected.Take(DefaultConferenceCount).ToList();
|
||||
}
|
||||
|
||||
var refreshed = 0;
|
||||
for (var i = 0; i < selected.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var conf = selected[i];
|
||||
try
|
||||
{
|
||||
var detail = await _apiClient.RefreshConferenceAsync(conf.Acronym, cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
_logger.LogDebug("Chaosflix sync: cached {Title} ({EventCount} events)",
|
||||
conf.Title, detail?.Events?.Count ?? 0);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Chaosflix sync: failed to fetch {Acronym}", conf.Acronym);
|
||||
}
|
||||
|
||||
progress.Report(5 + (55.0 * (i + 1) / selected.Count));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Chaosflix sync: completed — {Count} of {Total} conferences cached",
|
||||
refreshed, selected.Count);
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks Jellyfin for the children of every selected conference folder, which is what
|
||||
/// makes it store their talks. The walk goes top down because a folder can only be
|
||||
/// addressed by the item id Jellyfin gave it, and that id does not exist until its
|
||||
/// parent has been listed once.
|
||||
/// </summary>
|
||||
private async Task IngestAsync(
|
||||
List<CccConference> conferences, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (conferences.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = await _channelManager.GetChannelsInternalAsync(new ChannelQuery()).ConfigureAwait(false);
|
||||
var channel = channels.Items
|
||||
.FirstOrDefault(c => string.Equals(c.Name, ChaosflixChannel.ChannelName, StringComparison.Ordinal));
|
||||
|
||||
if (channel == null)
|
||||
{
|
||||
_logger.LogDebug("Chaosflix sync: the channel is not registered yet, nothing to ingest");
|
||||
return;
|
||||
}
|
||||
|
||||
var wanted = conferences
|
||||
.Select(c => ChaosflixChannel.ConferenceFolderId(c.Acronym))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var root = await ChildrenAsync(channel.Id, Guid.Empty, cancellationToken).ConfigureAwait(false);
|
||||
var years = root.FirstOrDefault(i =>
|
||||
string.Equals(i.ExternalId, ChaosflixChannel.FolderBrowseByYear, StringComparison.Ordinal));
|
||||
|
||||
if (years == null)
|
||||
{
|
||||
_logger.LogWarning("Chaosflix sync: the channel root has no year folder, skipping the walk");
|
||||
return;
|
||||
}
|
||||
|
||||
var yearFolders = await ChildrenAsync(channel.Id, years.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var walked = 0;
|
||||
foreach (var yearFolder in yearFolders)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var conferenceFolders = await ChildrenAsync(channel.Id, yearFolder.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var folder in conferenceFolders.Where(f => wanted.Contains(f.ExternalId ?? string.Empty)))
|
||||
{
|
||||
var talks = await ChildrenAsync(channel.Id, folder.Id, cancellationToken).ConfigureAwait(false);
|
||||
walked++;
|
||||
_logger.LogDebug("Chaosflix sync: stored {Count} talks of {Conference}",
|
||||
talks.Count, folder.Name);
|
||||
|
||||
progress.Report(60 + (40.0 * walked / conferences.Count));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Chaosflix sync: walked {Count} of {Total} conferences into the library",
|
||||
walked, conferences.Count);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BaseItem>> ChildrenAsync(
|
||||
Guid channelId, Guid parentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
ChannelIds = [channelId],
|
||||
ParentId = parentId
|
||||
};
|
||||
|
||||
var result = await _channelManager
|
||||
.GetChannelItemsInternal(query, new Progress<double>(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result.Items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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.Chaosflix.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the watch state of one talk in sync across the copies the channel
|
||||
/// hands out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Jellyfin stores a channel item as one library item with exactly one parent,
|
||||
/// so a talk listed in several folders needs one id per folder (#15) and ends up
|
||||
/// as several library items — each with its own user data. Marking a talk
|
||||
/// unwatched or clearing its position then only affects the copy in front of the
|
||||
/// user (#53). This mirrors played state and resume position from the copy that
|
||||
/// was saved to its siblings.
|
||||
/// </remarks>
|
||||
public sealed class ChaosflixUserDataMirror : IHostedService
|
||||
{
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<ChaosflixUserDataMirror> _logger;
|
||||
|
||||
// Jellyfin raises UserDataSaved from inside SaveUserData, so a mirrored save
|
||||
// comes back as another event; the copies it is about are in here while it runs.
|
||||
private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixUserDataMirror"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userDataManager">Source of the save events and sink for the mirrored state.</param>
|
||||
/// <param name="libraryManager">Used to find the other copies of a talk.</param>
|
||||
/// <param name="userManager">Resolves the user a save belongs to.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ChaosflixUserDataMirror(
|
||||
IUserDataManager userDataManager,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
ILogger<ChaosflixUserDataMirror> logger)
|
||||
{
|
||||
_userDataManager = userDataManager;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved += OnUserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Mirror(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not mirror the watch state of {Item}", e.Item?.Name);
|
||||
}
|
||||
}
|
||||
|
||||
internal void Mirror(UserDataSaveEventArgs e)
|
||||
{
|
||||
var item = e.Item;
|
||||
var source = e.UserData;
|
||||
if (item is null || source is null || !IsWatchState(e.SaveReason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
|
||||
if (eventGuid 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, eventGuid))
|
||||
{
|
||||
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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mirroring.TryRemove((e.UserId, sibling.Id), out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import is a bulk reason and carries no user action; the rest is what a
|
||||
// client does when it plays a talk or toggles its watched state.
|
||||
private static bool IsWatchState(UserDataSaveReason reason) => reason is
|
||||
UserDataSaveReason.PlaybackStart or
|
||||
UserDataSaveReason.PlaybackProgress or
|
||||
UserDataSaveReason.PlaybackFinished or
|
||||
UserDataSaveReason.TogglePlayed or
|
||||
UserDataSaveReason.UpdateUserData;
|
||||
|
||||
// A save whose state already matches produces no save of its own, which is
|
||||
// what stops a mirrored save from travelling back and forth.
|
||||
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 eventGuid)
|
||||
{
|
||||
// Looked up by name within the channel rather than by scanning every
|
||||
// channel item: the scopes a talk can appear under are open-ended
|
||||
// (related-<guid> exists per talk), so its other ids cannot be enumerated.
|
||||
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 copies of {Item}", item.Name);
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
return candidates
|
||||
.Where(candidate =>
|
||||
!candidate.Id.Equals(item.Id) &&
|
||||
string.Equals(ChaosflixChannel.EventGuidOf(candidate.ExternalId), eventGuid, StringComparison.Ordinal))
|
||||
// Through the library cache, not as the query materialised them: user
|
||||
// data is read off the BaseItem instance the cache holds, so saving
|
||||
// against a fresh instance reaches the database but leaves what the
|
||||
// server serves stale until a restart.
|
||||
.Select(candidate => _libraryManager.GetItemById(candidate.Id) ?? candidate);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user