Skip to content

Commit f3bc104

Browse files
committed
feat: add a generated playlist controller
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.)
1 parent c2ca416 commit f3bc104

File tree

8 files changed

+354
-59
lines changed

8 files changed

+354
-59
lines changed

Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public CL_AnimeEpisode_User GetNextUnwatchedEpisode(int animeSeriesID, int userI
7878
if (series is null)
7979
return null;
8080

81-
var episode = seriesService.GetNextEpisode(series, userID);
81+
var episode = seriesService.GetNextUpEpisode(series, userID, new());
8282
if (episode is null)
8383
return null;
8484

Shoko.Server/API/v3/Controllers/DashboardController.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ public ListResult<Series> GetRecentlyAddedSeries(
348348
.Select(record => RepoFactory.AnimeSeries.GetByID(record.AnimeSeriesID))
349349
.Where(series => user.AllowedSeries(series) &&
350350
(includeRestricted || !series.AniDB_Anime.IsRestricted))
351-
.Select(series => (series, episode: _seriesService.GetNextEpisode(
351+
.Select(series => (series, episode: _seriesService.GetNextUpEpisode(
352352
series,
353353
user.JMMUserID,
354354
new()

Shoko.Server/API/v3/Controllers/FileController.cs

+1-23
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@
1111
using Microsoft.AspNetCore.StaticFiles;
1212
using Quartz;
1313
using Shoko.Models.Enums;
14-
using Shoko.Models.Server;
1514
using Shoko.Server.API.Annotations;
1615
using Shoko.Server.API.ModelBinders;
1716
using Shoko.Server.API.v3.Helpers;
1817
using Shoko.Server.API.v3.Models.Common;
1918
using Shoko.Server.API.v3.Models.Shoko;
20-
using Shoko.Server.API.v3.Models.Shoko.Relocation;
2119
using Shoko.Server.Models;
2220
using Shoko.Server.Providers.TraktTV;
2321
using Shoko.Server.Repositories;
@@ -57,16 +55,14 @@ public class FileController : BaseController
5755

5856
private readonly TraktTVHelper _traktHelper;
5957
private readonly ISchedulerFactory _schedulerFactory;
60-
private readonly GeneratedPlaylistService _playlistService;
6158
private readonly VideoLocalService _vlService;
6259
private readonly VideoLocal_PlaceService _vlPlaceService;
6360
private readonly VideoLocal_UserRepository _vlUsers;
6461
private readonly WatchedStatusService _watchedService;
6562

66-
public FileController(TraktTVHelper traktHelper, ISchedulerFactory schedulerFactory, GeneratedPlaylistService playlistService, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService, VideoLocalService vlService) : base(settingsProvider)
63+
public FileController(TraktTVHelper traktHelper, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService, VideoLocalService vlService) : base(settingsProvider)
6764
{
6865
_traktHelper = traktHelper;
69-
_playlistService = playlistService;
7066
_vlPlaceService = vlPlaceService;
7167
_vlUsers = vlUsers;
7268
_watchedService = watchedService;
@@ -589,24 +585,6 @@ public ActionResult GetExternalSubtitle([FromRoute, Range(1, int.MaxValue)] int
589585
return NotFound();
590586
}
591587

592-
/// <summary>
593-
/// Generate a playlist for the specified file.
594-
/// </summary>
595-
/// <param name="fileID">File ID</param>
596-
/// <returns>The m3u8 playlist.</returns>
597-
[ProducesResponseType(typeof(FileStreamResult), 200)]
598-
[ProducesResponseType(404)]
599-
[Produces("application/x-mpegURL")]
600-
[HttpGet("{fileID}/Stream.m3u8")]
601-
[HttpHead("{fileID}/Stream.m3u8")]
602-
public ActionResult GetFileStreamPlaylist([FromRoute, Range(1, int.MaxValue)] int fileID)
603-
{
604-
if (RepoFactory.VideoLocal.GetByID(fileID) is not { } file)
605-
return NotFound(FileNotFoundWithFileID);
606-
607-
return _playlistService.GeneratePlaylistForVideo(file);
608-
}
609-
610588
/// <summary>
611589
/// Get the MediaInfo model for file with VideoLocal ID
612590
/// </summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using Microsoft.AspNetCore.Authorization;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Shoko.Plugin.Abstractions.DataModels;
6+
using Shoko.Plugin.Abstractions.DataModels.Shoko;
7+
using Shoko.Server.API.Annotations;
8+
using Shoko.Server.API.ModelBinders;
9+
using Shoko.Server.API.v3.Models.Common;
10+
using Shoko.Server.API.v3.Models.Shoko;
11+
using Shoko.Server.Models;
12+
using Shoko.Server.Repositories.Cached;
13+
using Shoko.Server.Services;
14+
using Shoko.Server.Settings;
15+
16+
#nullable enable
17+
namespace Shoko.Server.API.v3.Controllers;
18+
19+
[ApiController, Route("/api/v{version:apiVersion}/[controller]"), ApiV3, Authorize]
20+
public class PlaylistController : BaseController
21+
{
22+
private readonly GeneratedPlaylistService _playlistService;
23+
24+
private readonly AnimeSeriesRepository _seriesRepository;
25+
26+
private readonly AnimeEpisodeRepository _episodeRepository;
27+
28+
private readonly VideoLocalRepository _videoRepository;
29+
30+
public PlaylistController(ISettingsProvider settingsProvider, GeneratedPlaylistService playlistService, AnimeSeriesRepository animeSeriesRepository, AnimeEpisodeRepository animeEpisodeRepository, VideoLocalRepository videoRepository) : base(settingsProvider)
31+
{
32+
_playlistService = playlistService;
33+
_seriesRepository = animeSeriesRepository;
34+
_episodeRepository = animeEpisodeRepository;
35+
_videoRepository = videoRepository;
36+
}
37+
38+
/// <summary>
39+
/// Generate an on-demand playlist for the specified list of items.
40+
/// </summary>
41+
/// <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>
42+
/// <param name="releaseGroupID">The preferred release group ID if available.</param>
43+
/// <param name="onlyUnwatched">Only show the next unwatched episode.</param>
44+
/// <param name="includeSpecials">Include specials in the search.</param>
45+
/// <param name="includeOthers">Include other type episodes in the search.</param>
46+
/// <param name="includeRewatching">Include already watched episodes in the
47+
/// search if we determine the user is "re-watching" the series.</param>
48+
/// <param name="includeMediaInfo">Include media info data.</param>
49+
/// <param name="includeAbsolutePaths">Include absolute paths for the file locations.</param>
50+
/// <param name="includeXRefs">Include file/episode cross-references with the episodes.</param>
51+
/// <param name="includeDataFrom">Include data from selected <see cref="DataSource"/>s.</param>
52+
/// <returns></returns>
53+
[HttpGet("Generate")]
54+
public ActionResult<IReadOnlyList<(Episode, List<File>)>> GetGeneratedPlaylistJson(
55+
[FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null,
56+
[FromQuery] int? releaseGroupID = null,
57+
[FromQuery] bool onlyUnwatched = false,
58+
[FromQuery] bool includeSpecials = false,
59+
[FromQuery] bool includeOthers = false,
60+
[FromQuery] bool includeRewatching = false,
61+
[FromQuery] bool includeMediaInfo = false,
62+
[FromQuery] bool includeAbsolutePaths = false,
63+
[FromQuery] bool includeXRefs = false,
64+
[FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet<DataSource>? includeDataFrom = null
65+
)
66+
{
67+
var playlist = GetGeneratedPlaylistInternal(items, releaseGroupID, onlyUnwatched, includeSpecials, includeOthers, includeRewatching);
68+
if (!ModelState.IsValid)
69+
return ValidationProblem(ModelState);
70+
71+
return playlist
72+
.Select(tuple => (
73+
new Episode(HttpContext, (tuple.episode as SVR_AnimeEpisode)!, includeDataFrom, withXRefs: includeXRefs),
74+
tuple.videos
75+
.Select(video => new File(HttpContext, (video as SVR_VideoLocal)!, withXRefs: includeXRefs, includeDataFrom, includeMediaInfo, includeAbsolutePaths))
76+
.ToList()
77+
))
78+
.ToList();
79+
}
80+
81+
/// <summary>
82+
/// Generate an on-demand playlist for the specified list of items, as a .m3u8 file.
83+
/// </summary>
84+
/// <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>
85+
/// <param name="releaseGroupID">The preferred release group ID if available.</param>
86+
/// <param name="onlyUnwatched">Only show the next unwatched episode.</param>
87+
/// <param name="includeSpecials">Include specials in the search.</param>
88+
/// <param name="includeOthers">Include other type episodes in the search.</param>
89+
/// <param name="includeRewatching">Include already watched episodes in the
90+
/// search if we determine the user is "re-watching" the series.</param>
91+
/// <returns></returns>
92+
[ProducesResponseType(typeof(FileStreamResult), 200)]
93+
[ProducesResponseType(404)]
94+
[Produces("application/x-mpegURL")]
95+
[HttpGet("Generate.m3u8")]
96+
[HttpHead("Generate.m3u8")]
97+
public ActionResult GetGeneratedPlaylistM3U8(
98+
[FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] string[]? items = null,
99+
[FromQuery] int? releaseGroupID = null,
100+
[FromQuery] bool onlyUnwatched = false,
101+
[FromQuery] bool includeSpecials = false,
102+
[FromQuery] bool includeOthers = false,
103+
[FromQuery] bool includeRewatching = false
104+
)
105+
{
106+
var playlist = GetGeneratedPlaylistInternal(items, releaseGroupID, onlyUnwatched, includeSpecials, includeOthers, includeRewatching);
107+
if (!ModelState.IsValid)
108+
return ValidationProblem(ModelState);
109+
return _playlistService.GeneratePlaylist(playlist, "Mixed");
110+
}
111+
112+
private IReadOnlyList<(IShokoEpisode episode, IReadOnlyList<IVideo> videos)> GetGeneratedPlaylistInternal(
113+
string[]? items,
114+
int? releaseGroupID,
115+
bool onlyUnwatched = true,
116+
bool includeSpecials = true,
117+
bool includeOthers = false,
118+
bool includeRewatching = false
119+
)
120+
{
121+
items ??= [];
122+
var playlist = new List<(IShokoEpisode, IReadOnlyList<IVideo>)>();
123+
var index = -1;
124+
foreach (var id in items)
125+
{
126+
index++;
127+
if (string.IsNullOrEmpty(id))
128+
continue;
129+
130+
switch (id[0]) {
131+
case 'f':
132+
{
133+
if (!int.TryParse(id[1..], out var fileID) || fileID <= 0 || _videoRepository.GetByID(fileID) is not { } video)
134+
{
135+
ModelState.AddModelError(index.ToString(), $"Invalid file ID \"{id}\" at index {index}");
136+
continue;
137+
}
138+
139+
foreach (var tuple in _playlistService.GetListForVideo(video))
140+
playlist.Add(tuple);
141+
break;
142+
}
143+
144+
case 'e':
145+
{
146+
if (!int.TryParse(id[1..], out var episodeID) || episodeID <= 0 || _episodeRepository.GetByID(episodeID) is not { } episode)
147+
{
148+
ModelState.AddModelError(index.ToString(), $"Invalid episode ID \"{id}\" at index {index}");
149+
continue;
150+
}
151+
152+
foreach (var tuple in _playlistService.GetListForEpisode(episode, releaseGroupID))
153+
playlist.Add(tuple);
154+
break;
155+
}
156+
157+
case 's':
158+
{
159+
if (!int.TryParse(id[1..], out var seriesID) || seriesID <= 0 || _seriesRepository.GetByID(seriesID) is not { } series)
160+
{
161+
ModelState.AddModelError(index.ToString(), $"Invalid series ID \"{id}\" at index {index}");
162+
continue;
163+
}
164+
165+
foreach (var tuple in _playlistService.GetListForSeries(series, releaseGroupID, new()
166+
{
167+
IncludeCurrentlyWatching = !onlyUnwatched,
168+
IncludeSpecials = includeSpecials,
169+
IncludeOthers = includeOthers,
170+
IncludeRewatching = includeRewatching,
171+
}))
172+
playlist.Add(tuple);
173+
break;
174+
}
175+
176+
default:
177+
{
178+
if (!int.TryParse(id, out var seriesID) || seriesID <= 0 || _seriesRepository.GetByID(seriesID) is not { } series)
179+
{
180+
ModelState.AddModelError(index.ToString(), $"Invalid series ID \"{id}\" at index {index}");
181+
continue;
182+
}
183+
184+
foreach (var tuple in _playlistService.GetListForSeries(series, releaseGroupID, new()
185+
{
186+
IncludeCurrentlyWatching = !onlyUnwatched,
187+
IncludeSpecials = includeSpecials,
188+
IncludeOthers = includeOthers,
189+
IncludeRewatching = includeRewatching,
190+
}))
191+
playlist.Add(tuple);
192+
break;
193+
}
194+
}
195+
}
196+
return playlist;
197+
}
198+
}

Shoko.Server/API/v3/Controllers/SeriesController.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -2160,7 +2160,7 @@ public ActionResult<Episode> GetNextUnwatchedEpisode([FromRoute, Range(1, int.Ma
21602160
if (!user.AllowedSeries(series))
21612161
return Forbid(SeriesForbiddenForUser);
21622162

2163-
var episode = _seriesService.GetNextEpisode(series, user.JMMUserID, new()
2163+
var episode = _seriesService.GetNextUpEpisode(series, user.JMMUserID, new()
21642164
{
21652165
IncludeCurrentlyWatching = !onlyUnwatched,
21662166
IncludeMissing = includeMissing,

Shoko.Server/API/v3/Models/Shoko/FileCrossReference.cs

+12-10
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using System.Collections.Generic;
44
using System.Linq;
55
using Shoko.Models.Enums;
6+
using Shoko.Plugin.Abstractions.DataModels;
7+
using Shoko.Plugin.Abstractions.Enums;
68
using Shoko.Server.Models;
79
using Shoko.Server.Repositories;
810

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

139-
public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episode> crossReferences)
141+
public static List<FileCrossReference> From(IEnumerable<IVideoCrossReference> crossReferences)
140142
=> crossReferences
141143
.Select(xref =>
142144
{
143145
// Percentages.
144146
Tuple<int, int> percentage = new(0, 100);
145-
int? releaseGroup = xref.CrossRefSource == (int)CrossRefSource.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref.Hash, xref.FileSize)?.GroupID ?? 0 : null;
147+
int? releaseGroup = xref.Source == DataSourceEnum.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref.ED2K, xref.Size)?.GroupID ?? 0 : null;
146148
var assumedFileCount = PercentageToFileCount(xref.Percentage);
147149
if (assumedFileCount > 1)
148150
{
149-
var xrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.EpisodeID)
151+
var xrefs = RepoFactory.CrossRef_File_Episode.GetByEpisodeID(xref.AnidbEpisodeID)
150152
// 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.
151153
.Where(xref2 => PercentageToFileCount(xref2.Percentage) == assumedFileCount && (xref2.CrossRefSource == (int)CrossRefSource.AniDB ? RepoFactory.AniDB_File.GetByHashAndFileSize(xref2.Hash, xref2.FileSize)?.GroupID ?? -1 : null) == releaseGroup)
152154
// This will order by the "full" episode if the xref is linked to both a "full" episode and "part" episode,
@@ -162,7 +164,7 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
162164
.ThenBy(tuple => tuple.episode?.EpisodeNumber)
163165
.ThenBy(tuple => tuple.xref.EpisodeOrder)
164166
.ToList();
165-
var index = xrefs.FindIndex(tuple => tuple.xref.CrossRef_File_EpisodeID == xref.CrossRef_File_EpisodeID);
167+
var index = xrefs.FindIndex(tuple => string.Equals(tuple.xref.Hash, xref.ED2K) && tuple.xref.FileSize == xref.Size);
166168
if (index > 0)
167169
{
168170
// Note: this is bound to be inaccurate if we don't have all the files linked to the episode locally, but as long
@@ -183,13 +185,13 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
183185
}
184186
}
185187

186-
var shokoEpisode = xref.AnimeEpisode;
188+
var shokoEpisode = xref.ShokoEpisode as SVR_AnimeEpisode;
187189
return (
188190
xref,
189191
dto: new EpisodeCrossReferenceIDs
190192
{
191193
ID = shokoEpisode?.AnimeEpisodeID,
192-
AniDB = xref.EpisodeID,
194+
AniDB = xref.AnidbEpisodeID,
193195
ReleaseGroup = releaseGroup,
194196
TMDB = new()
195197
{
@@ -213,9 +215,9 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
213215
Start = percentage.Item1,
214216
End = percentage.Item2,
215217
},
216-
ED2K = xref.Hash,
217-
FileSize = xref.FileSize,
218-
Source = xref.CrossRefSource == (int)CrossRefSource.AniDB ? "AniDB" : "User",
218+
ED2K = xref.ED2K,
219+
FileSize = xref.Size,
220+
Source = xref.Source == DataSourceEnum.AniDB ? "AniDB" : "User",
219221
}
220222
);
221223
})
@@ -226,7 +228,7 @@ public static List<FileCrossReference> From(IEnumerable<SVR_CrossRef_File_Episod
226228
// we will attempt to lookup the episode to grab it's id but fallback
227229
// to the cross-reference anime id if the episode is not locally available
228230
// yet.
229-
.GroupBy(tuple => tuple.xref.AniDBEpisode?.AnimeID ?? tuple.xref.AnimeID)
231+
.GroupBy(tuple => tuple.xref.AnidbEpisode?.SeriesID ?? tuple.xref.AnidbAnimeID)
230232
.Select(tuples =>
231233
{
232234
var shokoSeries = RepoFactory.AnimeSeries.GetByAnimeID(tuples.Key);

0 commit comments

Comments
 (0)