Files
stream-cinema/CinemaJellyfin/CinemaMediaAnalyzer.cs
Roman Vanicek ac0ee442d8
Some checks failed
continuous-integration/drone/push Build is failing
Embedded subtitles work. External not yet tested.
2025-04-13 23:24:29 +00:00

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