1166 lines
38 KiB
C#
1166 lines
38 KiB
C#
#pragma warning disable CA1002
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using Jellyfin.Data.Entities;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Controller.LiveTv;
|
|
using MediaBrowser.Controller.Persistence;
|
|
using MediaBrowser.Model.Dto;
|
|
using MediaBrowser.Model.Entities;
|
|
using MediaBrowser.Model.MediaInfo;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Primitives;
|
|
using CinemaLib.API;
|
|
using MediaBrowser.Controller;
|
|
using MediaBrowser.Controller.MediaEncoding;
|
|
using System.Runtime.CompilerServices;
|
|
using LinkGenerator = CinemaLib.Webshare.LinkGenerator;
|
|
using MediaBrowser.Controller.Configuration;
|
|
|
|
namespace Jellyfin.Plugin.Cinema;
|
|
|
|
sealed class CinemaMediaSourceManager : IMediaSourceManager
|
|
{
|
|
private const double BitrateMargin = 0.1; // 10 %
|
|
private const double WebshareFreeBitrate = 300000 * 8;
|
|
|
|
private const int FirstRelativeVideoIndex = -2;
|
|
private const int FirstRelativeAudioIndex = -5;
|
|
private const int FirstRelativeSubtitleIndex = -15;
|
|
|
|
private const int MediaInspectionMinFileSize = 65536 * 2 + 16384;
|
|
private const int MediaInspectionBaseSize = 65536 * 2;
|
|
private const int MediaInspectionSizeDivisor = 1024 * 1024 / 64;
|
|
private static readonly Uri FakeVideoUri = new Uri("https://127.0.0.1");
|
|
|
|
internal const string ContextItemsMediaSourceIdKey = "mediaSource-9ad56bce";
|
|
|
|
private static readonly TimeSpan VersionValidityTimeout = TimeSpan.FromMinutes(180);
|
|
private static readonly TimeSpan LinkValidityTimeout = VersionValidityTimeout;
|
|
internal static readonly TimeSpan SubfolderValidityTimeout = VersionValidityTimeout;
|
|
|
|
private static CinemaMediaSourceManager? _instance;
|
|
|
|
private readonly IMediaSourceManager _inner;
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly IServerApplicationHost _host;
|
|
private readonly IHttpContextAccessor _http;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly IMediaEncoder _mediaEncoder;
|
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
|
private readonly IUserManager _userManager;
|
|
private readonly ConcurrentDictionary<Guid, VersionSetEntry> _videoVersions;
|
|
private readonly ConcurrentDictionary<LinkKey, LinkEntry> _links;
|
|
|
|
public CinemaMediaSourceManager(CinemaInnerMediaSourceManager innerMediaSourceManager, ILibraryManager libraryManager, IServerApplicationHost host,
|
|
IServiceProvider svc, IHttpContextAccessor http, IHttpClientFactory httpClientFactory, IMediaEncoder mediaEncoder,
|
|
IServerConfigurationManager serverConfigurationManager, IUserManager userManager)
|
|
{
|
|
if (innerMediaSourceManager == null || svc == null)
|
|
throw new ArgumentNullException();
|
|
|
|
IMediaSourceManager? inner = svc.GetService(innerMediaSourceManager.InnerType) as IMediaSourceManager;
|
|
if (inner == null)
|
|
throw new InvalidOperationException("Original MediaSourceManager service not found.");
|
|
this._inner = inner;
|
|
|
|
this._libraryManager = libraryManager;
|
|
this._host = host;
|
|
this._http = http;
|
|
this._httpClientFactory = httpClientFactory;
|
|
this._mediaEncoder = mediaEncoder;
|
|
this._serverConfigurationManager = serverConfigurationManager;
|
|
this._userManager = userManager;
|
|
this._videoVersions = new ConcurrentDictionary<Guid, VersionSetEntry>();
|
|
this._links = new ConcurrentDictionary<LinkKey, LinkEntry>();
|
|
|
|
_instance = this;
|
|
}
|
|
|
|
internal static bool TryGetInstance([NotNullWhen(true)] out CinemaMediaSourceManager? instance)
|
|
{
|
|
instance = _instance;
|
|
return instance != null;
|
|
}
|
|
|
|
#region IMediaSourceManager Members
|
|
|
|
public Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, string cacheKey, bool addProbeDelay, bool isLiveStream, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.AddMediaInfoWithProbe(mediaSource, isAudio, cacheKey, addProbeDelay, isLiveStream, cancellationToken);
|
|
}
|
|
|
|
public void AddParts(IEnumerable<IMediaSourceProvider> providers)
|
|
{
|
|
_inner.AddParts(providers);
|
|
}
|
|
|
|
public Task CloseLiveStream(string id)
|
|
{
|
|
return _inner.CloseLiveStream(id);
|
|
}
|
|
|
|
public Task<MediaSourceInfo> GetLiveStream(string id, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.GetLiveStream(id, cancellationToken);
|
|
}
|
|
|
|
public ILiveStream GetLiveStreamInfo(string id)
|
|
{
|
|
return _inner.GetLiveStreamInfo(id);
|
|
}
|
|
|
|
public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId)
|
|
{
|
|
return _inner.GetLiveStreamInfoByUniqueId(uniqueId);
|
|
}
|
|
|
|
public Task<MediaSourceInfo> GetLiveStreamMediaInfo(string id, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.GetLiveStreamMediaInfo(id, cancellationToken);
|
|
}
|
|
|
|
public Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.GetLiveStreamWithDirectStreamProvider(id, cancellationToken);
|
|
}
|
|
|
|
public List<MediaAttachment> GetMediaAttachments(Guid itemId)
|
|
{
|
|
return _inner.GetMediaAttachments(itemId);
|
|
}
|
|
|
|
public List<MediaAttachment> GetMediaAttachments(MediaAttachmentQuery query)
|
|
{
|
|
return _inner.GetMediaAttachments(query);
|
|
}
|
|
|
|
public Task<MediaSourceInfo> GetMediaSource(BaseItem item, string mediaSourceId, string liveStreamId, bool enablePathSubstitution, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.GetMediaSource(item, mediaSourceId, liveStreamId, enablePathSubstitution, cancellationToken);
|
|
}
|
|
|
|
public List<MediaStream> GetMediaStreams(Guid itemId)
|
|
{
|
|
return _inner.GetMediaStreams(itemId);
|
|
}
|
|
|
|
public List<MediaStream> GetMediaStreams(MediaStreamQuery query)
|
|
{
|
|
return _inner.GetMediaStreams(query);
|
|
}
|
|
|
|
public MediaProtocol GetPathProtocol(string path)
|
|
{
|
|
return _inner.GetPathProtocol(path);
|
|
}
|
|
|
|
public Task<List<MediaSourceInfo>> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.GetRecordingStreamMediaSources(info, cancellationToken);
|
|
}
|
|
|
|
public List<MediaSourceInfo> GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User? user = null)
|
|
{
|
|
// Intercept for CinemaItems
|
|
if (item == null
|
|
|| !CinemaQueryExtensions.IsCinemaExternalId(item.ExternalId)
|
|
|| string.IsNullOrEmpty(item.ExternalId))
|
|
return _inner.GetStaticMediaSources(item, enablePathSubstitution, user);
|
|
|
|
List<MediaSourceInfo> result = GetPlaybackMediaSourcesInternal(item, user, false, enablePathSubstitution, needsPreciseStreamIndicies: false, default).GetAwaiter().GetResult();
|
|
|
|
// HACK Prevent crash
|
|
if (result.Count == 0)
|
|
result.Append(new MediaSourceInfo() { MediaStreams = new List<MediaStream>() });
|
|
|
|
return result;
|
|
}
|
|
|
|
public Task<List<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User? user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
|
|
{
|
|
return GetPlaybackMediaSourcesInternal(item, user, allowMediaProbe, enablePathSubstitution, needsPreciseStreamIndicies: true, cancellationToken);
|
|
}
|
|
|
|
private async Task<List<MediaSourceInfo>> GetPlaybackMediaSourcesInternal(BaseItem item, User? user, bool allowMediaProbe, bool enablePathSubstitution, bool needsPreciseStreamIndicies, CancellationToken cancellationToken)
|
|
{
|
|
// Intercept for CinemaItems
|
|
if (item == null
|
|
|| !CinemaQueryExtensions.IsCinemaExternalId(item.ExternalId)
|
|
|| string.IsNullOrEmpty(item.ExternalId))
|
|
return await _inner.GetPlaybackMediaSources(item, user, allowMediaProbe, enablePathSubstitution, cancellationToken);
|
|
|
|
List<MediaSourceInfo> result = new List<MediaSourceInfo>();
|
|
switch (item)
|
|
{
|
|
case Video video:
|
|
VersionSetEntry ver = await GetVersionSet(video, out BaseItem? videoPrimary, out string? csId, default);
|
|
if (ver.Versions != null)
|
|
{
|
|
bool isFreeAccount = await CinemaHost.IsWebshareFreeAccount(cancellationToken);
|
|
HttpContext? ctx = _http.HttpContext;
|
|
|
|
IEnumerable<Video> items;
|
|
bool sortByPrefs;
|
|
switch (video)
|
|
{
|
|
case CinemaMovie movie: items = GetVideoVersionsEnumerate(csId!, movie, videoPrimary!, ver.Versions); sortByPrefs = false; break;
|
|
case CinemaEpisode episode: items = GetVideoVersionsEnumerate(csId!, episode, videoPrimary!, ver.Versions); sortByPrefs = true; break;
|
|
case MusicVideo musicVideo: items = GetVideoVersionsEnumerate(csId!, musicVideo, videoPrimary!, ver.Versions); sortByPrefs = false; break;
|
|
default: throw new NotSupportedException(string.Format("BaseItem type '{0}' not supported in CinemaMediaSources.", video.GetType().Name));
|
|
}
|
|
|
|
// HACK: Currently Jellyfin behaves extremely stupid and even if we offer alternative
|
|
// versions, even sorted versions with best coming first it always comes back asking
|
|
// for the primary version. So ignore mediaSourceId and always return single best version
|
|
// with faked id.
|
|
// HACK: One of our callers (GetMasterHlsVideoPlaylist) does not bother resolving current user
|
|
string? userIdS;
|
|
if (sortByPrefs && user == null && ctx?.User != null && (userIdS = ctx.User.Claims.Where(x => x.Type == "Jellyfin-UserId").Select(x => x.Value).FirstOrDefault()) != null && Guid.TryParse(userIdS, out Guid userId))
|
|
user = _userManager.GetUserById(userId);
|
|
|
|
int[]? scores;
|
|
int bestIdx = -1;
|
|
if (sortByPrefs && ver.Versions.Length != 0 && (scores = ScoreVersionsByPreferences(user, ver.Versions, isFreeAccount)) != null)
|
|
{
|
|
// The output must be stable with score-ties so sort also by _id
|
|
int delta;
|
|
bestIdx = 0;
|
|
for (int i = 0; i < scores.Length; i++)
|
|
if ((delta = scores[i] - scores[bestIdx]) > 0 || (delta == 0 && string.CompareOrdinal(ver.Versions[i].Meta._id, ver.Versions[bestIdx].Meta._id) < 0))
|
|
bestIdx = i;
|
|
}
|
|
|
|
if (needsPreciseStreamIndicies)
|
|
{
|
|
// We need to directly inspect the video file and therefore need to know which
|
|
// version to specifically return. For free accounts it is not possible to just
|
|
// read files at random, for VIP accounts that would slow playback startup also.
|
|
Guid mediaSourceId;
|
|
if (ctx == null
|
|
|| !ctx.Items.TryGetValue(ContextItemsMediaSourceIdKey, out object? mediaSourceIdO)
|
|
|| mediaSourceIdO is not string mediaSourceIdS
|
|
|| (!Guid.TryParse(mediaSourceIdS, out mediaSourceId)))
|
|
throw new InvalidOperationException("For precise stream indexing knowing mediaSourceId is required.");
|
|
|
|
// Warning: We assume GetVideoVersionsEnumerate keeps the order between its input and output collections
|
|
int idxVer = 0;
|
|
Video? videoVer = null;
|
|
foreach (Video i in items)
|
|
if ((!sortByPrefs && i.Id == mediaSourceId)
|
|
|| (sortByPrefs && idxVer == bestIdx))
|
|
{
|
|
videoVer = i;
|
|
break;
|
|
}
|
|
else
|
|
idxVer++;
|
|
if (videoVer == null)
|
|
// Version not found, return empty set
|
|
break;
|
|
|
|
// Get few first kb of the file that is going to be played anyway and
|
|
// inspect it for available streams.
|
|
VersionEntry metaVer = ver.Versions[idxVer];
|
|
Uri link = await GenerateLink(metaVer.Meta.provider, metaVer.Meta.ident, metaVer.Meta.name, cancellationToken);
|
|
if (metaVer.AnalyzedInfo == null)
|
|
metaVer.AnalyzedInfo = await CinemaMediaAnalyzer.AnalyzeResourceAsync(link, metaVer.Meta.size, metaVer.Meta, _httpClientFactory, _mediaEncoder, _serverConfigurationManager, cancellationToken);
|
|
result.Add(GetVersionInfo(videoVer, metaVer.Meta, metaVer.AnalyzedInfo.MediaStreams, isFreeAccount, link));
|
|
}
|
|
else
|
|
{
|
|
// Warning: We assume GetVideoVersionsEnumerate keeps the order between its input and output collections
|
|
// Note: Also makes sure BaseItems exist for each version
|
|
int idx = 0;
|
|
foreach (Video i in items)
|
|
result.Add(GetVersionInfo(i, ver.Versions[idx++].Meta, null, isFreeAccount, FakeVideoUri));
|
|
|
|
if (sortByPrefs && ver.Versions.Length != 0 && bestIdx >= 0)
|
|
{
|
|
MediaSourceInfo best = result[bestIdx];
|
|
result.Clear();
|
|
result.Add(best);
|
|
}
|
|
}
|
|
|
|
// For episodes we must choose the audio/subtitle automatically as there is no UI for it
|
|
// and MediaInfoHelper.SetDeviceSpecificData and deeper StreamBuilder.BuildVideoItem does
|
|
// a poor job at selecting the proper streams
|
|
if (sortByPrefs)
|
|
{
|
|
foreach (MediaSourceInfo i in result)
|
|
{
|
|
ForceByPreferences(user, i, isFreeAccount);
|
|
i.Id = videoPrimary!.Id.ToString("N", CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public Task<LiveStreamResponse> OpenLiveStream(LiveStreamRequest request, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.OpenLiveStream(request, cancellationToken);
|
|
}
|
|
|
|
public Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.OpenLiveStreamInternal(request, cancellationToken);
|
|
}
|
|
|
|
public void SetDefaultAudioAndSubtitleStreamIndices(BaseItem item, MediaSourceInfo source, User user)
|
|
{
|
|
_inner.SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
|
|
}
|
|
|
|
public bool SupportsDirectStream(string path, MediaProtocol protocol)
|
|
{
|
|
return _inner.SupportsDirectStream(path, protocol);
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Sorts media verions by preferences as episodes currently do not support multiple versions
|
|
/// and the first one gets played.
|
|
/// </summary>
|
|
private static int[]? ScoreVersionsByPreferences(User? user, VersionEntry[] items, bool isFreeAccount)
|
|
{
|
|
if (user == null)
|
|
// We have no info about the user so nothing to sort
|
|
return null;
|
|
|
|
double? bitrateLimit = isFreeAccount ? WebshareFreeBitrate : null;
|
|
string? audioLang = user.AudioLanguagePreference;
|
|
string? subtitleLang = user.SubtitleLanguagePreference;
|
|
int[] scores = new int[items.Length];
|
|
for (int i = 0; i < items.Length; i++)
|
|
{
|
|
var m = items[i].Meta;
|
|
int score = 0;
|
|
|
|
StreamVideo? video = m.video?.FirstOrDefault();
|
|
if (video != null)
|
|
{
|
|
score += video.height / 100; // range [1..30]
|
|
score += video.hasHdr ? 3 : 0; // HDR is plus but resolution ust rule
|
|
}
|
|
|
|
if (m.subtitles != null && subtitleLang != null)
|
|
foreach (StreamSubtitle j in m.subtitles)
|
|
if (ISO639_1ToISO639_2(j.language) == subtitleLang)
|
|
{
|
|
score += 4; // proper subtitles are preferred but much better resulution still rules
|
|
break;
|
|
}
|
|
|
|
if (m.audio != null && audioLang != null)
|
|
foreach (StreamAudio j in m.audio)
|
|
if (ISO639_1ToISO639_2(j.language) == audioLang)
|
|
{
|
|
score += 25; // proper audio channel overrides resolution and other minor bonuses
|
|
break;
|
|
}
|
|
|
|
if (bitrateLimit != null && video != null)
|
|
{
|
|
double bitRate = (m.size ?? 0.0) * 8 / (video.duration ?? 1.0);
|
|
score += bitRate / (1 + BitrateMargin) < bitrateLimit.Value ? 50 : 0; // without proper bitrate it is not playable at all
|
|
}
|
|
|
|
scores[i] = score;
|
|
}
|
|
|
|
return scores;
|
|
}
|
|
|
|
private static void ForceByPreferences(User? user, MediaSourceInfo item, bool isFreeAccount)
|
|
{
|
|
if (user == null)
|
|
// We have no info about the user so nothing to force
|
|
return;
|
|
|
|
string? audioLang = user.AudioLanguagePreference;
|
|
// Free account prevents seeking and thus subtitle exctraction
|
|
string? subtitleLang = isFreeAccount ? null : user.SubtitleLanguagePreference;
|
|
|
|
foreach (MediaStream i in item.MediaStreams)
|
|
{
|
|
// We cannot use DefaultAudioStreamIndex and DefaultSubtitleStreamIndex as we do not
|
|
// have absolute stream index values in i.Index. Thankfully MediaSourceInfo.GetDefaultAudioStream
|
|
// uses IsDefault as a fallback.
|
|
if (i.Type == MediaStreamType.Audio && audioLang != null && audioLang == i.Language)
|
|
i.IsDefault = true;
|
|
else if (i.Type == MediaStreamType.Subtitle && subtitleLang != null && subtitleLang == i.Language)
|
|
i.IsDefault = true;
|
|
else
|
|
i.IsDefault = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets stream metadata for a video version (ie. for Movie or Episode).
|
|
/// </summary>
|
|
internal async Task<IEnumerable<T>?> GetVideoVersions<T>(T item, CancellationToken cancel)
|
|
where T : Video, new()
|
|
{
|
|
if (item == null)
|
|
throw new ArgumentNullException();
|
|
|
|
VersionSetEntry ver = await GetVersionSet(item, out BaseItem? primary, out string? csId, cancel);
|
|
|
|
return ver.Versions == null ? null : GetVideoVersionsEnumerate(csId!, item, primary!, ver.Versions);
|
|
}
|
|
|
|
private IEnumerable<T> GetVideoVersionsEnumerate<T>(string csId, T item, BaseItem primary, VersionEntry[] versions)
|
|
where T : Video, new()
|
|
{
|
|
bool isFirst = true;
|
|
foreach (var i in versions)
|
|
{
|
|
T a;
|
|
if (isFirst)
|
|
{
|
|
// The primary item represents the first version
|
|
a = (T)primary;
|
|
isFirst = false;
|
|
}
|
|
else
|
|
{
|
|
a = CinemaQueryExtensions.GetMediaItemById<T>(csId, i.Meta._id, out bool isNew);
|
|
if (isNew)
|
|
{
|
|
// Copy properties from the parent version
|
|
// Note: non-primary versions are never persisted so a new volatile instance got created
|
|
Guid aId = a.Id;
|
|
item.DeepCopy(a);
|
|
a.Id = aId;
|
|
a.SetPrimaryVersionId(item.Id.ToString("N", CultureInfo.InvariantCulture));
|
|
|
|
// Just a volatile item
|
|
_libraryManager.RegisterItem(a);
|
|
}
|
|
}
|
|
yield return a;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a version set for a video.
|
|
/// </summary>
|
|
private Task<VersionSetEntry> GetVersionSet(Video item, out BaseItem? primary, out string? csId, CancellationToken cancel)
|
|
{
|
|
if (item == null)
|
|
throw new ArgumentNullException();
|
|
|
|
if (!CinemaQueryExtensions.IsCinemaExternalId(item.ExternalId)
|
|
|| string.IsNullOrEmpty(item.ExternalId))
|
|
{
|
|
// Not a Stream Cinema video
|
|
primary = null;
|
|
csId = null;
|
|
return Task.FromResult<VersionSetEntry>(default);
|
|
}
|
|
|
|
Guid primaryId;
|
|
string externalId;
|
|
if (!string.IsNullOrEmpty(item.PrimaryVersionId))
|
|
{
|
|
// Move to the primary item
|
|
primaryId = Guid.Parse(item.PrimaryVersionId, CultureInfo.InvariantCulture);
|
|
primary = _libraryManager.GetItemById(primaryId);
|
|
if (primary == null
|
|
|| !CinemaQueryExtensions.IsCinemaExternalId(primary.ExternalId)
|
|
|| string.IsNullOrEmpty(primary.ExternalId))
|
|
{
|
|
// Not a Stream Cinema video
|
|
csId = null;
|
|
return Task.FromResult<VersionSetEntry>(default);
|
|
}
|
|
|
|
externalId = primary.ExternalId;
|
|
}
|
|
else
|
|
{
|
|
primary = item;
|
|
primaryId = item.Id;
|
|
externalId = item.ExternalId;
|
|
}
|
|
|
|
if (!CinemaQueryExtensions.TryGetCinemaIdFromExternalId(externalId, out csId))
|
|
throw new InvalidOperationException("Cannot parse Cinema identifier.");
|
|
|
|
return GetVersionSet(primaryId, csId, cancel);
|
|
}
|
|
|
|
private async Task<VersionSetEntry> GetVersionSet(Guid primaryId, string csId, CancellationToken cancel)
|
|
{
|
|
DateTime now = DateTime.UtcNow;
|
|
VersionSetEntry result;
|
|
if (!_videoVersions.TryGetValue(primaryId, out result) || result.ValidUntil < now)
|
|
{
|
|
var a = await Metadata.StreamsAsync(csId, cancel);
|
|
if (a != null)
|
|
{
|
|
result.Versions = new VersionEntry[a.Length];
|
|
for (int i = 0; i < a.Length; i++)
|
|
result.Versions[i] = new VersionEntry(a[i]);
|
|
}
|
|
else
|
|
result.Versions = null;
|
|
result.ValidUntil = now + VersionValidityTimeout;
|
|
_videoVersions[primaryId] = result;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async Task<VersionEntry?> GetVersion(Video item, CancellationToken cancel)
|
|
{
|
|
VersionSetEntry vers = await GetVersionSet(item, out BaseItem? primary, out string? csId, cancel);
|
|
if (vers.Versions == null)
|
|
return null;
|
|
|
|
bool isFirst = true;
|
|
foreach (var i in vers.Versions)
|
|
{
|
|
Guid id;
|
|
if (isFirst)
|
|
{
|
|
// The primary item represents the first version
|
|
isFirst = false;
|
|
id = primary!.Id;
|
|
}
|
|
else
|
|
id = CinemaQueryExtensions.GetMediaItemId(csId!, i.Meta._id);
|
|
|
|
if (id == item.Id)
|
|
return i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public async Task<MediaSourceInfo?> GetVersionInfo(Video item, CancellationToken cancel)
|
|
{
|
|
if (item == null)
|
|
throw new ArgumentNullException();
|
|
|
|
VersionEntry? ver = await GetVersion(item, cancel);
|
|
if (ver == null)
|
|
return null;
|
|
|
|
bool isFreeAccount = await CinemaHost.IsWebshareFreeAccount(cancel);
|
|
|
|
return GetVersionInfo(item, ver.Meta, null, isFreeAccount, FakeVideoUri);
|
|
}
|
|
|
|
private MediaSourceInfo GetVersionInfo(Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams, bool isFreeAccount, Uri path)
|
|
{
|
|
MediaSourceInfo result = new MediaSourceInfo();
|
|
result.VideoType = VideoType.VideoFile;
|
|
result.Id = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
|
result.Protocol = MediaProtocol.Http;
|
|
// Warning: We set some properties on item that get used below
|
|
result.MediaStreams = VersionToMediaStreams(item, ver, analyzedStreams);
|
|
|
|
double bitRate = (ver.size ?? 0.0) * 8 / (item.RunTimeTicks ?? 1.0) * TimeSpan.TicksPerSecond;
|
|
bool showBitrateWarning = isFreeAccount && bitRate / (1 + BitrateMargin) > WebshareFreeBitrate;
|
|
|
|
string name = showBitrateWarning ? "LOGIN Webshare! " : "";
|
|
name += $"{Math.Round((double)(ver.size ?? 0) / (1024 * 1024 * 1024), 2)} GB ({ver.date_added?.ToShortDateString()})"
|
|
+ $" {Math.Round((item.RunTimeTicks ?? 0.0) / TimeSpan.TicksPerMinute)} min";
|
|
MediaStream? firstVid = result.MediaStreams.Where(x => x.Type == MediaStreamType.Video).FirstOrDefault();
|
|
if (firstVid != null)
|
|
{
|
|
var hdr = firstVid.VideoRangeType;
|
|
var hdr2 = firstVid.VideoDoViTitle;
|
|
string hdrS = hdr == Data.Enums.VideoRangeType.Unknown || hdr == Data.Enums.VideoRangeType.SDR
|
|
? " SDR"
|
|
: " HDR" + (string.IsNullOrEmpty(hdr2) ? "" : "[" + hdr2 + "]");
|
|
string is3DS = ver!.video!.First().is3d ? " (3D)" : "";
|
|
name += " " + firstVid.Width.ToString() + "x" + firstVid.Height.ToString() + hdrS + is3DS;
|
|
}
|
|
StringBuilder audioAndSub = new StringBuilder();
|
|
if (ver.audio != null)
|
|
{
|
|
foreach (StreamAudio i in ver.audio)
|
|
audioAndSub.Append($"{i.language} {i.codec} {i.channels}").Append(",");
|
|
if (audioAndSub.Length != 0)
|
|
{
|
|
audioAndSub.Remove(audioAndSub.Length - 1, 1);
|
|
name += " {" + audioAndSub + "}";
|
|
audioAndSub.Clear();
|
|
}
|
|
}
|
|
if (ver.subtitles != null)
|
|
{
|
|
foreach (StreamSubtitle i in ver.subtitles)
|
|
audioAndSub.Append($"{i.language}").Append(",");
|
|
if (audioAndSub.Length != 0)
|
|
{
|
|
audioAndSub.Remove(audioAndSub.Length - 1, 1);
|
|
name += " [" + audioAndSub + "]";
|
|
audioAndSub.Clear();
|
|
}
|
|
}
|
|
result.Name = name;
|
|
result.Path = path.ToString();
|
|
result.Container = item.Container;
|
|
result.RunTimeTicks = item.RunTimeTicks;
|
|
result.Size = item.Size;
|
|
|
|
// Propagate "-seekable 0" to ffmpeg as free accounts on Webshare cannot use "Range: bytes" HTTP header
|
|
// HACK: We misuse the user-agent as GetExtraArguments in MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
|
|
// does not properly escape its value for command line
|
|
if (isFreeAccount)
|
|
{
|
|
if (result.RequiredHttpHeaders == null)
|
|
result.RequiredHttpHeaders = new Dictionary<string, string>();
|
|
result.RequiredHttpHeaders["User-Agent"] = "Lavf/60\" -seekable \"0";
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static List<MediaStream> VersionToMediaStreams(Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams)
|
|
{
|
|
List<MediaStream> result = new List<MediaStream>();
|
|
|
|
if (analyzedStreams != null && analyzedStreams.Count != 0)
|
|
{
|
|
// Use the analyzed streams and just add external subtitles
|
|
result.AddRange(analyzedStreams);
|
|
if (ver.subtitles != null)
|
|
{
|
|
foreach (StreamSubtitle j in ver.subtitles)
|
|
{
|
|
if (!string.IsNullOrEmpty(j.src))
|
|
{
|
|
MediaStream a = new MediaStream();
|
|
a.Index = -1;
|
|
a.Type = MediaStreamType.Subtitle;
|
|
a.Language = ISO639_1ToISO639_2(j.language);
|
|
a.IsForced = j.forced;
|
|
a.DeliveryUrl = j.src;
|
|
a.IsExternalUrl = true;
|
|
result.Add(a);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// HACK: Propagate some values to the item also
|
|
item.Size = ver.size;
|
|
//stream_id;
|
|
//stream.name;
|
|
//stream.provider
|
|
//stream.ident;
|
|
//stream.media
|
|
//stream.date_added
|
|
|
|
// We must assign MediaStream.Index to something at least unique as this
|
|
// values is also passed from client (ie. to identify transcoding channel).
|
|
// Therefore use clearly insane values. Also -1 seems to be used on many places
|
|
// as "no channel" so start with -2.
|
|
if (ver.video != null)
|
|
{
|
|
int uniqueId = FirstRelativeVideoIndex;
|
|
foreach (StreamVideo j in ver.video)
|
|
{
|
|
MediaStream a = new MediaStream();
|
|
a.Index = uniqueId--;
|
|
a.Type = MediaStreamType.Video;
|
|
a.Width = j.width;
|
|
a.Height = j.height;
|
|
a.Codec = j.codec;
|
|
a.AspectRatio = j.aspect.ToString();
|
|
ConvertHdr(j.hasHdr, j.hdrQuality, a);
|
|
|
|
// HACK: Those values are read after we return (not before)
|
|
item.RunTimeTicks = (long?)(j.duration * TimeSpan.TicksPerSecond);
|
|
if (j.is3d)
|
|
// Just a probability guess
|
|
item.Video3DFormat = Video3DFormat.HalfSideBySide;
|
|
|
|
result.Add(a);
|
|
}
|
|
}
|
|
|
|
if (ver.audio != null)
|
|
{
|
|
int uniqueId = FirstRelativeAudioIndex;
|
|
foreach (StreamAudio j in ver.audio)
|
|
{
|
|
MediaStream a = new MediaStream();
|
|
a.Index = uniqueId--;
|
|
a.Type = MediaStreamType.Audio;
|
|
a.Language = ISO639_1ToISO639_2(j.language);
|
|
a.Codec = j.codec;
|
|
a.Channels = j.channels;
|
|
|
|
result.Add(a);
|
|
}
|
|
}
|
|
|
|
if (ver.subtitles != null)
|
|
{
|
|
int uniqueId = FirstRelativeSubtitleIndex;
|
|
foreach (StreamSubtitle j in ver.subtitles)
|
|
{
|
|
MediaStream a = new MediaStream();
|
|
a.Index = uniqueId--;
|
|
a.Type = MediaStreamType.Subtitle;
|
|
a.Language = ISO639_1ToISO639_2(j.language);
|
|
a.IsForced = j.forced;
|
|
a.DeliveryUrl = j.src;
|
|
a.IsExternalUrl = true;
|
|
|
|
result.Add(a);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
|
|
internal static string EncodingHelper_GetMapArgs_New(EncodingHelper @this, EncodingJobInfo state)
|
|
{
|
|
return "";/*
|
|
if ((state.VideoStream == null && state.AudioStream == null)
|
|
|| (state.VideoStream != null && state.VideoStream.Index == -1)
|
|
|| (state.AudioStream != null && state.AudioStream.Index == -1))
|
|
// Use original implementation
|
|
// Note: The condition reflects the beginning of the original method
|
|
return EncodingHelper_GetMapArgs_Orig(@this, state);
|
|
|
|
// HACK: Stream indicies below FirstRelativeVideoIndex are misused by VersionToMediaStreams to
|
|
// indicate relative indexing for ffmpeg
|
|
if ((state.VideoStream?.Index ?? 0) > FirstRelativeVideoIndex
|
|
&& (state.AudioStream?.Index ?? 0) > FirstRelativeAudioIndex
|
|
&& (state.SubtitleStream?.Index ?? 0) > FirstRelativeSubtitleIndex)
|
|
// Use original implementation
|
|
return EncodingHelper_GetMapArgs_Orig(@this, state);
|
|
|
|
// Video stream
|
|
string args = "";
|
|
if (state.VideoStream == null)
|
|
{
|
|
// No known video stream
|
|
args += "-vn";
|
|
}
|
|
else if (state.VideoStream.Index <= FirstRelativeVideoIndex)
|
|
{
|
|
args += string.Format(CultureInfo.InvariantCulture, "-map 0:v:{0}", -(state.VideoStream.Index - FirstRelativeVideoIndex));
|
|
}
|
|
else
|
|
{
|
|
int videoStreamIndex = EncodingHelper.FindIndex(state.MediaSource.MediaStreams, state.VideoStream);
|
|
args += string.Format(CultureInfo.InvariantCulture, "-map 0:{0}", videoStreamIndex);
|
|
}
|
|
|
|
// Audio stream
|
|
if (state.AudioStream == null)
|
|
{
|
|
args += " -map -0:a";
|
|
}
|
|
else if (state.AudioStream.Index <= FirstRelativeAudioIndex)
|
|
{
|
|
args += string.Format(CultureInfo.InvariantCulture, " -map 0:a:{0}", -(state.AudioStream.Index - FirstRelativeAudioIndex));
|
|
}
|
|
else
|
|
{
|
|
int audioStreamIndex = EncodingHelper.FindIndex(state.MediaSource.MediaStreams, state.AudioStream);
|
|
if (state.AudioStream.IsExternal)
|
|
{
|
|
throw new InvalidOperationException("Cinema shall not have external audio streams.");
|
|
}
|
|
else
|
|
{
|
|
args += string.Format(CultureInfo.InvariantCulture, " -map 0:{0}", audioStreamIndex);
|
|
}
|
|
}
|
|
|
|
// Subtitle stream
|
|
var subtitleMethod = state.SubtitleDeliveryMethod;
|
|
if (state.SubtitleStream == null || subtitleMethod == SubtitleDeliveryMethod.Hls)
|
|
{
|
|
args += " -map -0:s";
|
|
}
|
|
else if (subtitleMethod == SubtitleDeliveryMethod.Embed)
|
|
{
|
|
if (state.SubtitleStream.Index <= FirstRelativeSubtitleIndex)
|
|
{
|
|
args += string.Format(CultureInfo.InvariantCulture, " -map 0:s:{0}", -(state.SubtitleStream.Index - FirstRelativeSubtitleIndex));
|
|
}
|
|
else
|
|
{
|
|
int subtitleStreamIndex = EncodingHelper.FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream);
|
|
|
|
args += string.Format(CultureInfo.InvariantCulture, " -map 0:{0}", subtitleStreamIndex);
|
|
}
|
|
}
|
|
else if (state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)
|
|
{
|
|
int externalSubtitleStreamIndex = EncodingHelper.FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream);
|
|
|
|
args += string.Format(CultureInfo.InvariantCulture, " -map 1:{0} -sn", externalSubtitleStreamIndex);
|
|
}
|
|
|
|
// Do not call the original
|
|
return args;*/
|
|
}
|
|
|
|
internal static string EncodingHelper_GetMapArgs_Orig(EncodingHelper @this, EncodingJobInfo state)
|
|
{
|
|
throw new InvalidOperationException();
|
|
}
|
|
|
|
private static void ConvertHdr(bool isHdr, string? hdr, MediaStream dst)
|
|
{
|
|
if (!isHdr)
|
|
return;
|
|
|
|
if (hdr != null)
|
|
{
|
|
// Examples:
|
|
// Dolby Vision
|
|
// Dolby Vision / SMPTE ST 2094 App 4
|
|
// Dolby Vision / SMPTE ST 2086
|
|
// SMPTE ST 2086
|
|
string[] sections = hdr.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
if (sections.Length != 0)
|
|
{
|
|
string lastValue = sections[sections.Length - 1];
|
|
if (lastValue != "Dolby Vision")
|
|
{
|
|
dst.ColorTransfer = lastValue;
|
|
switch (lastValue)
|
|
{
|
|
case "SMPTE ST 2094 App 4":
|
|
// DoVi Profile 5
|
|
dst.DvProfile = 5;
|
|
dst.RpuPresentFlag = 1;
|
|
dst.BlPresentFlag = 1;
|
|
dst.DvBlSignalCompatibilityId = 0;
|
|
dst.CodecTag = "dovi";
|
|
break;
|
|
|
|
case "SMPTE ST 2084":
|
|
// HDR10
|
|
dst.ColorTransfer = "smpte2084";
|
|
break;
|
|
|
|
case "SMPTE ST 2086":
|
|
// fall through for basic HDR level
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Indicate the "lowest" HDR level
|
|
// See function GetVideoColorRange in MediaBrowser.Model/Entities/MediaStream.cs in Jellyfin for reasons
|
|
dst.DvProfile = 10;
|
|
dst.RpuPresentFlag = 1;
|
|
dst.BlPresentFlag = 1;
|
|
dst.DvBlSignalCompatibilityId = 0;
|
|
dst.CodecTag = "dovi";
|
|
return;
|
|
}
|
|
|
|
public async Task<Uri> GenerateLink(string provider, string ident, string? name, CancellationToken cancel)
|
|
{
|
|
name = name ?? "";
|
|
DateTime now = DateTime.UtcNow;
|
|
LinkKey key = new LinkKey(provider, ident, name);
|
|
LinkEntry entry;
|
|
if (!_links.TryGetValue(key, out entry) || !entry.IsValid(now))
|
|
{
|
|
Uri link;
|
|
switch (provider)
|
|
{
|
|
case "webshare": link = await LinkGenerator.GenerateDownloadLinkAsync(ident, name, CinemaHost.Webshare, cancel); break;
|
|
default: throw new InvalidOperationException();
|
|
}
|
|
|
|
entry = new LinkEntry(now + LinkValidityTimeout, link);
|
|
_links[key] = entry;
|
|
}
|
|
|
|
return entry.Link;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts two-letter 639-1 language code to three letter 639-2.
|
|
/// </summary>
|
|
private static string? ISO639_1ToISO639_2(string? twoLetter639_1)
|
|
{
|
|
// Based on https://www.loc.gov/standards/iso639-2/php/code_list-utf8.php
|
|
switch (twoLetter639_1)
|
|
{
|
|
case "aa": return "aar";
|
|
case "ab": return "abk";
|
|
case "af": return "afr";
|
|
case "ak": return "aka";
|
|
case "sq": return "alb";
|
|
case "am": return "amh";
|
|
case "ar": return "ara";
|
|
case "an": return "arg";
|
|
case "hy": return "arm";
|
|
case "as": return "asm";
|
|
case "av": return "ava";
|
|
case "ae": return "ave";
|
|
case "ay": return "aym";
|
|
case "az": return "aze";
|
|
case "ba": return "bak";
|
|
case "bm": return "bam";
|
|
case "eu": return "baq";
|
|
case "be": return "bel";
|
|
case "bn": return "ben";
|
|
case "bi": return "bis";
|
|
case "bo": return "tib";
|
|
case "bs": return "bos";
|
|
case "br": return "bre";
|
|
case "bg": return "bul";
|
|
case "my": return "bur";
|
|
case "ca": return "cat";
|
|
case "cs": return "cze";
|
|
case "ch": return "cha";
|
|
case "ce": return "che";
|
|
case "zh": return "chi";
|
|
case "cu": return "chu";
|
|
case "cv": return "chv";
|
|
case "kw": return "cor";
|
|
case "co": return "cos";
|
|
case "cr": return "cre";
|
|
case "cy": return "wel";
|
|
case "da": return "dan";
|
|
case "de": return "ger";
|
|
case "dv": return "div";
|
|
case "nl": return "dut";
|
|
case "dz": return "dzo";
|
|
case "el": return "gre";
|
|
case "en": return "eng";
|
|
case "eo": return "epo";
|
|
case "et": return "est";
|
|
case "ee": return "ewe";
|
|
case "fo": return "fao";
|
|
case "fa": return "per";
|
|
case "fj": return "fij";
|
|
case "fi": return "fin";
|
|
case "fr": return "fre";
|
|
case "fy": return "fry";
|
|
case "ff": return "ful";
|
|
case "ka": return "geo";
|
|
case "gd": return "gla";
|
|
case "ga": return "gle";
|
|
case "gl": return "glg";
|
|
case "gv": return "glv";
|
|
case "gn": return "grn";
|
|
case "gu": return "guj";
|
|
case "ht": return "hat";
|
|
case "ha": return "hau";
|
|
case "he": return "heb";
|
|
case "hz": return "her";
|
|
case "hi": return "hin";
|
|
case "ho": return "hmo";
|
|
case "hr": return "hrv";
|
|
case "hu": return "hun";
|
|
case "ig": return "ibo";
|
|
case "is": return "ice";
|
|
case "io": return "ido";
|
|
case "ii": return "iii";
|
|
case "iu": return "iku";
|
|
case "ie": return "ile";
|
|
case "ia": return "ina";
|
|
case "id": return "ind";
|
|
case "ik": return "ipk";
|
|
case "it": return "ita";
|
|
case "jv": return "jav";
|
|
case "ja": return "jpn";
|
|
case "kl": return "kal";
|
|
case "kn": return "kan";
|
|
case "ks": return "kas";
|
|
case "kr": return "kau";
|
|
case "kk": return "kaz";
|
|
case "km": return "khm";
|
|
case "ki": return "kik";
|
|
case "rw": return "kin";
|
|
case "ky": return "kir";
|
|
case "kv": return "kom";
|
|
case "kg": return "kon";
|
|
case "ko": return "kor";
|
|
case "kj": return "kua";
|
|
case "ku": return "kur";
|
|
case "lo": return "lao";
|
|
case "la": return "lat";
|
|
case "lv": return "lav";
|
|
case "li": return "lim";
|
|
case "ln": return "lin";
|
|
case "lt": return "lit";
|
|
case "lb": return "ltz";
|
|
case "lu": return "lub";
|
|
case "lg": return "lug";
|
|
case "mk": return "mac";
|
|
case "mh": return "mah";
|
|
case "ml": return "mal";
|
|
case "mi": return "mao";
|
|
case "mr": return "mar";
|
|
case "ms": return "may";
|
|
case "mg": return "mlg";
|
|
case "mt": return "mlt";
|
|
case "mn": return "mon";
|
|
case "na": return "nau";
|
|
case "nv": return "nav";
|
|
case "nr": return "nbl";
|
|
case "nd": return "nde";
|
|
case "ng": return "ndo";
|
|
case "ne": return "nep";
|
|
case "nn": return "nno";
|
|
case "nb": return "nob";
|
|
case "no": return "nor";
|
|
case "ny": return "nya";
|
|
case "oc": return "oci";
|
|
case "oj": return "oji";
|
|
case "or": return "ori";
|
|
case "om": return "orm";
|
|
case "os": return "oss";
|
|
case "pa": return "pan";
|
|
case "pi": return "pli";
|
|
case "pl": return "pol";
|
|
case "pt": return "por";
|
|
case "ps": return "pus";
|
|
case "qu": return "que";
|
|
case "rm": return "roh";
|
|
case "ro": return "rum";
|
|
case "rn": return "run";
|
|
case "ru": return "rus";
|
|
case "sg": return "sag";
|
|
case "sa": return "san";
|
|
case "si": return "sin";
|
|
case "sk": return "slo";
|
|
case "sl": return "slv";
|
|
case "se": return "sme";
|
|
case "sm": return "smo";
|
|
case "sn": return "sna";
|
|
case "sd": return "snd";
|
|
case "so": return "som";
|
|
case "st": return "sot";
|
|
case "es": return "spa";
|
|
case "sc": return "srd";
|
|
case "sr": return "srp";
|
|
case "ss": return "ssw";
|
|
case "su": return "sun";
|
|
case "sw": return "swa";
|
|
case "sv": return "swe";
|
|
case "ty": return "tah";
|
|
case "ta": return "tam";
|
|
case "tt": return "tat";
|
|
case "te": return "tel";
|
|
case "tg": return "tgk";
|
|
case "tl": return "tgl";
|
|
case "th": return "tha";
|
|
case "ti": return "tir";
|
|
case "to": return "ton";
|
|
case "tn": return "tsn";
|
|
case "ts": return "tso";
|
|
case "tk": return "tuk";
|
|
case "tr": return "tur";
|
|
case "tw": return "twi";
|
|
case "ug": return "uig";
|
|
case "uk": return "ukr";
|
|
case "ur": return "urd";
|
|
case "uz": return "uzb";
|
|
case "ve": return "ven";
|
|
case "vi": return "vie";
|
|
case "vo": return "vol";
|
|
case "wa": return "wln";
|
|
case "wo": return "wol";
|
|
case "xh": return "xho";
|
|
case "yi": return "yid";
|
|
case "yo": return "yor";
|
|
case "za": return "zha";
|
|
case "zu": return "zul";
|
|
default: return twoLetter639_1;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
struct VersionSetEntry
|
|
{
|
|
internal DateTime ValidUntil;
|
|
internal VersionEntry[]? Versions;
|
|
}
|
|
|
|
class VersionEntry
|
|
{
|
|
private readonly CinemaLib.API.Stream _meta;
|
|
|
|
internal VersionEntry(CinemaLib.API.Stream meta)
|
|
{
|
|
this._meta = meta;
|
|
}
|
|
|
|
internal CinemaLib.API.Stream Meta => _meta;
|
|
|
|
internal MediaSourceInfo? AnalyzedInfo { get; set; }
|
|
}
|
|
|
|
struct LinkKey : IEquatable<LinkKey>
|
|
{
|
|
private readonly string _provider;
|
|
private readonly string _ident;
|
|
private readonly string _name;
|
|
|
|
internal LinkKey(string provider, string ident, string name)
|
|
{
|
|
this._provider = provider;
|
|
this._ident = ident;
|
|
this._name = name;
|
|
}
|
|
|
|
public override bool Equals([NotNullWhen(true)] object? obj)
|
|
{
|
|
return obj is LinkKey && Equals((LinkKey)obj);
|
|
}
|
|
|
|
public bool Equals(LinkKey other)
|
|
{
|
|
return _provider == other._provider && _ident == other._ident && _name == other._name;
|
|
}
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
return _provider.GetHashCode() + 3 * _ident.GetHashCode() + 7 * _name.GetHashCode();
|
|
}
|
|
}
|
|
|
|
struct LinkEntry
|
|
{
|
|
private readonly DateTime _validUntil;
|
|
private readonly Uri _link;
|
|
|
|
internal LinkEntry(DateTime validUntil, Uri link)
|
|
{
|
|
this._validUntil = validUntil;
|
|
this._link = link;
|
|
}
|
|
|
|
public bool IsValid(DateTime now)
|
|
{
|
|
return _validUntil > now;
|
|
}
|
|
|
|
public Uri Link => _link;
|
|
}
|
|
}
|