1216 lines
42 KiB
C#
1216 lines
42 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.Database.Implementations.Entities;
|
|
using MediaBrowser.Common.Net;
|
|
using MediaBrowser.Controller;
|
|
using MediaBrowser.Controller.Configuration;
|
|
using MediaBrowser.Controller.MediaEncoding;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.IO;
|
|
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 System.Runtime.CompilerServices;
|
|
using LinkGenerator = CinemaLib.Webshare.LinkGenerator;
|
|
using Jellyfin.Extensions;
|
|
|
|
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 IPathManager _pathManager;
|
|
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, IPathManager pathManager)
|
|
{
|
|
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._pathManager = pathManager;
|
|
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 IReadOnlyList<MediaAttachment> GetMediaAttachments(Guid itemId)
|
|
{
|
|
return _inner.GetMediaAttachments(itemId);
|
|
}
|
|
|
|
public IReadOnlyList<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 IReadOnlyList<MediaStream> GetMediaStreams(Guid itemId)
|
|
{
|
|
return _inner.GetMediaStreams(itemId);
|
|
}
|
|
|
|
public IReadOnlyList<MediaStream> GetMediaStreams(MediaStreamQuery query)
|
|
{
|
|
return _inner.GetMediaStreams(query);
|
|
}
|
|
|
|
public MediaProtocol GetPathProtocol(string path)
|
|
{
|
|
return _inner.GetPathProtocol(path);
|
|
}
|
|
|
|
public Task<IReadOnlyList<MediaSourceInfo>> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken)
|
|
{
|
|
return _inner.GetRecordingStreamMediaSources(info, cancellationToken);
|
|
}
|
|
|
|
public IReadOnlyList<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);
|
|
|
|
IReadOnlyList<MediaSourceInfo> result = GetPlaybackMediaSourcesInternal(item, user, false, enablePathSubstitution, needsPreciseStreamIndicies: false, default).GetAwaiter().GetResult();
|
|
|
|
// HACK Prevent crash
|
|
if (result.Count == 0)
|
|
{
|
|
MediaSourceInfo[] result2 = new MediaSourceInfo[result.Count + 1];
|
|
result.CopyTo(result2, 0);
|
|
result2[result2.Length - 1] = new MediaSourceInfo() { MediaStreams = new List<MediaStream>() };
|
|
result = result2;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User? user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
|
|
{
|
|
return GetPlaybackMediaSourcesInternal(item, user, allowMediaProbe, enablePathSubstitution, needsPreciseStreamIndicies: true, cancellationToken);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<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.
|
|
// Warning: We assume GetVideoVersionsEnumerate keeps the order between its input and output collections
|
|
Video? videoVer;
|
|
int idxVer;
|
|
if (!sortByPrefs) {
|
|
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.");
|
|
|
|
idxVer = -1;
|
|
videoVer = items.Where((x, idx) => {
|
|
bool isMatch = x.Id == mediaSourceId;
|
|
if (isMatch)
|
|
idxVer = idx;
|
|
return isMatch;
|
|
}).FirstOrDefault();
|
|
} else {
|
|
idxVer = bestIdx;
|
|
videoVer = items.Skip(bestIdx).FirstOrDefault();
|
|
}
|
|
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(await GetVersionInfo(videoVer, metaVer.Meta, metaVer.AnalyzedInfo.MediaStreams, isFreeAccount, link, cancellationToken));
|
|
}
|
|
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(await GetVersionInfo(i, ver.Versions[idx++].Meta, null, isFreeAccount, FakeVideoUri, cancellationToken));
|
|
|
|
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).Primary == subtitleLang
|
|
|| ISO639_1ToISO639_2(j.language).Synonym == 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).Primary == audioLang
|
|
|| ISO639_1ToISO639_2(j.language).Synonym == 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 await GetVersionInfo(item, ver.Meta, null, isFreeAccount, FakeVideoUri, cancel);
|
|
}
|
|
|
|
private async ValueTask<MediaSourceInfo> GetVersionInfo(Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams, bool isFreeAccount, Uri path, CancellationToken cancel)
|
|
{
|
|
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 = await VersionToMediaStreams(result.Id, item, ver, analyzedStreams, cancel);
|
|
|
|
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 async ValueTask<List<MediaStream>> VersionToMediaStreams(string mediaSourceId, Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams, CancellationToken cancel) {
|
|
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) && Uri.TryCreate(j.src, UriKind.Absolute, out Uri? src))
|
|
{
|
|
MediaStream a = new MediaStream();
|
|
a.Index = result.Count;
|
|
a.Type = MediaStreamType.Subtitle;
|
|
a.Language = ISO639_1ToISO639_2(j.language).Primary;
|
|
a.IsForced = j.forced;
|
|
a.IsExternal = true;
|
|
// Note: This prevents subtitle burn-in and does not restart playback while activating
|
|
a.SupportsExternalStream = true;
|
|
a.Codec = "subrip";
|
|
#if NOT_BROKEN_GetStream
|
|
a.Path = (await GenerateLinkOrKeepUrl(src, cancel)).ToString();
|
|
#else
|
|
// BUG: SubtitleEncoder.GetStream is broken as it disposes the returned Stream
|
|
// before returning. Therefore we must cache the subtitle file locally
|
|
// HACK: We expect the ordering in ver.subtitles is stable as Jellyfin
|
|
// uses index to determine the filename
|
|
string path =_pathManager.GetSubtitlePath(mediaSourceId, a.Index, ".srt");
|
|
a.IsExternal = false;
|
|
|
|
if (!File.Exists(path)) {
|
|
// PERF: Subtitle files are expected not to be large (< 1 MB)
|
|
Uri link = await GenerateLinkOrKeepUrl(src, cancel);
|
|
using HttpResponseMessage response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(link, cancel);
|
|
byte[] contentB = await response.Content.ReadAsByteArrayAsync(cancel);
|
|
await File.WriteAllBytesAsync(path, contentB, cancel);
|
|
}
|
|
#endif
|
|
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).Primary;
|
|
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).Primary;
|
|
a.IsForced = j.forced;
|
|
if (a.IsExternal = j.src != null) {
|
|
a.Path = j.src;
|
|
a.DeliveryMethod = MediaBrowser.Model.Dlna.SubtitleDeliveryMethod.External;
|
|
}
|
|
|
|
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 ValueTask<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;
|
|
}
|
|
|
|
private ValueTask<Uri> GenerateLinkOrKeepUrl(Uri uri, CancellationToken cancel) {
|
|
switch (uri.Host) {
|
|
case "webshare.cz":
|
|
const string WebsharePrefix = "#/file/";
|
|
if (uri.AbsolutePath == "/" && uri.Fragment.StartsWith(WebsharePrefix)) {
|
|
string ident = uri.Fragment.Substring(WebsharePrefix.Length);
|
|
return GenerateLink("webshare", ident, name: null, cancel);
|
|
}
|
|
break;
|
|
}
|
|
|
|
return new ValueTask<Uri>(uri);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts two-letter 639-1 language code to three letter 639-2.
|
|
/// </summary>
|
|
private static (string? Primary, string? Synonym) 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", null);
|
|
case "ab": return ("abk", null);
|
|
case "af": return ("afr", null);
|
|
case "ak": return ("aka", null);
|
|
case "sq": return ("alb", "sqi");
|
|
case "am": return ("amh", null);
|
|
case "ar": return ("ara", null);
|
|
case "an": return ("arg", null);
|
|
case "hy": return ("arm", "hye");
|
|
case "as": return ("asm", null);
|
|
case "av": return ("ava", null);
|
|
case "ae": return ("ave", null);
|
|
case "ay": return ("aym", null);
|
|
case "az": return ("aze", null);
|
|
case "ba": return ("bak", null);
|
|
case "bm": return ("bam", null);
|
|
case "eu": return ("baq", "eus");
|
|
case "be": return ("bel", null);
|
|
case "bn": return ("ben", null);
|
|
case "bi": return ("bis", null);
|
|
case "bo": return ("tib", "bod");
|
|
case "bs": return ("bos", null);
|
|
case "br": return ("bre", null);
|
|
case "bg": return ("bul", null);
|
|
case "my": return ("bur", "mya");
|
|
case "ca": return ("cat", null);
|
|
case "cs": return ("cze", "ces");
|
|
case "ch": return ("cha", null);
|
|
case "ce": return ("che", null);
|
|
case "zh": return ("chi", "zho");
|
|
case "cu": return ("chu", null);
|
|
case "cv": return ("chv", null);
|
|
case "kw": return ("cor", null);
|
|
case "co": return ("cos", null);
|
|
case "cr": return ("cre", null);
|
|
case "cy": return ("wel", "cym");
|
|
case "da": return ("dan", null);
|
|
case "de": return ("ger", "deu");
|
|
case "dv": return ("div", null);
|
|
case "nl": return ("dut", "nld");
|
|
case "dz": return ("dzo", null);
|
|
case "el": return ("gre", "ell");
|
|
case "en": return ("eng", null);
|
|
case "eo": return ("epo", null);
|
|
case "et": return ("est", null);
|
|
case "ee": return ("ewe", null);
|
|
case "fo": return ("fao", null);
|
|
case "fa": return ("per", "fas");
|
|
case "fj": return ("fij", null);
|
|
case "fi": return ("fin", null);
|
|
case "fr": return ("fre", "fra");
|
|
case "fy": return ("fry", null);
|
|
case "ff": return ("ful", null);
|
|
case "ka": return ("geo", "kat");
|
|
case "gd": return ("gla", null);
|
|
case "ga": return ("gle", null);
|
|
case "gl": return ("glg", null);
|
|
case "gv": return ("glv", null);
|
|
case "gn": return ("grn", null);
|
|
case "gu": return ("guj", null);
|
|
case "ht": return ("hat", null);
|
|
case "ha": return ("hau", null);
|
|
case "he": return ("heb", null);
|
|
case "hz": return ("her", null);
|
|
case "hi": return ("hin", null);
|
|
case "ho": return ("hmo", null);
|
|
case "hr": return ("hrv", null);
|
|
case "hu": return ("hun", null);
|
|
case "ig": return ("ibo", null);
|
|
case "is": return ("ice", "isl");
|
|
case "io": return ("ido", null);
|
|
case "ii": return ("iii", null);
|
|
case "iu": return ("iku", null);
|
|
case "ie": return ("ile", null);
|
|
case "ia": return ("ina", null);
|
|
case "id": return ("ind", null);
|
|
case "ik": return ("ipk", null);
|
|
case "it": return ("ita", null);
|
|
case "jv": return ("jav", null);
|
|
case "ja": return ("jpn", null);
|
|
case "kl": return ("kal", null);
|
|
case "kn": return ("kan", null);
|
|
case "ks": return ("kas", null);
|
|
case "kr": return ("kau", null);
|
|
case "kk": return ("kaz", null);
|
|
case "km": return ("khm", null);
|
|
case "ki": return ("kik", null);
|
|
case "rw": return ("kin", null);
|
|
case "ky": return ("kir", null);
|
|
case "kv": return ("kom", null);
|
|
case "kg": return ("kon", null);
|
|
case "ko": return ("kor", null);
|
|
case "kj": return ("kua", null);
|
|
case "ku": return ("kur", null);
|
|
case "lo": return ("lao", null);
|
|
case "la": return ("lat", null);
|
|
case "lv": return ("lav", null);
|
|
case "li": return ("lim", null);
|
|
case "ln": return ("lin", null);
|
|
case "lt": return ("lit", null);
|
|
case "lb": return ("ltz", null);
|
|
case "lu": return ("lub", null);
|
|
case "lg": return ("lug", null);
|
|
case "mk": return ("mac", "mkd");
|
|
case "mh": return ("mah", null);
|
|
case "ml": return ("mal", null);
|
|
case "mi": return ("mao", "mri");
|
|
case "mr": return ("mar", null);
|
|
case "ms": return ("may", "msa");
|
|
case "mg": return ("mlg", null);
|
|
case "mt": return ("mlt", null);
|
|
case "mn": return ("mon", null);
|
|
case "na": return ("nau", null);
|
|
case "nv": return ("nav", null);
|
|
case "nr": return ("nbl", null);
|
|
case "nd": return ("nde", null);
|
|
case "ng": return ("ndo", null);
|
|
case "ne": return ("nep", null);
|
|
case "nn": return ("nno", null);
|
|
case "nb": return ("nob", null);
|
|
case "no": return ("nor", null);
|
|
case "ny": return ("nya", null);
|
|
case "oc": return ("oci", null);
|
|
case "oj": return ("oji", null);
|
|
case "or": return ("ori", null);
|
|
case "om": return ("orm", null);
|
|
case "os": return ("oss", null);
|
|
case "pa": return ("pan", null);
|
|
case "pi": return ("pli", null);
|
|
case "pl": return ("pol", null);
|
|
case "pt": return ("por", null);
|
|
case "ps": return ("pus", null);
|
|
case "qu": return ("que", null);
|
|
case "rm": return ("roh", null);
|
|
case "ro": return ("rum", "ron");
|
|
case "rn": return ("run", null);
|
|
case "ru": return ("rus", null);
|
|
case "sg": return ("sag", null);
|
|
case "sa": return ("san", null);
|
|
case "si": return ("sin", null);
|
|
case "sk": return ("slo", "slk");
|
|
case "sl": return ("slv", null);
|
|
case "se": return ("sme", null);
|
|
case "sm": return ("smo", null);
|
|
case "sn": return ("sna", null);
|
|
case "sd": return ("snd", null);
|
|
case "so": return ("som", null);
|
|
case "st": return ("sot", null);
|
|
case "es": return ("spa", null);
|
|
case "sc": return ("srd", null);
|
|
case "sr": return ("srp", null);
|
|
case "ss": return ("ssw", null);
|
|
case "su": return ("sun", null);
|
|
case "sw": return ("swa", null);
|
|
case "sv": return ("swe", null);
|
|
case "ty": return ("tah", null);
|
|
case "ta": return ("tam", null);
|
|
case "tt": return ("tat", null);
|
|
case "te": return ("tel", null);
|
|
case "tg": return ("tgk", null);
|
|
case "tl": return ("tgl", null);
|
|
case "th": return ("tha", null);
|
|
case "ti": return ("tir", null);
|
|
case "to": return ("ton", null);
|
|
case "tn": return ("tsn", null);
|
|
case "ts": return ("tso", null);
|
|
case "tk": return ("tuk", null);
|
|
case "tr": return ("tur", null);
|
|
case "tw": return ("twi", null);
|
|
case "ug": return ("uig", null);
|
|
case "uk": return ("ukr", null);
|
|
case "ur": return ("urd", null);
|
|
case "uz": return ("uzb", null);
|
|
case "ve": return ("ven", null);
|
|
case "vi": return ("vie", null);
|
|
case "vo": return ("vol", null);
|
|
case "wa": return ("wln", null);
|
|
case "wo": return ("wol", null);
|
|
case "xh": return ("xho", null);
|
|
case "yi": return ("yid", null);
|
|
case "yo": return ("yor", null);
|
|
case "za": return ("zha", null);
|
|
case "zu": return ("zul", null);
|
|
default: return (twoLetter639_1, null);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
}
|
|
}
|