diff --git a/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs b/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs index 6084bcd0..b65dc794 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs @@ -37,6 +37,7 @@ public interface IWatchStatusRepository // public delegate Task ResourceEventHandler(T resource); Task> GetAll( + Filter? filter = default, Include? include = default, Pagination? limit = default); diff --git a/back/src/Kyoo.Abstractions/Models/IWatchlist.cs b/back/src/Kyoo.Abstractions/Models/IWatchlist.cs index 1c3e61ec..8e2b76b8 100644 --- a/back/src/Kyoo.Abstractions/Models/IWatchlist.cs +++ b/back/src/Kyoo.Abstractions/Models/IWatchlist.cs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Attributes; namespace Kyoo.Abstractions.Models; @@ -24,8 +23,6 @@ namespace Kyoo.Abstractions.Models; /// /// A watch list item. /// -[OneOf(Types = new[] { typeof(Show), typeof(Movie), typeof(Episode) })] -public interface IWatchlist : IResource, IThumbnails, IMetadata, IAddedDate, IQuery -{ - static Sort IQuery.DefaultSort => new Sort.By(nameof(AddedDate), true); -} +[OneOf(Types = new[] { typeof(Show), typeof(Movie) })] +public interface IWatchlist : IResource, IThumbnails, IMetadata, IAddedDate +{ } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs index 55aae426..1d0a728e 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs @@ -31,7 +31,7 @@ namespace Kyoo.Abstractions.Models /// /// A class to represent a single show's episode. /// - public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews, IWatchlist + public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews { // Use absolute numbers by default and fallback to season/episodes if it does not exists. public static Sort DefaultSort => new Sort.Conglomerate( diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Include.cs b/back/src/Kyoo.Abstractions/Models/Utils/Include.cs index 42ea5c34..8b902995 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Include.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Include.cs @@ -30,7 +30,7 @@ public class Include /// /// The aditional fields to include in the result. /// - public ICollection Metadatas { get; init; } = ArraySegment.Empty; + public ICollection Metadatas { get; set; } = ArraySegment.Empty; public abstract record Metadata(string Name); diff --git a/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs b/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs index 50c7ed25..f8d54d37 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs @@ -62,7 +62,9 @@ public static class DapperHelper if (key == "kind") return "kind"; string[] keys = config - .Where(x => key == "id" || x.Value.GetProperty(key) != null) + .Where(x => !x.Key.StartsWith('_')) + // If first char is lower, assume manual sql instead of reflection. + .Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null) .Select(x => $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute()?.Name ?? key.ToSnakeCase()}") .ToArray(); if (keys.Length == 1) @@ -167,7 +169,9 @@ public static class DapperHelper } IEnumerable properties = config - .Where(x => key == "id" || x.Value.GetProperty(key) != null) + .Where(x => !x.Key.StartsWith('_')) + // If first char is lower, assume manual sql instead of reflection. + .Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null) .Select(x => $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute()?.Name ?? key.ToSnakeCase()}"); FormattableString ret = $"{properties.First():raw} {op}"; @@ -204,7 +208,7 @@ public static class DapperHelper _ => throw new NotImplementedException(), }; } - return $"\nwhere {Process(filter)}"; + return Process(filter); } public static string ExpendProjections(Type type, string? prefix, Include include) @@ -235,8 +239,10 @@ public static class DapperHelper // Include handling include ??= new(); var (includeProjection, includeJoin, includeTypes, mapIncludes) = ProcessInclude(include, config); - query.AppendLiteral(includeJoin); - query.Replace("/* includes */", $"{includeProjection:raw}", out bool replaced); + query.Replace("/* includesJoin */", $"{includeJoin:raw}", out bool replaced); + if (!replaced) + query.AppendLiteral(includeJoin); + query.Replace("/* includes */", $"{includeProjection:raw}", out replaced); if (!replaced) throw new ArgumentException("Missing '/* includes */' placeholder in top level sql select to support includes."); @@ -248,7 +254,12 @@ public static class DapperHelper filter = Filter.And(filter, keysetFilter); } if (filter != null) - query += ProcessFilter(filter, config); + { + FormattableString filterSql = ProcessFilter(filter, config); + query.Replace("/* where */", $"and {filterSql}", out replaced); + if (!replaced) + query += $"\nwhere {filterSql}"; + } if (sort != null) query += $"\norder by {ProcessSort(sort, limit?.Reverse ?? false, config):raw}"; if (limit != null) diff --git a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs index de7bd406..817f29b2 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs @@ -76,8 +76,7 @@ public class WatchStatusRepository : IWatchStatusRepository protected FormattableString Sql => $""" select s.*, - m.*, - e.* + m.* /* includes */ from ( select @@ -99,42 +98,36 @@ public class WatchStatusRepository : IWatchStatusRepository movies as m inner join movie_watch_status as mw on mw.movie_id = m.id and mw.user_id = [current_user]) as m on false - full outer join ( - select - e.*, -- Episode as e - ew.*, - ew.added_date as order, - ew.status as watch_status - from - episodes as e - inner join episode_watch_status as ew on ew.episode_id = e.id - and ew.user_id = [current_user]) as e on false + /* includesJoin */ where - coalesce(s.watch_status, m.watch_status, e.watch_status) = 'watching'::watch_status - or coalesce(s.watch_status, m.watch_status, e.watch_status) = 'completed'::watch_status + (coalesce(s.watch_status, m.watch_status) = 'watching'::watch_status + or coalesce(s.watch_status, m.watch_status) = 'completed'::watch_status) + /* where */ order by - coalesce(s.order, m.order, e.order) desc, - coalesce(s.id, m.id, e.id) asc + coalesce(s.order, m.order) desc, + coalesce(s.id, m.id) asc """; protected Dictionary Config => new() { { "s", typeof(Show) }, - { "sw", typeof(ShowWatchStatus) }, + { "_sw", typeof(ShowWatchStatus) }, { "m", typeof(Movie) }, - { "mw", typeof(MovieWatchStatus) }, - { "e", typeof(Episode) }, - { "ew", typeof(EpisodeWatchStatus) }, + { "_mw", typeof(MovieWatchStatus) }, }; protected IWatchlist Mapper(List items) { if (items[0] is Show show && show.Id != Guid.Empty) + { + show.WatchStatus = items[1] as ShowWatchStatus; return show; - if (items[1] is Movie movie && movie.Id != Guid.Empty) + } + if (items[2] is Movie movie && movie.Id != Guid.Empty) + { + movie.WatchStatus = items[3] as MovieWatchStatus; return movie; - if (items[2] is Episode episode && episode.Id != Guid.Empty) - return episode; + } throw new InvalidDataException(); } @@ -161,18 +154,39 @@ public class WatchStatusRepository : IWatchStatusRepository } /// - public Task> GetAll( + public async Task> GetAll( + Filter? filter = default, Include? include = default, Pagination? limit = default) { - return _db.Query( + if (include != null) + include.Metadatas = include.Metadatas.Where(x => x.Name != nameof(Show.WatchStatus)).ToList(); + + // We can't use the generic after id hanler since the sort depends on a relation. + if (limit?.AfterID != null) + { + dynamic cursor = await Get(limit.AfterID.Value); + filter = Filter.And( + filter, + Filter.Or( + new Filter.Lt("order", cursor.WatchStatus.AddedDate), + Filter.And( + new Filter.Eq("order", cursor.WatchStatus.AddedDate), + new Filter.Gt("Id", cursor.Id) + ) + ) + ); + limit.AfterID = null; + } + + return await _db.Query( Sql, Config, Mapper, (id) => Get(id), _context, include, - null, + filter, null, limit ?? new() ); diff --git a/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs b/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs index deead782..041ed467 100644 --- a/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs @@ -51,6 +51,7 @@ namespace Kyoo.Core.Api /// /// Get all resources that match the given filter. /// + /// Filter the returned items. /// How many items per page should be returned, where should the page start... /// The aditional fields to include in the result. /// A list of resources that match every filters. @@ -60,10 +61,12 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] public async Task>> GetAll( + [FromQuery] Filter? filter, [FromQuery] Pagination pagination, [FromQuery] Include? fields) { ICollection resources = await _repository.GetAll( + filter, fields, pagination ); diff --git a/back/src/Kyoo.Postgresql/PostgresModule.cs b/back/src/Kyoo.Postgresql/PostgresModule.cs index bc942734..4c2e8779 100644 --- a/back/src/Kyoo.Postgresql/PostgresModule.cs +++ b/back/src/Kyoo.Postgresql/PostgresModule.cs @@ -91,6 +91,7 @@ namespace Kyoo.Postgresql SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true; + InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false; } ///