Obtain mediaSourceId from ASP.NET. Analyze video file before play to get proper stream indicies. For TV Series report only single version based on user preferences.
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2024-12-09 03:06:50 +01:00
parent f973f23443
commit 6ae2e56686
3 changed files with 266 additions and 92 deletions

View File

@@ -20,6 +20,12 @@ using CinemaLib.API;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dlna;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Routing;
using LinkGenerator = Cinema.Webshare.LinkGenerator;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Plugin.Cinema;
@@ -32,6 +38,10 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
private const int FirstRelativeAudioIndex = -5;
private const int FirstRelativeSubtitleIndex = -15;
private const int MediaInspectionFileSize = 16384;
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;
@@ -42,10 +52,16 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
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)
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();
@@ -56,8 +72,12 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
this._inner = inner;
this._libraryManager = libraryManager;
this._http = http;
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>();
@@ -157,7 +177,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
|| string.IsNullOrEmpty(item.ExternalId))
return _inner.GetStaticMediaSources(item, enablePathSubstitution, user);
List<MediaSourceInfo> result = GetPlaybackMediaSources(item, user, false, enablePathSubstitution, default).GetAwaiter().GetResult();
List<MediaSourceInfo> result = GetPlaybackMediaSourcesInternal(item, user, false, enablePathSubstitution, needsPreciseStreamIndicies: false, default).GetAwaiter().GetResult();
// HACK Prevent crash
if (result.Count == 0)
@@ -166,7 +186,12 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
return result;
}
public async Task<List<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
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
HttpContext? ctx = _http.HttpContext;
@@ -187,25 +212,102 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
bool isFreeAccount = await CinemaHost.IsWebshareFreeAccount(cancellationToken);
IEnumerable<Video> items;
bool sortByPrefs;
switch (video)
{
case CinemaMovie movie: items = GetVideoVersionsEnumerate<CinemaMovie>(csId!, movie, videoPrimary!, ver.Versions); break;
case CinemaEpisode episode: items = GetVideoVersionsEnumerate<CinemaEpisode>(csId!, episode, videoPrimary!, SortVersionsByPreferences(user, ver.Versions, isFreeAccount)); break;
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;
default: throw new NotSupportedException(string.Format("BaseItem type '{0}' not supported in CinemaMediaSources.", video.GetType().Name));
}
int idx = 0;
// Warning: We assume GetVideoVersionsEnumerate keeps the order between its input and output collections
// Note: Also makes sure BaseItems exist for each version
foreach (var i in items)
result.Add(GetVersionInfo(i, ver.Versions[idx++].Meta, thisServerBaseUri, isFreeAccount));
// 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.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];
if (metaVer.AnalyzedInfo == null)
{
Uri link = await GenerateLink(metaVer.Meta.provider, metaVer.Meta.ident, metaVer.Meta.name, cancellationToken);
byte[] dataVer = new byte[MediaInspectionFileSize];
int dataLen = await GetPartialDataResourceAsync(link, dataVer, cancellationToken);
metaVer.AnalyzedInfo = await AnalyzeVideoStreamDataAsync(dataVer, dataLen, cancellationToken);
}
result.Add(GetVersionInfo(videoVer, metaVer.Meta, metaVer.AnalyzedInfo.MediaStreams, thisServerBaseUri, isFreeAccount));
}
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, thisServerBaseUri, isFreeAccount));
if (sortByPrefs && ver.Versions.Length != 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 (video is CinemaEpisode)
if (sortByPrefs)
{
foreach (MediaSourceInfo i in result)
{
ForceByPreferences(user, i, isFreeAccount);
i.Id = videoPrimary!.Id.ToString("N", CultureInfo.InvariantCulture);
}
}
}
break;
}
@@ -239,11 +341,11 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
/// Sorts media verions by preferences as episodes currently do not support multiple versions
/// and the first one gets played.
/// </summary>
private static VersionEntry[] SortVersionsByPreferences(User user, VersionEntry[] items, bool isFreeAccount)
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 items;
return null;
double? bitrateLimit = isFreeAccount ? WebshareFreeBitrate : null;
string? audioLang = user.AudioLanguagePreference;
@@ -286,12 +388,10 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
scores[i] = score;
}
Array.Sort(scores, items);
Array.Reverse(items);
return items;
return scores;
}
private static void ForceByPreferences(User user, MediaSourceInfo item, bool isFreeAccount)
private static void ForceByPreferences(User? user, MediaSourceInfo item, bool isFreeAccount)
{
if (user == null)
// We have no info about the user so nothing to force
@@ -472,17 +572,17 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
bool isFreeAccount = await CinemaHost.IsWebshareFreeAccount(cancel);
return GetVersionInfo(item, ver.Meta, thisServerBaseUri, isFreeAccount);
return GetVersionInfo(item, ver.Meta, null, thisServerBaseUri, isFreeAccount);
}
private MediaSourceInfo GetVersionInfo(Video item, CinemaLib.API.Stream ver, Uri thisServerBaseUri, bool isFreeAccount)
private MediaSourceInfo GetVersionInfo(Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams, Uri thisServerBaseUri, bool isFreeAccount)
{
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);
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;
@@ -545,9 +645,34 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
return result;
}
private static List<MediaStream> VersionToMediaStreams(Video item, CinemaLib.API.Stream ver)
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;
@@ -611,7 +736,8 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
a.Type = MediaStreamType.Subtitle;
a.Language = ISO639_1ToISO639_2(j.language);
a.IsForced = j.forced;
a.Path = j.src;
a.DeliveryUrl = j.src;
a.IsExternalUrl = true;
result.Add(a);
}
@@ -620,8 +746,10 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
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))
@@ -703,7 +831,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
}
// Do not call the original
return args;
return args;*/
}
internal static string EncodingHelper_GetMapArgs_Orig(EncodingHelper @this, EncodingJobInfo state)
@@ -981,6 +1109,49 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
}
}
private async Task<int> GetPartialDataResourceAsync(Uri uri, byte[] buffer, CancellationToken cancel)
{
using (HttpResponseMessage res = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancel))
using (System.IO.Stream resBody = await res.Content.ReadAsStreamAsync(cancel))
{
int read, offset = 0;
while ((read = await resBody.ReadAsync(buffer, offset, buffer.Length - offset)) != 0)
offset += read;
return offset;
}
}
private async Task<MediaSourceInfo> AnalyzeVideoStreamDataAsync(byte[] partialVideoData, int partialDataSize, CancellationToken cancel)
{
MediaSourceInfo result;
string path = Path.Combine(_serverConfigurationManager.GetTranscodePath(), "cnm-" + Guid.NewGuid().ToString("N"));
FileStream partialVideoDataS = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
try
{
await partialVideoDataS.WriteAsync(partialVideoData, 0, partialDataSize, cancel);
await partialVideoDataS.FlushAsync(cancel);
result = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest()
{
MediaType = DlnaProfileType.Video,
ExtractChapters = false,
MediaSource = new MediaSourceInfo()
{
Path = path,
Protocol = MediaProtocol.File,
VideoType = VideoType.VideoFile,
}
}, cancel);
}
finally
{
partialVideoDataS.Close();
File.Delete(path);
}
return result;
}
struct VersionSetEntry
{
internal DateTime ValidUntil;
@@ -998,7 +1169,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
internal CinemaLib.API.Stream Meta => _meta;
internal Uri? DownloadLink { get; set; }
internal MediaSourceInfo? AnalyzedInfo { get; set; }
}
struct LinkKey : IEquatable<LinkKey>

View File

@@ -25,14 +25,6 @@ public class CinemaPlugin : BasePlugin<CinemaPluginConfiguration>, IHasWebPages
: base(applicationPaths, xmlSerializer)
{
Instance = this;
// Perform patching
MethodInfo encodingGetMapArgs_orig = ((Func<EncodingJobInfo, string>)new EncodingHelper(null, null, null, null, null).GetMapArgs).Method;
MethodInfo encodingGetMapArgs_new = ((Func<EncodingHelper, EncodingJobInfo, string>)CinemaMediaSourceManager.EncodingHelper_GetMapArgs_New).Method;
MethodInfo encodingGetMapArgs_proxy = ((Func<EncodingHelper, EncodingJobInfo, string>)CinemaMediaSourceManager.EncodingHelper_GetMapArgs_Orig).Method;
RedirectTo(encodingGetMapArgs_proxy, encodingGetMapArgs_orig);
RedirectTo(encodingGetMapArgs_orig, encodingGetMapArgs_new);
}
/// <inheritdoc />
@@ -61,64 +53,5 @@ public class CinemaPlugin : BasePlugin<CinemaPluginConfiguration>, IHasWebPages
}
};
}
private static void RedirectTo(MethodInfo origin, MethodInfo target)
{
IntPtr ori = GetMethodAddress(origin);
IntPtr tar = GetMethodAddress(target);
Marshal.Copy(new IntPtr[] { Marshal.ReadIntPtr(tar) }, 0, ori, 1);
}
private static IntPtr GetMethodAddress(MethodInfo mi)
{
const ushort SLOT_NUMBER_MASK = 0xffff; // 2 bytes mask
const int MT_OFFSET_32BIT = 0x28; // 40 bytes offset
const int MT_OFFSET_64BIT = 0x40; // 64 bytes offset
IntPtr address;
// JIT compilation of the method
RuntimeHelpers.PrepareMethod(mi.MethodHandle);
IntPtr md = mi.MethodHandle.Value; // MethodDescriptor address
IntPtr mt = mi.DeclaringType!.TypeHandle.Value; // MethodTable address
bool isNetFramework = RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework");
if (mi.IsVirtual)
{
// The fixed-size portion of the MethodTable structure depends on the process type
int offset = IntPtr.Size == 4 ? MT_OFFSET_32BIT : MT_OFFSET_64BIT;
// First method slot = MethodTable address + fixed-size offset
// This is the address of the first method of any type (i.e. ToString)
IntPtr ms = Marshal.ReadIntPtr(mt + offset);
// Get the slot number of the virtual method entry from the MethodDesc data structure
long shift = Marshal.ReadInt64(md) >> 32;
int slot = (int)(shift & SLOT_NUMBER_MASK);
// Get the virtual method address relative to the first method slot
address = ms + (slot * IntPtr.Size);
}
else
{
// Bypass default MethodDescriptor padding (8 bytes)
// Reach the CodeOrIL field which contains the address of the JIT-compiled code
if (isNetFramework)
address = md + 8;
else
address = md + 16;
}
nint a = mi.MethodHandle.GetFunctionPointer();
nint b = Marshal.ReadIntPtr(address);
if (b != a)
throw new InvalidOperationException("Method patching primitives are broken.");
return address;
}
}

View File

@@ -3,6 +3,8 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Providers;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Cinema;
@@ -60,5 +62,73 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator
}
}
// HACK: Extract values for some API calls that are otherwise inaccessible
serviceCollection.AddMvc(options =>
{
options.Conventions.Add(new MediaSourceIdExtractConvention());
});
}
sealed class MediaSourceIdExtractConvention : IApplicationModelConvention
{
public void Apply(ApplicationModel application)
{
ControllerModel? mediaInfo = application.Controllers.Where(x => x.ControllerName == "MediaInfo").FirstOrDefault();
ControllerModel? dynamicHls = application.Controllers.Where(x => x.ControllerName == "DynamicHls").FirstOrDefault();
ActionModel? getPostedPlaybackInfo, getMasterHlsVideoPlaylist, getVariantHlsVideoPlaylist, getHlsVideoSegment;
if (mediaInfo == null
|| (getPostedPlaybackInfo = mediaInfo.Actions.Where(x => x.ActionName == "GetPostedPlaybackInfo").FirstOrDefault()) == null
|| dynamicHls == null
|| (getMasterHlsVideoPlaylist = dynamicHls.Actions.Where(x => x.ActionName == "GetMasterHlsVideoPlaylist").FirstOrDefault()) == null
|| (getVariantHlsVideoPlaylist = dynamicHls.Actions.Where(x => x.ActionName == "GetVariantHlsVideoPlaylist").FirstOrDefault()) == null
|| (getHlsVideoSegment = dynamicHls.Actions.Where(x => x.ActionName == "GetHlsVideoSegment").FirstOrDefault()) == null)
throw new InvalidOperationException("Failed to register for MediaSourceId extraction from the Jellyfin's MediaInfo controller.");
getPostedPlaybackInfo.Filters.Add(new GetPostedPlaybackInfoMediaSourceIdFilter());
// Note: All DynamicHls controller actions share the same parameter name so we can
// re-use the same filter for all
getMasterHlsVideoPlaylist.Filters.Add(new DynamicHlsMediaSourceIdFilter());
getVariantHlsVideoPlaylist.Filters.Add(new DynamicHlsMediaSourceIdFilter());
getHlsVideoSegment.Filters.Add(new DynamicHlsMediaSourceIdFilter());
}
}
sealed class GetPostedPlaybackInfoMediaSourceIdFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
string? mediaSourceId;
if (!context.ActionArguments.TryGetValue("playbackInfoDto", out object? playbackInfoO)
|| (mediaSourceId = playbackInfoO!.GetType().GetProperty("MediaSourceId")?.GetValue(playbackInfoO) as string) == null)
{
// Fallback to the obsolete query argument
if (!context.ActionArguments.TryGetValue("mediaSourceId", out object? mediaSourceIdO)
|| (mediaSourceId = mediaSourceIdO as string) == null)
throw new InvalidOperationException("Cannot extract MediaSourceId from the Jellyfin's action MediaInfo.GetPostedPlaybackInfo.");
}
context.HttpContext.Items.Add(CinemaMediaSourceManager.ContextItemsMediaSourceIdKey, mediaSourceId);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
sealed class DynamicHlsMediaSourceIdFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
string? mediaSourceId;
if (!context.ActionArguments.TryGetValue("mediaSourceId", out object? mediaSourceIdO)
|| (mediaSourceId = mediaSourceIdO as string) == null)
throw new InvalidOperationException("Cannot extract MediaSourceId from the Jellyfin's controller DynamicHls.");
context.HttpContext.Items.Add(CinemaMediaSourceManager.ContextItemsMediaSourceIdKey, mediaSourceId);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
}