Skip to content

Commit

Permalink
refactor: change generated playlists (again)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
revam committed Oct 26, 2024
1 parent 991ac1c commit 9182c13
Show file tree
Hide file tree
Showing 3 changed files with 332 additions and 145 deletions.
130 changes: 10 additions & 120 deletions Shoko.Server/API/v3/Controllers/PlaylistController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,38 +37,28 @@ public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistS
/// Generate an on-demand playlist for the specified list of items.
/// </summary>
/// <param name="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.</param>
/// <param name="releaseGroupID">The preferred release group ID if available.</param>
/// <param name="onlyUnwatched">Only show the next unwatched episode.</param>
/// <param name="includeSpecials">Include specials in the search.</param>
/// <param name="includeOthers">Include other type episodes in the search.</param>
/// <param name="includeRewatching">Include already watched episodes in the
/// search if we determine the user is "re-watching" the series.</param>
/// <param name="includeMediaInfo">Include media info data.</param>
/// <param name="includeAbsolutePaths">Include absolute paths for the file locations.</param>
/// <param name="includeXRefs">Include file/episode cross-references with the episodes.</param>
/// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param>
/// <returns></returns>
[HttpGet("Generate")]
public ActionResult<IReadOnlyList<(Episode, List<File>)>> 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<IReadOnlyList<PlaylistItem>> 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<DataSource>? 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()
Expand All @@ -82,117 +70,19 @@ public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistS
/// Generate an on-demand playlist for the specified list of items, as a .m3u8 file.
/// </summary>
/// <param name="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.</param>
/// <param name="releaseGroupID">The preferred release group ID if available.</param>
/// <param name="onlyUnwatched">Only show the next unwatched episode.</param>
/// <param name="includeSpecials">Include specials in the search.</param>
/// <param name="includeOthers">Include other type episodes in the search.</param>
/// <param name="includeRewatching">Include already watched episodes in the
/// search if we determine the user is "re-watching" the series.</param>
/// <returns></returns>
[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
[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<IVideo> 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<IVideo>)>();
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");
}
}
38 changes: 38 additions & 0 deletions Shoko.Server/API/v3/Models/Shoko/PlaylistItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;

namespace Shoko.Server.API.v3.Models.Shoko;

/// <summary>
/// Playlist item.
/// </summary>
public class PlaylistItem
{
/// <summary>
/// The main episode for the playlist item.
/// </summary>
public Episode Episode { get; }

/// <summary>
/// Any additional episodes for the playlist item, if any.
/// </summary>
public IReadOnlyList<Episode> AdditionalEpisodes { get; }

/// <summary>
/// All file parts for the playlist item.
/// </summary>
public IReadOnlyList<File> Parts { get; }

/// <summary>
/// Initializes a new <see cref="PlaylistItem"/>.
/// </summary>
/// <param name="episodes">Episodes.</param>
/// <param name="files">Files.</param>
public PlaylistItem(IReadOnlyList<Episode> episodes, IReadOnlyList<File> files)
{
Episode = episodes[0];
AdditionalEpisodes = episodes.Skip(1).ToList();
Parts = files;
}
}

Loading

0 comments on commit 9182c13

Please sign in to comment.