Add percent, unseen episodes count and show completions handling

This commit is contained in:
Zoe Roux 2023-11-13 22:16:37 +01:00
parent 32050bcdcd
commit 6567e78c8c
8 changed files with 211 additions and 24 deletions

View File

@ -52,7 +52,15 @@ public interface IWatchStatusRepository
/// <param name="watchedTime">Where the user has stopped watching. Only usable if Status
/// is <see cref="WatchStatus.Watching"/></param>
/// <returns>The movie's status</returns>
Task<MovieWatchStatus> SetMovieStatus(int movieId, int userId, WatchStatus status, int? watchedTime);
Task<MovieWatchStatus?> SetMovieStatus(int movieId, int userId, WatchStatus status, int? watchedTime);
/// <summary>
/// Delete the watch status of a movie.
/// </summary>
/// <param name="where">The movie selector.</param>
/// <param name="userId">The id of the user.</param>
/// <returns>Nothing.</returns>
Task DeleteMovieStatus(Expression<Func<Movie, bool>> where, int userId);
/// <summary>
/// Get the watch status of a show.
@ -69,7 +77,15 @@ public interface IWatchStatusRepository
/// <param name="userId">The id of the user.</param>
/// <param name="status">The new status.</param>
/// <returns>The shows's status</returns>
Task<ShowWatchStatus> SetShowStatus(int showId, int userId, WatchStatus status);
Task<ShowWatchStatus?> SetShowStatus(int showId, int userId, WatchStatus status);
/// <summary>
/// Delete the watch status of a show.
/// </summary>
/// <param name="where">The show selector.</param>
/// <param name="userId">The id of the user.</param>
/// <returns>Nothing.</returns>
Task DeleteShowStatus(Expression<Func<Show, bool>> where, int userId);
/// <summary>
/// Get the watch status of an episode.
@ -88,5 +104,13 @@ public interface IWatchStatusRepository
/// <param name="watchedTime">Where the user has stopped watching. Only usable if Status
/// is <see cref="WatchStatus.Watching"/></param>
/// <returns>The episode's status</returns>
Task<EpisodeWatchStatus> SetEpisodeStatus(int episodeId, int userId, WatchStatus status, int? watchedTime);
Task<EpisodeWatchStatus?> SetEpisodeStatus(int episodeId, int userId, WatchStatus status, int? watchedTime);
/// <summary>
/// Delete the watch status of an episode.
/// </summary>
/// <param name="where">The episode selector.</param>
/// <param name="userId">The id of the user.</param>
/// <returns>Nothing.</returns>
Task DeleteEpisodeStatus(Expression<Func<Episode, bool>> where, int userId);
}

View File

@ -178,6 +178,15 @@ namespace Kyoo.Abstractions.Models
.ThenBy(x => x.EpisodeNumber)
.FirstOrDefault();
/// <summary>
/// The number of episodes in this season.
/// </summary>
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
[NotMapped]
public int EpisodesCount { get; set; }
private int _EpisodesCount => Episodes!.Count;
[SerializeIgnore] public ICollection<ShowWatchStatus> Watched { get; set; }
/// <summary>

View File

@ -17,6 +17,7 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using EntityFrameworkCore.Projectables;
using Kyoo.Abstractions.Models.Attributes;
@ -89,6 +90,14 @@ namespace Kyoo.Abstractions.Models
/// Null if the status is not Watching.
/// </remarks>
public int? WatchedTime { get; set; }
/// <summary>
/// Where the player has stopped watching the movie (in percentage between 0 and 100).
/// </summary>
/// <remarks>
/// Null if the status is not Watching.
/// </remarks>
public int? WatchedPercent { get; set; }
}
public class EpisodeWatchStatus : IAddedDate
@ -128,6 +137,14 @@ namespace Kyoo.Abstractions.Models
/// Null if the status is not Watching.
/// </remarks>
public int? WatchedTime { get; set; }
/// <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>
public int? WatchedPercent { get; set; }
}
public class ShowWatchStatus : IAddedDate
@ -160,6 +177,11 @@ namespace Kyoo.Abstractions.Models
/// </summary>
public WatchStatus Status { get; set; }
/// <summary>
/// The numder of episodes the user has not seen.
/// </summary>
public int UnseenEpisodesCount { get; set; }
/// <summary>
/// The ID of the episode started.
/// </summary>
@ -177,8 +199,21 @@ namespace Kyoo.Abstractions.Models
/// 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

@ -30,33 +30,65 @@ namespace Kyoo.Core.Controllers;
public class WatchStatusRepository : IWatchStatusRepository
{
/// <summary>
/// If the watch percent is below this value, don't consider the item started.
/// </summary>
public const int MinWatchPercent = 5;
/// <summary>
/// If the watch percent is higher than this value, consider the item completed.
/// </summary>
/// <remarks>
/// This value is lower to account credits in movies that can last really long.
/// </remarks>
public const int MaxWatchPercent = 90;
private readonly DatabaseContext _database;
private readonly IRepository<Episode> _episodes;
private readonly IRepository<Movie> _movies;
public WatchStatusRepository(DatabaseContext database, IRepository<Episode> episodes)
public WatchStatusRepository(DatabaseContext database,
IRepository<Episode> episodes,
IRepository<Movie> movies)
{
_database = database;
_episodes = episodes;
_movies = movies;
}
/// <inheritdoc />
public Task<MovieWatchStatus?> GetMovieStatus(Expression<Func<Movie, bool>> where, int userId)
{
return _database.MovieWatchInfo.FirstOrDefaultAsync(x =>
return _database.MovieWatchStatus.FirstOrDefaultAsync(x =>
x.Movie == _database.Movies.FirstOrDefault(where)
&& x.UserId == userId
);
}
/// <inheritdoc />
public async Task<MovieWatchStatus> SetMovieStatus(
public async Task<MovieWatchStatus?> SetMovieStatus(
int movieId,
int userId,
WatchStatus status,
int? watchedTime)
{
Movie movie = await _movies.Get(movieId);
int? percent = watchedTime != null && movie.Runtime > 0
? (int)Math.Round(watchedTime.Value / (movie.Runtime * 60f) * 100f)
: null;
if (percent < MinWatchPercent)
return null;
if (percent > MaxWatchPercent)
{
status = WatchStatus.Completed;
watchedTime = null;
percent = null;
}
if (watchedTime.HasValue && status != WatchStatus.Watching)
throw new ValidationException("Can't have a watched time if the status is not watching.");
MovieWatchStatus ret = new()
{
UserId = userId,
@ -64,27 +96,45 @@ public class WatchStatusRepository : IWatchStatusRepository
Status = status,
WatchedTime = watchedTime,
};
await _database.MovieWatchInfo.Upsert(ret)
await _database.MovieWatchStatus.Upsert(ret)
.UpdateIf(x => !(status == WatchStatus.Watching && x.Status == WatchStatus.Completed))
.RunAsync();
return ret;
}
/// <inheritdoc />
public async Task DeleteMovieStatus(
Expression<Func<Movie, bool>> where,
int userId)
{
await _database.MovieWatchStatus
.Where(x => x.Movie == _database.Movies.FirstOrDefault(where)
&& x.UserId == userId)
.ExecuteDeleteAsync();
}
/// <inheritdoc />
public Task<ShowWatchStatus?> GetShowStatus(Expression<Func<Show, bool>> where, int userId)
{
return _database.ShowWatchInfo.FirstOrDefaultAsync(x =>
return _database.ShowWatchStatus.FirstOrDefaultAsync(x =>
x.Show == _database.Shows.FirstOrDefault(where)
&& x.UserId == userId
);
}
/// <inheritdoc />
public async Task<ShowWatchStatus> SetShowStatus(
public async Task<ShowWatchStatus?> SetShowStatus(
int showId,
int userId,
WatchStatus status)
{
int unseenEpisodeCount = await _database.Episodes
.Where(x => x.ShowId == showId)
.Where(x => x.WatchStatus!.Status != WatchStatus.Completed)
.CountAsync();
if (unseenEpisodeCount == 0)
status = WatchStatus.Completed;
ShowWatchStatus ret = new()
{
UserId = userId,
@ -98,41 +148,85 @@ public class WatchStatusRepository : IWatchStatusRepository
reverse: true
)
: null,
UnseenEpisodesCount = unseenEpisodeCount,
};
await _database.ShowWatchInfo.Upsert(ret)
await _database.ShowWatchStatus.Upsert(ret)
.UpdateIf(x => !(status == WatchStatus.Watching && x.Status == WatchStatus.Completed))
.RunAsync();
return ret;
}
/// <inheritdoc />
public async Task DeleteShowStatus(
Expression<Func<Show, bool>> where,
int userId)
{
await _database.ShowWatchStatus
.Where(x => x.Show == _database.Shows.FirstOrDefault(where)
&& x.UserId == userId)
.ExecuteDeleteAsync();
await _database.EpisodeWatchStatus
.Where(x => x.Episode.Show == _database.Shows.FirstOrDefault(where)
&& x.UserId == userId)
.ExecuteDeleteAsync();
}
/// <inheritdoc />
public Task<EpisodeWatchStatus?> GetEpisodeStatus(Expression<Func<Episode, bool>> where, int userId)
{
return _database.EpisodeWatchInfo.FirstOrDefaultAsync(x =>
return _database.EpisodeWatchStatus.FirstOrDefaultAsync(x =>
x.Episode == _database.Episodes.FirstOrDefault(where)
&& x.UserId == userId
);
}
/// <inheritdoc />
public async Task<EpisodeWatchStatus> SetEpisodeStatus(
public async Task<EpisodeWatchStatus?> SetEpisodeStatus(
int episodeId,
int userId,
WatchStatus status,
int? watchedTime)
{
Episode episode = await _episodes.Get(episodeId);
int? percent = watchedTime != null && episode.Runtime > 0
? (int)Math.Round(watchedTime.Value / (episode.Runtime * 60f) * 100f)
: null;
if (percent < MinWatchPercent)
return null;
if (percent > MaxWatchPercent)
{
status = WatchStatus.Completed;
watchedTime = null;
percent = null;
}
if (watchedTime.HasValue && status != WatchStatus.Watching)
throw new ValidationException("Can't have a watched time if the status is not watching.");
EpisodeWatchStatus ret = new()
{
UserId = userId,
EpisodeId = episodeId,
Status = status,
WatchedTime = watchedTime,
WatchedPercent = percent,
};
await _database.EpisodeWatchInfo.Upsert(ret).RunAsync();
await _database.EpisodeWatchStatus.Upsert(ret)
.UpdateIf(x => !(status == WatchStatus.Watching && x.Status == WatchStatus.Completed))
.RunAsync();
await SetShowStatus(episode.ShowId, userId, WatchStatus.Watching);
return ret;
}
/// <inheritdoc />
public async Task DeleteEpisodeStatus(
Expression<Func<Episode, bool>> where,
int userId)
{
await _database.EpisodeWatchStatus
.Where(x => x.Episode == _database.Episodes.FirstOrDefault(where)
&& x.UserId == userId)
.ExecuteDeleteAsync();
}
}

View File

@ -163,7 +163,6 @@ namespace Kyoo.Core.Api
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/watchStatus")]
[HttpGet("{identifier:id}/watchStatus", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -187,16 +186,17 @@ namespace Kyoo.Core.Api
/// <param name="watchedTime">Where the user stopped watching.</param>
/// <returns>The newly set status.</returns>
/// <response code="200">The status has been set</response>
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
/// <response code="400">WatchedTime can't be specified if status is not watching.</response>
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/watchStatus")]
[HttpGet("{identifier:id}/watchStatus", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[HttpPost("{identifier:id}/watchStatus")]
[HttpPost("{identifier:id}/watchStatus", Order = AlternativeRoute)]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<MovieWatchStatus> SetWatchStatus(Identifier identifier, WatchStatus status, int? watchedTime)
public async Task<MovieWatchStatus?> SetWatchStatus(Identifier identifier, WatchStatus status, int? watchedTime)
{
int id = await identifier.Match(
id => Task.FromResult(id),
@ -209,5 +209,28 @@ namespace Kyoo.Core.Api
watchedTime
);
}
/// <summary>
/// Delete watch status
/// </summary>
/// <remarks>
/// Delete watch status (to rewatch for example).
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
/// <returns>The newly set status.</returns>
/// <response code="204">The status has been deleted.</response>
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpDelete("{identifier:id}/watchStatus")]
[HttpDelete("{identifier:id}/watchStatus", Order = AlternativeRoute)]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task DeleteWatchStatus(Identifier identifier)
{
await _libraryManager.WatchStatus.DeleteMovieStatus(
identifier.IsSame<Movie>(),
User.GetId()!.Value
);
}
}
}

View File

@ -100,11 +100,11 @@ namespace Kyoo.Postgresql
// /// </summary>
// public DbSet<PeopleRole> PeopleRoles { get; set; }
public DbSet<MovieWatchStatus> MovieWatchInfo { get; set; }
public DbSet<MovieWatchStatus> MovieWatchStatus { get; set; }
public DbSet<ShowWatchStatus> ShowWatchInfo { get; set; }
public DbSet<ShowWatchStatus> ShowWatchStatus { get; set; }
public DbSet<EpisodeWatchStatus> EpisodeWatchInfo { get; set; }
public DbSet<EpisodeWatchStatus> EpisodeWatchStatus { get; set; }
/// <summary>
/// Add a many to many link between two resources.

View File

@ -77,6 +77,8 @@ namespace Kyoo.Tests.Database
LibraryManager = new LibraryManager(
libraryItem,
null,
null,
collection,
movies,
show,

View File

@ -49,7 +49,7 @@ namespace Kyoo.Tests
.UseNpgsql(Connection)
.Options;
using PostgresContext context = new(_options);
using PostgresContext context = new(_options, null);
context.Database.Migrate();
using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection();
@ -62,7 +62,7 @@ namespace Kyoo.Tests
public void Dispose()
{
using PostgresContext context = new(_options);
using PostgresContext context = new(_options, null);
context.Database.EnsureDeleted();
}
}
@ -119,7 +119,7 @@ namespace Kyoo.Tests
public override DatabaseContext New()
{
return new PostgresContext(_context);
return new PostgresContext(_context, null);
}
public override DbConnection NewConnection()