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