diff --git a/back/src/Kyoo.Abstractions/Controllers/IRepository.cs b/back/src/Kyoo.Abstractions/Controllers/IRepository.cs index 9665e091..65f6df39 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IRepository.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IRepository.cs @@ -62,9 +62,18 @@ namespace Kyoo.Abstractions.Controllers /// /// A predicate to filter the resource. /// The related fields to include. + /// A custom sort method to handle cases where multiples items match the filters. + /// Reverse the sort. + /// Select the first element after this id if it was in a list. /// If the item could not be found. /// The resource found - Task Get(Filter filter, Include? include = default); + Task Get( + Filter filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ); /// /// Get a resource from it's ID or null if it is not found. @@ -89,11 +98,13 @@ namespace Kyoo.Abstractions.Controllers /// The related fields to include. /// A custom sort method to handle cases where multiples items match the filters. /// Reverse the sort. + /// Select the first element after this id if it was in a list. /// The resource found Task GetOrDefault(Filter? filter, Include? include = default, Sort? sortBy = default, - bool reverse = false); + bool reverse = false, + Guid? afterId = default); /// /// Search for resources with the database. diff --git a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs index 1a72fbfb..c6f14bc5 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs @@ -216,22 +216,14 @@ namespace Kyoo.Abstractions.Models /// /// Null if the status is not Watching or if the next episode is not started. /// - [Projectable(UseMemberBody = nameof(_WatchedTime), NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] - [NotMapped] public int? WatchedTime { get; set; } - private int? _WatchedTime => NextEpisode?.Watched!.FirstOrDefault()?.WatchedTime; - /// /// Where the player has stopped watching the episode (in percentage between 0 and 100). /// /// /// Null if the status is not Watching or if the next episode is not started. /// - [Projectable(UseMemberBody = nameof(_WatchedPercent), NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] - [NotMapped] public int? WatchedPercent { get; set; } - - private int? _WatchedPercent => NextEpisode?.Watched!.FirstOrDefault()?.WatchedPercent; } } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Include.cs b/back/src/Kyoo.Abstractions/Models/Utils/Include.cs index 87218ae2..42ea5c34 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Include.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Include.cs @@ -52,50 +52,41 @@ public class Include : Include /// public ICollection Fields => Metadatas.Select(x => x.Name).ToList(); + public Include() { } + + public Include(params string[] fields) + { + Type[] types = typeof(T).GetCustomAttribute()?.Types ?? new[] { typeof(T) }; + Metadatas = fields.SelectMany(key => + { + var relations = types + .Select(x => x.GetProperty(key, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)!) + .Select(prop => (prop, attr: prop?.GetCustomAttribute()!)) + .Where(x => x.prop != null && x.attr != null) + .ToList(); + if (!relations.Any()) + throw new ValidationException($"No loadable relation with the name {key}."); + return relations + .Select(x => + { + (PropertyInfo prop, LoadableRelationAttribute attr) = x; + + if (attr.RelationID != null) + return new SingleRelation(prop.Name, prop.PropertyType, attr.RelationID) as Metadata; + if (attr.Sql != null) + return new CustomRelation(prop.Name, prop.PropertyType, attr.Sql, attr.On, prop.DeclaringType!); + if (attr.Projected != null) + return new ProjectedRelation(prop.Name, attr.Projected); + throw new NotImplementedException(); + }) + .Distinct(); + }).ToArray(); + } + public static Include From(string? fields) { if (string.IsNullOrEmpty(fields)) return new Include(); - - Type[] types = typeof(T).GetCustomAttribute()?.Types ?? new[] { typeof(T) }; - return new Include - { - Metadatas = fields.Split(',').SelectMany(key => - { - var relations = types - .Select(x => x.GetProperty(key, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)!) - .Select(prop => (prop, attr: prop?.GetCustomAttribute()!)) - .Where(x => x.prop != null && x.attr != null) - .ToList(); - if (!relations.Any()) - throw new ValidationException($"No loadable relation with the name {key}."); - return relations - .Select(x => - { - (PropertyInfo prop, LoadableRelationAttribute attr) = x; - - if (attr.RelationID != null) - return new SingleRelation(prop.Name, prop.PropertyType, attr.RelationID) as Metadata; - - // Multiples relations are disabled due to: - // - Cartesian Explosions perfs - // - Code complexity added. - // if (typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) && prop.PropertyType != typeof(string)) - // { - // // The property is either a list or a an array. - // return new MultipleRelation( - // prop.Name, - // prop.PropertyType.GetElementType() ?? prop.PropertyType.GenericTypeArguments.First() - // ); - // } - if (attr.Sql != null) - return new CustomRelation(prop.Name, prop.PropertyType, attr.Sql, attr.On, prop.DeclaringType!); - if (attr.Projected != null) - return new ProjectedRelation(prop.Name, attr.Projected); - throw new NotImplementedException(); - }) - .Distinct(); - }).ToArray() - }; + return new Include(fields.Split(',')); } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs b/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs index c6b1aaf6..fba4c5b4 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs @@ -321,7 +321,8 @@ public static class DapperHelper Include? include, Filter? filter, Sort? sort = null, - bool reverse = false) + bool reverse = false, + Guid? afterId = default) where T : class, IResource, IQuery { ICollection ret = await db.Query( @@ -333,7 +334,7 @@ public static class DapperHelper include, filter, sort, - new Pagination(1, reverse: reverse) + new Pagination(1, afterId, reverse) ); return ret.FirstOrDefault(); } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs index 7806fe65..0a7c05a1 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs @@ -69,10 +69,13 @@ public abstract class DapperRepository : IRepository } /// - public virtual async Task Get(Filter filter, - Include? include = default) + public virtual async Task Get(Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default) { - T? ret = await GetOrDefault(filter, include: include); + T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); if (ret == null) throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); return ret; @@ -135,10 +138,11 @@ public abstract class DapperRepository : IRepository } /// - public Task GetOrDefault(Filter? filter, - Include? include = null, - Sort? sortBy = null, - bool reverse = false) + public virtual Task GetOrDefault(Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default) { return Database.QuerySingle( Sql, @@ -147,7 +151,9 @@ public abstract class DapperRepository : IRepository Context, include, filter, - sortBy + sortBy, + reverse, + afterId ); } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs index 4f69ca5f..68d03cf5 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs @@ -221,9 +221,15 @@ namespace Kyoo.Core.Controllers } /// - public virtual async Task Get(Filter filter, Include? include = default) + public virtual async Task Get( + Filter filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ) { - T? ret = await GetOrDefault(filter, include: include); + T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); if (ret == null) throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); return ret; @@ -255,18 +261,20 @@ namespace Kyoo.Core.Controllers } /// - public virtual Task GetOrDefault(Filter? filter, + public virtual async Task GetOrDefault(Filter? filter, Include? include = default, Sort? sortBy = default, - bool reverse = false) + bool reverse = false, + Guid? afterId = default) { - IQueryable query = Sort( - AddIncludes(Database.Set(), include), - sortBy + IQueryable query = await ApplyFilters( + Database.Set(), + filter, + sortBy, + new Pagination(1, afterId, reverse), + include ); - if (reverse) - query = query.Reverse(); - return query.FirstOrDefaultAsync(ParseFilter(filter)); + return await query.FirstOrDefaultAsync(); } /// @@ -285,12 +293,13 @@ namespace Kyoo.Core.Controllers public abstract Task> Search(string query, Include? include = default); /// - public virtual Task> GetAll(Filter? filter = null, + public virtual async Task> GetAll(Filter? filter = null, Sort? sort = default, Include? include = default, Pagination? limit = default) { - return ApplyFilters(Database.Set(), filter, sort, limit, include); + IQueryable query = await ApplyFilters(Database.Set(), filter, sort, limit, include); + return await query.ToListAsync(); } /// @@ -302,7 +311,7 @@ namespace Kyoo.Core.Controllers /// Pagination information (where to start and how many to get) /// Related fields to also load with this query. /// The filtered query - protected async Task> ApplyFilters(IQueryable query, + protected async Task> ApplyFilters(IQueryable query, Filter? filter = null, Sort? sort = default, Pagination? limit = default, @@ -317,7 +326,6 @@ namespace Kyoo.Core.Controllers T reference = await Get(limit.AfterID.Value); Filter? keysetFilter = RepositoryHelper.KeysetPaginate(sort, reference, !limit.Reverse); filter = Filter.And(filter, keysetFilter); - Console.WriteLine(filter); } if (filter != null) query = query.Where(ParseFilter(filter)); @@ -329,7 +337,7 @@ namespace Kyoo.Core.Controllers if (limit.Reverse) query = query.Reverse(); - return await query.ToListAsync(); + return query; } /// diff --git a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs index 13d84c4a..dd17cf86 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs @@ -19,6 +19,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; @@ -98,7 +99,8 @@ public class WatchStatusRepository : IWatchStatusRepository MovieId = movieId, Status = status, WatchedTime = watchedTime, - PlayedDate = DateTime.UtcNow + AddedDate = DateTime.UtcNow, + PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, }; await _database.MovieWatchStatus.Upsert(ret) .UpdateIf(x => status != Watching || x.Status != Completed) @@ -135,23 +137,44 @@ public class WatchStatusRepository : IWatchStatusRepository if (unseenEpisodeCount == 0) status = WatchStatus.Completed; + Episode? cursor = null; + Guid? nextEpisodeId = null; + if (status == WatchStatus.Watching) + { + cursor = await _episodes.GetOrDefault( + new Filter.Lambda( + x => x.ShowId == showId + && (x.WatchStatus!.Status == WatchStatus.Completed + || x.WatchStatus.Status == WatchStatus.Watching) + ), + new Include(nameof(Episode.WatchStatus)), + reverse: true + ); + nextEpisodeId = cursor?.WatchStatus?.Status == WatchStatus.Watching + ? cursor.Id + : ((await _episodes.GetOrDefault( + new Filter.Lambda( + x => x.ShowId == showId && x.WatchStatus!.Status != WatchStatus.Completed + ), + afterId: cursor?.Id + ))?.Id); + } + ShowWatchStatus ret = new() { UserId = userId, ShowId = showId, Status = status, - NextEpisode = status == WatchStatus.Watching - ? await _episodes.GetOrDefault( - new Filter.Lambda( - x => x.ShowId == showId - && (x.WatchStatus!.Status == WatchStatus.Watching - || x.WatchStatus.Status == WatchStatus.Completed) - ), - reverse: true - ) + AddedDate = DateTime.UtcNow, + NextEpisodeId = nextEpisodeId, + WatchedTime = cursor?.WatchStatus?.Status == WatchStatus.Watching + ? cursor.WatchStatus.WatchedTime + : null, + WatchedPercent = cursor?.WatchStatus?.Status == WatchStatus.Watching + ? cursor.WatchStatus.WatchedPercent : null, UnseenEpisodesCount = unseenEpisodeCount, - PlayedDate = DateTime.UtcNow + PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, }; await _database.ShowWatchStatus.Upsert(ret) .UpdateIf(x => status != Watching || x.Status != Completed) @@ -210,7 +233,8 @@ public class WatchStatusRepository : IWatchStatusRepository Status = status, WatchedTime = watchedTime, WatchedPercent = percent, - PlayedDate = DateTime.UtcNow + AddedDate = DateTime.UtcNow, + PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, }; await _database.EpisodeWatchStatus.Upsert(ret) .UpdateIf(x => status != Watching || x.Status != Completed) diff --git a/back/src/Kyoo.Postgresql/Migrations/20231203194301_Watchlist.Designer.cs b/back/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.Designer.cs similarity index 99% rename from back/src/Kyoo.Postgresql/Migrations/20231203194301_Watchlist.Designer.cs rename to back/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.Designer.cs index 12456592..2d6f5523 100644 --- a/back/src/Kyoo.Postgresql/Migrations/20231203194301_Watchlist.Designer.cs +++ b/back/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Kyoo.Postgresql.Migrations { [DbContext(typeof(PostgresContext))] - [Migration("20231203194301_Watchlist")] + [Migration("20231204000849_Watchlist")] partial class Watchlist { /// @@ -511,6 +511,14 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("integer") .HasColumnName("unseen_episodes_count"); + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + b.HasKey("UserId", "ShowId") .HasName("pk_show_watch_status"); diff --git a/back/src/Kyoo.Postgresql/Migrations/20231203194301_Watchlist.cs b/back/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.cs similarity index 97% rename from back/src/Kyoo.Postgresql/Migrations/20231203194301_Watchlist.cs rename to back/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.cs index 4c893e21..cb699d9d 100644 --- a/back/src/Kyoo.Postgresql/Migrations/20231203194301_Watchlist.cs +++ b/back/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.cs @@ -105,7 +105,9 @@ namespace Kyoo.Postgresql.Migrations played_date = table.Column(type: "timestamp with time zone", nullable: true), status = table.Column(type: "watch_status", nullable: false), unseen_episodes_count = table.Column(type: "integer", nullable: false), - next_episode_id = table.Column(type: "uuid", nullable: true) + next_episode_id = table.Column(type: "uuid", nullable: true), + watched_time = table.Column(type: "integer", nullable: true), + watched_percent = table.Column(type: "integer", nullable: true) }, constraints: table => { diff --git a/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs b/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs index 1b236f4f..78f883ff 100644 --- a/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs +++ b/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs @@ -508,6 +508,14 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("integer") .HasColumnName("unseen_episodes_count"); + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + b.HasKey("UserId", "ShowId") .HasName("pk_show_watch_status");