mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Handle next episode position/percent in show watch status
This commit is contained in:
parent
e124113d41
commit
bd48032a50
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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(','));
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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/>
|
||||
|
@ -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)
|
||||
|
@ -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");
|
||||
|
@ -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 =>
|
||||
{
|
@ -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");
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user