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()