From 7c760e2c9999c7e880610b9a31d2dbb9ca08dea2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Tue, 22 Oct 2024 14:11:21 +0200 Subject: [PATCH] feat: add generated playlist service - Add a new generated playlist service to generate m3u8 playlists for media player consumption. For now it only has a method to generate a playlist for a single video, but the ground work has been laid to support multi video/episode playlists in the future. - Add a new endpoint to get a single-entry playlist for a file (generated on-demand, of course). --- Shoko.Server/API/APIExtensions.cs | 2 + .../API/v3/Controllers/FileController.cs | 22 ++++- .../Services/GeneratedPlaylistService.cs | 96 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 Shoko.Server/Services/GeneratedPlaylistService.cs diff --git a/Shoko.Server/API/APIExtensions.cs b/Shoko.Server/API/APIExtensions.cs index dd8429dc0..786b4690a 100644 --- a/Shoko.Server/API/APIExtensions.cs +++ b/Shoko.Server/API/APIExtensions.cs @@ -25,6 +25,7 @@ using Shoko.Server.API.v3.Models.Shoko; using Shoko.Server.API.WebUI; using Shoko.Server.Plugin; +using Shoko.Server.Services; using Shoko.Server.Utilities; using File = System.IO.File; using AniDBEmitter = Shoko.Server.API.SignalR.Aggregate.AniDBEmitter; @@ -49,6 +50,7 @@ public static IServiceCollection AddAPI(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index e117f6337..e665236b4 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -57,14 +57,16 @@ public class FileController : BaseController private readonly TraktTVHelper _traktHelper; private readonly ISchedulerFactory _schedulerFactory; + private readonly GeneratedPlaylistService _playlistService; private readonly VideoLocalService _vlService; private readonly VideoLocal_PlaceService _vlPlaceService; private readonly VideoLocal_UserRepository _vlUsers; private readonly WatchedStatusService _watchedService; - public FileController(TraktTVHelper traktHelper, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService, VideoLocalService vlService) : base(settingsProvider) + public FileController(TraktTVHelper traktHelper, ISchedulerFactory schedulerFactory, GeneratedPlaylistService playlistService, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService, VideoLocalService vlService) : base(settingsProvider) { _traktHelper = traktHelper; + _playlistService = playlistService; _vlPlaceService = vlPlaceService; _vlUsers = vlUsers; _watchedService = watchedService; @@ -587,6 +589,24 @@ public ActionResult GetExternalSubtitle([FromRoute, Range(1, int.MaxValue)] int return NotFound(); } + /// + /// Generate a playlist for the specified file. + /// + /// File ID + /// The m3u8 playlist. + [ProducesResponseType(typeof(FileStreamResult), 200)] + [ProducesResponseType(404)] + [Produces("application/x-mpegURL")] + [HttpGet("{fileID}/Stream.m3u8")] + [HttpHead("{fileID}/Stream.m3u8")] + public ActionResult GetFileStreamPlaylist([FromRoute, Range(1, int.MaxValue)] int fileID) + { + if (RepoFactory.VideoLocal.GetByID(fileID) is not { } file) + return NotFound(FileNotFoundWithFileID); + + return _playlistService.GeneratePlaylistForVideo(file); + } + /// /// Get the MediaInfo model for file with VideoLocal ID /// diff --git a/Shoko.Server/Services/GeneratedPlaylistService.cs b/Shoko.Server/Services/GeneratedPlaylistService.cs new file mode 100644 index 000000000..35c6cb2bb --- /dev/null +++ b/Shoko.Server/Services/GeneratedPlaylistService.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.Utilities; + +#nullable enable +namespace Shoko.Server.Services; + +public class GeneratedPlaylistService +{ + private readonly HttpContext _context; + + public GeneratedPlaylistService(IHttpContextAccessor contentAccessor) + { + _context = contentAccessor.HttpContext!; + } + + public FileStreamResult GeneratePlaylistForVideo(IVideo video) + { + var episode = video.CrossReferences + .Select(xref => xref.AnidbEpisode) + .WhereNotNull() + .OrderBy(episode => episode.Type) + .ThenBy(episode => episode.EpisodeNumber) + .FirstOrDefault(); + return GeneratePlaylistForEpisodeList(episode is not null ? [(episode, [video])] : []); + } + + private FileStreamResult GeneratePlaylistForEpisodeList( + IEnumerable<(IEpisode ep, IReadOnlyList videos)> episodeList, + string name = "Playlist" + ) + { + var m3U8 = new StringBuilder("#EXTM3U\n"); + var request = _context.Request; + var uri = new UriBuilder( + request.Scheme, + request.Host.Host, + request.Host.Port ?? (request.Scheme == "https" ? 443 : 80), + request.PathBase, + null + ); + foreach (var (episode, videos) in episodeList) + { + var anime = episode.Series; + if (anime is null) + continue; + + var index = 0; + foreach (var video in videos) + m3U8.Append(GetEpisodeEntry(new UriBuilder(uri.ToString()), anime, episode, video, ++index, videos.Count)); + } + + var bytes = Encoding.UTF8.GetBytes(m3U8.ToString()); + var stream = new MemoryStream(bytes); + return new FileStreamResult(stream, "application/x-mpegURL") + { + FileDownloadName = $"{name}.m3u8", + }; + } + + private static string GetEpisodeEntry(UriBuilder uri, ISeries anime, IEpisode episode, IVideo video, int part, int totalParts) + { + var poster = anime.GetPreferredImageForType(ImageEntityType.Poster) ?? anime.DefaultPoster; + var parts = totalParts > 1 ? $" ({part}/{totalParts})" : string.Empty; + var episodeNumber = episode.Type is EpisodeType.Episode + ? episode.EpisodeNumber.ToString() + : $"{episode.Type.ToString()[0]}{episode.EpisodeNumber}"; + var queryString = HttpUtility.ParseQueryString(string.Empty); + queryString.Add("shokoVersion", Utils.GetApplicationVersion()); + + // These fields are for media player plugins to consume. + if (poster is not null && !string.IsNullOrEmpty(poster.RemoteURL)) + queryString.Add("posterUrl", poster.RemoteURL); + queryString.Add("appId", "07a58b50-5109-5aa3-abbc-782fed0df04f"); // plugin id + queryString.Add("animeId", anime.ID.ToString()); + queryString.Add("animeName", anime.PreferredTitle); + queryString.Add("epId", episode.ID.ToString()); + queryString.Add("episodeName", episode.PreferredTitle); + queryString.Add("epNo", episodeNumber); + queryString.Add("epCount", anime.EpisodeCounts.Episodes.ToString()); + queryString.Add("restricted", anime.Restricted ? "true" : "false"); + + uri.Path = $"{(uri.Path.Length > 1 ? uri.Path + "/" : "/")}api/v3/File/{video.ID}/Stream"; + uri.Query = queryString.ToString(); + return $"#EXTINF:-1,{episode.PreferredTitle}{parts}\n{uri}\n"; + } +}