ISO639-1 to ISO639-2 conversion. TV series automatic version choice. Stream selection during playback - only transcoding working.
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2024-12-05 17:16:04 +01:00
parent 5cc359bd2c
commit a5099f60ce
2 changed files with 404 additions and 27 deletions

View File

@@ -18,6 +18,8 @@ using Microsoft.Extensions.Primitives;
using Cinema.Webshare;
using CinemaLib.API;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dlna;
namespace Jellyfin.Plugin.Cinema;
@@ -27,6 +29,10 @@ public 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 static readonly TimeSpan VersionValidityTimeout = TimeSpan.FromMinutes(180);
private static readonly TimeSpan LinkValidityTimeout = VersionValidityTimeout;
internal static readonly TimeSpan SubfolderValidityTimeout = VersionValidityTimeout;
@@ -194,6 +200,8 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
result.Add(GetVersionInfo(i, ver.Versions[idx++].Meta, thisServerBaseUri));
// 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)
foreach (MediaSourceInfo i in result)
ForceByPreferences(user, i);
@@ -237,8 +245,8 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
return items;
double? bitrateLimit = IsWebshareFreeAccount ? WebshareFreeBitrate : null;
string? audioLang = FixLangToIso(user.AudioLanguagePreference);
string? subtitleLang = FixLangToIso(user.SubtitleLanguagePreference);
string? audioLang = user.AudioLanguagePreference;
string? subtitleLang = user.SubtitleLanguagePreference;
int[] scores = new int[items.Length];
for (int i = 0; i < items.Length; i++)
{
@@ -254,7 +262,7 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
if (m.subtitles != null && subtitleLang != null)
foreach (StreamSubtitle j in m.subtitles)
if (j.language == subtitleLang)
if (ISO639_1ToISO639_2(j.language) == subtitleLang)
{
score += 4; // proper subtitles are preferred but much better resulution still rules
break;
@@ -262,7 +270,7 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
if (m.audio != null && audioLang != null)
foreach (StreamAudio j in m.audio)
if (j.language == audioLang)
if (ISO639_1ToISO639_2(j.language) == audioLang)
{
score += 25; // proper audio channel overrides resolution and other minor bonuses
break;
@@ -282,29 +290,27 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
return items;
}
private static void ForceByPreferences(User user, MediaSourceInfo item) {
private static void ForceByPreferences(User user, MediaSourceInfo item)
{
if (user == null)
// We have no info about the user so nothing to force
return;
string? audioLang = FixLangToIso(user.AudioLanguagePreference);
string? subtitleLang = FixLangToIso(user.SubtitleLanguagePreference);
string? audioLang = user.AudioLanguagePreference;
// Free account prevents seeking and thus subtitle exctraction
string? subtitleLang = IsWebshareFreeAccount ? null : user.SubtitleLanguagePreference;
foreach (var i in item.MediaStreams) {
if (i.Type == MediaStreamType.Audio && audioLang != null)
i.IsForced = audioLang == i.Language;
else if (i.Type == MediaStreamType.Subtitle && subtitleLang != null)
i.IsForced = subtitleLang == i.Language;
}
}
private static string? FixLangToIso(string? nonIso)
{
switch (nonIso)
foreach (MediaStream i in item.MediaStreams)
{
case "cze": return "cs";
case "eng": return "en";
default: return nonIso;
// 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;
}
}
@@ -548,12 +554,17 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
//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();
result.Add(a);
a.Index = uniqueId--;
a.Type = MediaStreamType.Video;
a.Width = j.width;
a.Height = j.height;
@@ -566,38 +577,137 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
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();
result.Add(a);
a.Index = uniqueId--;
a.Type = MediaStreamType.Audio;
a.Language = j.language;
a.Language = ISO639_1ToISO639_2(j.language);
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();
result.Add(a);
a.Index = uniqueId--;
a.Type = MediaStreamType.Subtitle;
a.Language = j.language;
a.Language = ISO639_1ToISO639_2(j.language);
a.IsForced = j.forced;
a.Path = j.src;
result.Add(a);
}
}
return result;
}
internal static string EncodingHelper_GetMapArgs_New(EncodingHelper @this, EncodingJobInfo state)
{
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)
@@ -673,6 +783,201 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
return entry.Link;
}
/// <summary>
/// Converts two-letter 639-1 language code to three letter 639-2.
/// </summary>
private static string? 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";
case "ab": return "abk";
case "af": return "afr";
case "ak": return "aka";
case "sq": return "alb";
case "am": return "amh";
case "ar": return "ara";
case "an": return "arg";
case "hy": return "arm";
case "as": return "asm";
case "av": return "ava";
case "ae": return "ave";
case "ay": return "aym";
case "az": return "aze";
case "ba": return "bak";
case "bm": return "bam";
case "eu": return "baq";
case "be": return "bel";
case "bn": return "ben";
case "bi": return "bis";
case "bo": return "tib";
case "bs": return "bos";
case "br": return "bre";
case "bg": return "bul";
case "my": return "bur";
case "ca": return "cat";
case "cs": return "cze";
case "ch": return "cha";
case "ce": return "che";
case "zh": return "chi";
case "cu": return "chu";
case "cv": return "chv";
case "kw": return "cor";
case "co": return "cos";
case "cr": return "cre";
case "cy": return "wel";
case "da": return "dan";
case "de": return "ger";
case "dv": return "div";
case "nl": return "dut";
case "dz": return "dzo";
case "el": return "gre";
case "en": return "eng";
case "eo": return "epo";
case "et": return "est";
case "ee": return "ewe";
case "fo": return "fao";
case "fa": return "per";
case "fj": return "fij";
case "fi": return "fin";
case "fr": return "fre";
case "fy": return "fry";
case "ff": return "ful";
case "ka": return "geo";
case "gd": return "gla";
case "ga": return "gle";
case "gl": return "glg";
case "gv": return "glv";
case "gn": return "grn";
case "gu": return "guj";
case "ht": return "hat";
case "ha": return "hau";
case "he": return "heb";
case "hz": return "her";
case "hi": return "hin";
case "ho": return "hmo";
case "hr": return "hrv";
case "hu": return "hun";
case "ig": return "ibo";
case "is": return "ice";
case "io": return "ido";
case "ii": return "iii";
case "iu": return "iku";
case "ie": return "ile";
case "ia": return "ina";
case "id": return "ind";
case "ik": return "ipk";
case "it": return "ita";
case "jv": return "jav";
case "ja": return "jpn";
case "kl": return "kal";
case "kn": return "kan";
case "ks": return "kas";
case "kr": return "kau";
case "kk": return "kaz";
case "km": return "khm";
case "ki": return "kik";
case "rw": return "kin";
case "ky": return "kir";
case "kv": return "kom";
case "kg": return "kon";
case "ko": return "kor";
case "kj": return "kua";
case "ku": return "kur";
case "lo": return "lao";
case "la": return "lat";
case "lv": return "lav";
case "li": return "lim";
case "ln": return "lin";
case "lt": return "lit";
case "lb": return "ltz";
case "lu": return "lub";
case "lg": return "lug";
case "mk": return "mac";
case "mh": return "mah";
case "ml": return "mal";
case "mi": return "mao";
case "mr": return "mar";
case "ms": return "may";
case "mg": return "mlg";
case "mt": return "mlt";
case "mn": return "mon";
case "na": return "nau";
case "nv": return "nav";
case "nr": return "nbl";
case "nd": return "nde";
case "ng": return "ndo";
case "ne": return "nep";
case "nn": return "nno";
case "nb": return "nob";
case "no": return "nor";
case "ny": return "nya";
case "oc": return "oci";
case "oj": return "oji";
case "or": return "ori";
case "om": return "orm";
case "os": return "oss";
case "pa": return "pan";
case "pi": return "pli";
case "pl": return "pol";
case "pt": return "por";
case "ps": return "pus";
case "qu": return "que";
case "rm": return "roh";
case "ro": return "rum";
case "rn": return "run";
case "ru": return "rus";
case "sg": return "sag";
case "sa": return "san";
case "si": return "sin";
case "sk": return "slo";
case "sl": return "slv";
case "se": return "sme";
case "sm": return "smo";
case "sn": return "sna";
case "sd": return "snd";
case "so": return "som";
case "st": return "sot";
case "es": return "spa";
case "sc": return "srd";
case "sr": return "srp";
case "ss": return "ssw";
case "su": return "sun";
case "sw": return "swa";
case "sv": return "swe";
case "ty": return "tah";
case "ta": return "tam";
case "tt": return "tat";
case "te": return "tel";
case "tg": return "tgk";
case "tl": return "tgl";
case "th": return "tha";
case "ti": return "tir";
case "to": return "ton";
case "tn": return "tsn";
case "ts": return "tso";
case "tk": return "tuk";
case "tr": return "tur";
case "tw": return "twi";
case "ug": return "uig";
case "uk": return "ukr";
case "ur": return "urd";
case "uz": return "uzb";
case "ve": return "ven";
case "vi": return "vie";
case "vo": return "vol";
case "wa": return "wln";
case "wo": return "wol";
case "xh": return "xho";
case "yi": return "yid";
case "yo": return "yor";
case "za": return "zha";
case "zu": return "zul";
default: return twoLetter639_1;
}
}
struct VersionSetEntry
{
internal DateTime ValidUntil;

View File

@@ -1,12 +1,16 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Controller.MediaEncoding;
using Jellyfin.Plugin.Cinema.Configuration;
using System.Linq.Expressions;
namespace Jellyfin.Plugin.Cinema;
@@ -21,6 +25,14 @@ 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 />
@@ -49,4 +61,64 @@ 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;
}
}