From 9182c13fca1bd4ac8fc1168d5ef09eb85795500a Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sun, 27 Oct 2024 00:53:39 +0200 Subject: [PATCH] refactor: change generated playlists (again) Added better support for multi-episode files in playlists by changing the structure from 1-N to N-N, but we restrict playlist items to a 1-N or N-1 pattern even if the playlist theoretically allows N-N items. Tweaked the playlist hydration to be more flexible, and moved the hydration logic from the API layer to the service layer. Series and episodes now use AniDB IDs, while files use either local IDs or lookup by ED2K hash, optionally with a file size provided. The query options have all been moved into the playlist near their respective item, instead of being applied globally, so we can apply the series options on a per series basis and preferred release group ID per series and/or episode. Because of the new flexibility then we can now provide episodes and files together to to watch one or more files for one or more episodes, instead of leaving the file/episode choice up to the auto-detect logic. See the example below for a made up example of how this would work **Example** Given a dehydrated playlist as follows; ```txt api/v3/Playlist/Generate?playlist=e2345+f234234,e2346,e953423+20A3246CC1BD7C513598FD49F250348B,3456,s69+onlyUnwatched+includeSpecials,e23423+e953423+20A3246CC1BD7C513598FD49F250348B-135345345345,s12+r123+includeRewatching ``` It would hydrate to the following: - `e2345+f234234`: Add episode `2345` with local file `234234`. - `e2346`: Add episode `2346` without a file specified, letting the system auto-detect the file to use. - `e953423+20A3246CC1BD7C513598FD49F250348B`: Add episode `953423` with the local file specified by the ED2K hash `20A3246CC1BD7C513598FD49F250348B`. - `3456`: Add local file `3456` without an episode specified, letting the system auto-detect the episode to use. - `s69+onlyUnwatched+includeSpecials`: Add unwatched episodes and specials from series `69`. - `e23423+e953423+20A3246CC1BD7C513598FD49F250348B-135345345345`: Add the local file specified by the ED2K hash `20A3246CC1BD7C513598FD49F250348B` and file size `135345345345` for episode `23423` and `953423`. - `s12+r123+includeRewatching`: Prefer release group `123` for series `12` and start the playlist from the first episode that's being re-watched if any, or otherwise fallback to the normal behavior. --- .../API/v3/Controllers/PlaylistController.cs | 130 +------- .../API/v3/Models/Shoko/PlaylistItem.cs | 38 +++ .../Services/GeneratedPlaylistService.cs | 309 ++++++++++++++++-- 3 files changed, 332 insertions(+), 145 deletions(-) create mode 100644 Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs diff --git a/Shoko.Server/API/v3/Controllers/PlaylistController.cs b/Shoko.Server/API/v3/Controllers/PlaylistController.cs index 3f6a9146b..287758eb0 100644 --- a/Shoko.Server/API/v3/Controllers/PlaylistController.cs +++ b/Shoko.Server/API/v3/Controllers/PlaylistController.cs @@ -2,8 +2,6 @@ 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; @@ -39,38 +37,28 @@ public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistS /// 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, + public ActionResult> GetGeneratedPlaylistJson( + [FromQuery(Name = "playlist"), ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null, [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) + if (!_playlistService.TryParsePlaylist(items ?? [], out var playlist, ModelState)) return ValidationProblem(ModelState); return playlist - .Select(tuple => ( - new Episode(HttpContext, (tuple.episode as SVR_AnimeEpisode)!, includeDataFrom, withXRefs: includeXRefs), + .Select(tuple => new PlaylistItem( + tuple.episodes + .Select(episode => new Episode(HttpContext, (episode as SVR_AnimeEpisode)!, includeDataFrom, withXRefs: includeXRefs)) + .ToList(), tuple.videos .Select(video => new File(HttpContext, (video as SVR_VideoLocal)!, withXRefs: includeXRefs, includeDataFrom, includeMediaInfo, includeAbsolutePaths)) .ToList() @@ -82,12 +70,6 @@ public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistS /// 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)] @@ -95,104 +77,12 @@ public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistS [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 + [FromQuery(Name = "playlist"), ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null ) { - var playlist = GetGeneratedPlaylistInternal(items, releaseGroupID, onlyUnwatched, includeSpecials, includeOthers, includeRewatching); - if (!ModelState.IsValid) + if (!_playlistService.TryParsePlaylist(items ?? [], out var playlist, ModelState)) 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; + return _playlistService.GeneratePlaylist(playlist, "Mixed"); } } diff --git a/Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs b/Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs new file mode 100644 index 000000000..f8b3091e0 --- /dev/null +++ b/Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Shoko.Server.API.v3.Models.Shoko; + +/// +/// Playlist item. +/// +public class PlaylistItem +{ + /// + /// The main episode for the playlist item. + /// + public Episode Episode { get; } + + /// + /// Any additional episodes for the playlist item, if any. + /// + public IReadOnlyList AdditionalEpisodes { get; } + + /// + /// All file parts for the playlist item. + /// + public IReadOnlyList Parts { get; } + + /// + /// Initializes a new . + /// + /// Episodes. + /// Files. + public PlaylistItem(IReadOnlyList episodes, IReadOnlyList files) + { + Episode = episodes[0]; + AdditionalEpisodes = episodes.Skip(1).ToList(); + Parts = files; + } +} + diff --git a/Shoko.Server/Services/GeneratedPlaylistService.cs b/Shoko.Server/Services/GeneratedPlaylistService.cs index 68d387be3..8bd0966a4 100644 --- a/Shoko.Server/Services/GeneratedPlaylistService.cs +++ b/Shoko.Server/Services/GeneratedPlaylistService.cs @@ -6,6 +6,7 @@ using System.Web; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Shoko.Commons.Extensions; using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.DataModels.Shoko; @@ -17,6 +18,7 @@ using FileCrossReference = Shoko.Server.API.v3.Models.Shoko.FileCrossReference; +#pragma warning disable CA1822 #nullable enable namespace Shoko.Server.Services; @@ -26,16 +28,279 @@ public class GeneratedPlaylistService private readonly AnimeSeriesService _animeSeriesService; - private readonly VideoLocalRepository _videoLocalRepository; + private readonly AnimeSeriesRepository _seriesRepository; - public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSeriesService animeSeriesService, VideoLocalRepository videoLocalRepository) + private readonly AnimeEpisodeRepository _episodeRepository; + + private readonly VideoLocalRepository _videoRepository; + + public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSeriesService animeSeriesService, AnimeSeriesRepository seriesRepository, AnimeEpisodeRepository episodeRepository, VideoLocalRepository videoRepository) { _context = contentAccessor.HttpContext!; _animeSeriesService = animeSeriesService; - _videoLocalRepository = videoLocalRepository; + _seriesRepository = seriesRepository; + _episodeRepository = episodeRepository; + _videoRepository = videoRepository; + } + + public bool TryParsePlaylist(string[] items, out IReadOnlyList<(IReadOnlyList episodes, IReadOnlyList videos)> playlist, ModelStateDictionary? modelState = null, string fieldName = "playlist") + { + modelState ??= new(); + playlist = ParsePlaylist(items, modelState, fieldName); + return modelState.IsValid; } - public IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> GetListForSeries(IShokoSeries series, int? releaseGroupID = null, AnimeSeriesService.NextUpQueryOptions? options = null) + public IReadOnlyList<(IReadOnlyList episodes, IReadOnlyList videos)> ParsePlaylist(string[] items, ModelStateDictionary? modelState = null, string fieldName = "playlist") + { + items ??= []; + var playlist = new List<(IReadOnlyList episodes, IReadOnlyList videos)>(); + var index = -1; + foreach (var item in items) + { + index++; + if (string.IsNullOrEmpty(item)) + continue; + + var releaseGroupID = -2; + var subItems = item.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (subItems.Any(subItem => subItem[0] == 's')) + { + var seriesItem = subItems.First(subItem => subItem[0] == 's'); + var releaseItem = subItems.FirstOrDefault(subItem => subItem[0] == 'r'); + if (releaseItem is not null) + { + if (subItems.Length > 2) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid item \"{item}\"."); + continue; + } + + if (!int.TryParse(releaseItem[1..], out releaseGroupID) || releaseGroupID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid release group ID \"{releaseItem}\"."); + continue; + } + } + else if (subItems.Length > 1) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid item \"{item}\"."); + continue; + } + + var endIndex = seriesItem.IndexOf('+'); + if (endIndex == -1) + endIndex = seriesItem.Length; + var plusExtras = endIndex == seriesItem.Length ? [] : seriesItem[(endIndex + 1)..].Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (!int.TryParse(seriesItem[1..endIndex], out var seriesID) || seriesID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Invalid series ID \"{item}\"."); + continue; + } + if (_seriesRepository.GetByAnimeID(seriesID) is not { } series) + { + modelState?.AddModelError($"{fieldName}[{index}]", $"Unknown series ID \"{item}\"."); + continue; + } + + // Check if we've included any extra options. + var onlyUnwatched = false; + var includeSpecials = false; + var includeOthers = false; + var includeRewatching = false; + if (plusExtras.Length > 0) + { + if (plusExtras.Contains("onlyUnwatched")) + onlyUnwatched = true; + if (plusExtras.Contains("includeSpecials")) + includeSpecials = true; + if (plusExtras.Contains("includeOthers")) + includeOthers = true; + if (plusExtras.Contains("includeRewatching")) + includeRewatching = true; + } + + // Get the playlist items for the series. + foreach (var tuple in GetListForSeries( + series, + releaseGroupID, + new() + { + IncludeCurrentlyWatching = !onlyUnwatched, + IncludeSpecials = includeSpecials, + IncludeOthers = includeOthers, + IncludeRewatching = includeRewatching, + } + )) + playlist.Add(tuple); + + continue; + } + + var offset = -1; + var episodes = new List(); + var videos = new List(); + foreach (var subItem in subItems) + { + offset++; + var rawValue = subItem; + switch (subItem[0]) + { + case 'r': + { + if (releaseGroupID is not -2) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unexpected release group ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + if (!int.TryParse(rawValue[1..], out releaseGroupID) || releaseGroupID < -1) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid release group ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + break; + } + + case 'e': + { + if (!int.TryParse(rawValue[1..], out var episodeID) || episodeID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid episode ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + if (_episodeRepository.GetByAniDBEpisodeID(episodeID) is not { } extraEpisode) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown episode ID \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + episodes.Add(extraEpisode); + break; + } + + case 'f': + rawValue = rawValue[1..]; + goto default; + + default: + { + // Lookup by ED2K (optionally also by file size) + if (rawValue.Length >= 32) + { + var ed2kHash = rawValue[0..32]; + var fileSize = 0L; + if (rawValue[32] == '-') + { + if (!long.TryParse(rawValue[33..], out fileSize) || fileSize <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid file size \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + } + if ((fileSize > 0 ? _videoRepository.GetByHashAndSize(ed2kHash, fileSize) : _videoRepository.GetByHash(ed2kHash)) is not { } video0) + { + if (fileSize == 0) + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown hash \"{rawValue}\" at index {index} at offset {offset}"); + else + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown hash/size pair \"{rawValue}\" at index {index} at offset {offset}"); + continue; + } + videos.Add(video0); + continue; + } + + // Lookup by file ID + if (!int.TryParse(rawValue, out var fileID) || fileID <= 0) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Invalid file ID \"{rawValue}\"."); + continue; + } + if (_videoRepository.GetByID(fileID) is not { } video) + { + modelState?.AddModelError($"{fieldName}[{index}][{offset}]", $"Unknown file ID \"{rawValue}\"."); + continue; + } + videos.Add(video); + break; + } + } + } + + // Make sure all the videos and episodes are connected for each item. + // This will generally allow 1-N and N-1 relationships, but not N-N relationships. + foreach (var video in videos) + { + foreach (var episode in episodes) + { + if (video.Episodes.Any(x => x.ID == episode.ID)) + continue; + modelState?.AddModelError($"{fieldName}[{index}]", $"Video ID \"{video.ID}\" does not belong to episode ID \"{episode.AnidbEpisodeID}\"."); + continue; + } + } + + // Skip adding it to the playlist if it's empty. + if (episodes.Count == 0 && videos.Count == 0) + continue; + + // Add video to playlist. + if (episodes.Count is 0) + { + if (releaseGroupID is not -2) + { + modelState?.AddModelError($"{fieldName}[{index}]", "Cannot specify a release group ID for a video."); + continue; + } + + if (videos.Count > 1) + { + modelState?.AddModelError($"{fieldName}[{index}]", "Cannot combine multiple videos."); + continue; + } + + foreach (var tuple in GetListForVideo(videos[0])) + playlist.Add(tuple); + continue; + } + + // Add episode to playlist. + if (videos.Count is 0) + { + if (episodes.Count > 1) + { + modelState?.AddModelError($"{fieldName}[{index}]", "Cannot combine multiple episodes."); + continue; + } + + foreach (var tuple in GetListForEpisode(episodes[0], releaseGroupID)) + playlist.Add(tuple); + continue; + } + + // Add video and episode combination to the playlist. + playlist.Add((episodes, videos)); + } + + // Combine episodes with the same video into a single playlist entry. + index = 1; + while (index < playlist.Count) + { +#pragma warning disable IDE0042 + var current = playlist[index]; + var previous = playlist[index - 1]; +#pragma warning restore IDE0042 + if (previous.videos.Count is 1 && current.videos.Count is 1 && previous.videos[0].ID == current.videos[0].ID) + { + previous.episodes = [.. previous.episodes, .. current.episodes]; + playlist.RemoveAt(index); + continue; + } + + index++; + } + + return playlist; + } + + private IEnumerable<(IReadOnlyList episodes, IReadOnlyList videos)> GetListForSeries(IShokoSeries series, int? releaseGroupID = null, AnimeSeriesService.NextUpQueryOptions? options = null) { options ??= new(); options.IncludeMissing = false; @@ -45,8 +310,8 @@ public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSerie // 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)) + var releaseGroups = xrefs.GroupBy(xref => xref.ReleaseGroup ?? -1).ToDictionary(xref => xref.Key, xref => xref.Count()); + if (releaseGroups.Count > 0 && (releaseGroupID is null || !releaseGroups.ContainsKey(releaseGroupID.Value))) releaseGroupID = releaseGroups.MaxBy(xref => xref.Value).Key; if (releaseGroupID is -1) releaseGroupID = null; @@ -56,7 +321,7 @@ public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSerie yield return tuple; } - public IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> GetListForEpisode(IShokoEpisode episode, int? releaseGroupID = null) + private IEnumerable<(IReadOnlyList episodes, 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 ?? []; @@ -64,8 +329,8 @@ public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSerie 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)) + var releaseGroups = xrefs.GroupBy(xref => xref.ReleaseGroup ?? -1).ToDictionary(xref => xref.Key, xref => xref.Count()); + if (releaseGroups.Count > 0 && (releaseGroupID is null || !releaseGroups.ContainsKey(releaseGroupID.Value))) releaseGroupID = releaseGroups.MaxBy(xref => xref.Value).Key; if (releaseGroupID is -1) releaseGroupID = null; @@ -74,30 +339,23 @@ public GeneratedPlaylistService(IHttpContextAccessor contentAccessor, AnimeSerie xrefs = xrefs .Where(xref => xref.ReleaseGroup == releaseGroupID) .ToList(); - var videos = xrefs.Select(xref => _videoLocalRepository.GetByHashAndSize(xref.ED2K, xref.FileSize)) + var videos = xrefs.Select(xref => _videoRepository.GetByHashAndSize(xref.ED2K, xref.FileSize)) .WhereNotNull() .ToList(); - yield return (episode, videos); + yield return ([episode], videos); } - public IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> GetListForVideo(IVideo video) + private IEnumerable<(IReadOnlyList episodes, IReadOnlyList videos)> GetListForVideo(IVideo video) { var episode = video.Episodes .OrderBy(episode => episode.Type) .ThenBy(episode => episode.EpisodeNumber) .FirstOrDefault(); - 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; + return episode is not null ? [([episode], [video])] : []; } public FileStreamResult GeneratePlaylist( - IEnumerable<(IShokoEpisode ep, IReadOnlyList videos)> episodeList, + IEnumerable<(IReadOnlyList episodes, IReadOnlyList videos)> playlist, string name = "Playlist" ) { @@ -110,15 +368,15 @@ public FileStreamResult GeneratePlaylist( request.PathBase, null ); - foreach (var (episode, videos) in episodeList) + foreach (var (episodes, videos) in playlist) { - var series = episode.Series; + var series = episodes[0].Series; if (series is null) continue; var index = 0; foreach (var video in videos) - m3U8.Append(GetEpisodeEntry(new UriBuilder(uri.ToString()), series, episode, video, ++index, videos.Count)); + m3U8.Append(GetEpisodeEntry(new UriBuilder(uri.ToString()), series, episodes[0], video, ++index, videos.Count, episodes.Count)); } var bytes = Encoding.UTF8.GetBytes(m3U8.ToString()); @@ -129,7 +387,7 @@ public FileStreamResult GeneratePlaylist( }; } - private static string GetEpisodeEntry(UriBuilder uri, IShokoSeries series, IShokoEpisode episode, IVideo video, int part, int totalParts) + private static string GetEpisodeEntry(UriBuilder uri, IShokoSeries series, IShokoEpisode episode, IVideo video, int part, int totalParts, int episodeRange) { var poster = series.GetPreferredImageForType(ImageEntityType.Poster) ?? series.DefaultPoster; var parts = totalParts > 1 ? $" ({part}/{totalParts})" : string.Empty; @@ -149,6 +407,7 @@ private static string GetEpisodeEntry(UriBuilder uri, IShokoSeries series, IShok queryString.Add("epId", episode.AnidbEpisodeID.ToString()); queryString.Add("episodeName", episode.PreferredTitle); queryString.Add("epNo", episodeNumber); + queryString.Add("epNoRange", episodeRange.ToString()); queryString.Add("epCount", series.EpisodeCounts.Episodes.ToString()); if (totalParts > 1) {