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