281 lines
8.6 KiB
C#
281 lines
8.6 KiB
C#
using MediaBrowser.Common.Configuration;
|
|
using MediaBrowser.Common.Net;
|
|
using MediaBrowser.Controller.Configuration;
|
|
using MediaBrowser.Controller.MediaEncoding;
|
|
using MediaBrowser.Model.Dlna;
|
|
using MediaBrowser.Model.Dto;
|
|
using MediaBrowser.Model.Entities;
|
|
using MediaBrowser.Model.MediaInfo;
|
|
|
|
namespace Jellyfin.Plugin.Cinema;
|
|
|
|
/// <summary>
|
|
/// Downloads video and simultaneously analyzes it until sufficient
|
|
/// metadata gets collected about it.
|
|
/// </summary>
|
|
sealed class CinemaMediaAnalyzer
|
|
{
|
|
private const int MaxAnalyzeSizeBase = 1 * 1024 * 1024;
|
|
private const int MaxAnalyzeSizeDivisor = 1024 * 1024 / 64;
|
|
private const int MaxAnalyzeSizeLimit = 32 * 1024 * 1024;
|
|
|
|
private const int WaitIfNoProgressMs = 50; // ms
|
|
|
|
private static readonly Task DownloadFinishedTask = Task.Delay(0);
|
|
|
|
private Task _currentReadTask;
|
|
|
|
private CinemaMediaAnalyzer()
|
|
{
|
|
this._currentReadTask = Task.CompletedTask;
|
|
}
|
|
|
|
private Task VolatileCurrentReadTask
|
|
{
|
|
get
|
|
{
|
|
// Note: The comparison always fails. We need just a volatile read.
|
|
return Interlocked.CompareExchange(ref _currentReadTask, null!, null);
|
|
}
|
|
set
|
|
{
|
|
if (value == null)
|
|
throw new ArgumentNullException();
|
|
Interlocked.Exchange(ref _currentReadTask, value);
|
|
}
|
|
}
|
|
|
|
public static Task<MediaSourceInfo> AnalyzeResourceAsync(Uri resource, long? fileSize, CinemaLib.API.Stream meta, IHttpClientFactory httpClientFactory, IMediaEncoder mediaEncoder, IServerConfigurationManager serverConfigurationManager, CancellationToken cancel)
|
|
{
|
|
if (resource == null || meta == null || httpClientFactory == null || mediaEncoder == null || serverConfigurationManager == null)
|
|
throw new ArgumentNullException();
|
|
|
|
string tmpFilePath = Path.Combine(serverConfigurationManager.GetTranscodePath(), "cnm-" + Guid.NewGuid().ToString("N"));
|
|
HttpClient http = httpClientFactory.CreateClient(NamedClient.Default);
|
|
|
|
return new CinemaMediaAnalyzer().AnalyzeAsyncInternal(resource, http, tmpFilePath, fileSize, meta, mediaEncoder, cancel);
|
|
}
|
|
|
|
public async Task<MediaSourceInfo> AnalyzeAsyncInternal(Uri resource, HttpClient http, string tmpFilePath, long? fileSize, CinemaLib.API.Stream meta, IMediaEncoder mediaEncoder, CancellationToken cancel)
|
|
{
|
|
int? maxAnalyzeSize = MaxAnalyzeSizeBase + (int?)(fileSize / MaxAnalyzeSizeDivisor);
|
|
if (maxAnalyzeSize == null || maxAnalyzeSize > MaxAnalyzeSizeLimit)
|
|
maxAnalyzeSize = MaxAnalyzeSizeLimit;
|
|
|
|
// Run two tasks cooperatively in parallel so we can stop
|
|
// downloading (possibly at slow speed) as soon as we analyze
|
|
// sufficient data. We try analyzing repeatedly after each chunk
|
|
// of data is received as we consider it cheap in contrast to
|
|
// downloading the data over network.
|
|
MediaSourceInfo? result = null;
|
|
CancellationTokenSource innerCancel = new CancellationTokenSource();
|
|
using (CancellationTokenRegistration cancelReg = cancel.Register(() => innerCancel.Cancel()))
|
|
{
|
|
Stream tmpFile = new FileStream(tmpFilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
|
|
Task? tDownload = null;
|
|
Task<MediaSourceInfo?>? tProbe = null;
|
|
try
|
|
{
|
|
tDownload = GetPartialDataAsync(resource, http, tmpFile, maxAnalyzeSize.Value, innerCancel.Token);
|
|
tProbe = ProbeDataAsync(meta, tmpFilePath, mediaEncoder, innerCancel.Token);
|
|
|
|
// Get the data we want
|
|
result = await tProbe;
|
|
|
|
// Cancel downloading
|
|
innerCancel.Cancel();
|
|
await tDownload;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Cancel the other task
|
|
innerCancel.Cancel();
|
|
|
|
// Wait for completition of the other one so
|
|
// we can delete the temporary file
|
|
try
|
|
{
|
|
Task? t = (tDownload?.IsCompleted ?? true) ? tProbe : tDownload;
|
|
if (t != null)
|
|
await t;
|
|
}
|
|
catch (Exception) { }
|
|
|
|
// Only throw if we did not get result from ProbeDataAsync as GetPartialDataAsync
|
|
// most probably threw due to task cancellation.
|
|
if (result == null)
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
tmpFile.Close();
|
|
File.Delete(tmpFilePath);
|
|
}
|
|
}
|
|
|
|
cancel.ThrowIfCancellationRequested();
|
|
|
|
if (result == null)
|
|
// Shall be null only if cancellation has been triggered
|
|
throw new InvalidOperationException();
|
|
|
|
// Fix the bitrates as some may have been calculated from the tiny file size
|
|
int? fileSizeBasedBitrate = (int?)(meta.size * 8 / meta.video?.FirstOrDefault()?.duration);
|
|
if (fileSizeBasedBitrate == null)
|
|
fileSizeBasedBitrate = (int?)(meta.size * 8 / (90 * 60)); // assume 90 minute movie
|
|
|
|
foreach (MediaStream i in result.MediaStreams)
|
|
switch (i.Type)
|
|
{
|
|
case MediaStreamType.Video:
|
|
if (i.BitRate == null || i.BitRate.Value < fileSizeBasedBitrate / 2)
|
|
i.BitRate = fileSizeBasedBitrate;
|
|
break;
|
|
|
|
case MediaStreamType.Audio:
|
|
if (i.BitRate == null || i.BitRate < 16 * 1024)
|
|
i.BitRate = 16 * 1024;
|
|
break;
|
|
|
|
case MediaStreamType.Subtitle:
|
|
// Required for subtitle extraction
|
|
i.SupportsExternalStream = true;
|
|
break;
|
|
}
|
|
|
|
// Add unrecognized bogus streams so stream Index values are continous and sorted in the
|
|
// ascending order as implementation of EncodingHelper.FindIndex is extremely stupid.
|
|
Dictionary<int, MediaStream> indicies = new Dictionary<int, MediaStream>();
|
|
int minI = int.MaxValue, maxI = -1;
|
|
foreach (MediaStream i in result.MediaStreams)
|
|
{
|
|
indicies.Add(i.Index, i);
|
|
if (minI > i.Index)
|
|
minI = i.Index;
|
|
if (maxI < i.Index)
|
|
maxI = i.Index;
|
|
}
|
|
if (minI != 0 || maxI + 1 != indicies.Count || !indicies.ContainsKey(0))
|
|
{
|
|
List<MediaStream> streams = new List<MediaStream>();
|
|
for (int i = 0; i < maxI; i++)
|
|
if (indicies.TryGetValue(i, out MediaStream? a))
|
|
// Regular stream
|
|
streams.Add(a);
|
|
else
|
|
// Bogus stream to fill index position
|
|
streams.Add(new MediaStream() { Type = MediaStreamType.Data, Index = i });
|
|
result.MediaStreams = streams;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task GetPartialDataAsync(Uri src, HttpClient http, Stream dst, int maxLength, CancellationToken cancel)
|
|
{
|
|
byte[] buffer = new byte[65536];
|
|
|
|
// Let the other task have something to await for
|
|
var tReq = http.GetAsync(src, HttpCompletionOption.ResponseHeadersRead, cancel);
|
|
VolatileCurrentReadTask = tReq;
|
|
int readTotal = 0;
|
|
|
|
using (HttpResponseMessage res = await tReq)
|
|
{
|
|
var tStream = res.Content.ReadAsStreamAsync(cancel);
|
|
VolatileCurrentReadTask = tStream;
|
|
using (Stream resBody = await tStream)
|
|
{
|
|
while (readTotal < maxLength && !cancel.IsCancellationRequested)
|
|
{
|
|
var tRead = resBody.ReadAsync(buffer, 0, Math.Min(buffer.Length, maxLength - readTotal), cancel);
|
|
VolatileCurrentReadTask = tRead;
|
|
|
|
int read = await tRead;
|
|
|
|
if (read == 0)
|
|
break;
|
|
|
|
await dst.WriteAsync(buffer, 0, read, cancel);
|
|
|
|
readTotal += read;
|
|
|
|
await dst.FlushAsync(cancel);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Indicate no more data will arrive
|
|
VolatileCurrentReadTask = DownloadFinishedTask;
|
|
}
|
|
|
|
private async Task<MediaSourceInfo?> ProbeDataAsync(CinemaLib.API.Stream meta, string dataPath, IMediaEncoder mediaEncoder, CancellationToken cancel)
|
|
{
|
|
// The hardest thing to get is the video PixelFormat so
|
|
// continue cycling until we resolve it.
|
|
Task lastReadTask = _currentReadTask;
|
|
Task toWait = lastReadTask;
|
|
while (!cancel.IsCancellationRequested)
|
|
{
|
|
await toWait;
|
|
if (cancel.IsCancellationRequested)
|
|
break;
|
|
|
|
if (toWait == lastReadTask)
|
|
{
|
|
// Do the probe
|
|
MediaSourceInfo? result;
|
|
try
|
|
{
|
|
result = await mediaEncoder.GetMediaInfo(new MediaInfoRequest()
|
|
{
|
|
MediaType = DlnaProfileType.Video,
|
|
ExtractChapters = false,
|
|
MediaSource = new MediaSourceInfo()
|
|
{
|
|
Path = dataPath,
|
|
Protocol = MediaProtocol.File,
|
|
VideoType = VideoType.VideoFile,
|
|
}
|
|
}, cancel);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
result = null;
|
|
}
|
|
|
|
// Evaluate the quality of the probe
|
|
if (result != null)
|
|
{
|
|
bool allVideoStreamsHavePixelFormat = true;
|
|
int videoCount = 0;
|
|
foreach (MediaStream i in result.MediaStreams)
|
|
if (i.Type == MediaStreamType.Video)
|
|
{
|
|
allVideoStreamsHavePixelFormat &= !string.IsNullOrEmpty(i.PixelFormat);
|
|
videoCount++;
|
|
}
|
|
|
|
if (allVideoStreamsHavePixelFormat && videoCount != 0)
|
|
return result;
|
|
}
|
|
}
|
|
|
|
if (cancel.IsCancellationRequested)
|
|
break;
|
|
|
|
// Get something to wait for
|
|
// This has been just a synthetic wait
|
|
toWait = VolatileCurrentReadTask;
|
|
if (lastReadTask == DownloadFinishedTask)
|
|
throw new IOException("Video file ended before stream metadata could be fully extracted");
|
|
else if (toWait == lastReadTask)
|
|
// Still nothing waitable from the downloader
|
|
toWait = Task.Delay(WaitIfNoProgressMs);
|
|
else
|
|
lastReadTask = toWait;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
} |