Files
stream-cinema/CinemaJellyfin/CinemaMediaSourceManager.cs
Roman Vaníček 0bcb6d571f
All checks were successful
continuous-integration/drone/push Build is passing
Rename to Cinema
2024-11-30 00:50:10 +01:00

616 lines
23 KiB
C#

#pragma warning disable CA1002
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
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 Cinema.Webshare;
using CinemaLib.API;
namespace Jellyfin.Plugin.Cinema;
public class CinemaMediaSourceManager : IMediaSourceManager
{
private const bool IsWebshareFreeAccount = true;
private const double BitrateMargin = 0.1; // 10 %
private const double WebshareFreeBitrate = 300000 * 8;
private static readonly TimeSpan VersionValidityTimeout = TimeSpan.FromMinutes(180);
private static CinemaMediaSourceManager? _instance;
private readonly IMediaSourceManager _inner;
private readonly ILibraryManager _libraryManager;
private readonly IHttpContextAccessor _http;
private readonly ConcurrentDictionary<Guid, VersionSetEntry> _videoVersions;
public CinemaMediaSourceManager(ICinemaInnerMediaSourceManager innerMediaSourceManager, ILibraryManager libraryManager, IServiceProvider svc, IHttpContextAccessor http)
{
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._http = http;
this._videoVersions = new ConcurrentDictionary<Guid, VersionSetEntry>();
_instance = this;
}
internal static bool TryGetInstance([NotNullWhen(true)] out CinemaMediaSourceManager? instance)
{
instance = _instance;
return instance != null;
}
#region IMediaSourceManager Members
public Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, string cacheKey, bool addProbeDelay, bool isLiveStream, CancellationToken cancellationToken)
{
return _inner.AddMediaInfoWithProbe(mediaSource, isAudio, cacheKey, addProbeDelay, isLiveStream, cancellationToken);
}
public void AddParts(IEnumerable<IMediaSourceProvider> providers)
{
_inner.AddParts(providers);
}
public Task CloseLiveStream(string id)
{
return _inner.CloseLiveStream(id);
}
public Task<MediaSourceInfo> GetLiveStream(string id, CancellationToken cancellationToken)
{
return _inner.GetLiveStream(id, cancellationToken);
}
public ILiveStream GetLiveStreamInfo(string id)
{
return _inner.GetLiveStreamInfo(id);
}
public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId)
{
return _inner.GetLiveStreamInfoByUniqueId(uniqueId);
}
public Task<MediaSourceInfo> GetLiveStreamMediaInfo(string id, CancellationToken cancellationToken)
{
return _inner.GetLiveStreamMediaInfo(id, cancellationToken);
}
public Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken)
{
return _inner.GetLiveStreamWithDirectStreamProvider(id, cancellationToken);
}
public List<MediaAttachment> GetMediaAttachments(Guid itemId)
{
return _inner.GetMediaAttachments(itemId);
}
public List<MediaAttachment> GetMediaAttachments(MediaAttachmentQuery query)
{
return _inner.GetMediaAttachments(query);
}
public Task<MediaSourceInfo> GetMediaSource(BaseItem item, string mediaSourceId, string liveStreamId, bool enablePathSubstitution, CancellationToken cancellationToken)
{
return _inner.GetMediaSource(item, mediaSourceId, liveStreamId, enablePathSubstitution, cancellationToken);
}
public List<MediaStream> GetMediaStreams(Guid itemId)
{
return _inner.GetMediaStreams(itemId);
/*
// Intercept for CinemaItems
Video? item = _libraryManager.GetItemById<Video>(itemId);
if (item == null
|| item.ProviderIds == null
|| !item.ProviderIds.ContainsKey(CinemaPlugin.CinemaProviderName)
|| string.IsNullOrEmpty(item.ExternalId))
return _inner.GetMediaStreams(itemId);
CinemaLib.API.Stream? ver = GetVersion(item, default).GetAwaiter().GetResult();
return ver != null ? VersionToMediaStreams(item, ver) : new List<MediaStream>();
*/
}
public List<MediaStream> GetMediaStreams(MediaStreamQuery query)
{
return _inner.GetMediaStreams(query);
}
public MediaProtocol GetPathProtocol(string path)
{
return _inner.GetPathProtocol(path);
}
public Task<List<MediaSourceInfo>> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken)
{
return _inner.GetRecordingStreamMediaSources(info, cancellationToken);
}
public List<MediaSourceInfo> GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User? user = null)
{
// Intercept for CinemaItems
if (item == null
|| item.ProviderIds == null
|| !item.ProviderIds.ContainsKey(CinemaPlugin.CinemaProviderName)
|| string.IsNullOrEmpty(item.ExternalId))
return _inner.GetStaticMediaSources(item, enablePathSubstitution, user);
List<MediaSourceInfo> result = new List<MediaSourceInfo>();
switch (item)
{
case Video video:
VersionSetEntry ver = GetVersionSet(video, out BaseItem? videoPrimary, default).GetAwaiter().GetResult();
if (ver.Versions != null)
{
IEnumerable<Video> items;
switch (video)
{
case CinemaMovie movie: items = GetVideoVersionsEnumerate<CinemaMovie>(movie, videoPrimary!, ver.Versions); 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));
}
break;
}
// HACK Prevent crash
if (result.Count == 0)
result.Append(new MediaSourceInfo() { MediaStreams = new List<MediaStream>() });
return result;
}
public async Task<List<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
{
// Intercept for CinemaItems
if (item == null
|| item.ProviderIds == null
|| !item.ProviderIds.ContainsKey(CinemaPlugin.CinemaProviderName)
|| string.IsNullOrEmpty(item.ExternalId))
return await _inner.GetPlaybackMediaSources(item, user, allowMediaProbe, enablePathSubstitution, cancellationToken);
// HACK: We do not want to generate links to all media sources - just the one going
// to be played. Unfortunately our caller MediaInfoHelper.GetPlaybackInfo and indirectly
// from MediaInfoController.GetPostedPlaybackInfo does not pass this info to us
// so we will grab it directly from the HttpRequest
HttpContext? ctx = _http.HttpContext;
StringValues mediaSourceIdS;
string? mediaSourceId;
if (ctx == null
|| !ctx.Request.Path.HasValue
|| !ctx.Request.Path.Value.EndsWith("/PlaybackInfo")
|| (mediaSourceIdS = ctx.Request.Query["mediaSourceId"]).Count != 1
|| string.IsNullOrEmpty(mediaSourceId = mediaSourceIdS[0]))
throw new NotSupportedException(string.Format("Unsupported caller '{0}' of CinemaMediaSourceManager", ctx != null && ctx.Request.Path.HasValue ? ctx.Request.Path.Value : "?"));
BaseItem? verItem = _libraryManager.GetItemById(Guid.Parse(mediaSourceId));
List<MediaSourceInfo> result = new List<MediaSourceInfo>();
switch (verItem)
{
case Video video:
VersionEntry? ver = await GetVersion(video, cancellationToken);
if (ver != null)
{
Uri? path = ver.DownloadLink;
if (path == null)
{
switch (ver.Meta.provider)
{
case "webshare": path = await LinkGenerator.GenerateDownloadLinkAsync(ver.Meta.ident, ver.Meta.name, cancellationToken); break;
default: path = null; break;
}
ver.DownloadLink = path;
}
MediaSourceInfo a = GetVersionInfo(video, ver.Meta);
if (path != null)
a.Path = path.ToString();
// 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 (IsWebshareFreeAccount)
{
if (a.RequiredHttpHeaders == null)
a.RequiredHttpHeaders = new Dictionary<string, string>();
a.RequiredHttpHeaders["User-Agent"] = "Lavf/60\" -seekable \"0";
}
result.Add(a);
}
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>
/// 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, cancel);
return ver.Versions == null ? null : GetVideoVersionsEnumerate(item, primary!, ver.Versions);
}
private IEnumerable<T> GetVideoVersionsEnumerate<T>(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 = CinemaFilterFolder.GetMediaItemById<T>(item.ExternalId, 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, CancellationToken cancel)
{
if (item == null)
throw new ArgumentNullException();
if (item.ProviderIds == null
|| !item.ProviderIds.ContainsKey(CinemaPlugin.CinemaProviderName)
|| string.IsNullOrEmpty(item.ExternalId))
{
// Not a Stream Cinema video
primary = null;
return Task.FromResult<VersionSetEntry>(default);
}
Guid primaryId;
string csId;
if (!string.IsNullOrEmpty(item.PrimaryVersionId))
{
// Move to the primary item
primaryId = Guid.Parse(item.PrimaryVersionId, CultureInfo.InvariantCulture);
primary = _libraryManager.GetItemById(primaryId);
if (primary == null
|| primary.ProviderIds == null
|| !primary.ProviderIds.ContainsKey(CinemaPlugin.CinemaProviderName)
|| string.IsNullOrEmpty(primary.ExternalId))
// Not a Stream Cinema video
return Task.FromResult<VersionSetEntry>(default);
csId = primary.ExternalId;
}
else
{
primary = item;
primaryId = item.Id;
csId = item.ExternalId;
}
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, 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 = CinemaFilterFolder.GetMediaItemId(primary!.ExternalId, 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;
return GetVersionInfo(item, ver.Meta);
}
private MediaSourceInfo GetVersionInfo(Video item, CinemaLib.API.Stream ver)
{
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);
double bitRate = (ver.size ?? 0.0) * 8 / (item.RunTimeTicks ?? 1.0) * TimeSpan.TicksPerSecond;
bool showBitrateWarning = IsWebshareFreeAccount && 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;
//Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path,
result.Container = item.Container;
result.RunTimeTicks = item.RunTimeTicks;
result.Size = item.Size;
return result;
}
private static List<MediaStream> VersionToMediaStreams(Video item, CinemaLib.API.Stream ver)
{
List<MediaStream> result = new List<MediaStream>();
// 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
if (ver.video != null)
{
foreach (StreamVideo j in ver.video)
{
MediaStream a = new MediaStream();
result.Add(a);
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;
}
}
if (ver.audio != null)
{
foreach (StreamAudio j in ver.audio)
{
MediaStream a = new MediaStream();
result.Add(a);
a.Type = MediaStreamType.Audio;
a.Language = j.language;
a.Codec = j.codec;
a.Channels = j.channels;
}
}
if (ver.subtitles != null)
{
foreach (StreamSubtitle j in ver.subtitles)
{
MediaStream a = new MediaStream();
result.Add(a);
a.Type = MediaStreamType.Subtitle;
a.Language = j.language;
a.IsForced = j.forced;
a.Path = j.src;
}
}
return result;
}
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;
}
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 Uri? DownloadLink { get; set; }
}
}