diff --git a/Shoko.Server/API/v3/Controllers/SeriesController.cs b/Shoko.Server/API/v3/Controllers/SeriesController.cs index 69fe43eaa..7c3a91541 100644 --- a/Shoko.Server/API/v3/Controllers/SeriesController.cs +++ b/Shoko.Server/API/v3/Controllers/SeriesController.cs @@ -45,6 +45,7 @@ using TmdbShow = Shoko.Server.API.v3.Models.TMDB.Show; #pragma warning disable CA1822 +#nullable enable namespace Shoko.Server.API.v3.Controllers; [ApiController] @@ -160,7 +161,7 @@ public ActionResult> GetAllSeries( /// [HttpGet("{seriesID}")] public ActionResult GetSeries([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromQuery] bool randomImages = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null) { var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) @@ -335,7 +336,7 @@ public ActionResult> GetShokoRelationsBySeriesID([FromRoute public ActionResult> GetSeriesWithoutFiles( [FromQuery, Range(0, 100)] int pageSize = 50, [FromQuery, Range(1, int.MaxValue)] int page = 1, - [FromQuery] string search = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true) { var user = User; @@ -377,7 +378,7 @@ public ActionResult> GetSeriesWithoutFiles( public ActionResult> GetSeriesWithManuallyLinkedFiles( [FromQuery, Range(0, 100)] int pageSize = 50, [FromQuery, Range(1, int.MaxValue)] int page = 1, - [FromQuery] string search = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true) { var user = User; @@ -609,7 +610,7 @@ public ActionResult> GetAnidbRelationsBySeriesID([FromRoute if (startDate.HasValue) { - if (endDate.Value > DateTime.Now) + if (endDate!.Value > DateTime.Now) ModelState.AddModelError(nameof(endDate), "End date cannot be set into the future."); if (startDate.Value > endDate.Value) @@ -682,14 +683,15 @@ private static List GetWatchedAnimeForPeriod( return userDataQuery .OrderByDescending(userData => userData.LastUpdated) .Select(userData => RepoFactory.VideoLocal.GetByID(userData.VideoLocalID)) - .Where(file => file != null) + .WhereNotNull() .Select(file => file.EpisodeCrossRefs.OrderBy(xref => xref.EpisodeOrder).ThenBy(xref => xref.Percentage) .FirstOrDefault()) - .Where(xref => xref != null) + .WhereNotNull() .Select(xref => RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(xref.EpisodeID)) - .Where(episode => episode != null) + .WhereNotNull() .DistinctBy(episode => episode.AnimeSeriesID) - .Select(episode => episode.AnimeSeries.AniDB_Anime) + .Select(episode => episode.AnimeSeries?.AniDB_Anime) + .WhereNotNull() .Where(anime => user.AllowedAnime(anime) && (includeRestricted || !anime.IsRestricted)) .ToList(); } @@ -703,11 +705,11 @@ private static List GetWatchedAnimeForPeriod( /// Optional. Re-use an existing list of the watched anime. /// The unwatched anime for the user. [NonAction] - private static Dictionary GetUnwatchedAnime( + private static Dictionary GetUnwatchedAnime( SVR_JMMUser user, bool showAll, bool includeRestricted = false, - IEnumerable watchedAnime = null) + IEnumerable? watchedAnime = null) { // Get all watched series (reuse if date is not set) var watchedSeriesSet = (watchedAnime ?? GetWatchedAnimeForPeriod(user)) @@ -718,15 +720,15 @@ private static List GetWatchedAnimeForPeriod( { return RepoFactory.AniDB_Anime.GetAll() .Where(anime => user.AllowedAnime(anime) && !watchedSeriesSet.Contains(anime.AnimeID) && (includeRestricted || !anime.IsRestricted)) - .ToDictionary(anime => anime.AnimeID, + .ToDictionary(anime => anime.AnimeID, anime => (anime, null)); } return RepoFactory.AnimeSeries.GetAll() .Where(series => user.AllowedSeries(series) && !watchedSeriesSet.Contains(series.AniDB_ID)) .Select(series => (anime: series.AniDB_Anime, series)) - .Where(tuple => includeRestricted || !tuple.anime.IsRestricted) - .ToDictionary(tuple => tuple.anime.AnimeID); + .Where(tuple => tuple.anime is not null && (includeRestricted || !tuple.anime.IsRestricted)) + .ToDictionary<(SVR_AniDB_Anime? anime, SVR_AnimeSeries series), int, (SVR_AniDB_Anime, SVR_AnimeSeries?)>(tuple => tuple.anime!.AnimeID, tuple => (tuple.anime!, tuple.series)); } #endregion @@ -834,7 +836,7 @@ public ActionResult> GetAnidbRelationsByAnidbID([FromRoute] /// [HttpGet("AniDB/{anidbID}/Series")] public ActionResult GetSeriesByAnidbID([FromRoute] int anidbID, [FromQuery] bool randomImages = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null) { var series = RepoFactory.AnimeSeries.GetByAnimeID(anidbID); if (series == null) @@ -1005,8 +1007,8 @@ public async Task ScheduleAutoMatchTMDBMoviesBySeriesID( [HttpGet("{seriesID}/TMDB/Movie")] public ActionResult> GetTMDBMoviesBySeriesID( [FromRoute, Range(1, int.MaxValue)] int seriesID, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet include = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet language = null + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null ) { var series = RepoFactory.AnimeSeries.GetByID(seriesID); @@ -1213,8 +1215,8 @@ await Task.WhenAll( [HttpGet("{seriesID}/TMDB/Show")] public ActionResult> GetTMDBShowsBySeriesID( [FromRoute, Range(1, int.MaxValue)] int seriesID, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet include = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet language = null + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null ) { var series = RepoFactory.AnimeSeries.GetByID(seriesID); @@ -1480,24 +1482,27 @@ public async Task OverrideTMDBEpisodeMappingsBySeriesID( { var shokoEpisode = RepoFactory.AnimeEpisode.GetByAniDBEpisodeID(link.AniDBID); var anidbEpisode = shokoEpisode?.AniDB_Episode; - if (anidbEpisode == null) + if (anidbEpisode is null) { ModelState.AddModelError("Mapping", $"Unable to find an AniDB Episode with id '{link.AniDBID}'"); continue; } - if (shokoEpisode.AnimeSeriesID != series.AnimeSeriesID) + if (shokoEpisode is null || shokoEpisode.AnimeSeriesID != series.AnimeSeriesID) { ModelState.AddModelError("Mapping", $"The AniDB Episode with id '{link.AniDBID}' is not part of the series."); continue; } var tmdbEpisode = link.TmdbID == 0 ? null : RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(link.TmdbID); - if (link.TmdbID != 0 && tmdbEpisode == null) + if (link.TmdbID != 0) { - ModelState.AddModelError("Mapping", $"Unable to find TMDB Episode with the id '{link.TmdbID}' locally."); - continue; + if (tmdbEpisode is null) + { + ModelState.AddModelError("Mapping", $"Unable to find TMDB Episode with the id '{link.TmdbID}' locally."); + continue; + } + if (!showIDs.Contains(tmdbEpisode.TmdbShowID)) + missingIDs.Add(tmdbEpisode.TmdbShowID); } - if (link.TmdbID != 0 && !showIDs.Contains(tmdbEpisode.TmdbShowID)) - missingIDs.Add(tmdbEpisode.TmdbShowID); mapping.Add((link, anidbEpisode)); } @@ -1607,7 +1612,7 @@ public async Task OverrideTMDBEpisodeMappingsBySeriesID( [HttpPost("{seriesID}/TMDB/Show/CrossReferences/Episode/Auto")] public async Task AutoTMDBEpisodeMappingsBySeriesID( [FromRoute, Range(1, int.MaxValue)] int seriesID, - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Series.Input.AutoMatchTmdbEpisodesBody body = null + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Series.Input.AutoMatchTmdbEpisodesBody? body = null ) { body ??= new(); @@ -1739,8 +1744,8 @@ public ActionResult RemoveTMDBEpisodeMappingsBySeriesID( [HttpGet("{seriesID}/TMDB/Season")] public ActionResult> GetTMDBSeasonsBySeriesID( [FromRoute, Range(1, int.MaxValue)] int seriesID, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet include = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet language = null + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? include = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? language = null ) { var series = RepoFactory.AnimeSeries.GetByID(seriesID); @@ -1803,13 +1808,13 @@ public ActionResult> GetEpisodes( [FromQuery] IncludeOnlyFilter includeHidden = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeWatched = IncludeOnlyFilter.True, [FromQuery] IncludeOnlyFilter includeManuallyLinked = IncludeOnlyFilter.True, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet type = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? type = null, [FromQuery] bool includeFiles = false, [FromQuery] bool includeMediaInfo = false, [FromQuery] bool includeAbsolutePaths = false, [FromQuery] bool includeXRefs = false, - [FromQuery] string search = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true ) { @@ -1845,8 +1850,8 @@ public async Task MarkSeriesWatched( [FromQuery] IncludeOnlyFilter includeUnaired = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeHidden = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeWatched = IncludeOnlyFilter.True, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet type = null, - [FromQuery] string search = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? type = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true) { var series = RepoFactory.AnimeSeries.GetByID(seriesID); @@ -1875,8 +1880,8 @@ public ParallelQuery GetEpisodesInternal( IncludeOnlyFilter includeHidden, IncludeOnlyFilter includeWatched, IncludeOnlyFilter includeManuallyLinked, - HashSet type, - string search, + HashSet? type, + string? search, bool fuzzy) { var user = User; @@ -1965,7 +1970,7 @@ public ParallelQuery GetEpisodesInternal( return episodes .Search( search, - ep => RepoFactory.AniDB_Episode_Title.GetByEpisodeID(ep.AniDB.EpisodeID) + ep => RepoFactory.AniDB_Episode_Title.GetByEpisodeID(ep.AniDB!.EpisodeID) .Where(title => title != null && languages.Contains(title.Language)) .Select(title => title.Title) .Append(ep.Shoko.PreferredTitle) @@ -1978,8 +1983,8 @@ public ParallelQuery GetEpisodesInternal( // Order the episodes since we're not using the search ordering. return episodes - .OrderBy(episode => episode.AniDB.EpisodeType) - .ThenBy(episode => episode.AniDB.EpisodeNumber) + .OrderBy(episode => episode.AniDB!.EpisodeType) + .ThenBy(episode => episode.AniDB!.EpisodeNumber) .Select(a => a.Shoko); } @@ -2009,8 +2014,8 @@ public ParallelQuery GetEpisodesInternal( [FromQuery] IncludeOnlyFilter includeUnaired = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeHidden = IncludeOnlyFilter.False, [FromQuery] IncludeOnlyFilter includeWatched = IncludeOnlyFilter.True, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet type = null, - [FromQuery] string search = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? type = null, + [FromQuery] string? search = null, [FromQuery] bool fuzzy = true) { var anidbSeries = RepoFactory.AniDB_Anime.GetByAnimeID(anidbID); @@ -2059,7 +2064,7 @@ public ParallelQuery GetEpisodesInternal( // If we should hide missing episodes and the episode has no files, then hide it. // Or if we should only show missing episodes and the episode has files, the hide it. var shouldHideMissing = includeMissing == IncludeOnlyFilter.False; - var isMissing = shoko.VideoLocals.Count == 0 && anidb.HasAired; + var isMissing = shoko is not null && shoko.VideoLocals.Count == 0 && anidb.HasAired; if (shouldHideMissing == isMissing) return false; } @@ -2068,7 +2073,7 @@ public ParallelQuery GetEpisodesInternal( // If we should hide unaired episodes and the episode has no files, then hide it. // Or if we should only show unaired episodes and the episode has files, the hide it. var shouldHideUnaired = includeUnaired == IncludeOnlyFilter.False; - var isUnaired = shoko.VideoLocals.Count == 0 && !anidb.HasAired; + var isUnaired = shoko is not null && shoko.VideoLocals.Count == 0 && !anidb.HasAired; if (shouldHideUnaired == isUnaired) return false; } @@ -2146,7 +2151,7 @@ public ActionResult GetNextUnwatchedEpisode([FromRoute, Range(1, int.Ma [FromQuery] bool includeMediaInfo = false, [FromQuery] bool includeAbsolutePaths = false, [FromQuery] bool includeXRefs = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? includeDataFrom = null) { if (RepoFactory.AnimeSeries.GetByID(seriesID) is not { } series) return NotFound(SeriesNotFoundWithSeriesID); @@ -2288,10 +2293,14 @@ public async Task PostSeriesUserVote([FromRoute, Range(1, int.MaxV private static readonly HashSet _allowedImageTypes = [Image.ImageType.Poster, Image.ImageType.Banner, Image.ImageType.Backdrop, Image.ImageType.Logo]; + private const string InvalidImageTypeForSeries = "Invalid image type for series."; + private const string InvalidIDForSource = "Invalid image id for selected source."; private const string InvalidImageIsDisabled = "Image is disabled."; + private const string NoDefaultImageForType = "No default image for type."; + /// /// Get all images for series with ID, optionally with Disabled images, as well. /// @@ -2330,7 +2339,7 @@ public ActionResult GetSeriesDefaultImageForType([FromRoute, Range(1, int [FromRoute] Image.ImageType imageType) { if (!_allowedImageTypes.Contains(imageType)) - return NotFound(); + return BadRequest(InvalidImageTypeForSeries); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) @@ -2344,8 +2353,8 @@ public ActionResult GetSeriesDefaultImageForType([FromRoute, Range(1, int if (preferredImage != null) return new Image(preferredImage); - var images = series.GetImages().ToDto(); - return imageEntityType switch + var images = series.GetImages(imageEntityType).ToDto(); + var image = imageEntityType switch { ImageEntityType.Poster => images.Posters.FirstOrDefault(), ImageEntityType.Banner => images.Banners.FirstOrDefault(), @@ -2353,6 +2362,11 @@ public ActionResult GetSeriesDefaultImageForType([FromRoute, Range(1, int ImageEntityType.Logo => images.Logos.FirstOrDefault(), _ => null }; + + if (image is null) + return NotFound(NoDefaultImageForType); + + return image; } @@ -2368,7 +2382,7 @@ public ActionResult SetSeriesDefaultImageForType([FromRoute, Range(1, int [FromRoute] Image.ImageType imageType, [FromBody] Image.Input.DefaultImageBody body) { if (!_allowedImageTypes.Contains(imageType)) - return NotFound(); + return BadRequest(InvalidImageTypeForSeries); var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) @@ -2409,7 +2423,7 @@ public ActionResult SetSeriesDefaultImageForType([FromRoute, Range(1, int public ActionResult DeleteSeriesDefaultImageForType([FromRoute, Range(1, int.MaxValue)] int seriesID, [FromRoute] Image.ImageType imageType) { if (!_allowedImageTypes.Contains(imageType)) - return NotFound(); + return BadRequest(InvalidImageTypeForSeries); // Check if the series exists and if the user can access the series. var series = RepoFactory.AnimeSeries.GetByID(seriesID); @@ -2592,7 +2606,7 @@ public ActionResult> GetSeriesTagsFromPath([FromRoute, Range(1, int.Ma /// [HttpGet("{seriesID}/Cast")] public ActionResult> GetSeriesCast([FromRoute, Range(1, int.MaxValue)] int seriesID, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet roleType = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet? roleType = null) { var series = RepoFactory.AnimeSeries.GetByID(seriesID); if (series == null) @@ -2828,16 +2842,20 @@ public ActionResult> PathEndsWith([FromRoute] string path) query = query.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar) .TrimEnd(Path.DirectorySeparatorChar); // There should be no circumstance where FullServerPath has no Directory Name, unless you have missing import folders - return RepoFactory.VideoLocalPlace.GetAll().AsParallel() + return RepoFactory.VideoLocalPlace.GetAll() .Where(a => { if (a.FullServerPath == null) return false; var dir = Path.GetDirectoryName(a.FullServerPath); return dir != null && dir.EndsWith(query, StringComparison.OrdinalIgnoreCase); }) - .SelectMany(a => a.VideoLocal?.AnimeEpisodes ?? Enumerable.Empty()).Select(a => a.AnimeSeries) - .Distinct() - .Where(ser => ser != null && user.AllowedSeries(ser)).Select(a => new Series(a, User.JMMUserID)).ToList(); + .SelectMany(a => a.VideoLocal?.AnimeEpisodes ?? Enumerable.Empty()) + .DistinctBy(a => a.AnimeSeriesID) + .Select(a => a.AnimeSeries) + .WhereNotNull() + .Where(user.AllowedSeries) + .Select(a => new Series(a, User.JMMUserID)) + .ToList(); } #region Helpers @@ -2852,7 +2870,7 @@ private static void CheckTitlesStartsWith(SVR_AnimeSeries a, string query, } var titles = a.AniDB_Anime.GetAllTitles(); - if ((titles?.Count ?? 0) == 0) + if (titles is null || titles.Count == 0) { return; }