diff --git a/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs b/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs index 0a60f458..a4b7fa3f 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs @@ -52,7 +52,15 @@ public interface IWatchStatusRepository /// Where the user has stopped watching. Only usable if Status /// is /// The movie's status - Task SetMovieStatus(int movieId, int userId, WatchStatus status, int? watchedTime); + Task SetMovieStatus(int movieId, int userId, WatchStatus status, int? watchedTime); + + /// + /// Delete the watch status of a movie. + /// + /// The movie selector. + /// The id of the user. + /// Nothing. + Task DeleteMovieStatus(Expression> where, int userId); /// /// Get the watch status of a show. @@ -69,7 +77,15 @@ public interface IWatchStatusRepository /// The id of the user. /// The new status. /// The shows's status - Task SetShowStatus(int showId, int userId, WatchStatus status); + Task SetShowStatus(int showId, int userId, WatchStatus status); + + /// + /// Delete the watch status of a show. + /// + /// The show selector. + /// The id of the user. + /// Nothing. + Task DeleteShowStatus(Expression> where, int userId); /// /// Get the watch status of an episode. @@ -88,5 +104,13 @@ public interface IWatchStatusRepository /// Where the user has stopped watching. Only usable if Status /// is /// The episode's status - Task SetEpisodeStatus(int episodeId, int userId, WatchStatus status, int? watchedTime); + Task SetEpisodeStatus(int episodeId, int userId, WatchStatus status, int? watchedTime); + + /// + /// Delete the watch status of an episode. + /// + /// The episode selector. + /// The id of the user. + /// Nothing. + Task DeleteEpisodeStatus(Expression> where, int userId); } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs index bd2bf657..ac178071 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs @@ -178,6 +178,15 @@ namespace Kyoo.Abstractions.Models .ThenBy(x => x.EpisodeNumber) .FirstOrDefault(); + /// + /// The number of episodes in this season. + /// + [Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] + [NotMapped] + public int EpisodesCount { get; set; } + + private int _EpisodesCount => Episodes!.Count; + [SerializeIgnore] public ICollection Watched { get; set; } /// diff --git a/back/src/Kyoo.Abstractions/Models/Resources/WatchInfo.cs b/back/src/Kyoo.Abstractions/Models/Resources/WatchInfo.cs index 09ce0308..5e8e7bab 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/WatchInfo.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/WatchInfo.cs @@ -17,6 +17,7 @@ // along with Kyoo. If not, see . 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. /// public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the movie (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching. + /// + public int? WatchedPercent { get; set; } } public class EpisodeWatchStatus : IAddedDate @@ -128,6 +137,14 @@ namespace Kyoo.Abstractions.Models /// Null if the status is not Watching. /// public int? WatchedTime { get; set; } + + /// + /// 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. + /// + public int? WatchedPercent { get; set; } } public class ShowWatchStatus : IAddedDate @@ -160,6 +177,11 @@ namespace Kyoo.Abstractions.Models /// public WatchStatus Status { get; set; } + /// + /// The numder of episodes the user has not seen. + /// + public int UnseenEpisodesCount { get; set; } + /// /// The ID of the episode started. /// @@ -177,8 +199,21 @@ 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.Core/Controllers/Repositories/WatchStatusRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs index 743e5e64..d4be5192 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs @@ -30,33 +30,65 @@ namespace Kyoo.Core.Controllers; public class WatchStatusRepository : IWatchStatusRepository { + /// + /// If the watch percent is below this value, don't consider the item started. + /// + public const int MinWatchPercent = 5; + + /// + /// If the watch percent is higher than this value, consider the item completed. + /// + /// + /// This value is lower to account credits in movies that can last really long. + /// + public const int MaxWatchPercent = 90; + private readonly DatabaseContext _database; private readonly IRepository _episodes; + private readonly IRepository _movies; - public WatchStatusRepository(DatabaseContext database, IRepository episodes) + public WatchStatusRepository(DatabaseContext database, + IRepository episodes, + IRepository movies) { _database = database; _episodes = episodes; + _movies = movies; } /// public Task GetMovieStatus(Expression> where, int userId) { - return _database.MovieWatchInfo.FirstOrDefaultAsync(x => + return _database.MovieWatchStatus.FirstOrDefaultAsync(x => x.Movie == _database.Movies.FirstOrDefault(where) && x.UserId == userId ); } /// - public async Task SetMovieStatus( + public async Task 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; } + /// + public async Task DeleteMovieStatus( + Expression> where, + int userId) + { + await _database.MovieWatchStatus + .Where(x => x.Movie == _database.Movies.FirstOrDefault(where) + && x.UserId == userId) + .ExecuteDeleteAsync(); + } + /// public Task GetShowStatus(Expression> where, int userId) { - return _database.ShowWatchInfo.FirstOrDefaultAsync(x => + return _database.ShowWatchStatus.FirstOrDefaultAsync(x => x.Show == _database.Shows.FirstOrDefault(where) && x.UserId == userId ); } /// - public async Task SetShowStatus( + public async Task 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; } + /// + public async Task DeleteShowStatus( + Expression> 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(); + } + /// public Task GetEpisodeStatus(Expression> where, int userId) { - return _database.EpisodeWatchInfo.FirstOrDefaultAsync(x => + return _database.EpisodeWatchStatus.FirstOrDefaultAsync(x => x.Episode == _database.Episodes.FirstOrDefault(where) && x.UserId == userId ); } /// - public async Task SetEpisodeStatus( + public async Task 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; } + + /// + public async Task DeleteEpisodeStatus( + Expression> where, + int userId) + { + await _database.EpisodeWatchStatus + .Where(x => x.Episode == _database.Episodes.FirstOrDefault(where) + && x.UserId == userId) + .ExecuteDeleteAsync(); + } } diff --git a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs index eca3006d..75b21b44 100644 --- a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs @@ -163,7 +163,6 @@ namespace Kyoo.Core.Api /// No movie with the given ID or slug could be found. [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 /// Where the user stopped watching. /// The newly set status. /// The status has been set + /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). /// WatchedTime can't be specified if status is not watching. /// No movie with the given ID or slug could be found. - [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 SetWatchStatus(Identifier identifier, WatchStatus status, int? watchedTime) + public async Task 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 ); } + + /// + /// Delete watch status + /// + /// + /// Delete watch status (to rewatch for example). + /// + /// The ID or slug of the . + /// The newly set status. + /// The status has been deleted. + /// No movie with the given ID or slug could be found. + [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(), + User.GetId()!.Value + ); + } } } diff --git a/back/src/Kyoo.Postgresql/DatabaseContext.cs b/back/src/Kyoo.Postgresql/DatabaseContext.cs index 3163e5a6..643551ea 100644 --- a/back/src/Kyoo.Postgresql/DatabaseContext.cs +++ b/back/src/Kyoo.Postgresql/DatabaseContext.cs @@ -100,11 +100,11 @@ namespace Kyoo.Postgresql // /// // public DbSet PeopleRoles { get; set; } - public DbSet MovieWatchInfo { get; set; } + public DbSet MovieWatchStatus { get; set; } - public DbSet ShowWatchInfo { get; set; } + public DbSet ShowWatchStatus { get; set; } - public DbSet EpisodeWatchInfo { get; set; } + public DbSet EpisodeWatchStatus { get; set; } /// /// Add a many to many link between two resources. diff --git a/back/tests/Kyoo.Tests/Database/RepositoryActivator.cs b/back/tests/Kyoo.Tests/Database/RepositoryActivator.cs index f2c409e6..fc8cb77b 100644 --- a/back/tests/Kyoo.Tests/Database/RepositoryActivator.cs +++ b/back/tests/Kyoo.Tests/Database/RepositoryActivator.cs @@ -77,6 +77,8 @@ namespace Kyoo.Tests.Database LibraryManager = new LibraryManager( libraryItem, + null, + null, collection, movies, show, diff --git a/back/tests/Kyoo.Tests/Database/TestContext.cs b/back/tests/Kyoo.Tests/Database/TestContext.cs index 91e1241a..baab6954 100644 --- a/back/tests/Kyoo.Tests/Database/TestContext.cs +++ b/back/tests/Kyoo.Tests/Database/TestContext.cs @@ -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()