diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index c9a0e6546..bf10877a3 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -61,14 +61,11 @@ public FileController(TraktTVHelper traktHelper, ICommandRequestFactory commandF /// /// Limits the number of results per page. Set to 0 to disable the limit. /// Page number. + /// Include items that are not included by default + /// Exclude items of certain types + /// Filter to only include items of certain types /// Sort ordering. Attach '-' at the start to reverse the order of the criteria. /// Include data from selected s. - /// Filter the search to only files for a given shoko series. - /// Filter the search to only files for a given shoko episode. - /// Filter the search to only files for a given anidb series. - /// Filter the search to only files for a given anidb episode. - /// An optional search query to filter files based on their absolute paths. - /// Indicates that fuzzy-matching should be used for the search query. /// A sliced part of the results for the current query. [HttpGet] public ActionResult> GetFiles( @@ -78,65 +75,15 @@ public ActionResult> GetFiles( [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileExcludeTypes[] exclude = default, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileIncludeOnlyType[] include_only = default, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] List sortOrder = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, - [FromQuery] int? seriesID = null, - [FromQuery] int? episodeID = null, - [FromQuery] int? anidbSeriesID = null, - [FromQuery] int? anidbEpisodeID = null, - [FromQuery] string search = null, - [FromQuery] bool fuzzy = true) + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) { include ??= Array.Empty(); exclude ??= Array.Empty(); include_only ??= Array.Empty(); - // Map shoko series id to anidb series id and check if the series - // exists. - if (seriesID.HasValue) - { - if (seriesID.Value <= 0) - return new ListResult(); - - var series = RepoFactory.AnimeSeries.GetByID(seriesID.Value); - if (series == null) - return new ListResult(); - - anidbSeriesID = series.AniDB_ID; - } - // Map shoko episode id to anidb episode id and check if the episode - // exists. - if (episodeID.HasValue) - { - if (episodeID.Value <= 0) - return new ListResult(); - - var episode = RepoFactory.AnimeEpisode.GetByID(episodeID.Value); - if (episode == null) - return new ListResult(); - - anidbEpisodeID = episode.AniDB_EpisodeID; - } - // Check if the anidb episode exists locally, and if it is part of the - // same anidb series if both are provided. - if (anidbEpisodeID.HasValue) - { - var anidbEpisode = RepoFactory.AniDB_Episode.GetByEpisodeID(anidbEpisodeID.Value); - if (anidbEpisode == null) - return new ListResult(); - - if (anidbSeriesID.HasValue && anidbEpisode.AnimeID != anidbSeriesID.Value) - return new ListResult(); - } - // Check if the anidb anime exists locally. - else if (anidbSeriesID.HasValue) - { - var anidbSeries = RepoFactory.AniDB_Anime.GetByAnimeID(anidbSeriesID.Value); - if (anidbSeries == null) - return new ListResult(); - } // Filtering. var user = User; - var includeLocations = exclude.Contains(FileExcludeTypes.Duplicates) || !string.IsNullOrEmpty(search) || + var includeLocations = exclude.Contains(FileExcludeTypes.Duplicates) || (sortOrder?.Any(criteria => criteria.Contains(FileSortCriteria.DuplicateCount.ToString())) ?? false); var includeUserRecord = exclude.Contains(FileExcludeTypes.Watched) || (sortOrder?.Any(criteria => criteria.Contains(FileSortCriteria.ViewedAt.ToString()) || criteria.Contains(FileSortCriteria.WatchedAt.ToString())) ?? false); @@ -149,7 +96,7 @@ public ActionResult> GetFiles( )) .Where(tuple => { - var (video, bestLocation, locations, userRecord) = tuple; + var (video, _, locations, userRecord) = tuple; var xrefs = video.EpisodeCrossRefs; var isAnimeAllowed = xrefs .Select(xref => xref.AnimeID) @@ -160,23 +107,6 @@ public ActionResult> GetFiles( if (!isAnimeAllowed) return false; - if (anidbSeriesID.HasValue || anidbEpisodeID.HasValue) - { - var isLinkedToAnimeOrEpisode = xrefs - .Where( - anidbSeriesID.HasValue && anidbEpisodeID.HasValue ? ( - xref => xref.AnimeID == anidbSeriesID.Value && xref.EpisodeID == anidbEpisodeID.Value - ) : anidbSeriesID.HasValue ? ( - xref => xref.AnimeID == anidbSeriesID.Value - ) : ( - xref => xref.EpisodeID == anidbEpisodeID.Value - ) - ) - .Any(); - if (!isLinkedToAnimeOrEpisode) - return false; - } - if (!include.Contains(FileNonDefaultIncludeType.Ignored) && video.IsIgnored) return false; if (include_only.Contains(FileIncludeOnlyType.Ignored) && !video.IsIgnored) return false; @@ -195,16 +125,10 @@ public ActionResult> GetFiles( return true; }); - // Search. - if (!string.IsNullOrEmpty(search)) - enumerable = enumerable - .Search(search, tuple => tuple.Locations.Select(place => place.FullServerPath).Where(path => path != null), fuzzy) - .Select(result => result.Result); - // Sorting. if (sortOrder != null && sortOrder.Count > 0) enumerable = Models.Shoko.File.OrderBy(enumerable, sortOrder); - else if (string.IsNullOrEmpty(search)) + else enumerable = Models.Shoko.File.OrderBy(enumerable, new() { // First sort by import folder from A-Z. @@ -218,6 +142,91 @@ public ActionResult> GetFiles( tuple => new File(tuple.UserRecord, tuple.Video, include.Contains(FileNonDefaultIncludeType.XRefs), includeDataFrom, include.Contains(FileNonDefaultIncludeType.MediaInfo), include.Contains(FileNonDefaultIncludeType.AbsolutePaths)), page, pageSize); } + + /// + /// Get or search through the files accessible to the current user. + /// + /// Limits the number of results per page. Set to 0 to disable the limit. + /// Page number. + /// Include items that are not included by default + /// Exclude items of certain types + /// Filter to only include items of certain types + /// Sort ordering. Attach '-' at the start to reverse the order of the criteria. + /// Include data from selected s. + /// An optional search query to filter files based on their absolute paths. + /// Indicates that fuzzy-matching should be used for the search query. + /// A sliced part of the results for the current query. + [HttpGet("Search/{*query}")] + public ActionResult> Search([FromRoute] string query, + [FromQuery, Range(0, 1000)] int pageSize = 100, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileNonDefaultIncludeType[] include = default, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileExcludeTypes[] exclude = default, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileIncludeOnlyType[] include_only = default, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] List sortOrder = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, + [FromQuery] bool fuzzy = true) + { + include ??= Array.Empty(); + exclude ??= Array.Empty(); + include_only ??= Array.Empty(); + + // Filtering. + var user = User; + var includeUserRecord = exclude.Contains(FileExcludeTypes.Watched) || (sortOrder?.Any(criteria => + criteria.Contains(FileSortCriteria.ViewedAt.ToString()) || criteria.Contains(FileSortCriteria.WatchedAt.ToString())) ?? false); + var enumerable = RepoFactory.VideoLocal.GetAll() + .Select(video => ( + Video: video, + BestLocation: video.GetBestVideoLocalPlace(), + Locations: video.Places, + UserRecord: includeUserRecord ? video.GetUserRecord(user.JMMUserID) : null + )) + .Where(tuple => + { + var (video, _, locations, userRecord) = tuple; + var xrefs = video.EpisodeCrossRefs; + var isAnimeAllowed = xrefs + .Select(xref => xref.AnimeID) + .Distinct() + .Select(anidbID => RepoFactory.AniDB_Anime.GetByAnimeID(anidbID)) + .Where(anime => anime != null) + .All(user.AllowedAnime); + if (!isAnimeAllowed) + return false; + + if (!include.Contains(FileNonDefaultIncludeType.Ignored) && video.IsIgnored) return false; + if (include_only.Contains(FileIncludeOnlyType.Ignored) && !video.IsIgnored) return false; + + if (exclude.Contains(FileExcludeTypes.Duplicates) && locations.Count > 1) return false; + if (include_only.Contains(FileIncludeOnlyType.Duplicates) && locations.Count <= 1) return false; + + if (exclude.Contains(FileExcludeTypes.Unrecognized) && xrefs.Count == 0) return false; + if (include_only.Contains(FileIncludeOnlyType.Unrecognized) && xrefs.Count > 0) return false; + + if (exclude.Contains(FileExcludeTypes.ManualLinks) && xrefs.Count > 0 && xrefs.Any(xref => xref.CrossRefSource != (int)CrossRefSource.AniDB)) return false; + if (include_only.Contains(FileIncludeOnlyType.ManualLinks) && xrefs.Count == 0 || xrefs.Any(xref => xref.CrossRefSource == (int)CrossRefSource.AniDB)) return false; + + if (exclude.Contains(FileExcludeTypes.Watched) && userRecord?.WatchedDate != null) return false; + if (include_only.Contains(FileIncludeOnlyType.Watched) && userRecord?.WatchedDate == null) return false; + + return true; + }); + + // Search. + enumerable = enumerable + .Search(query, tuple => tuple.Locations.Select(place => place.FullServerPath).Where(path => path != null), fuzzy) + .Select(result => result.Result); + + // Sorting. + if (sortOrder != null && sortOrder.Count > 0) + enumerable = Models.Shoko.File.OrderBy(enumerable, sortOrder); + + // Skip and limit. + return enumerable.ToListResult( + tuple => new File(tuple.UserRecord, tuple.Video, include.Contains(FileNonDefaultIncludeType.XRefs), includeDataFrom, + include.Contains(FileNonDefaultIncludeType.MediaInfo), include.Contains(FileNonDefaultIncludeType.AbsolutePaths)), page, pageSize); + } /// /// Batch delete files using file ids. diff --git a/Shoko.Server/API/v3/Controllers/TreeController.cs b/Shoko.Server/API/v3/Controllers/TreeController.cs index 00a93aa5b..cf25ab725 100644 --- a/Shoko.Server/API/v3/Controllers/TreeController.cs +++ b/Shoko.Server/API/v3/Controllers/TreeController.cs @@ -6,8 +6,6 @@ using Microsoft.AspNetCore.Mvc; using Shoko.Commons.Extensions; using Shoko.Models.Enums; -using Shoko.Plugin.Abstractions.DataModels; -using Shoko.Plugin.Abstractions.Extensions; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; @@ -17,11 +15,8 @@ using Shoko.Server.Models; using Shoko.Server.Repositories; using Shoko.Server.Settings; -using Shoko.Server.Utilities; - -using EpisodeType = Shoko.Server.API.v3.Models.Shoko.EpisodeType; -using AniDBEpisodeType = Shoko.Models.Enums.EpisodeType; using DataSource = Shoko.Server.API.v3.Models.Common.DataSource; +using FileSortCriteria = Shoko.Server.API.v3.Models.Shoko.File.FileSortCriteria; namespace Shoko.Server.API.v3.Controllers; @@ -542,18 +537,23 @@ public ActionResult GetMainSeriesInGroup([FromRoute] int groupID, [FromQ /// Get the s for the with the given . /// /// Episode ID - /// Set to true to include series and episode cross-references. + /// Limits the number of results per page. Set to 0 to disable the limit. + /// Page number. + /// Include items that are not included by default + /// Exclude items of certain types + /// Filter to only include items of certain types + /// Sort ordering. Attach '-' at the start to reverse the order of the criteria. /// Include data from selected s. - /// Omit to select all files. Set to true to only select manually - /// linked files, or set to false to only select automatically linked files. - /// Include media info data. - /// Include absolute paths for the file locations. /// [HttpGet("Episode/{episodeID}/File")] - public ActionResult> GetFilesForEpisode([FromRoute] int episodeID, [FromQuery] bool includeXRefs = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, - [FromQuery] bool? isManuallyLinked = null, [FromQuery] bool includeMediaInfo = false, - [FromQuery] bool includeAbsolutePaths = false) + public ActionResult> GetFilesForEpisode([FromRoute] int episodeID, + [FromQuery, Range(0, 1000)] int pageSize = 100, + [FromQuery, Range(1, int.MaxValue)] int page = 1, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileNonDefaultIncludeType[] include = default, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileExcludeTypes[] exclude = default, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] FileIncludeOnlyType[] include_only = default, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] List sortOrder = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null) { var episode = RepoFactory.AnimeEpisode.GetByID(episodeID); if (episode == null) @@ -561,6 +561,10 @@ public ActionResult> GetFilesForEpisode([FromRoute] int episodeID, [F return NotFound(EpisodeController.EpisodeNotFoundWithEpisodeID); } + include ??= Array.Empty(); + exclude ??= Array.Empty(); + include_only ??= Array.Empty(); + var series = episode.GetAnimeSeries(); if (series == null) { @@ -572,9 +576,65 @@ public ActionResult> GetFilesForEpisode([FromRoute] int episodeID, [F return Forbid(EpisodeController.EpisodeForbiddenForUser); } - return episode.GetVideoLocals(isManuallyLinked.HasValue ? isManuallyLinked.Value ? CrossRefSource.User : CrossRefSource.AniDB : null) - .Select(file => new File(HttpContext, file, includeXRefs, includeDataFrom, includeMediaInfo, includeAbsolutePaths)) - .ToList(); + var user = User; + var includeLocations = exclude.Contains(FileExcludeTypes.Duplicates) || + (sortOrder?.Any(criteria => criteria.Contains(FileSortCriteria.DuplicateCount.ToString())) ?? false); + var includeUserRecord = exclude.Contains(FileExcludeTypes.Watched) || (sortOrder?.Any(criteria => + criteria.Contains(FileSortCriteria.ViewedAt.ToString()) || criteria.Contains(FileSortCriteria.WatchedAt.ToString())) ?? false); + var enumerable = episode.GetVideoLocals() + .Select(video => ( + Video: video, + BestLocation: video.GetBestVideoLocalPlace(), + Locations: includeLocations ? video.Places : null, + UserRecord: includeUserRecord ? video.GetUserRecord(user.JMMUserID) : null + )) + .Where(tuple => + { + var (video, bestLocation, locations, userRecord) = tuple; + var xrefs = video.EpisodeCrossRefs; + var isAnimeAllowed = xrefs + .Select(xref => xref.AnimeID) + .Distinct() + .Select(anidbID => RepoFactory.AniDB_Anime.GetByAnimeID(anidbID)) + .Where(anime => anime != null) + .All(user.AllowedAnime); + if (!isAnimeAllowed) + return false; + + if (!include.Contains(FileNonDefaultIncludeType.Ignored) && video.IsIgnored) return false; + if (include_only.Contains(FileIncludeOnlyType.Ignored) && !video.IsIgnored) return false; + + if (exclude.Contains(FileExcludeTypes.Duplicates) && locations.Count > 1) return false; + if (include_only.Contains(FileIncludeOnlyType.Duplicates) && locations.Count <= 1) return false; + + if (exclude.Contains(FileExcludeTypes.Unrecognized) && xrefs.Count == 0) return false; + if (include_only.Contains(FileIncludeOnlyType.Unrecognized) && xrefs.Count > 0) return false; + + if (exclude.Contains(FileExcludeTypes.ManualLinks) && xrefs.Count > 0 && xrefs.Any(xref => xref.CrossRefSource != (int)CrossRefSource.AniDB)) return false; + if (include_only.Contains(FileIncludeOnlyType.ManualLinks) && xrefs.Count == 0 || xrefs.Any(xref => xref.CrossRefSource == (int)CrossRefSource.AniDB)) return false; + + if (exclude.Contains(FileExcludeTypes.Watched) && userRecord?.WatchedDate != null) return false; + if (include_only.Contains(FileIncludeOnlyType.Watched) && userRecord?.WatchedDate == null) return false; + + return true; + }); + + // Sorting. + if (sortOrder != null && sortOrder.Count > 0) + enumerable = Models.Shoko.File.OrderBy(enumerable, sortOrder); + else + enumerable = Models.Shoko.File.OrderBy(enumerable, new() + { + // First sort by import folder from A-Z. + FileSortCriteria.ImportFolderName.ToString(), + // Then by the relative path inside the import folder, from A-Z. + FileSortCriteria.RelativePath.ToString(), + }); + + // Skip and limit. + return enumerable.ToListResult( + tuple => new File(tuple.UserRecord, tuple.Video, include.Contains(FileNonDefaultIncludeType.XRefs), includeDataFrom, + include.Contains(FileNonDefaultIncludeType.MediaInfo), include.Contains(FileNonDefaultIncludeType.AbsolutePaths)), page, pageSize); } #endregion