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;
///
/// 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 related-<guid>, 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.
///
public class ChaosflixSimilarItems : ILocalSimilarItemsProvider
{
/// How many related talks are offered when the caller names no limit.
internal const int DefaultLimit = 12;
private readonly CccApiClient _apiClient;
private readonly ILibraryManager _libraryManager;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
public ChaosflixSimilarItems(
CccApiClient apiClient, ILibraryManager libraryManager, ILogger logger)
{
_apiClient = apiClient;
_libraryManager = libraryManager;
_logger = logger;
}
///
public string Name => ChaosflixChannel.ChannelName;
///
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
///
/// Gets how long Jellyfin may reuse an answer. The recommendations change when
/// media.ccc.de recomputes them, which is far slower than people browse.
///
public TimeSpan? CacheDuration => TimeSpan.FromHours(6);
///
public bool Supports(Type itemType) => typeof(Video).IsAssignableFrom(itemType);
///
public async Task> GetSimilarItemsAsync(
BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken)
{
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
if (eventGuid == null)
{
return Array.Empty();
}
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
if (cccEvent?.Related == null || cccEvent.Related.Count == 0)
{
return Array.Empty();
}
var stored = StoredTalks(item.ChannelId);
var excluded = query.ExcludeItemIds?.ToHashSet() ?? new HashSet();
var similar = cccEvent.Related
.OrderByDescending(r => r.Weight)
.Select(r => stored.GetValueOrDefault(r.EventGuid))
.OfType()
.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;
}
///
/// 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.
///
private Dictionary StoredTalks(Guid channelId)
{
var talks = new Dictionary(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;
}
}