Files
stream-cinema/CinemaJellyfin/CinemaMediaSourceManager.cs
Roman Vanicek 56ad9eb57a
Some checks failed
continuous-integration/drone/push Build is failing
External subtitles
2025-04-14 16:03:31 +00:00

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;
}
}