Skip to content

Commit

Permalink
feat: add a generated playlist controller
Browse files Browse the repository at this point in the history
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.)
  • Loading branch information
revam committed Oct 24, 2024
1 parent c2ca416 commit f3bc104
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion Shoko.Server/API/v3/Controllers/DashboardController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ public ListResult<Series> 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()
Expand Down
24 changes: 1 addition & 23 deletions Shoko.Server/API/v3/Controllers/FileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -589,24 +585,6 @@ public ActionResult GetExternalSubtitle([FromRoute, Range(1, int.MaxValue)] int
return NotFound();
}

/// <summary>
/// Generate a playlist for the specified file.
/// </summary>
/// <param name="fileID">File ID</param>
/// <returns>The m3u8 playlist.</returns>
[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);
}

/// <summary>
/// Get the MediaInfo model for file with VideoLocal ID
/// </summary>
Expand Down
198 changes: 198 additions & 0 deletions Shoko.Server/API/v3/Controllers/PlaylistController.cs
Original file line number Diff line number Diff line change
@@ -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;
}

/// <summary>
/// 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,
[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)
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();
}

/// <summary>
/// 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
)
{
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<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;
}
}
2 changes: 1 addition & 1 deletion Shoko.Server/API/v3/Controllers/SeriesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2160,7 +2160,7 @@ public ActionResult<Episode> 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,
Expand Down
22 changes: 12 additions & 10 deletions Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -136,17 +138,17 @@ private static int PercentageToFileCount(int percentage)
_ => 0, // anything below this we can't reliably measure.
};

public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episode> crossReferences)
public static List<FileCrossReference> From(IEnumerable<IVideoCrossReference> crossReferences)
=> crossReferences
.Select(xref =>
{
// Percentages.
Tuple<int, int> 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,
Expand All @@ -162,7 +164,7 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
.ThenBy(tuple => 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
Expand All @@ -183,13 +185,13 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
}
}

var shokoEpisode = xref.AnimeEpisode;
var shokoEpisode = xref.ShokoEpisode as SVR_AnimeEpisode;
return (
xref,
dto: new EpisodeCrossReferenceIDs
{
ID = shokoEpisode?.AnimeEpisodeID,
AniDB = xref.EpisodeID,
AniDB = xref.AnidbEpisodeID,
ReleaseGroup = releaseGroup,
TMDB = new()
{
Expand All @@ -213,9 +215,9 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
Start = percentage.Item1,
End = percentage.Item2,
},
ED2K = xref.Hash,
FileSize = xref.FileSize,
Source = xref.CrossRefSource == (int)CrossRefSource.AniDB ? "AniDB" : "User",
ED2K = xref.ED2K,
FileSize = xref.Size,
Source = xref.Source == DataSourceEnum.AniDB ? "AniDB" : "User",
}
);
})
Expand All @@ -226,7 +228,7 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
// we will attempt to lookup the episode to grab it's id but fallback
// to the cross-reference anime id if the episode is not locally available
// yet.
.GroupBy(tuple => 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);
Expand Down
Loading

0 comments on commit f3bc104

Please sign in to comment.