From f3bc1040a0ab33a460ac16b38914929c43aed79a Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 24 Oct 2024 14:30:07 +0200 Subject: [PATCH] feat: add a generated playlist controller for the generated playlist service Added a new generated playlist controller which can be used to generate and/or preview a playlist based on the given list of _item IDs_. An _item ID_ can be a series ID without any prefix, or a series, episode or file ID with a 's', 'e', or 'f' prefix to represent their type. (no 'x' prefix for those wondering.) --- .../ShokoServiceImplementation_Entities.cs | 2 +- .../API/v3/Controllers/DashboardController.cs | 2 +- .../API/v3/Controllers/FileController.cs | 24 +-- .../API/v3/Controllers/PlaylistController.cs | 198 ++++++++++++++++++ .../API/v3/Controllers/SeriesController.cs | 2 +- .../API/v3/Models/Shoko/FileCrossReference.cs | 22 +- Shoko.Server/Services/AnimeSeriesService.cs | 91 ++++++-- .../Services/GeneratedPlaylistService.cs | 72 ++++++- 8 files changed, 354 insertions(+), 59 deletions(-) create mode 100644 Shoko.Server/API/v3/Controllers/PlaylistController.cs diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs index c590499ce..1044dcd19 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs @@ -78,7 +78,7 @@ public CL_AnimeEpisode_User GetNextUnwatchedEpisode(int animeSeriesID, int userI if (series is null) return null; - var episode = seriesService.GetNextEpisode(series, userID); + var episode = seriesService.GetNextUpEpisode(series, userID, new()); if (episode is null) return null; diff --git a/Shoko.Server/API/v3/Controllers/DashboardController.cs b/Shoko.Server/API/v3/Controllers/DashboardController.cs index 0bddf4592..eef9cf722 100644 --- a/Shoko.Server/API/v3/Controllers/DashboardController.cs +++ b/Shoko.Server/API/v3/Controllers/DashboardController.cs @@ -348,7 +348,7 @@ public ListResult GetRecentlyAddedSeries( .Select(record => RepoFactory.AnimeSeries.GetByID(record.AnimeSeriesID)) .Where(series => user.AllowedSeries(series) && (includeRestricted || !series.AniDB_Anime.IsRestricted)) - .Select(series => (series, episode: _seriesService.GetNextEpisode( + .Select(series => (series, episode: _seriesService.GetNextUpEpisode( series, user.JMMUserID, new() diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index e665236b4..b6fdf373b 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -11,13 +11,11 @@ using Microsoft.AspNetCore.StaticFiles; using Quartz; using Shoko.Models.Enums; -using Shoko.Models.Server; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.API.v3.Models.Shoko; -using Shoko.Server.API.v3.Models.Shoko.Relocation; using Shoko.Server.Models; using Shoko.Server.Providers.TraktTV; using Shoko.Server.Repositories; @@ -57,16 +55,14 @@ 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, GeneratedPlaylistService playlistService, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService, VideoLocalService vlService) : base(settingsProvider) + public FileController(TraktTVHelper traktHelper, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService, VideoLocalService vlService) : base(settingsProvider) { _traktHelper = traktHelper; - _playlistService = playlistService; _vlPlaceService = vlPlaceService; _vlUsers = vlUsers; _watchedService = watchedService; @@ -589,24 +585,6 @@ 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/API/v3/Controllers/PlaylistController.cs b/Shoko.Server/API/v3/Controllers/PlaylistController.cs new file mode 100644 index 000000000..3f6a9146b --- /dev/null +++ b/Shoko.Server/API/v3/Controllers/PlaylistController.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Server.API.Annotations; +using Shoko.Server.API.ModelBinders; +using Shoko.Server.API.v3.Models.Common; +using Shoko.Server.API.v3.Models.Shoko; +using Shoko.Server.Models; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Services; +using Shoko.Server.Settings; + +#nullable enable +namespace Shoko.Server.API.v3.Controllers; + +[ApiController, Route("/api/v{version:apiVersion}/[controller]"), ApiV3, Authorize] +public class PlaylistController : BaseController +{ + private readonly GeneratedPlaylistService _playlistService; + + private readonly AnimeSeriesRepository _seriesRepository; + + private readonly AnimeEpisodeRepository _episodeRepository; + + private readonly VideoLocalRepository _videoRepository; + + public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistService playlistService, AnimeSeriesRepository animeSeriesRepository, AnimeEpisodeRepository animeEpisodeRepository, VideoLocalRepository videoRepository) : base(settingsProvider) + { + _playlistService = playlistService; + _seriesRepository = animeSeriesRepository; + _episodeRepository = animeEpisodeRepository; + _videoRepository = videoRepository; + } + + /// + /// Generate an on-demand playlist for the specified list of items. + /// + /// The list of item IDs to include in the playlist. If no prefix is provided for an id then it will be assumed to be a series id. + /// The preferred release group ID if available. + /// Only show the next unwatched episode. + /// Include specials in the search. + /// Include other type episodes in the search. + /// Include already watched episodes in the + /// search if we determine the user is "re-watching" the series. + /// Include media info data. + /// Include absolute paths for the file locations. + /// Include file/episode cross-references with the episodes. + /// Include data from selected s. + /// + [HttpGet("Generate")] + public ActionResult)>> GetGeneratedPlaylistJson( + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null, + [FromQuery] int? releaseGroupID = null, + [FromQuery] bool onlyUnwatched = false, + [FromQuery] bool includeSpecials = false, + [FromQuery] bool includeOthers = false, + [FromQuery] bool includeRewatching = false, + [FromQuery] bool includeMediaInfo = false, + [FromQuery] bool includeAbsolutePaths = false, + [FromQuery] bool includeXRefs = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null + ) + { + var playlist = GetGeneratedPlaylistInternal(items, releaseGroupID, onlyUnwatched, includeSpecials, includeOthers, includeRewatching); + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + + return playlist + .Select(tuple => ( + new Episode(HttpContext, (tuple.episode as SVR_AnimeEpisode)!, includeDataFrom, withXRefs: includeXRefs), + tuple.videos + .Select(video => new File(HttpContext, (video as SVR_VideoLocal)!, withXRefs: includeXRefs, includeDataFrom, includeMediaInfo, includeAbsolutePaths)) + .ToList() + )) + .ToList(); + } + + /// + /// Generate an on-demand playlist for the specified list of items, as a .m3u8 file. + /// + /// The list of item IDs to include in the playlist. If no prefix is provided for an id then it will be assumed to be a series id. + /// The preferred release group ID if available. + /// Only show the next unwatched episode. + /// Include specials in the search. + /// Include other type episodes in the search. + /// Include already watched episodes in the + /// search if we determine the user is "re-watching" the series. + /// + [ProducesResponseType(typeof(FileStreamResult), 200)] + [ProducesResponseType(404)] + [Produces("application/x-mpegURL")] + [HttpGet("Generate.m3u8")] + [HttpHead("Generate.m3u8")] + public ActionResult GetGeneratedPlaylistM3U8( + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null, + [FromQuery] int? releaseGroupID = null, + [FromQuery] bool onlyUnwatched = false, + [FromQuery] bool includeSpecials = false, + [FromQuery] bool includeOthers = false, + [FromQuery] bool includeRewatching = false + ) + { + var playlist = GetGeneratedPlaylistInternal(items, releaseGroupID, onlyUnwatched, includeSpecials, includeOthers, includeRewatching); + if (!ModelState.IsValid) + return ValidationProblem(ModelState); + return _playlistService.GeneratePlaylist(playlist, "Mixed"); + } + + private IReadOnlyList<(IShokoEpisode episode, IReadOnlyList videos)> GetGeneratedPlaylistInternal( + string[]? items, + int? releaseGroupID, + bool onlyUnwatched = true, + bool includeSpecials = true, + bool includeOthers = false, + bool includeRewatching = false + ) + { + items ??= []; + var playlist = new List<(IShokoEpisode, IReadOnlyList)>(); + var index = -1; + foreach (var id in items) + { + index++; + if (string.IsNullOrEmpty(id)) + continue; + + switch (id[0]) { + case 'f': + { + if (!int.TryParse(id[1..], out var fileID) || fileID <= 0 || _videoRepository.GetByID(fileID) is not { } video) + { + ModelState.AddModelError(index.ToString(), $"Invalid file ID \"{id}\" at index {index}"); + continue; + } + + foreach (var tuple in _playlistService.GetListForVideo(video)) + playlist.Add(tuple); + break; + } + + case 'e': + { + if (!int.TryParse(id[1..], out var episodeID) || episodeID <= 0 || _episodeRepository.GetByID(episodeID) is not { } episode) + { + ModelState.AddModelError(index.ToString(), $"Invalid episode ID \"{id}\" at index {index}"); + continue; + } + + foreach (var tuple in _playlistService.GetListForEpisode(episode, releaseGroupID)) + playlist.Add(tuple); + break; + } + + case 's': + { + if (!int.TryParse(id[1..], out var seriesID) || seriesID <= 0 || _seriesRepository.GetByID(seriesID) is not { } series) + { + ModelState.AddModelError(index.ToString(), $"Invalid series ID \"{id}\" at index {index}"); + continue; + } + + foreach (var tuple in _playlistService.GetListForSeries(series, releaseGroupID, new() + { + IncludeCurrentlyWatching = !onlyUnwatched, + IncludeSpecials = includeSpecials, + IncludeOthers = includeOthers, + IncludeRewatching = includeRewatching, + })) + playlist.Add(tuple); + break; + } + + default: + { + if (!int.TryParse(id, out var seriesID) || seriesID <= 0 || _seriesRepository.GetByID(seriesID) is not { } series) + { + ModelState.AddModelError(index.ToString(), $"Invalid series ID \"{id}\" at index {index}"); + continue; + } + + foreach (var tuple in _playlistService.GetListForSeries(series, releaseGroupID, new() + { + IncludeCurrentlyWatching = !onlyUnwatched, + IncludeSpecials = includeSpecials, + IncludeOthers = includeOthers, + IncludeRewatching = includeRewatching, + })) + playlist.Add(tuple); + break; + } + } + } + return playlist; + } +} diff --git a/Shoko.Server/API/v3/Controllers/SeriesController.cs b/Shoko.Server/API/v3/Controllers/SeriesController.cs index 7c3a91541..c1770e230 100644 --- a/Shoko.Server/API/v3/Controllers/SeriesController.cs +++ b/Shoko.Server/API/v3/Controllers/SeriesController.cs @@ -2160,7 +2160,7 @@ public ActionResult GetNextUnwatchedEpisode([FromRoute, Range(1, int.Ma if (!user.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - var episode = _seriesService.GetNextEpisode(series, user.JMMUserID, new() + var episode = _seriesService.GetNextUpEpisode(series, user.JMMUserID, new() { IncludeCurrentlyWatching = !onlyUnwatched, IncludeMissing = includeMissing, diff --git a/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs b/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs index 321960986..06e66d043 100644 --- a/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs +++ b/Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; using Shoko.Server.Models; using Shoko.Server.Repositories; @@ -136,17 +138,17 @@ private static int PercentageToFileCount(int percentage) _ => 0, // anything below this we can't reliably measure. }; - public static List From(IEnumerable crossReferences) + public static List From(IEnumerable crossReferences) => crossReferences .Select(xref => { // Percentages. Tuple percentage = new(0, 100); - int? releaseGroup = xref.CrossRefSource == (int)CrossRefSource.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref.Hash, xref.FileSize)?.GroupID ?? 0 : null; + int? releaseGroup = xref.Source == DataSourceEnum.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref.ED2K, xref.Size)?.GroupID ?? 0 : null; var assumedFileCount = PercentageToFileCount(xref.Percentage); if (assumedFileCount > 1) { - var xrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.EpisodeID) + var xrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID) // Filter to only cross-references which are partially linked in the same number of parts to the episode, and from the same group as the current cross-reference. .Where(xref2 => PercentageToFileCount(xref2.Percentage) == assumedFileCount && (xref2.CrossRefSource == (int)CrossRefSource.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref2.Hash, xref2.FileSize)?.GroupID ?? -1 : null) == releaseGroup) // This will order by the "full" episode if the xref is linked to both a "full" episode and "part" episode, @@ -162,7 +164,7 @@ public static List From(IEnumerable tuple.episode?.EpisodeNumber) .ThenBy(tuple => tuple.xref.EpisodeOrder) .ToList(); - var index = xrefs.FindIndex(tuple => tuple.xref.CrossRef_File_EpisodeID == xref.CrossRef_File_EpisodeID); + var index = xrefs.FindIndex(tuple => string.Equals(tuple.xref.Hash, xref.ED2K) && tuple.xref.FileSize == xref.Size); if (index > 0) { // Note: this is bound to be inaccurate if we don't have all the files linked to the episode locally, but as long @@ -183,13 +185,13 @@ public static List From(IEnumerable From(IEnumerable From(IEnumerable tuple.xref.AniDBEpisode?.AnimeID ?? tuple.xref.AnimeID) + .GroupBy(tuple => tuple.xref.AnidbEpisode?.SeriesID ?? tuple.xref.AnidbAnimeID) .Select(tuples => { var shokoSeries = RepoFactory.AnimeSeries.GetByAnimeID(tuples.Key); diff --git a/Shoko.Server/Services/AnimeSeriesService.cs b/Shoko.Server/Services/AnimeSeriesService.cs index 49dc700c1..c1265efa1 100644 --- a/Shoko.Server/Services/AnimeSeriesService.cs +++ b/Shoko.Server/Services/AnimeSeriesService.cs @@ -873,16 +873,37 @@ public SVR_AnimeEpisode GetActiveEpisode(SVR_AnimeSeries series, int userID, boo return episode; } + #region Next-up Episode(s) +#nullable enable + /// - /// Series next-up query options for use with . + /// Series next-up query options for use with . /// - public class NextUpQueryOptions + public class NextUpQuerySingleOptions : NextUpQueryOptions { /// /// Disable the first episode in the series from showing up. /// /// public bool DisableFirstEpisode { get; set; } = false; + public NextUpQuerySingleOptions() { } + + public NextUpQuerySingleOptions(NextUpQueryOptions options) + { + IncludeCurrentlyWatching = options.IncludeCurrentlyWatching; + IncludeMissing = options.IncludeMissing; + IncludeUnaired = options.IncludeUnaired; + IncludeRewatching = options.IncludeRewatching; + IncludeSpecials = options.IncludeSpecials; + IncludeOthers = options.IncludeOthers; + } + } + + /// + /// Series next-up query options for use with . + /// + public class NextUpQueryOptions + { /// /// Include currently watching episodes in the search. /// @@ -922,17 +943,16 @@ public class NextUpQueryOptions /// User ID /// Next-up query options. /// - public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextUpQueryOptions options = null) + public SVR_AnimeEpisode? GetNextUpEpisode(SVR_AnimeSeries series, int userID, NextUpQuerySingleOptions options) { - // Initialise the options if they're not provided. - options ??= new NextUpQueryOptions(); - var episodeList = series.AnimeEpisodes - .Select(episode => (episode, episode.AniDB_Episode)) + .Select(shoko => (shoko, anidb: shoko.AniDB_Episode!)) .Where(tuple => - (tuple.AniDB_Episode.EpisodeTypeEnum is EpisodeType.Episode) || - (options.IncludeSpecials && tuple.AniDB_Episode.EpisodeTypeEnum is EpisodeType.Special) || - (options.IncludeOthers && tuple.AniDB_Episode.EpisodeTypeEnum is EpisodeType.Other) + tuple.anidb is not null && ( + (tuple.anidb.EpisodeTypeEnum is EpisodeType.Episode) || + (options.IncludeSpecials && tuple.anidb.EpisodeTypeEnum is EpisodeType.Special) || + (options.IncludeOthers && tuple.anidb.EpisodeTypeEnum is EpisodeType.Other) + ) ) .ToList(); @@ -941,7 +961,7 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU if (options.IncludeCurrentlyWatching) { var (currentlyWatchingEpisode, _) = episodeList - .SelectMany(tuple => tuple.episode.VideoLocals.Select(file => (tuple.episode, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) + .SelectMany(tuple => tuple.shoko.VideoLocals.Select(file => (tuple.shoko, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) .Where(tuple => tuple.fileUR is not null) .OrderByDescending(tuple => tuple.fileUR.LastUpdated) .FirstOrDefault(tuple => tuple.fileUR.ResumePosition > 0); @@ -951,7 +971,7 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU } // Skip check if there is an active watch session for the series, and we // don't allow active watch sessions. - else if (episodeList.Any(tuple => tuple.episode.VideoLocals.Any(file => (_vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)?.ResumePosition ?? 0) > 0))) + else if (episodeList.Any(tuple => tuple.shoko.VideoLocals.Any(file => (_vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)?.ResumePosition ?? 0) > 0))) { return null; } @@ -962,8 +982,8 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU { var order = new List() { EpisodeType.Episode, EpisodeType.Other, EpisodeType.Special }; episodeList = episodeList - .OrderBy(tuple => order.IndexOf(tuple.episode.AniDB_Episode.EpisodeTypeEnum)) - .ThenBy(tuple => tuple.episode.AniDB_Episode.EpisodeNumber) + .OrderBy(tuple => order.IndexOf(tuple.anidb.EpisodeTypeEnum)) + .ThenBy(tuple => tuple.anidb.EpisodeNumber) .ToList(); } @@ -972,7 +992,7 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU if (options.IncludeRewatching) { var (lastWatchedEpisode, _) = episodeList - .SelectMany(tuple => tuple.episode.VideoLocals.Select(file => (tuple.episode, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) + .SelectMany(tuple => tuple.shoko.VideoLocals.Select(file => (tuple.shoko, fileUR: _vlUsers.GetByUserIDAndVideoLocalID(userID, file.VideoLocalID)))) .Where(tuple => tuple.fileUR is { WatchedDate: not null }) .OrderByDescending(tuple => tuple.fileUR.LastUpdated) .FirstOrDefault(); @@ -980,13 +1000,13 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU if (lastWatchedEpisode is not null) { // Return `null` if we're on the last episode in the list. - var nextIndex = episodeList.FindIndex(tuple => tuple.episode.AnimeEpisodeID == lastWatchedEpisode.AnimeEpisodeID) + 1; + var nextIndex = episodeList.FindIndex(tuple => tuple.shoko.AnimeEpisodeID == lastWatchedEpisode.AnimeEpisodeID) + 1; if (nextIndex == episodeList.Count) return null; var (nextEpisode, _) = episodeList .Skip(nextIndex) - .FirstOrDefault(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.AniDB_Episode.HasAired : tuple => tuple.episode.VideoLocals.Count > 0 && tuple.AniDB_Episode.HasAired); + .FirstOrDefault(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.anidb.HasAired : tuple => tuple.shoko.VideoLocals.Count > 0 && tuple.anidb.HasAired); return nextEpisode; } } @@ -995,13 +1015,13 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU var (unwatchedEpisode, anidbEpisode) = episodeList .Where(tuple => { - var episodeUserRecord = tuple.episode.GetUserRecord(userID); + var episodeUserRecord = tuple.shoko.GetUserRecord(userID); if (episodeUserRecord is null) return true; return !episodeUserRecord.WatchedDate.HasValue; }) - .FirstOrDefault(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.AniDB_Episode.HasAired : tuple => tuple.episode.VideoLocals.Count > 0 && tuple.AniDB_Episode.HasAired); + .FirstOrDefault(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.anidb.HasAired : tuple => tuple.shoko.VideoLocals.Count > 0 && tuple.anidb.HasAired); // Disable first episode from showing up in the search. if (options.DisableFirstEpisode && anidbEpisode is not null && anidbEpisode.EpisodeType == (int)EpisodeType.Episode && anidbEpisode.EpisodeNumber == 1) @@ -1010,6 +1030,39 @@ public SVR_AnimeEpisode GetNextEpisode(SVR_AnimeSeries series, int userID, NextU return unwatchedEpisode; } + public IReadOnlyList GetNextUpEpisodes(SVR_AnimeSeries series, int userID, NextUpQueryOptions options) + { + var firstEpisode = GetNextUpEpisode(series, userID, new(options)); + if (firstEpisode is null) + return []; + + var order = new List() { EpisodeType.Episode, EpisodeType.Other, EpisodeType.Special }; + var allEpisodes = series.AnimeEpisodes + .Select(shoko => (shoko, anidb: shoko.AniDB_Episode!)) + .Where(tuple => + tuple.anidb is not null && ( + (tuple.anidb.EpisodeTypeEnum is EpisodeType.Episode) || + (options.IncludeSpecials && tuple.anidb.EpisodeTypeEnum is EpisodeType.Special) || + (options.IncludeOthers && tuple.anidb.EpisodeTypeEnum is EpisodeType.Other) + ) + ) + .Where(options.IncludeUnaired ? _ => true : options.IncludeMissing ? tuple => tuple.anidb.HasAired : tuple => tuple.shoko.VideoLocals.Count > 0 && tuple.anidb.HasAired) + .OrderBy(tuple => order.IndexOf(tuple.anidb.EpisodeTypeEnum)) + .ThenBy(tuple => tuple.anidb.EpisodeNumber) + .ToList(); + var index = allEpisodes.FindIndex(tuple => tuple.shoko.AnimeEpisodeID == firstEpisode.AnimeEpisodeID); + if (index == -1) + return []; + + return allEpisodes + .Skip(index) + .Select(tuple => tuple.shoko) + .ToList(); + } + +#nullable disable + #endregion + internal class EpisodeList : List { public EpisodeList(AnimeType ept) diff --git a/Shoko.Server/Services/GeneratedPlaylistService.cs b/Shoko.Server/Services/GeneratedPlaylistService.cs index 75a11ca48..b261a3b52 100644 --- a/Shoko.Server/Services/GeneratedPlaylistService.cs +++ b/Shoko.Server/Services/GeneratedPlaylistService.cs @@ -6,11 +6,17 @@ using System.Web; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Shoko.Commons.Extensions; using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.DataModels.Shoko; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Server.API; +using Shoko.Server.Models; +using Shoko.Server.Repositories.Cached; using Shoko.Server.Utilities; +using FileCrossReference = Shoko.Server.API.v3.Models.Shoko.FileCrossReference; + #nullable enable namespace Shoko.Server.Services; @@ -18,21 +24,79 @@ public class GeneratedPlaylistService { private readonly HttpContext _context; - public GeneratedPlaylistService(IHttpContextAccessor contentAccessor) + private readonly AnimeSeriesService _animeSeriesService; + + private readonly VideoLocalRepository _videoLocalRepository; + + public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSeriesService animeSeriesService, VideoLocalRepository videoLocalRepository) { _context = contentAccessor.HttpContext!; + _animeSeriesService = animeSeriesService; + _videoLocalRepository = videoLocalRepository; + } + + public IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> GetListForSeries(IShokoSeries series, int? releaseGroupID = null, AnimeSeriesService.NextUpQueryOptions? options = null) + { + options ??= new(); + options.IncludeMissing = false; + options.IncludeUnaired = false; + var user = _context.GetUser(); + var episodes = _animeSeriesService.GetNextUpEpisodes((series as SVR_AnimeSeries)!, user.JMMUserID, options); + + // Make sure the release group is in the list, otherwise pick the most used group. + var xrefs = FileCrossReference.From(series.CrossReferences).FirstOrDefault(seriesXRef => seriesXRef.SeriesID.ID == series.ID)?.EpisodeIDs ?? []; + var releaseGroups = xrefs.Select(xref => xref.ReleaseGroup ?? -1).GroupBy(xref => xref).ToDictionary(xref => xref.Key, xref => xref.Count()); + if (releaseGroupID is null || !releaseGroups.ContainsKey(releaseGroupID.Value)) + releaseGroupID = releaseGroups.MaxBy(xref => xref.Value).Key; + if (releaseGroupID is -1) + releaseGroupID = null; + + foreach (var episode in episodes) + foreach (var tuple in GetListForEpisode(episode, releaseGroupID)) + yield return tuple; } - public FileStreamResult GeneratePlaylistForVideo(IVideo video) + public IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> GetListForEpisode(IShokoEpisode episode, int? releaseGroupID = null) + { + // For now we're just re-using the logic used in the API layer. In the future it should be moved to the service layer or somewhere else. + var xrefs = FileCrossReference.From(episode.CrossReferences).FirstOrDefault(seriesXRef => seriesXRef.SeriesID.ID == episode.SeriesID)?.EpisodeIDs ?? []; + if (xrefs.Count is 0) + yield break; + + // Make sure the release group is in the list, otherwise pick the most used group. + var releaseGroups = xrefs.Select(xref => xref.ReleaseGroup ?? -1).GroupBy(xref => xref).ToDictionary(xref => xref.Key, xref => xref.Count()); + if (releaseGroupID is null || !releaseGroups.ContainsKey(releaseGroupID.Value)) + releaseGroupID = releaseGroups.MaxBy(xref => xref.Value).Key; + if (releaseGroupID is -1) + releaseGroupID = null; + + // Filter to only cross-references which from the specified release group. + xrefs = xrefs + .Where(xref => xref.ReleaseGroup == releaseGroupID) + .ToList(); + var videos = xrefs.Select(xref => _videoLocalRepository.GetByHashAndSize(xref.ED2K, xref.FileSize)) + .WhereNotNull() + .ToList(); + yield return (episode, videos); + } + + public IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> GetListForVideo(IVideo video) { var episode = video.Episodes .OrderBy(episode => episode.Type) .ThenBy(episode => episode.EpisodeNumber) .FirstOrDefault(); - return GeneratePlaylistForEpisodeList(episode is not null ? [(episode, [video])] : []); + return episode is not null ? [(episode, [video])] : []; + } + + public IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> GetListForVideos(IEnumerable videos) + { + foreach (var video in videos) + foreach (var tuple in GetListForVideo(video)) + yield return tuple; } - private FileStreamResult GeneratePlaylistForEpisodeList( + public FileStreamResult GeneratePlaylist( IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> episodeList, string name = "Playlist" )