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>
/// <param name="filter">A predicate to filter the resource.</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>
/// <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>
/// 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="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>
/// <returns>The resource found</returns>
Task<T?> GetOrDefault(Filter<T>? filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false);
bool reverse = false,
Guid? afterId = default);
/// <summary>
/// Search for resources with the database.

View File

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

View File

@ -52,50 +52,41 @@ public class Include<T> : Include
/// </summary>
public ICollection<string> Fields => Metadatas.Select(x => x.Name).ToList();
public Include() { }
public Include(params string[] fields)
{
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.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<LoadableRelationAttribute>()!))
.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<T> From(string? fields)
{
if (string.IsNullOrEmpty(fields))
return new Include<T>();
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
return new Include<T>
{
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<LoadableRelationAttribute>()!))
.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<T>(fields.Split(','));
}
}

View File

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

View File

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

View File

@ -221,9 +221,15 @@ namespace Kyoo.Core.Controllers
}
/// <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)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate.");
return ret;
@ -255,18 +261,20 @@ namespace Kyoo.Core.Controllers
}
/// <inheritdoc />
public virtual Task<T?> GetOrDefault(Filter<T>? filter,
public virtual async Task<T?> GetOrDefault(Filter<T>? filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false)
bool reverse = false,
Guid? afterId = default)
{
IQueryable<T> query = Sort(
AddIncludes(Database.Set<T>(), include),
sortBy
IQueryable<T> query = await ApplyFilters(
Database.Set<T>(),
filter,
sortBy,
new Pagination(1, afterId, reverse),
include
);
if (reverse)
query = query.Reverse();
return query.FirstOrDefaultAsync(ParseFilter(filter));
return await query.FirstOrDefaultAsync();
}
/// <inheritdoc/>
@ -285,12 +293,13 @@ namespace Kyoo.Core.Controllers
public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default);
/// <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,
Include<T>? include = 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>
@ -302,7 +311,7 @@ namespace Kyoo.Core.Controllers
/// <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>
/// <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,
Sort<T>? sort = default,
Pagination? limit = default,
@ -317,7 +326,6 @@ namespace Kyoo.Core.Controllers
T reference = await Get(limit.AfterID.Value);
Filter<T>? 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;
}
/// <inheritdoc/>

View File

@ -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<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()
{
UserId = userId,
ShowId = showId,
Status = status,
NextEpisode = status == WatchStatus.Watching
? await _episodes.GetOrDefault(
new Filter<Episode>.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)

View File

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Kyoo.Postgresql.Migrations
{
[DbContext(typeof(PostgresContext))]
[Migration("20231203194301_Watchlist")]
[Migration("20231204000849_Watchlist")]
partial class Watchlist
{
/// <inheritdoc />
@ -511,6 +511,14 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("integer")
.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")
.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),
status = table.Column<WatchStatus>(type: "watch_status", 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 =>
{

View File

@ -508,6 +508,14 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("integer")
.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")
.HasName("pk_show_watch_status");