diff --git a/CinemaJellyfin/CinemaMediaSourceManager.cs b/CinemaJellyfin/CinemaMediaSourceManager.cs index ea10219..2feb99b 100644 --- a/CinemaJellyfin/CinemaMediaSourceManager.cs +++ b/CinemaJellyfin/CinemaMediaSourceManager.cs @@ -6,7 +6,12 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -16,11 +21,8 @@ using MediaBrowser.Model.MediaInfo; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; using CinemaLib.API; -using MediaBrowser.Controller; -using MediaBrowser.Controller.MediaEncoding; using System.Runtime.CompilerServices; using LinkGenerator = CinemaLib.Webshare.LinkGenerator; -using MediaBrowser.Controller.Configuration; using Jellyfin.Extensions; namespace Jellyfin.Plugin.Cinema; @@ -55,12 +57,13 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager private readonly IMediaEncoder _mediaEncoder; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IUserManager _userManager; + private readonly IPathManager _pathManager; private readonly ConcurrentDictionary _videoVersions; private readonly ConcurrentDictionary _links; public CinemaMediaSourceManager(CinemaInnerMediaSourceManager innerMediaSourceManager, ILibraryManager libraryManager, IServerApplicationHost host, IServiceProvider svc, IHttpContextAccessor http, IHttpClientFactory httpClientFactory, IMediaEncoder mediaEncoder, - IServerConfigurationManager serverConfigurationManager, IUserManager userManager) + IServerConfigurationManager serverConfigurationManager, IUserManager userManager, IPathManager pathManager) { if (innerMediaSourceManager == null || svc == null) throw new ArgumentNullException(); @@ -77,6 +80,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager this._mediaEncoder = mediaEncoder; this._serverConfigurationManager = serverConfigurationManager; this._userManager = userManager; + this._pathManager = pathManager; this._videoVersions = new ConcurrentDictionary(); this._links = new ConcurrentDictionary(); @@ -279,7 +283,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager Uri link = await GenerateLink(metaVer.Meta.provider, metaVer.Meta.ident, metaVer.Meta.name, cancellationToken); if (metaVer.AnalyzedInfo == null) 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 { @@ -287,7 +291,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager // Note: Also makes sure BaseItems exist for each version int idx = 0; 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) { @@ -569,17 +573,17 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager 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? analyzedStreams, bool isFreeAccount, Uri path) + private async ValueTask GetVersionInfo(Video item, CinemaLib.API.Stream ver, IReadOnlyList? analyzedStreams, bool isFreeAccount, Uri path, CancellationToken cancel) { 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, analyzedStreams); + result.MediaStreams = await VersionToMediaStreams(result.Id, item, ver, analyzedStreams, cancel); double bitRate = (ver.size ?? 0.0) * 8 / (item.RunTimeTicks ?? 1.0) * TimeSpan.TicksPerSecond; bool showBitrateWarning = isFreeAccount && bitRate / (1 + BitrateMargin) > WebshareFreeBitrate; @@ -640,8 +644,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager return result; } - private static List VersionToMediaStreams(Video item, CinemaLib.API.Stream ver, IReadOnlyList? analyzedStreams) - { + private async ValueTask> VersionToMediaStreams(string mediaSourceId, Video item, CinemaLib.API.Stream ver, IReadOnlyList? analyzedStreams, CancellationToken cancel) { List result = new List(); if (analyzedStreams != null && analyzedStreams.Count != 0) @@ -652,15 +655,35 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager { 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(); - a.Index = -1; + a.Index = result.Count; a.Type = MediaStreamType.Subtitle; a.Language = ISO639_1ToISO639_2(j.language).Primary; a.IsForced = j.forced; - a.DeliveryUrl = j.src; 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 path =_pathManager.GetSubtitlePath(mediaSourceId, a.Index, ".srt"); + a.IsExternal = false; + + 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); + await File.WriteAllBytesAsync(path, contentB, cancel); + } +#endif result.Add(a); } } @@ -731,8 +754,10 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager a.Type = MediaStreamType.Subtitle; a.Language = ISO639_1ToISO639_2(j.language).Primary; a.IsForced = j.forced; - a.DeliveryUrl = j.src; - a.IsExternal = j.src != null; + if (a.IsExternal = j.src != null) { + a.Path = j.src; + a.DeliveryMethod = MediaBrowser.Model.Dlna.SubtitleDeliveryMethod.External; + } result.Add(a); } @@ -887,7 +912,7 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager return; } - public async Task GenerateLink(string provider, string ident, string? name, CancellationToken cancel) + public async ValueTask GenerateLink(string provider, string ident, string? name, CancellationToken cancel) { name = name ?? ""; DateTime now = DateTime.UtcNow; @@ -909,6 +934,20 @@ sealed class CinemaMediaSourceManager : IMediaSourceManager return entry.Link; } + private ValueTask 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); + } + /// /// Converts two-letter 639-1 language code to three letter 639-2. /// diff --git a/CinemaJellyfin/CinemaServiceRegistrator.cs b/CinemaJellyfin/CinemaServiceRegistrator.cs index c3af50e..3674bf4 100644 --- a/CinemaJellyfin/CinemaServiceRegistrator.cs +++ b/CinemaJellyfin/CinemaServiceRegistrator.cs @@ -75,13 +75,16 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator { ControllerModel? mediaInfo = application.Controllers.Where(x => x.ControllerName == "MediaInfo").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 || (getPostedPlaybackInfo = mediaInfo.Actions.Where(x => x.ActionName == "GetPostedPlaybackInfo").FirstOrDefault()) == null || dynamicHls == null || (getMasterHlsVideoPlaylist = dynamicHls.Actions.Where(x => x.ActionName == "GetMasterHlsVideoPlaylist").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."); getPostedPlaybackInfo.Filters.Add(new GetPostedPlaybackInfoMediaSourceIdFilter()); @@ -90,6 +93,8 @@ public class CinemaServiceRegistrator : IPluginServiceRegistrator getMasterHlsVideoPlaylist.Filters.Add(new DynamicHlsMediaSourceIdFilter()); getVariantHlsVideoPlaylist.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) + { + } + } } \ No newline at end of file