Handle next episode position/percent in show watch status

This commit is contained in:
Zoe Roux 2023-12-04 01:46:44 +01:00
parent e124113d41
commit bd48032a50
10 changed files with 141 additions and 90 deletions

View File

@ -62,9 +62,18 @@ namespace Kyoo.Abstractions.Controllers
/// </summary> /// </summary>
/// <param name="filter">A predicate to filter the resource.</param> /// <param name="filter">A predicate to filter the resource.</param>
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
/// <param name="reverse">Reverse the sort.</param>
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception> /// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns> /// <returns>The resource found</returns>
Task<T> Get(Filter<T> filter, Include<T>? include = default); Task<T> Get(
Filter<T> filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
);
/// <summary> /// <summary>
/// Get a resource from it's ID or null if it is not found. /// Get a resource from it's ID or null if it is not found.
@ -89,11 +98,13 @@ namespace Kyoo.Abstractions.Controllers
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param> /// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
/// <param name="reverse">Reverse the sort.</param> /// <param name="reverse">Reverse the sort.</param>
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
/// <returns>The resource found</returns> /// <returns>The resource found</returns>
Task<T?> GetOrDefault(Filter<T>? filter, Task<T?> GetOrDefault(Filter<T>? filter,
Include<T>? include = default, Include<T>? include = default,
Sort<T>? sortBy = default, Sort<T>? sortBy = default,
bool reverse = false); bool reverse = false,
Guid? afterId = default);
/// <summary> /// <summary>
/// Search for resources with the database. /// Search for resources with the database.

View File

@ -216,22 +216,14 @@ namespace Kyoo.Abstractions.Models
/// <remarks> /// <remarks>
/// Null if the status is not Watching or if the next episode is not started. /// Null if the status is not Watching or if the next episode is not started.
/// </remarks> /// </remarks>
[Projectable(UseMemberBody = nameof(_WatchedTime), NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)]
[NotMapped]
public int? WatchedTime { get; set; } public int? WatchedTime { get; set; }
private int? _WatchedTime => NextEpisode?.Watched!.FirstOrDefault()?.WatchedTime;
/// <summary> /// <summary>
/// Where the player has stopped watching the episode (in percentage between 0 and 100). /// Where the player has stopped watching the episode (in percentage between 0 and 100).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Null if the status is not Watching or if the next episode is not started. /// Null if the status is not Watching or if the next episode is not started.
/// </remarks> /// </remarks>
[Projectable(UseMemberBody = nameof(_WatchedPercent), NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)]
[NotMapped]
public int? WatchedPercent { get; set; } public int? WatchedPercent { get; set; }
private int? _WatchedPercent => NextEpisode?.Watched!.FirstOrDefault()?.WatchedPercent;
} }
} }

View File

@ -52,15 +52,12 @@ public class Include<T> : Include
/// </summary> /// </summary>
public ICollection<string> Fields => Metadatas.Select(x => x.Name).ToList(); public ICollection<string> Fields => Metadatas.Select(x => x.Name).ToList();
public static Include<T> From(string? fields) public Include() { }
{
if (string.IsNullOrEmpty(fields))
return new Include<T>();
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) }; public Include(params string[] fields)
return new Include<T>
{ {
Metadatas = fields.Split(',').SelectMany(key => Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
Metadatas = fields.SelectMany(key =>
{ {
var relations = types var relations = types
.Select(x => x.GetProperty(key, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)!) .Select(x => x.GetProperty(key, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)!)
@ -76,18 +73,6 @@ public class Include<T> : Include
if (attr.RelationID != null) if (attr.RelationID != null)
return new SingleRelation(prop.Name, prop.PropertyType, attr.RelationID) as Metadata; 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) if (attr.Sql != null)
return new CustomRelation(prop.Name, prop.PropertyType, attr.Sql, attr.On, prop.DeclaringType!); return new CustomRelation(prop.Name, prop.PropertyType, attr.Sql, attr.On, prop.DeclaringType!);
if (attr.Projected != null) if (attr.Projected != null)
@ -95,7 +80,13 @@ public class Include<T> : Include
throw new NotImplementedException(); throw new NotImplementedException();
}) })
.Distinct(); .Distinct();
}).ToArray() }).ToArray();
}; }
public static Include<T> From(string? fields)
{
if (string.IsNullOrEmpty(fields))
return new Include<T>();
return new Include<T>(fields.Split(','));
} }
} }

View File

@ -321,7 +321,8 @@ public static class DapperHelper
Include<T>? include, Include<T>? include,
Filter<T>? filter, Filter<T>? filter,
Sort<T>? sort = null, Sort<T>? sort = null,
bool reverse = false) bool reverse = false,
Guid? afterId = default)
where T : class, IResource, IQuery where T : class, IResource, IQuery
{ {
ICollection<T> ret = await db.Query<T>( ICollection<T> ret = await db.Query<T>(
@ -333,7 +334,7 @@ public static class DapperHelper
include, include,
filter, filter,
sort, sort,
new Pagination(1, reverse: reverse) new Pagination(1, afterId, reverse)
); );
return ret.FirstOrDefault(); return ret.FirstOrDefault();
} }

View File

@ -69,10 +69,13 @@ public abstract class DapperRepository<T> : IRepository<T>
} }
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<T> Get(Filter<T> filter, public virtual async Task<T> Get(Filter<T>? filter,
Include<T>? include = default) Include<T>? include = default,
Sort<T>? 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) if (ret == null)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate.");
return ret; return ret;
@ -135,10 +138,11 @@ public abstract class DapperRepository<T> : IRepository<T>
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<T?> GetOrDefault(Filter<T>? filter, public virtual Task<T?> GetOrDefault(Filter<T>? filter,
Include<T>? include = null, Include<T>? include = default,
Sort<T>? sortBy = null, Sort<T>? sortBy = default,
bool reverse = false) bool reverse = false,
Guid? afterId = default)
{ {
return Database.QuerySingle<T>( return Database.QuerySingle<T>(
Sql, Sql,
@ -147,7 +151,9 @@ public abstract class DapperRepository<T> : IRepository<T>
Context, Context,
include, include,
filter, filter,
sortBy sortBy,
reverse,
afterId
); );
} }

View File

@ -221,9 +221,15 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<T> Get(Filter<T> filter, Include<T>? include = default) public virtual async Task<T> Get(
Filter<T> filter,
Include<T>? include = default,
Sort<T>? 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) if (ret == null)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate.");
return ret; return ret;
@ -255,18 +261,20 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public virtual Task<T?> GetOrDefault(Filter<T>? filter, public virtual async Task<T?> GetOrDefault(Filter<T>? filter,
Include<T>? include = default, Include<T>? include = default,
Sort<T>? sortBy = default, Sort<T>? sortBy = default,
bool reverse = false) bool reverse = false,
Guid? afterId = default)
{ {
IQueryable<T> query = Sort( IQueryable<T> query = await ApplyFilters(
AddIncludes(Database.Set<T>(), include), Database.Set<T>(),
sortBy filter,
sortBy,
new Pagination(1, afterId, reverse),
include
); );
if (reverse) return await query.FirstOrDefaultAsync();
query = query.Reverse();
return query.FirstOrDefaultAsync(ParseFilter(filter));
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -285,12 +293,13 @@ namespace Kyoo.Core.Controllers
public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default); public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default);
/// <inheritdoc/> /// <inheritdoc/>
public virtual Task<ICollection<T>> GetAll(Filter<T>? filter = null, public virtual async Task<ICollection<T>> GetAll(Filter<T>? filter = null,
Sort<T>? sort = default, Sort<T>? sort = default,
Include<T>? include = default, Include<T>? include = default,
Pagination? limit = default) Pagination? limit = default)
{ {
return ApplyFilters(Database.Set<T>(), filter, sort, limit, include); IQueryable<T> query = await ApplyFilters(Database.Set<T>(), filter, sort, limit, include);
return await query.ToListAsync();
} }
/// <summary> /// <summary>
@ -302,7 +311,7 @@ namespace Kyoo.Core.Controllers
/// <param name="limit">Pagination information (where to start and how many to get)</param> /// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <param name="include">Related fields to also load with this query.</param> /// <param name="include">Related fields to also load with this query.</param>
/// <returns>The filtered query</returns> /// <returns>The filtered query</returns>
protected async Task<ICollection<T>> ApplyFilters(IQueryable<T> query, protected async Task<IQueryable<T>> ApplyFilters(IQueryable<T> query,
Filter<T>? filter = null, Filter<T>? filter = null,
Sort<T>? sort = default, Sort<T>? sort = default,
Pagination? limit = default, Pagination? limit = default,
@ -317,7 +326,6 @@ namespace Kyoo.Core.Controllers
T reference = await Get(limit.AfterID.Value); T reference = await Get(limit.AfterID.Value);
Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(sort, reference, !limit.Reverse); Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(sort, reference, !limit.Reverse);
filter = Filter.And(filter, keysetFilter); filter = Filter.And(filter, keysetFilter);
Console.WriteLine(filter);
} }
if (filter != null) if (filter != null)
query = query.Where(ParseFilter(filter)); query = query.Where(ParseFilter(filter));
@ -329,7 +337,7 @@ namespace Kyoo.Core.Controllers
if (limit.Reverse) if (limit.Reverse)
query = query.Reverse(); query = query.Reverse();
return await query.ToListAsync(); return query;
} }
/// <inheritdoc/> /// <inheritdoc/>

View File

@ -19,6 +19,7 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
@ -98,7 +99,8 @@ public class WatchStatusRepository : IWatchStatusRepository
MovieId = movieId, MovieId = movieId,
Status = status, Status = status,
WatchedTime = watchedTime, WatchedTime = watchedTime,
PlayedDate = DateTime.UtcNow AddedDate = DateTime.UtcNow,
PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null,
}; };
await _database.MovieWatchStatus.Upsert(ret) await _database.MovieWatchStatus.Upsert(ret)
.UpdateIf(x => status != Watching || x.Status != Completed) .UpdateIf(x => status != Watching || x.Status != Completed)
@ -135,23 +137,44 @@ public class WatchStatusRepository : IWatchStatusRepository
if (unseenEpisodeCount == 0) if (unseenEpisodeCount == 0)
status = WatchStatus.Completed; status = WatchStatus.Completed;
Episode? cursor = null;
Guid? nextEpisodeId = null;
if (status == WatchStatus.Watching)
{
cursor = await _episodes.GetOrDefault(
new Filter<Episode>.Lambda(
x => x.ShowId == showId
&& (x.WatchStatus!.Status == WatchStatus.Completed
|| x.WatchStatus.Status == WatchStatus.Watching)
),
new Include<Episode>(nameof(Episode.WatchStatus)),
reverse: true
);
nextEpisodeId = cursor?.WatchStatus?.Status == WatchStatus.Watching
? cursor.Id
: ((await _episodes.GetOrDefault(
new Filter<Episode>.Lambda(
x => x.ShowId == showId && x.WatchStatus!.Status != WatchStatus.Completed
),
afterId: cursor?.Id
))?.Id);
}
ShowWatchStatus ret = new() ShowWatchStatus ret = new()
{ {
UserId = userId, UserId = userId,
ShowId = showId, ShowId = showId,
Status = status, Status = status,
NextEpisode = status == WatchStatus.Watching AddedDate = DateTime.UtcNow,
? await _episodes.GetOrDefault( NextEpisodeId = nextEpisodeId,
new Filter<Episode>.Lambda( WatchedTime = cursor?.WatchStatus?.Status == WatchStatus.Watching
x => x.ShowId == showId ? cursor.WatchStatus.WatchedTime
&& (x.WatchStatus!.Status == WatchStatus.Watching : null,
|| x.WatchStatus.Status == WatchStatus.Completed) WatchedPercent = cursor?.WatchStatus?.Status == WatchStatus.Watching
), ? cursor.WatchStatus.WatchedPercent
reverse: true
)
: null, : null,
UnseenEpisodesCount = unseenEpisodeCount, UnseenEpisodesCount = unseenEpisodeCount,
PlayedDate = DateTime.UtcNow PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null,
}; };
await _database.ShowWatchStatus.Upsert(ret) await _database.ShowWatchStatus.Upsert(ret)
.UpdateIf(x => status != Watching || x.Status != Completed) .UpdateIf(x => status != Watching || x.Status != Completed)
@ -210,7 +233,8 @@ public class WatchStatusRepository : IWatchStatusRepository
Status = status, Status = status,
WatchedTime = watchedTime, WatchedTime = watchedTime,
WatchedPercent = percent, WatchedPercent = percent,
PlayedDate = DateTime.UtcNow AddedDate = DateTime.UtcNow,
PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null,
}; };
await _database.EpisodeWatchStatus.Upsert(ret) await _database.EpisodeWatchStatus.Upsert(ret)
.UpdateIf(x => status != Watching || x.Status != Completed) .UpdateIf(x => status != Watching || x.Status != Completed)

View File

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Kyoo.Postgresql.Migrations namespace Kyoo.Postgresql.Migrations
{ {
[DbContext(typeof(PostgresContext))] [DbContext(typeof(PostgresContext))]
[Migration("20231203194301_Watchlist")] [Migration("20231204000849_Watchlist")]
partial class Watchlist partial class Watchlist
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -511,6 +511,14 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("unseen_episodes_count"); .HasColumnName("unseen_episodes_count");
b.Property<int?>("WatchedPercent")
.HasColumnType("integer")
.HasColumnName("watched_percent");
b.Property<int?>("WatchedTime")
.HasColumnType("integer")
.HasColumnName("watched_time");
b.HasKey("UserId", "ShowId") b.HasKey("UserId", "ShowId")
.HasName("pk_show_watch_status"); .HasName("pk_show_watch_status");

View File

@ -105,7 +105,9 @@ namespace Kyoo.Postgresql.Migrations
played_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), played_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
status = table.Column<WatchStatus>(type: "watch_status", nullable: false), status = table.Column<WatchStatus>(type: "watch_status", nullable: false),
unseen_episodes_count = table.Column<int>(type: "integer", nullable: false), unseen_episodes_count = table.Column<int>(type: "integer", nullable: false),
next_episode_id = table.Column<Guid>(type: "uuid", nullable: true) next_episode_id = table.Column<Guid>(type: "uuid", nullable: true),
watched_time = table.Column<int>(type: "integer", nullable: true),
watched_percent = table.Column<int>(type: "integer", nullable: true)
}, },
constraints: table => constraints: table =>
{ {

View File

@ -508,6 +508,14 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("unseen_episodes_count"); .HasColumnName("unseen_episodes_count");
b.Property<int?>("WatchedPercent")
.HasColumnType("integer")
.HasColumnName("watched_percent");
b.Property<int?>("WatchedTime")
.HasColumnType("integer")
.HasColumnName("watched_time");
b.HasKey("UserId", "ShowId") b.HasKey("UserId", "ShowId")
.HasName("pk_show_watch_status"); .HasName("pk_show_watch_status");