Skip to content

Commit

Permalink
feat: add generated playlist service
Browse files Browse the repository at this point in the history
- 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).
  • Loading branch information
revam committed Oct 22, 2024
1 parent ef43f1e commit 7c760e2
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 1 deletion.
2 changes: 2 additions & 0 deletions Shoko.Server/API/APIExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -49,6 +50,7 @@ public static IServiceCollection AddAPI(this IServiceCollection services)
services.AddSingleton<AVDumpEmitter>();
services.AddSingleton<NetworkEmitter>();
services.AddSingleton<QueueEmitter>();
services.AddScoped<GeneratedPlaylistService>();
services.AddScoped<FilterFactory>();
services.AddScoped<WebUIFactory>();

Expand Down
22 changes: 21 additions & 1 deletion Shoko.Server/API/v3/Controllers/FileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -587,6 +589,24 @@ public ActionResult GetExternalSubtitle([FromRoute, Range(1, int.MaxValue)] int
return NotFound();
}

/// <summary>
/// Generate a playlist for the specified file.
/// </summary>
/// <param name="fileID">File ID</param>
/// <returns>The m3u8 playlist.</returns>
[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);
}

/// <summary>
/// Get the MediaInfo model for file with VideoLocal ID
/// </summary>
Expand Down
96 changes: 96 additions & 0 deletions Shoko.Server/Services/GeneratedPlaylistService.cs
Original file line number Diff line number Diff line change
@@ -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<IVideo> 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";
}
}

0 comments on commit 7c760e2

Please sign in to comment.