mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add percent, unseen episodes count and show completions handling
This commit is contained in:
parent
32050bcdcd
commit
6567e78c8c
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -77,6 +77,8 @@ namespace Kyoo.Tests.Database
|
||||
|
||||
LibraryManager = new LibraryManager(
|
||||
libraryItem,
|
||||
null,
|
||||
null,
|
||||
collection,
|
||||
movies,
|
||||
show,
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user