External subtitles work, internal are broken
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-04-14 16:03:31 +00:00
parent c9fda519d8
commit 6e80e621c0
2 changed files with 87 additions and 19 deletions

View File

@@ -6,7 +6,13 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Jellyfin.Data.Entities; using Jellyfin.Data.Entities;
using MediaBrowser.Common.Net;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Persistence;
@@ -16,11 +22,9 @@ using MediaBrowser.Model.MediaInfo;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using CinemaLib.API; using CinemaLib.API;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using LinkGenerator = CinemaLib.Webshare.LinkGenerator; using LinkGenerator = CinemaLib.Webshare.LinkGenerator;
using MediaBrowser.Controller.Configuration; using Jellyfin.Extensions;
namespace Jellyfin.Plugin.Cinema; namespace Jellyfin.Plugin.Cinema;
@@ -54,12 +58,13 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
private readonly IMediaEncoder _mediaEncoder; private readonly IMediaEncoder _mediaEncoder;
private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IApplicationPaths _pathManager;
private readonly ConcurrentDictionary<Guid, VersionSetEntry> _videoVersions; private readonly ConcurrentDictionary<Guid, VersionSetEntry> _videoVersions;
private readonly ConcurrentDictionary<LinkKey, LinkEntry> _links; private readonly ConcurrentDictionary<LinkKey, LinkEntry> _links;
public CinemaMediaSourceManager(CinemaInnerMediaSourceManager innerMediaSourceManager, ILibraryManager libraryManager, IServerApplicationHost host, public CinemaMediaSourceManager(CinemaInnerMediaSourceManager innerMediaSourceManager, ILibraryManager libraryManager, IServerApplicationHost host,
IServiceProvider svc, IHttpContextAccessor http, IHttpClientFactory httpClientFactory, IMediaEncoder mediaEncoder, IServiceProvider svc, IHttpContextAccessor http, IHttpClientFactory httpClientFactory, IMediaEncoder mediaEncoder,
IServerConfigurationManager serverConfigurationManager, IUserManager userManager) IServerConfigurationManager serverConfigurationManager, IUserManager userManager, IApplicationPaths pathManager)
{ {
if (innerMediaSourceManager == null || svc == null) if (innerMediaSourceManager == null || svc == null)
throw new ArgumentNullException(); throw new ArgumentNullException();
@@ -76,6 +81,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
this._mediaEncoder = mediaEncoder; this._mediaEncoder = mediaEncoder;
this._serverConfigurationManager = serverConfigurationManager; this._serverConfigurationManager = serverConfigurationManager;
this._userManager = userManager; this._userManager = userManager;
this._pathManager = pathManager;
this._videoVersions = new ConcurrentDictionary<Guid, VersionSetEntry>(); this._videoVersions = new ConcurrentDictionary<Guid, VersionSetEntry>();
this._links = new ConcurrentDictionary<LinkKey, LinkEntry>(); this._links = new ConcurrentDictionary<LinkKey, LinkEntry>();
@@ -273,7 +279,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
Uri link = await GenerateLink(metaVer.Meta.provider, metaVer.Meta.ident, metaVer.Meta.name, cancellationToken); Uri link = await GenerateLink(metaVer.Meta.provider, metaVer.Meta.ident, metaVer.Meta.name, cancellationToken);
if (metaVer.AnalyzedInfo == null) if (metaVer.AnalyzedInfo == null)
metaVer.AnalyzedInfo = await CinemaMediaAnalyzer.AnalyzeResourceAsync(link, metaVer.Meta.size, metaVer.Meta, _httpClientFactory, _mediaEncoder, _serverConfigurationManager, cancellationToken); metaVer.AnalyzedInfo = await CinemaMediaAnalyzer.AnalyzeResourceAsync(link, metaVer.Meta.size, metaVer.Meta, _httpClientFactory, _mediaEncoder, _serverConfigurationManager, cancellationToken);
result.Add(GetVersionInfo(videoVer, metaVer.Meta, metaVer.AnalyzedInfo.MediaStreams, isFreeAccount, link)); result.Add(await GetVersionInfo(videoVer, metaVer.Meta, metaVer.AnalyzedInfo.MediaStreams, isFreeAccount, link, cancellationToken));
} }
else else
{ {
@@ -281,7 +287,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
// Note: Also makes sure BaseItems exist for each version // Note: Also makes sure BaseItems exist for each version
int idx = 0; int idx = 0;
foreach (Video i in items) foreach (Video i in items)
result.Add(GetVersionInfo(i, ver.Versions[idx++].Meta, null, isFreeAccount, FakeVideoUri)); result.Add(await GetVersionInfo(i, ver.Versions[idx++].Meta, null, isFreeAccount, FakeVideoUri, cancellationToken));
if (sortByPrefs && ver.Versions.Length != 0 && bestIdx >= 0) if (sortByPrefs && ver.Versions.Length != 0 && bestIdx >= 0)
{ {
@@ -563,17 +569,17 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
bool isFreeAccount = await CinemaHost.IsWebshareFreeAccount(cancel); bool isFreeAccount = await CinemaHost.IsWebshareFreeAccount(cancel);
return GetVersionInfo(item, ver.Meta, null, isFreeAccount, FakeVideoUri); return await GetVersionInfo(item, ver.Meta, null, isFreeAccount, FakeVideoUri, cancel);
} }
private MediaSourceInfo GetVersionInfo(Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams, bool isFreeAccount, Uri path) private async ValueTask<MediaSourceInfo> GetVersionInfo(Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams, bool isFreeAccount, Uri path, CancellationToken cancel)
{ {
MediaSourceInfo result = new MediaSourceInfo(); MediaSourceInfo result = new MediaSourceInfo();
result.VideoType = VideoType.VideoFile; result.VideoType = VideoType.VideoFile;
result.Id = item.Id.ToString("N", CultureInfo.InvariantCulture); result.Id = item.Id.ToString("N", CultureInfo.InvariantCulture);
result.Protocol = MediaProtocol.Http; result.Protocol = MediaProtocol.Http;
// Warning: We set some properties on item that get used below // Warning: We set some properties on item that get used below
result.MediaStreams = VersionToMediaStreams(item, ver, analyzedStreams); result.MediaStreams = await VersionToMediaStreams(result.Id, item, ver, analyzedStreams, cancel);
double bitRate = (ver.size ?? 0.0) * 8 / (item.RunTimeTicks ?? 1.0) * TimeSpan.TicksPerSecond; double bitRate = (ver.size ?? 0.0) * 8 / (item.RunTimeTicks ?? 1.0) * TimeSpan.TicksPerSecond;
bool showBitrateWarning = isFreeAccount && bitRate / (1 + BitrateMargin) > WebshareFreeBitrate; bool showBitrateWarning = isFreeAccount && bitRate / (1 + BitrateMargin) > WebshareFreeBitrate;
@@ -634,8 +640,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
return result; return result;
} }
private static List<MediaStream> VersionToMediaStreams(Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams) private async ValueTask<List<MediaStream>> VersionToMediaStreams(string mediaSourceId, Video item, CinemaLib.API.Stream ver, IReadOnlyList<MediaStream>? analyzedStreams, CancellationToken cancel) {
{
List<MediaStream> result = new List<MediaStream>(); List<MediaStream> result = new List<MediaStream>();
if (analyzedStreams != null && analyzedStreams.Count != 0) if (analyzedStreams != null && analyzedStreams.Count != 0)
@@ -646,15 +651,40 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
{ {
foreach (StreamSubtitle j in ver.subtitles) foreach (StreamSubtitle j in ver.subtitles)
{ {
if (!string.IsNullOrEmpty(j.src)) if (!string.IsNullOrEmpty(j.src) && Uri.TryCreate(j.src, UriKind.Absolute, out Uri? src))
{ {
MediaStream a = new MediaStream(); MediaStream a = new MediaStream();
a.Index = -1; a.Index = result.Count;
a.Type = MediaStreamType.Subtitle; a.Type = MediaStreamType.Subtitle;
a.Language = ISO639_1ToISO639_2(j.language).Primary; a.Language = ISO639_1ToISO639_2(j.language).Primary;
a.IsForced = j.forced; a.IsForced = j.forced;
a.DeliveryUrl = j.src;
a.IsExternal = true; a.IsExternal = true;
// Note: This prevents subtitle burn-in and does not restart playback while activating
a.SupportsExternalStream = true;
a.Codec = "subrip";
#if NOT_BROKEN_GetStream
a.Path = (await GenerateLinkOrKeepUrl(src, cancel)).ToString();
#else
// BUG: SubtitleEncoder.GetStream is broken as it disposes the returned Stream
// before returning. Therefore we must cache the subtitle file locally
// HACK: We expect the ordering in ver.subtitles is stable as Jellyfin
// uses index to determine the filename
string subtitleCachePath = Path.Combine(_pathManager.DataPath, "subtitles");
string mediaSourceIdS = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture);
string folderPath = Path.Join(subtitleCachePath, mediaSourceIdS[..2], mediaSourceIdS);
string path = Path.Join(folderPath, a.Index.ToString(CultureInfo.InvariantCulture) + ".srt");
a.IsExternal = true;
a.Path = path;
if (!File.Exists(path)) {
// PERF: Subtitle files are expected not to be large (< 1 MB)
Uri link = await GenerateLinkOrKeepUrl(src, cancel);
using HttpResponseMessage response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(link, cancel);
byte[] contentB = await response.Content.ReadAsByteArrayAsync(cancel);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, contentB, cancel);
}
#endif
result.Add(a); result.Add(a);
} }
} }
@@ -725,8 +755,10 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
a.Type = MediaStreamType.Subtitle; a.Type = MediaStreamType.Subtitle;
a.Language = ISO639_1ToISO639_2(j.language).Primary; a.Language = ISO639_1ToISO639_2(j.language).Primary;
a.IsForced = j.forced; a.IsForced = j.forced;
a.DeliveryUrl = j.src; if (a.IsExternal = j.src != null) {
a.IsExternal = j.src != null; a.Path = j.src;
a.DeliveryMethod = MediaBrowser.Model.Dlna.SubtitleDeliveryMethod.External;
}
result.Add(a); result.Add(a);
} }
@@ -881,7 +913,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
return; return;
} }
public async Task<Uri> GenerateLink(string provider, string ident, string? name, CancellationToken cancel) public async ValueTask<Uri> GenerateLink(string provider, string ident, string? name, CancellationToken cancel)
{ {
name = name ?? ""; name = name ?? "";
DateTime now = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
@@ -903,6 +935,20 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager
return entry.Link; return entry.Link;
} }
private ValueTask<Uri> GenerateLinkOrKeepUrl(Uri uri, CancellationToken cancel) {
switch (uri.Host) {
case "webshare.cz":
const string WebsharePrefix = "#/file/";
if (uri.AbsolutePath == "/" && uri.Fragment.StartsWith(WebsharePrefix)) {
string ident = uri.Fragment.Substring(WebsharePrefix.Length);
return GenerateLink("webshare", ident, name: null, cancel);
}
break;
}
return new ValueTask<Uri>(uri);
}
/// <summary> /// <summary>
/// Converts two-letter 639-1 language code to three letter 639-2. /// Converts two-letter 639-1 language code to three letter 639-2.
/// </summary> /// </summary>

View File

@@ -75,13 +75,16 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator
{ {
ControllerModel? mediaInfo = application.Controllers.Where(x => x.ControllerName == "MediaInfo").FirstOrDefault(); ControllerModel? mediaInfo = application.Controllers.Where(x => x.ControllerName == "MediaInfo").FirstOrDefault();
ControllerModel? dynamicHls = application.Controllers.Where(x => x.ControllerName == "DynamicHls").FirstOrDefault(); ControllerModel? dynamicHls = application.Controllers.Where(x => x.ControllerName == "DynamicHls").FirstOrDefault();
ActionModel? getPostedPlaybackInfo, getMasterHlsVideoPlaylist, getVariantHlsVideoPlaylist, getHlsVideoSegment; ControllerModel? subtitle = application.Controllers.Where(x => x.ControllerName == "Subtitle").FirstOrDefault();
ActionModel? getPostedPlaybackInfo, getMasterHlsVideoPlaylist, getVariantHlsVideoPlaylist, getHlsVideoSegment, getSubtitleWithTicks;
if (mediaInfo == null if (mediaInfo == null
|| (getPostedPlaybackInfo = mediaInfo.Actions.Where(x => x.ActionName == "GetPostedPlaybackInfo").FirstOrDefault()) == null || (getPostedPlaybackInfo = mediaInfo.Actions.Where(x => x.ActionName == "GetPostedPlaybackInfo").FirstOrDefault()) == null
|| dynamicHls == null || dynamicHls == null
|| (getMasterHlsVideoPlaylist = dynamicHls.Actions.Where(x => x.ActionName == "GetMasterHlsVideoPlaylist").FirstOrDefault()) == null || (getMasterHlsVideoPlaylist = dynamicHls.Actions.Where(x => x.ActionName == "GetMasterHlsVideoPlaylist").FirstOrDefault()) == null
|| (getVariantHlsVideoPlaylist = dynamicHls.Actions.Where(x => x.ActionName == "GetVariantHlsVideoPlaylist").FirstOrDefault()) == null || (getVariantHlsVideoPlaylist = dynamicHls.Actions.Where(x => x.ActionName == "GetVariantHlsVideoPlaylist").FirstOrDefault()) == null
|| (getHlsVideoSegment = dynamicHls.Actions.Where(x => x.ActionName == "GetHlsVideoSegment").FirstOrDefault()) == null) || (getHlsVideoSegment = dynamicHls.Actions.Where(x => x.ActionName == "GetHlsVideoSegment").FirstOrDefault()) == null
|| subtitle == null
|| (getSubtitleWithTicks = subtitle.Actions.Where(x => x.ActionName == "GetSubtitleWithTicks").FirstOrDefault()) == null)
throw new InvalidOperationException("Failed to register for MediaSourceId extraction from the Jellyfin's MediaInfo controller."); throw new InvalidOperationException("Failed to register for MediaSourceId extraction from the Jellyfin's MediaInfo controller.");
getPostedPlaybackInfo.Filters.Add(new GetPostedPlaybackInfoMediaSourceIdFilter()); getPostedPlaybackInfo.Filters.Add(new GetPostedPlaybackInfoMediaSourceIdFilter());
@@ -90,6 +93,8 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator
getMasterHlsVideoPlaylist.Filters.Add(new DynamicHlsMediaSourceIdFilter()); getMasterHlsVideoPlaylist.Filters.Add(new DynamicHlsMediaSourceIdFilter());
getVariantHlsVideoPlaylist.Filters.Add(new DynamicHlsMediaSourceIdFilter()); getVariantHlsVideoPlaylist.Filters.Add(new DynamicHlsMediaSourceIdFilter());
getHlsVideoSegment.Filters.Add(new DynamicHlsMediaSourceIdFilter()); getHlsVideoSegment.Filters.Add(new DynamicHlsMediaSourceIdFilter());
// Slightly different parameter name
getSubtitleWithTicks.Filters.Add(new SubtitleMediaSourceIdFilter());
} }
} }
@@ -133,4 +138,21 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator
{ {
} }
} }
sealed class SubtitleMediaSourceIdFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
string? mediaSourceId;
if (!context.ActionArguments.TryGetValue("routeMediaSourceId", out object? mediaSourceIdO)
|| (mediaSourceId = mediaSourceIdO as string) == null)
throw new InvalidOperationException("Cannot extract MediaSourceId from the Jellyfin's controller Subtitle.");
context.HttpContext.Items.Add(CinemaMediaSourceManager.ContextItemsMediaSourceIdKey, mediaSourceId);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
} }