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)
{