diff --git a/Shoko.Plugin.Abstractions/DataModels/IUserData.cs b/Shoko.Plugin.Abstractions/DataModels/IUserData.cs new file mode 100644 index 000000000..0910bd68a --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/IUserData.cs @@ -0,0 +1,25 @@ +using System; +using Shoko.Plugin.Abstractions.DataModels.Shoko; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Represents user-specific data. +/// +public interface IUserData +{ + /// + /// Gets the ID of the user. + /// + int UserID { get; } + + /// + /// Gets the date and time when the user data was last updated. + /// + DateTime LastUpdatedAt { get; } + + /// + /// Gets the user associated with this video data. + /// + IShokoUser User { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/IVideoUserData.cs b/Shoko.Plugin.Abstractions/DataModels/IVideoUserData.cs new file mode 100644 index 000000000..8252ed38f --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/IVideoUserData.cs @@ -0,0 +1,34 @@ +using System; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Represents user-specific data associated with a video. +/// +public interface IVideoUserData : IUserData +{ + /// + /// Gets the ID of the video. + /// + int VideoID { get; } + + /// + /// Gets the number of times the video has been played. + /// + int PlaybackCount { get; } + + /// + /// Gets the position in the video where playback was last resumed. + /// + TimeSpan ResumePosition { get; } + + /// + /// Gets the date and time when the video was last played. + /// + DateTime? LastPlayedAt { get; } + + /// + /// Gets the video associated with this user data. + /// + IVideo Video { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoUser.cs b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoUser.cs new file mode 100644 index 000000000..fa939b117 --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/Shoko/IShokoUser.cs @@ -0,0 +1,17 @@ +namespace Shoko.Plugin.Abstractions.DataModels.Shoko; + +/// +/// Shoko user. +/// +public interface IShokoUser +{ + /// + /// Unique ID. + /// + int ID { get; } + + /// + /// Username. + /// + string Username { get; } +} diff --git a/Shoko.Plugin.Abstractions/DataModels/VideoUserDataUpdate.cs b/Shoko.Plugin.Abstractions/DataModels/VideoUserDataUpdate.cs new file mode 100644 index 000000000..8d72e4188 --- /dev/null +++ b/Shoko.Plugin.Abstractions/DataModels/VideoUserDataUpdate.cs @@ -0,0 +1,31 @@ +using System; + +namespace Shoko.Plugin.Abstractions.DataModels; + +/// +/// Input data for updating a . +/// +/// An existing to derive data from. +public class VideoUserDataUpdate(IVideoUserData? userData = null) +{ + /// + /// Override or set the number of times the video has been played. + /// + public int? PlaybackCount { get; set; } = userData?.PlaybackCount; + + /// + /// Override or set the position at which the video should be resumed. + /// + public TimeSpan? ResumePosition { get; set; } = userData?.ResumePosition; + + /// + /// Override or set the date and time the video was last played. + /// + public DateTime? LastPlayedAt { get; set; } = userData?.LastPlayedAt; + + /// + /// Override when the data was last updated. If not set, then the current + /// time will be used. + /// + public DateTime? LastUpdatedAt { get; set; } = userData?.LastUpdatedAt; +} diff --git a/Shoko.Plugin.Abstractions/Enums/UserDataSaveReason.cs b/Shoko.Plugin.Abstractions/Enums/UserDataSaveReason.cs new file mode 100644 index 000000000..ef7c491ec --- /dev/null +++ b/Shoko.Plugin.Abstractions/Enums/UserDataSaveReason.cs @@ -0,0 +1,48 @@ + +namespace Shoko.Plugin.Abstractions.Enums; + +/// +/// The reason the user data is being saved. +/// +public enum UserDataSaveReason +{ + /// + /// The user data is being saved for no specific reason. + /// + None = 0, + + /// + /// The user data is being saved because of user interaction. + /// + UserInteraction, + + /// + /// The user data is being saved when playback of a video started. + /// + PlaybackStart, + + /// + /// The user data is being saved when playback of a video was paused. + /// + PlaybackPause, + + /// + /// The user data is being saved when playback of a video was resumed. + /// + PlaybackResume, + + /// + /// The user data is being saved when playback of a video progressed. + /// + PlaybackProgress, + + /// + /// The user data is being saved when playback of a video ended. + /// + PlaybackEnd, + + /// + /// The user data is being saved during an import from AniDB. + /// + AnidbImport, +} diff --git a/Shoko.Plugin.Abstractions/Events/VideoUserDataSavedEventArgs.cs b/Shoko.Plugin.Abstractions/Events/VideoUserDataSavedEventArgs.cs new file mode 100644 index 000000000..db72fd348 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Events/VideoUserDataSavedEventArgs.cs @@ -0,0 +1,47 @@ + +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Enums; + +namespace Shoko.Plugin.Abstractions.Events; + +/// +/// Dispatched when video user data was updated. +/// +public class VideoUserDataSavedEventArgs +{ + /// + /// The reason why the user data was updated. + /// + public UserDataSaveReason Reason { get; } + + /// + /// The user which had their data updated. + /// + public IShokoUser User { get; } + + /// + /// The video which had its user data updated. + /// + public IVideo Video { get; } + + /// + /// The updated video user data. + /// + public IVideoUserData UserData { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The reason why the user data was updated. + /// The user which had their data updated. + /// The video which had its user data updated. + /// The updated video user data. + public VideoUserDataSavedEventArgs(UserDataSaveReason reason, IShokoUser user, IVideo video, IVideoUserData userData) + { + Reason = reason; + User = user; + Video = video; + UserData = userData; + } +} diff --git a/Shoko.Plugin.Abstractions/Services/IUserDataService.cs b/Shoko.Plugin.Abstractions/Services/IUserDataService.cs new file mode 100644 index 000000000..6113287c7 --- /dev/null +++ b/Shoko.Plugin.Abstractions/Services/IUserDataService.cs @@ -0,0 +1,95 @@ + + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; + +namespace Shoko.Plugin.Abstractions.Services; +/// +/// User data service. +/// +public interface IUserDataService +{ + #region Video User Data + + /// + /// Dispatched when video user data is saved. + /// + event EventHandler VideoUserDataSaved; + + /// + /// Gets user video data for the given user and video. + /// + /// The user ID. + /// The video ID. + /// The user video data. + IVideoUserData? GetVideoUserData(int userID, int videoID); + + /// + /// Gets all user video data for the given user. + /// + /// The user ID. + /// The user video data. + IReadOnlyList GetVideoUserDataForUser(int userID); + + /// + /// Gets all user video data for the given video. + /// + /// The video ID. + /// A list of user video data. + IReadOnlyList GetVideoUserDataForVideo(int videoID); + + /// + /// Sets the video watch status. + /// + /// The user. + /// The video. + /// Optional. If set to true the video is watched; otherwise, false. + /// Optional. The watched at. + /// Optional. The reason why the video watch status was updated. + /// if set to true will update the series stats immediately after saving. + /// The is null. + /// The is null. + /// A task. + Task SetVideoWatchedStatus(IShokoUser user, IVideo video, bool watched = true, DateTime? watchedAt = null, UserDataSaveReason reason = UserDataSaveReason.None, bool updateStatsNow = true); + + /// + /// Saves the video user data. + /// + /// The user. + /// The video. + /// The user data update. + /// The reason why the user data was updated. + /// if set to true will update the series stats immediately after saving. + /// The is null. + /// The is null. + /// The task containing the new or updated user video data. + Task SaveVideoUserData(IShokoUser user, IVideo video, VideoUserDataUpdate userDataUpdate, UserDataSaveReason reason = UserDataSaveReason.None, bool updateStatsNow = true); + + #endregion + + #region Episode User Data + + /// + /// Sets the episode watch status. + /// + /// + /// Attempting to set the episode watch status for an episode without files will result in a no-op. + /// + /// The user. + /// The episode. + /// Optional. If set to true the episode is watched; otherwise, false. + /// Optional. The watched at. + /// Optional. The reason why the episode watch status was updated. + /// if set to true will update the series stats immediately after saving. + /// The is null. + /// The is null. + /// The task containing the result if the episode watch status was updated. + Task SetEpisodeWatchedStatus(IShokoUser user, IShokoEpisode episode, bool watched = true, DateTime? watchedAt = null, UserDataSaveReason reason = UserDataSaveReason.None, bool updateStatsNow = true); + + #endregion +} diff --git a/Shoko.Plugin.Abstractions/Services/IUserService.cs b/Shoko.Plugin.Abstractions/Services/IUserService.cs new file mode 100644 index 000000000..aed3f689a --- /dev/null +++ b/Shoko.Plugin.Abstractions/Services/IUserService.cs @@ -0,0 +1,23 @@ +using System.Linq; +using Shoko.Plugin.Abstractions.DataModels.Shoko; + +namespace Shoko.Plugin.Abstractions.Services; + +/// +/// User manager. +/// +public interface IUserService +{ + /// + /// Get all users as a queryable list. + /// + /// The users. + IQueryable GetUsers(); + + /// + /// Get a user by ID. + /// + /// The ID. + /// The user. + IShokoUser? GetUserByID(int id); +} diff --git a/Shoko.Server/API/v0/Controllers/PlexWebhook.cs b/Shoko.Server/API/v0/Controllers/PlexWebhook.cs index 2606d0318..0412fc6e5 100644 --- a/Shoko.Server/API/v0/Controllers/PlexWebhook.cs +++ b/Shoko.Server/API/v0/Controllers/PlexWebhook.cs @@ -16,6 +16,7 @@ using Shoko.Models.Plex.Connections; using Shoko.Models.Plex.Libraries; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Extensions; using Shoko.Server.Models; using Shoko.Server.Plex; @@ -23,9 +24,9 @@ using Shoko.Server.Repositories; using Shoko.Server.Scheduling; using Shoko.Server.Scheduling.Jobs.Plex; -using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; + #if DEBUG using Shoko.Models.Plex.Collection; using Shoko.Server.Plex.Libraries; @@ -41,16 +42,14 @@ public class PlexWebhook : BaseController private readonly ILogger _logger; private readonly TraktTVHelper _traktHelper; private readonly ISchedulerFactory _schedulerFactory; - private readonly AnimeSeriesService _seriesService; - private readonly AnimeGroupService _groupService; + private readonly IUserDataService _userDataService; - public PlexWebhook(ILogger logger, TraktTVHelper traktHelper, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, AnimeSeriesService seriesService, AnimeGroupService groupService) : base(settingsProvider) + public PlexWebhook(ILogger logger, TraktTVHelper traktHelper, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, IUserDataService userDataService) : base(settingsProvider) { _logger = logger; _traktHelper = traktHelper; _schedulerFactory = schedulerFactory; - _seriesService = seriesService; - _groupService = groupService; + _userDataService = userDataService; } //The second one is to just make sure @@ -133,11 +132,7 @@ private async Task Scrobble(PlexEvent data, SVR_JMMUser user) return; //At this point in time, we don't want to scrobble for unknown users } - var watchedService = Utils.ServiceContainer.GetRequiredService(); - await watchedService.SetWatchedStatus(episode, true, true, FromUnixTime(metadata.LastViewedAt), false, user.JMMUserID, - true); - _seriesService.UpdateStats(anime, true, false); - _groupService.UpdateStatsFromTopLevel(anime.AnimeGroup?.TopLevelAnimeGroup, true, true); + await _userDataService.SetEpisodeWatchedStatus(user, episode, true, FromUnixTime(metadata.LastViewedAt)); } #endregion diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs index a98c01e3d..27908e9a8 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs @@ -13,6 +13,7 @@ using Shoko.Models.Enums; using Shoko.Models.Interfaces; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.Annotations; using Shoko.Server.Extensions; using Shoko.Server.Filters.Legacy; @@ -50,7 +51,7 @@ public partial class ShokoServiceImplementation : Controller, IShokoServer private readonly ActionService _actionService; private readonly AnimeEpisodeService _episodeService; private readonly VideoLocalService _videoLocalService; - private readonly WatchedStatusService _watchedService; + private readonly IUserDataService _userDataService; public ShokoServiceImplementation( TraktTVHelper traktHelper, @@ -64,7 +65,7 @@ public ShokoServiceImplementation( AnimeGroupCreator groupCreator, JobFactory jobFactory, AnimeEpisodeService episodeService, - WatchedStatusService watchedService, + IUserDataService userDataService, VideoLocalService videoLocalService ) { @@ -79,7 +80,7 @@ VideoLocalService videoLocalService _groupCreator = groupCreator; _jobFactory = jobFactory; _episodeService = episodeService; - _watchedService = watchedService; + _userDataService = userDataService; _videoLocalService = videoLocalService; } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs index d343218fb..664a7af58 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs @@ -960,13 +960,24 @@ public string SetResumePosition(int videoLocalID, long resumeposition, int userI { try { - var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid is null) - { + var video = RepoFactory.VideoLocal.GetByID(videoLocalID); + if (video is null) return "Could not find video local record"; - } - _watchedService.SetResumePosition(vid, resumeposition, userID); + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) + return "Could not find user record"; + + var videoUserData = _userDataService.GetVideoUserData(userID, videoLocalID); + _userDataService.SaveVideoUserData( + user, + video, + new(videoUserData) + { + ResumePosition = TimeSpan.FromTicks(resumeposition), + LastUpdatedAt = DateTime.Now + } + ).GetAwaiter().GetResult(); return string.Empty; } catch (Exception ex) @@ -1235,13 +1246,15 @@ public string ToggleWatchedStatusOnVideo(int videoLocalID, bool watchedStatus, i { try { - var vid = RepoFactory.VideoLocal.GetByID(videoLocalID); - if (vid is null) - { + var video = RepoFactory.VideoLocal.GetByID(videoLocalID); + if (video is null) return "Could not find video local record"; - } - _watchedService.SetWatchedStatus(vid, watchedStatus, true, DateTime.Now, true, userID, true, true).GetAwaiter().GetResult(); + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) + return "Could not find user record"; + + _userDataService.SetVideoWatchedStatus(user, video, watchedStatus).GetAwaiter().GetResult(); return string.Empty; } catch (Exception ex) @@ -1265,7 +1278,14 @@ public CL_Response ToggleWatchedStatusOnEpisode(int animeE return response; } - _watchedService.SetWatchedStatus(ep, watchedStatus, true, DateTime.Now, false, userID, true).GetAwaiter().GetResult(); + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) + { + response.ErrorMessage = "Could not find user record"; + return response; + } + + _userDataService.SetEpisodeWatchedStatus(user, ep, watchedStatus, DateTime.Now).GetAwaiter().GetResult(); var series = ep.AnimeSeries; var seriesService = Utils.ServiceContainer.GetRequiredService(); seriesService.UpdateStats(series, true, false); @@ -1566,6 +1586,10 @@ public string SetWatchedStatusOnSeries(int animeSeriesID, bool watchedStatus, in { var eps = RepoFactory.AnimeEpisode.GetBySeriesID(animeSeriesID); + var user = RepoFactory.JMMUser.GetByID(userID); + if (user is null) + return "Could not find user record"; + SVR_AnimeSeries ser = null; var seriesService = Utils.ServiceContainer.GetRequiredService(); foreach (var ep in eps) @@ -1589,7 +1613,7 @@ public string SetWatchedStatusOnSeries(int animeSeriesID, bool watchedStatus, in if (currentStatus != watchedStatus) { _logger.LogInformation("Updating episode: {Num} to {Watched}", ep.AniDB_Episode.EpisodeNumber, watchedStatus); - _watchedService.SetWatchedStatus(ep, watchedStatus, true, DateTime.Now, false, userID, false).GetAwaiter().GetResult(); + _userDataService.SetEpisodeWatchedStatus(user, ep, watchedStatus, updateStatsNow: false).GetAwaiter().GetResult(); } } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs index 07e70bf94..b284c676d 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationMetro.cs @@ -12,6 +12,7 @@ using Shoko.Models.Enums; using Shoko.Models.Metro; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Extensions; using Shoko.Server.Filters; using Shoko.Server.Models; @@ -40,7 +41,7 @@ public class ShokoServiceImplementationMetro : IShokoServerMetro, IHttpContextAc private readonly JobFactory _jobFactory; - private readonly WatchedStatusService _watchedService; + private readonly IUserDataService _userDataService; private readonly AnimeEpisodeService _epService; @@ -49,13 +50,13 @@ public class ShokoServiceImplementationMetro : IShokoServerMetro, IHttpContextAc public HttpContext HttpContext { get; set; } public ShokoServiceImplementationMetro(TraktTVHelper traktHelper, ISettingsProvider settingsProvider, ShokoServiceImplementation service, - JobFactory jobFactory, WatchedStatusService watchedService, AnimeEpisodeService epService) + JobFactory jobFactory, IUserDataService userDataService, AnimeEpisodeService epService) { _traktHelper = traktHelper; _settingsProvider = settingsProvider; _service = service; _jobFactory = jobFactory; - _watchedService = watchedService; + _userDataService = userDataService; _epService = epService; } @@ -1123,24 +1124,20 @@ public CL_Response ToggleWatchedStatusOnEpisode(int animeE var response = new CL_Response { ErrorMessage = string.Empty, Result = null }; try { - var ep = RepoFactory.AnimeEpisode.GetByID(animeEpisodeID); - if (ep is null) + if (RepoFactory.AnimeEpisode.GetByID(animeEpisodeID) is not { } episode) { response.ErrorMessage = "Could not find anime episode record"; return response; } - _watchedService.SetWatchedStatus(ep, watchedStatus, true, DateTime.Now, false, userID, true).GetAwaiter().GetResult(); - var seriesService = Utils.ServiceContainer.GetRequiredService(); - var series = ep.AnimeSeries; - seriesService.UpdateStats(series, true, false); - var groupService = Utils.ServiceContainer.GetRequiredService(); - groupService.UpdateStatsFromTopLevel(series?.AnimeGroup?.TopLevelAnimeGroup, true, true); - - // refresh from db - ep = RepoFactory.AnimeEpisode.GetByID(animeEpisodeID); + if (RepoFactory.JMMUser.GetByID(userID) is not { } user) + { + response.ErrorMessage = "Could not find user record"; + return response; + } - response.Result = _epService.GetV1Contract(ep, userID); + _userDataService.SetEpisodeWatchedStatus(user, episode, watchedStatus, DateTime.Now).GetAwaiter().GetResult(); + response.Result = _epService.GetV1Contract(episode, userID); return response; } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs index fd8e6b03f..74ecfa2a5 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementationStream.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using NLog; using Shoko.Models.Interfaces; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.Annotations; using Shoko.Server.Models; using Shoko.Server.Repositories; @@ -151,8 +152,8 @@ private object StreamInfoResult(InfoResult r, bool? autoWatch) { Task.Factory.StartNew(async () => { - var watchedService = Utils.ServiceContainer.GetRequiredService(); - await watchedService.SetWatchedStatus(r.VideoLocal, true, r.User.JMMUserID); + var userDataService = Utils.ServiceContainer.GetRequiredService(); + await userDataService.SetVideoWatchedStatus(r.User, r.VideoLocal); }, new CancellationToken(), TaskCreationOptions.LongRunning, TaskScheduler.Default); diff --git a/Shoko.Server/API/v2/Modules/Common.cs b/Shoko.Server/API/v2/Modules/Common.cs index 88f629577..16e3c7139 100644 --- a/Shoko.Server/API/v2/Modules/Common.cs +++ b/Shoko.Server/API/v2/Modules/Common.cs @@ -16,6 +16,7 @@ using Shoko.Models.Enums; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.v2.Models.common; using Shoko.Server.API.v2.Models.core; using Shoko.Server.Extensions; @@ -50,10 +51,19 @@ public class Common : BaseController private readonly ISchedulerFactory _schedulerFactory; private readonly ActionService _actionService; private readonly QueueHandler _queueHandler; - private readonly WatchedStatusService _watchedService; + private readonly IUserDataService _userDataService; private readonly VideoLocal_UserRepository _vlUsers; - public Common(ISchedulerFactory schedulerFactory, ActionService actionService, ISettingsProvider settingsProvider, QueueHandler queueHandler, ShokoServiceImplementation service, AnimeSeriesService seriesService, AnimeGroupService groupService, WatchedStatusService watchedService, VideoLocal_UserRepository vlUsers) : base(settingsProvider) + public Common( + ISchedulerFactory schedulerFactory, + ActionService actionService, + ISettingsProvider settingsProvider, + QueueHandler queueHandler, + ShokoServiceImplementation service, + AnimeSeriesService seriesService, + AnimeGroupService groupService, + IUserDataService userDataService, + VideoLocal_UserRepository vlUsers) : base(settingsProvider) { _schedulerFactory = schedulerFactory; _actionService = actionService; @@ -61,7 +71,7 @@ public Common(ISchedulerFactory schedulerFactory, ActionService actionService, I _service = service; _seriesService = seriesService; _groupService = groupService; - _watchedService = watchedService; + _userDataService = userDataService; _vlUsers = vlUsers; } //class will be found automagically thanks to inherits also class need to be public (or it will 404) @@ -983,25 +993,26 @@ public List GetUnsort(int offset = 0, int level = 0, int limit = 0) [HttpPost("file/offset")] public ActionResult SetFileOffset([FromBody] API_Call_Parameters para) { - JMMUser user = HttpContext.GetUser(); - - var id = para.id; - var offset = para.offset; - - // allow to offset be 0 to reset position - if (id == 0 || offset < 0) - { + var videoId = para.id; + var resumePositionTicks = para.offset; + if (videoId == 0 || resumePositionTicks < 0) return BadRequest("Invalid arguments"); - } - var vlu = RepoFactory.VideoLocal.GetByID(id); - if (vlu != null) - { - _watchedService.SetResumePosition(vlu, offset, user.JMMUserID); - return Ok(); - } + if (RepoFactory.VideoLocal.GetByID(videoId) is not { } video) + return NotFound(); - return NotFound(); + var user = HttpContext.GetUser(); + var videoUserData = _userDataService.GetVideoUserData(user.JMMUserID, video.VideoLocalID); + _userDataService.SaveVideoUserData( + user, + video, + new(videoUserData) + { + ResumePosition = TimeSpan.FromTicks(resumePositionTicks), + LastUpdatedAt = DateTime.Now + } + ).GetAwaiter().GetResult(); + return Ok(); } /// @@ -1011,13 +1022,13 @@ public ActionResult SetFileOffset([FromBody] API_Call_Parameters para) [HttpGet("file/watch")] private async Task MarkFileAsWatched(int id) { - JMMUser user = HttpContext.GetUser(); - if (id != 0) - { - return await MarkFile(true, id, user.JMMUserID); - } + if (id == 0) + return BadRequest("missing 'id'"); + if (id < 0) + return BadRequest("invalid 'id'"); - return BadRequest("missing 'id'"); + var user = HttpContext.GetUser(); + return await MarkFile(true, id, user); } /// @@ -1027,13 +1038,13 @@ private async Task MarkFileAsWatched(int id) [HttpGet("file/unwatch")] private async Task MarkFileAsUnwatched(int id) { - JMMUser user = HttpContext.GetUser(); - if (id != 0) - { - return await MarkFile(false, id, user.JMMUserID); - } + if (id == 0) + return BadRequest("missing 'id'"); + if (id < 0) + return BadRequest("invalid 'id'"); - return BadRequest("missing 'id'"); + var user = HttpContext.GetUser(); + return await MarkFile(false, id, user); } #region internal function @@ -1094,40 +1105,16 @@ internal object GetAllFiles(int limit, int level, int uid) /// /// /// - /// + /// /// - internal async Task MarkFile(bool status, int id, int uid) + internal async Task MarkFile(bool status, int id, SVR_JMMUser user) { try { - var file = RepoFactory.VideoLocal.GetByID(id); - if (file == null) - { - return NotFound(); - } - - var list_ep = file.AnimeEpisodes; - if (list_ep == null) - { + if (RepoFactory.VideoLocal.GetByID(id) is not { } video) return NotFound(); - } - foreach (var ep in list_ep) - { - await _watchedService.SetWatchedStatus(ep, status, true, DateTime.Now, false, uid, true); - } - - var series = list_ep.Select(a => a.AnimeSeries).Where(a => a != null).DistinctBy(a => a.AnimeSeriesID).ToList(); - var groups = series.Select(a => a.AnimeGroup?.TopLevelAnimeGroup).Where(a => a != null) - .DistinctBy(a => a.AnimeGroupID); - foreach (var s in series) - { - _seriesService.UpdateStats(s, true, false); - } - foreach (var group in groups) - { - _groupService.UpdateStatsFromTopLevel(group, true, true); - } + await _userDataService.SetVideoWatchedStatus(user, video, status); return Ok(); } @@ -1312,13 +1299,13 @@ public List GetMissingEpisodes(bool all, int pic, TagFilter.Filter tagfil [HttpGet("ep/watch")] public async Task MarkEpisodeAsWatched(int id) { - JMMUser user = HttpContext.GetUser(); - if (id != 0) - { - return await MarkEpisode(true, id, user.JMMUserID); - } + if (id == 0) + return BadRequest("missing 'id'"); + if (id < 0) + return BadRequest("invalid 'id'"); - return BadRequest("missing 'id'"); + var user = HttpContext.GetUser(); + return await MarkEpisode(true, id, user); } /// @@ -1328,13 +1315,13 @@ public async Task MarkEpisodeAsWatched(int id) [HttpGet("ep/unwatch")] public async Task MarkEpisodeAsUnwatched(int id) { - JMMUser user = HttpContext.GetUser(); - if (id != 0) - { - return await MarkEpisode(false, id, user.JMMUserID); - } + if (id == 0) + return BadRequest("missing 'id'"); + if (id < 0) + return BadRequest("invalid 'id'"); - return BadRequest("missing 'id'"); + var user = HttpContext.GetUser(); + return await MarkEpisode(false, id, user); } /// @@ -1455,22 +1442,16 @@ public List ListWatchedEpisodes(string query, int pic, int level, int l /// /// true is watched, false is unwatched /// episode id - /// user id + /// user id /// APIStatus - internal async Task MarkEpisode(bool status, int id, int uid) + internal async Task MarkEpisode(bool status, int id, SVR_JMMUser user) { try { - var ep = RepoFactory.AnimeEpisode.GetByID(id); - if (ep == null) - { + if (RepoFactory.AnimeEpisode.GetByID(id) is not { } episode) return NotFound(); - } - await _watchedService.SetWatchedStatus(ep, status, true, DateTime.Now, false, uid, true); - var series = ep.AnimeSeries; - _seriesService.UpdateStats(series, true, false); - _groupService.UpdateStatsFromTopLevel(series?.AnimeGroup?.TopLevelAnimeGroup, true, true); + await _userDataService.SetEpisodeWatchedStatus(user, episode, status); return Ok(); } catch (Exception ex) @@ -1903,13 +1884,13 @@ public ActionResult> GetSeriesRecent([FromQuery] API_Call_Par [HttpGet("serie/watch")] public async Task MarkSerieAsWatched(int id) { - JMMUser user = HttpContext.GetUser(); - if (id != 0) - { - return await MarkSerieWatchStatus(id, true, user.JMMUserID); - } + if (id == 0) + return BadRequest("missing 'id'"); + if (id < 0) + return BadRequest("invalid 'id'"); - return BadRequest("missing 'id'"); + var user = HttpContext.GetUser(); + return await MarkSerieWatchStatus(id, true, user); } /// @@ -1919,13 +1900,13 @@ public async Task MarkSerieAsWatched(int id) [HttpGet("serie/unwatch")] public async Task MarkSerieAsUnwatched(int id) { - JMMUser user = HttpContext.GetUser(); - if (id != 0) - { - return await MarkSerieWatchStatus(id, false, user.JMMUserID); - } + if (id == 0) + return BadRequest("missing 'id'"); + if (id < 0) + return BadRequest("invalid 'id'"); - return BadRequest("missing 'id'"); + var user = HttpContext.GetUser(); + return await MarkSerieWatchStatus(id, false, user); } /// @@ -2392,31 +2373,27 @@ internal object GetSerieById(int series_id, bool nocast, bool notag, int level, /// /// serie id /// true is watched, false is unwatched - /// user id + /// user /// APIStatus - internal async Task MarkSerieWatchStatus(int id, bool watched, int uid) + internal async Task MarkSerieWatchStatus(int id, bool watched, SVR_JMMUser user) { try { - var seriesService = Utils.ServiceContainer.GetRequiredService(); - var ser = RepoFactory.AnimeSeries.GetByID(id); - if (ser == null) - { + if (RepoFactory.AnimeSeries.GetByID(id) is not { } ser) return BadRequest("Series not Found"); - } foreach (var ep in ser.AllAnimeEpisodes) { - var epUser = ep.GetUserRecord(uid); + var epUser = ep.GetUserRecord(user.JMMUserID); if (epUser != null) { if (epUser.WatchedCount <= 0 && watched) { - await _watchedService.SetWatchedStatus(ep, true, true, DateTime.Now, false, uid, false); + await _userDataService.SetEpisodeWatchedStatus(user, ep, true, updateStatsNow: false); } else if (epUser.WatchedCount > 0 && !watched) { - await _watchedService.SetWatchedStatus(ep, false, true, DateTime.Now, false, uid, false); + await _userDataService.SetEpisodeWatchedStatus(user, ep, false, updateStatsNow: false); } } } @@ -2821,8 +2798,8 @@ public object GetGroups([FromQuery] API_Call_Parameters para) [HttpGet("group/watch")] public async Task MarkGroupAsWatched(int id) { - JMMUser user = HttpContext.GetUser(); - return await MarkWatchedStatusOnGroup(id, user.JMMUserID, true); + var user = HttpContext.GetUser(); + return await MarkWatchedStatusOnGroup(id, user, true); } /// @@ -2832,8 +2809,8 @@ public async Task MarkGroupAsWatched(int id) [HttpGet("group/unwatch")] private async Task MarkGroupAsUnwatched(int id) { - JMMUser user = HttpContext.GetUser(); - return await MarkWatchedStatusOnGroup(id, user.JMMUserID, false); + var user = HttpContext.GetUser(); + return await MarkWatchedStatusOnGroup(id, user, false); } /// @@ -2923,10 +2900,10 @@ internal object GetGroup(int id, int uid, bool nocast, bool notag, int level, bo /// Set watch status for group /// /// group id - /// user id + /// user /// watch status /// APIStatus - internal async Task MarkWatchedStatusOnGroup(int groupid, int userid, bool watchedstatus) + internal async Task MarkWatchedStatusOnGroup(int groupid, SVR_JMMUser user, bool watchedstatus) { try { @@ -2938,24 +2915,12 @@ internal async Task MarkWatchedStatusOnGroup(int groupid, int user foreach (var series in group.AllSeries) { - foreach (var ep in series.AllAnimeEpisodes) + foreach (var episode in series.AllAnimeEpisodes) { - if (ep?.AniDB_Episode == null) - { - continue; - } - - if (ep.EpisodeTypeEnum == EpisodeType.Credits) - { + if (episode.EpisodeTypeEnum is EpisodeType.Credits or EpisodeType.Trailer) continue; - } - - if (ep.EpisodeTypeEnum == EpisodeType.Trailer) - { - continue; - } - await _watchedService.SetWatchedStatus(ep, watchedstatus, true, DateTime.Now, false, userid, true); + await _userDataService.SetEpisodeWatchedStatus(user, episode, watchedstatus, updateStatsNow: false); } _seriesService.UpdateStats(series, true, false); diff --git a/Shoko.Server/API/v3/Controllers/EpisodeController.cs b/Shoko.Server/API/v3/Controllers/EpisodeController.cs index 4b054dfd9..c53ba4742 100644 --- a/Shoko.Server/API/v3/Controllers/EpisodeController.cs +++ b/Shoko.Server/API/v3/Controllers/EpisodeController.cs @@ -11,6 +11,7 @@ using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.Enums; using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; @@ -54,7 +55,7 @@ public class EpisodeController : BaseController private readonly AnimeEpisodeService _episodeService; - private readonly WatchedStatusService _watchedService; + private readonly IUserDataService _userDataService; private readonly TmdbLinkingService _tmdbLinkingService; @@ -65,7 +66,7 @@ public EpisodeController( AnimeSeriesService seriesService, AnimeGroupService groupService, AnimeEpisodeService episodeService, - WatchedStatusService watchedService, + IUserDataService userDataService, TmdbLinkingService tmdbLinkingService, TmdbMetadataService tmdbMetadataService ) : base(settingsProvider) @@ -73,7 +74,7 @@ TmdbMetadataService tmdbMetadataService _seriesService = seriesService; _groupService = groupService; _episodeService = episodeService; - _watchedService = watchedService; + _userDataService = userDataService; _tmdbLinkingService = tmdbLinkingService; _tmdbMetadataService = tmdbMetadataService; } @@ -846,6 +847,8 @@ public ActionResult DeleteEpisodeDefaultImageForType([FromRoute, Range(1, int.Ma #endregion + #region Watch Status + /// /// Set the watched status on an episode /// @@ -863,14 +866,17 @@ public async Task SetWatchedStatusOnEpisode([FromRoute, Range(1, i if (series is null) return InternalError(EpisodeNoSeriesForEpisodeID); - if (!User.AllowedSeries(series)) + var user = User; + if (!user.AllowedSeries(series)) return Forbid(EpisodeForbiddenForUser); - await _watchedService.SetWatchedStatus(episode, watched, true, DateTime.Now, true, User.JMMUserID, true); + await _userDataService.SetEpisodeWatchedStatus(user, episode, watched); return Ok(); } + #endregion + /// /// Get all episodes with no files. /// diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index 931cabecf..fa7d4c3ce 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -12,6 +12,8 @@ using Microsoft.AspNetCore.StaticFiles; using Quartz; using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; @@ -59,14 +61,22 @@ public class FileController : BaseController private readonly VideoLocalService _vlService; private readonly VideoLocal_PlaceService _vlPlaceService; private readonly VideoLocal_UserRepository _vlUsers; - private readonly WatchedStatusService _watchedService; - - public FileController(TraktTVHelper traktHelper, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService, VideoLocalService vlService) : base(settingsProvider) + private readonly IUserDataService _userDataService; + + public FileController( + TraktTVHelper traktHelper, + ISchedulerFactory schedulerFactory, + ISettingsProvider settingsProvider, + VideoLocal_PlaceService vlPlaceService, + VideoLocal_UserRepository vlUsers, + IUserDataService watchedService, + VideoLocalService vlService + ) : base(settingsProvider) { _traktHelper = traktHelper; _vlPlaceService = vlPlaceService; _vlUsers = vlUsers; - _watchedService = watchedService; + _userDataService = watchedService; _vlService = vlService; _schedulerFactory = schedulerFactory; } @@ -544,7 +554,7 @@ public ActionResult GetFileStreamInternal(int fileID, string filename = null, bo if (streamPositionScrobbling) { - var scrobbleFile = new ScrobblingFileResult(file, User.JMMUserID, fileInfo.FullName, contentType) + var scrobbleFile = new ScrobblingFileResult(file, User, fileInfo.FullName, contentType) { FileDownloadName = filename ?? fileInfo.Name }; @@ -705,7 +715,7 @@ public async Task SetWatchedStatusOnFile([FromRoute, Range(1, int. if (file == null) return NotFound(FileNotFoundWithFileID); - await _watchedService.SetWatchedStatus(file, watched, User.JMMUserID); + await _userDataService.SetVideoWatchedStatus(User, file, watched); return Ok(); } @@ -727,12 +737,6 @@ public async Task ScrobbleFileAndEpisode([FromRoute, Range(1, int. if (file == null) return NotFound(FileNotFoundWithFileID); - // Handle legacy scrobble events. - if (string.IsNullOrEmpty(eventName)) - { - return await ScrobbleStatusOnFile(file, watched, resumePosition); - } - var episode = episodeID.HasValue ? RepoFactory.AnimeEpisode.GetByID(episodeID.Value) : file.AnimeEpisodes?.FirstOrDefault(); if (episode == null) return ValidationProblem($"Could not get Episode with ID: {episodeID}", nameof(episodeID)); @@ -768,9 +772,28 @@ public async Task ScrobbleFileAndEpisode([FromRoute, Range(1, int. break; } - if (watched.HasValue) - await _watchedService.SetWatchedStatus(file, watched.Value, User.JMMUserID); - _watchedService.SetResumePosition(file, playbackPositionTicks, User.JMMUserID); + var reason = eventName switch + { + "play" => UserDataSaveReason.PlaybackStart, + "resume" => UserDataSaveReason.PlaybackResume, + "pause" => UserDataSaveReason.PlaybackPause, + "stop" => UserDataSaveReason.PlaybackEnd, + "scrobble" => UserDataSaveReason.PlaybackProgress, + "user-interaction" => UserDataSaveReason.UserInteraction, + _ => UserDataSaveReason.None, + }; + var now = DateTime.Now; + var userData = _userDataService.GetVideoUserData(User.JMMUserID, file.VideoLocalID); + await _userDataService.SaveVideoUserData(User, file, new() + { + ResumePosition = resumePosition.HasValue + ? TimeSpan.FromTicks(playbackPositionTicks) + : (watched.HasValue ? TimeSpan.Zero : null), + LastPlayedAt = !watched.HasValue + ? (watched.Value ? now : null) + : userData?.LastPlayedAt, + LastUpdatedAt = now, + }, reason); return NoContent(); } @@ -789,32 +812,6 @@ private void ScrobbleToTrakt(SVR_VideoLocal file, SVR_AnimeEpisode episode, long _traktHelper.Scrobble(scrobbleType, episode.AnimeEpisodeID.ToString(), status, percentage); } - [NonAction] - private async Task ScrobbleStatusOnFile(SVR_VideoLocal file, bool? watched, long? resumePosition) - { - if (!(watched ?? false) && resumePosition != null) - { - var safeRP = resumePosition.Value; - if (safeRP < 0) safeRP = 0; - - if (safeRP >= file.Duration) - watched = true; - else - _watchedService.SetResumePosition(file, safeRP, User.JMMUserID); - } - - if (watched != null) - { - var safeWatched = watched.Value; - await _watchedService.SetWatchedStatus(file, safeWatched, User.JMMUserID); - if (safeWatched) - _watchedService.SetResumePosition(file, 0, User.JMMUserID); - - } - - return Ok(); - } - /// /// Mark or unmark a file as ignored. /// diff --git a/Shoko.Server/API/v3/Controllers/SeriesController.cs b/Shoko.Server/API/v3/Controllers/SeriesController.cs index 99006dcff..15da1e81a 100644 --- a/Shoko.Server/API/v3/Controllers/SeriesController.cs +++ b/Shoko.Server/API/v3/Controllers/SeriesController.cs @@ -17,6 +17,7 @@ using Shoko.Plugin.Abstractions.DataModels; using Shoko.Plugin.Abstractions.Enums; using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.Annotations; using Shoko.Server.API.ModelBinders; using Shoko.Server.API.v3.Helpers; @@ -69,7 +70,7 @@ public class SeriesController : BaseController private readonly CrossRef_File_EpisodeRepository _crossRefFileEpisode; - private readonly WatchedStatusService _watchedService; + private readonly IUserDataService _userDataService; public SeriesController( ISettingsProvider settingsProvider, @@ -81,7 +82,7 @@ public SeriesController( TmdbMetadataService tmdbMetadataService, TmdbSearchService tmdbSearchService, CrossRef_File_EpisodeRepository crossRefFileEpisode, - WatchedStatusService watchedService + IUserDataService userDataService ) : base(settingsProvider) { _seriesService = seriesService; @@ -92,7 +93,7 @@ WatchedStatusService watchedService _tmdbMetadataService = tmdbMetadataService; _tmdbSearchService = tmdbSearchService; _crossRefFileEpisode = crossRefFileEpisode; - _watchedService = watchedService; + _userDataService = userDataService; } #region Return messages @@ -1971,11 +1972,11 @@ public async Task MarkSeriesWatched( if (!User.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - var userId = User.JMMUserID; + var user = User; var now = DateTime.Now; // this has a parallel query to evaluate filters and data in parallel, but that makes awaiting the SetWatchedStatus calls more difficult, so we ToList() it await Task.WhenAll(GetEpisodesInternal(series, includeMissing, includeUnaired, includeHidden, includeWatched, IncludeOnlyFilter.True, type, search, fuzzy).ToList() - .Select(episode => _watchedService.SetWatchedStatus(episode, value, true, now, false, userId, true))); + .Select(episode => _userDataService.SetEpisodeWatchedStatus(user, episode, value, now, updateStatsNow: false))); _seriesService.UpdateStats(series, true, false); diff --git a/Shoko.Server/API/v3/Models/Shoko/File.cs b/Shoko.Server/API/v3/Models/Shoko/File.cs index be487f839..7b981ceac 100644 --- a/Shoko.Server/API/v3/Models/Shoko/File.cs +++ b/Shoko.Server/API/v3/Models/Shoko/File.cs @@ -8,10 +8,10 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.API.v3.Models.Common; using Shoko.Server.Models; using Shoko.Server.Repositories; -using Shoko.Server.Services; using Shoko.Server.Utilities; namespace Shoko.Server.API.v3.Models.Shoko; @@ -398,15 +398,14 @@ public FileUserStats MergeWithExisting(SVR_VideoLocal_User existing, SVR_VideoLo // Get the file associated with the user entry. file ??= existing.VideoLocal; - // Sync the watch date and aggregate the data up to the episode if needed. - var watchedService = Utils.ServiceContainer.GetRequiredService(); - watchedService.SetWatchedStatus(file, LastWatchedAt.HasValue, true, LastWatchedAt?.ToLocalTime(), true, existing.JMMUserID, true, true, - LastUpdatedAt.ToLocalTime()).GetAwaiter().GetResult(); - - // Update the rest of the data. The watch count have been bumped when toggling the watch state, so set it to it's intended value. - existing.WatchedCount = WatchedCount; - existing.ResumePositionTimeSpan = ResumePosition; - RepoFactory.VideoLocalUser.Save(existing); + var userDataService = Utils.ServiceContainer.GetRequiredService(); + userDataService.SaveVideoUserData(existing.User, file, new() + { + LastPlayedAt = LastWatchedAt, + LastUpdatedAt = LastUpdatedAt, + ResumePosition = ResumePosition, + PlaybackCount = WatchedCount, + }).GetAwaiter().GetResult(); // Return a new representation return new FileUserStats(existing); diff --git a/Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs b/Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs index a3dbe8c46..67036c8af 100644 --- a/Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs +++ b/Shoko.Server/API/v3/Models/Shoko/ScrobblingFileResult.cs @@ -6,7 +6,9 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Models; +using Shoko.Server.Repositories; using Shoko.Server.Services; using Shoko.Server.Utilities; @@ -15,18 +17,18 @@ namespace Shoko.Server.API.v3.Models.Shoko; public class ScrobblingFileResult : PhysicalFileResult { private SVR_VideoLocal VideoLocal { get; set; } - private int UserID { get; set; } - public ScrobblingFileResult(SVR_VideoLocal videoLocal, int userID, string fileName, string contentType) : base(fileName, contentType) + private SVR_JMMUser User { get; set; } + public ScrobblingFileResult(SVR_VideoLocal videoLocal, SVR_JMMUser user, string fileName, string contentType) : base(fileName, contentType) { VideoLocal = videoLocal; - UserID = userID; + User = user; EnableRangeProcessing = true; } - public ScrobblingFileResult(SVR_VideoLocal videoLocal, int userID, string fileName, MediaTypeHeaderValue contentType) : base(fileName, contentType) + public ScrobblingFileResult(SVR_VideoLocal videoLocal, SVR_JMMUser user, string fileName, MediaTypeHeaderValue contentType) : base(fileName, contentType) { VideoLocal = videoLocal; - UserID = userID; + User = user; EnableRangeProcessing = true; } @@ -36,9 +38,8 @@ public override async Task ExecuteResultAsync(ActionContext context) var end = GetRange(context.HttpContext, VideoLocal.FileSize); if (end != VideoLocal.FileSize) return; #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - var watchedService = Utils.ServiceContainer.GetRequiredService(); - Task.Factory.StartNew(() => watchedService.SetWatchedStatus(VideoLocal, true, UserID), new CancellationToken(), TaskCreationOptions.LongRunning, - TaskScheduler.Default); + var watchedService = Utils.ServiceContainer.GetRequiredService(); + Task.Factory.StartNew(() => watchedService.SetVideoWatchedStatus(User, VideoLocal), CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); #pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed } diff --git a/Shoko.Server/Models/SVR_JMMUser.cs b/Shoko.Server/Models/SVR_JMMUser.cs index d66e6c0ef..b2a8becda 100644 --- a/Shoko.Server/Models/SVR_JMMUser.cs +++ b/Shoko.Server/Models/SVR_JMMUser.cs @@ -7,12 +7,13 @@ using Newtonsoft.Json; using Shoko.Commons.Extensions; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels.Shoko; using Shoko.Server.Extensions; using Shoko.Server.Repositories; namespace Shoko.Server.Models; -public class SVR_JMMUser : JMMUser, IIdentity +public class SVR_JMMUser : JMMUser, IIdentity, IShokoUser { #region Image @@ -138,4 +139,10 @@ public bool AllowedTag(AniDB_Tag tag) [NotMapped] bool IIdentity.IsAuthenticated => true; [NotMapped] string IIdentity.Name => Username; + + [NotMapped] + int IShokoUser.ID => JMMUserID; + + [NotMapped] + string IShokoUser.Username => Username; } diff --git a/Shoko.Server/Models/SVR_VideoLocal_User.cs b/Shoko.Server/Models/SVR_VideoLocal_User.cs index 93596ab1c..520b307f4 100644 --- a/Shoko.Server/Models/SVR_VideoLocal_User.cs +++ b/Shoko.Server/Models/SVR_VideoLocal_User.cs @@ -1,11 +1,13 @@ using System; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; using Shoko.Server.Repositories; #nullable enable namespace Shoko.Server.Models; -public class SVR_VideoLocal_User : VideoLocal_User +public class SVR_VideoLocal_User : VideoLocal_User, IVideoUserData { public SVR_VideoLocal_User() { } @@ -26,6 +28,8 @@ public TimeSpan? ResumePositionTimeSpan set => ResumePosition = value.HasValue ? (long)Math.Round(value.Value.TotalMilliseconds) : 0; } + public SVR_JMMUser User => RepoFactory.JMMUser.GetByID(JMMUserID); + /// /// Get the related . /// @@ -42,4 +46,30 @@ public override string ToString() return $"{video.FileName} --- {video.Hash} --- User {JMMUserID}"; #pragma warning restore CS0618 } + + #region IUserData Implementation + + int IUserData.UserID => JMMUserID; + + DateTime IUserData.LastUpdatedAt => LastUpdated; + + IShokoUser IUserData.User => User ?? + throw new NullReferenceException($"Unable to find IShokoUser with the given id. (User={JMMUserID})"); + + #endregion + + #region IVideoUserData Implementation + + int IVideoUserData.VideoID => VideoLocalID; + + int IVideoUserData.PlaybackCount => WatchedCount; + + TimeSpan IVideoUserData.ResumePosition => ResumePositionTimeSpan ?? TimeSpan.Zero; + + DateTime? IVideoUserData.LastPlayedAt => WatchedDate; + + IVideo IVideoUserData.Video => VideoLocal ?? + throw new NullReferenceException($"Unable to find IVideo with the given id. (Video={VideoLocalID})"); + + #endregion } diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs index c749106c7..97a048e61 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/AddFileToMyListJob.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Quartz; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Models; using Shoko.Server.Providers.AniDB; using Shoko.Server.Providers.AniDB.Interfaces; @@ -33,7 +35,7 @@ public class AddFileToMyListJob : BaseJob private readonly ISchedulerFactory _schedulerFactory; private readonly ISettingsProvider _settingsProvider; private readonly VideoLocal_UserRepository _vlUsers; - private readonly WatchedStatusService _watchedService; + private readonly IUserDataService _userDataService; private SVR_VideoLocal _videoLocal; public string Hash { get; set; } @@ -71,11 +73,11 @@ public override async Task Process() // mark the video file as watched var aniDBUsers = RepoFactory.JMMUser.GetAniDBUsers(); - var juser = aniDBUsers.FirstOrDefault(); + var user = aniDBUsers.FirstOrDefault(); DateTime? originalWatchedDate = null; - if (juser != null) + if (user != null) { - originalWatchedDate = _vlUsers.GetByUserIDAndVideoLocalID(juser.JMMUserID, _videoLocal.VideoLocalID)?.WatchedDate?.ToUniversalTime(); + originalWatchedDate = _vlUsers.GetByUserIDAndVideoLocalID(user.JMMUserID, _videoLocal.VideoLocalID)?.WatchedDate?.ToUniversalTime(); } UDPResponse response = null; @@ -164,7 +166,7 @@ public override async Task Process() response?.Response?.IsWatched, settings.AniDb.MyList_StorageState, state, ReadStates, settings.AniDb.MyList_ReadWatched, settings.AniDb.MyList_ReadUnwatched ); - if (juser != null) + if (user != null) { var watched = newWatchedDate != null && !DateTime.UnixEpoch.Equals(newWatchedDate); var watchedLocally = originalWatchedDate != null; @@ -174,13 +176,21 @@ public override async Task Process() // handle import watched settings. Don't update AniDB in either case, we'll do that with the storage state if (settings.AniDb.MyList_ReadWatched && watched && !watchedLocally) { - await _watchedService.SetWatchedStatus(_videoLocal, true, false, newWatchedDate?.ToLocalTime(), false, juser.JMMUserID, - false, false); + await _userDataService.SaveVideoUserData(user, _videoLocal, new() + { + ResumePosition = TimeSpan.Zero, + LastPlayedAt = newWatchedDate ?? DateTime.Now, + LastUpdatedAt = response.Response.UpdatedAt ?? DateTime.Now, + }, UserDataSaveReason.AnidbImport).ConfigureAwait(false); } else if (settings.AniDb.MyList_ReadUnwatched && !watched && watchedLocally) { - await _watchedService.SetWatchedStatus(_videoLocal, false, false, null, false, juser.JMMUserID, - false, false); + await _userDataService.SaveVideoUserData(user, _videoLocal, new() + { + ResumePosition = TimeSpan.Zero, + LastPlayedAt = null, + LastUpdatedAt = response.Response.UpdatedAt ?? DateTime.Now, + }, UserDataSaveReason.AnidbImport).ConfigureAwait(false); } } } @@ -210,14 +220,14 @@ await scheduler.StartJob( } } } - - public AddFileToMyListJob(IRequestFactory requestFactory, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService) + + public AddFileToMyListJob(IRequestFactory requestFactory, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, VideoLocal_UserRepository vlUsers, IUserDataService userDataService) { _requestFactory = requestFactory; _settingsProvider = settingsProvider; _schedulerFactory = schedulerFactory; _vlUsers = vlUsers; - _watchedService = watchedService; + _userDataService = userDataService; } protected AddFileToMyListJob() { } diff --git a/Shoko.Server/Scheduling/Jobs/AniDB/SyncAniDBMyListJob.cs b/Shoko.Server/Scheduling/Jobs/AniDB/SyncAniDBMyListJob.cs index 13ee76318..5271cc989 100644 --- a/Shoko.Server/Scheduling/Jobs/AniDB/SyncAniDBMyListJob.cs +++ b/Shoko.Server/Scheduling/Jobs/AniDB/SyncAniDBMyListJob.cs @@ -11,6 +11,8 @@ using Shoko.Commons.Extensions; using Shoko.Models.Enums; using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Services; using Shoko.Server.Models; using Shoko.Server.Providers.AniDB; using Shoko.Server.Providers.AniDB.HTTP; @@ -39,7 +41,7 @@ public class SyncAniDBMyListJob : BaseJob private readonly IServerSettings _settings; private readonly AnimeSeriesService _seriesService; private readonly VideoLocal_UserRepository _vlUsers; - private readonly WatchedStatusService _watchedService; + private readonly IUserDataService _userDataService; public bool ForceRefresh { get; set; } @@ -179,43 +181,58 @@ private async Task CreateMyListBackup(HttpResponse> respons } } - private async Task ProcessStates(IReadOnlyList aniDBUsers, SVR_VideoLocal vl, ResponseMyList myitem, + private async Task ProcessStates(IReadOnlyList aniDBUsers, SVR_VideoLocal video, ResponseMyList myItem, int modifiedItems, ISet modifiedSeries) { // check watched states, read the states if needed, and update differences // aggregate and assume if one AniDB User has watched it, it should be marked // if multiple have, then take the latest // compare the states and update if needed - var localWatchedDate = aniDBUsers.Select(a => _vlUsers.GetByUserIDAndVideoLocalID(a.JMMUserID, vl.VideoLocalID)).Where(a => a?.WatchedDate != null) + var localWatchedDate = aniDBUsers.Select(a => _vlUsers.GetByUserIDAndVideoLocalID(a.JMMUserID, video.VideoLocalID)).Where(a => a?.WatchedDate != null) .Max(a => a.WatchedDate); if (localWatchedDate is not null && localWatchedDate.Value.Millisecond > 0) localWatchedDate = localWatchedDate.Value.AddMilliseconds(-localWatchedDate.Value.Millisecond); var localState = _settings.AniDb.MyList_StorageState; var shouldUpdate = false; - var updateDate = myitem.ViewedAt; + var updateDate = myItem.ViewedAt; // we don't support multiple AniDB accounts, so we can just only iterate to set states if (_settings.AniDb.MyList_ReadWatched && localWatchedDate == null && updateDate != null) { - foreach (var juser in aniDBUsers) + foreach (var user in aniDBUsers) { - var watchedDate = myitem.ViewedAt; modifiedItems++; - await _watchedService.SetWatchedStatus(vl, true, false, watchedDate, false, juser.JMMUserID, false, true); - vl.AnimeEpisodes.Select(a => a.AnimeSeries).Where(a => a != null) - .DistinctBy(a => a.AnimeSeriesID).ForEach(a => modifiedSeries.Add(a)); + await _userDataService.SaveVideoUserData(user, video, new() + { + ResumePosition = TimeSpan.Zero, + LastPlayedAt = updateDate, + LastUpdatedAt = myItem.UpdatedAt, + }, UserDataSaveReason.AnidbImport, false).ConfigureAwait(false); + video.AnimeEpisodes + .DistinctBy(a => a.AnimeSeriesID) + .Select(a => a.AnimeSeries) + .WhereNotNull() + .ForEach(a => modifiedSeries.Add(a)); } } // if we did the previous, then we don't want to undo it else if (_settings.AniDb.MyList_ReadUnwatched && localWatchedDate != null && updateDate == null) { - foreach (var juser in aniDBUsers) + foreach (var user in aniDBUsers) { modifiedItems++; - await _watchedService.SetWatchedStatus(vl, false, false, null, false, juser.JMMUserID, false, true); - vl.AnimeEpisodes.Select(a => a.AnimeSeries).Where(a => a != null) - .DistinctBy(a => a.AnimeSeriesID).ForEach(a => modifiedSeries.Add(a)); + await _userDataService.SaveVideoUserData(user, video, new() + { + ResumePosition = TimeSpan.Zero, + LastPlayedAt = null, + LastUpdatedAt = myItem.UpdatedAt, + }, UserDataSaveReason.AnidbImport, false).ConfigureAwait(false); + video.AnimeEpisodes + .DistinctBy(a => a.AnimeSeriesID) + .Select(a => a.AnimeSeries) + .WhereNotNull() + .ForEach(a => modifiedSeries.Add(a)); } } else if (_settings.AniDb.MyList_SetUnwatched && localWatchedDate == null && updateDate != null) @@ -230,13 +247,13 @@ private async Task ProcessStates(IReadOnlyList aniDBUsers, SVR } // check if the state needs to be updated - if ((int)myitem.State != (int)localState) shouldUpdate = true; + if ((int)myItem.State != (int)localState) shouldUpdate = true; if (!shouldUpdate) return modifiedItems; await (await _schedulerFactory.GetScheduler()).StartJob(a => { - a.Hash = vl.Hash; + a.Hash = video.Hash; a.Watched = updateDate != null; a.WatchedDate = updateDate; a.UpdateSeriesStats = false; @@ -248,12 +265,13 @@ private async Task ProcessStates(IReadOnlyList aniDBUsers, SVR private bool ShouldRun() { // we will always assume that an anime was downloaded via http first - var sched = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBMyListSync); - if (sched == null) + var schedule = RepoFactory.ScheduledUpdate.GetByUpdateType((int)ScheduledUpdateType.AniDBMyListSync); + if (schedule == null) { - sched = new ScheduledUpdate + schedule = new ScheduledUpdate { - UpdateType = (int)ScheduledUpdateType.AniDBMyListSync, UpdateDetails = string.Empty + UpdateType = (int)ScheduledUpdateType.AniDBMyListSync, + UpdateDetails = string.Empty }; } else @@ -261,15 +279,13 @@ private bool ShouldRun() var freqHours = Utils.GetScheduledHours(_settings.AniDb.MyList_UpdateFrequency); // if we have run this in the last 24 hours and are not forcing it, then exit - var tsLastRun = DateTime.Now - sched.LastUpdate; - if (tsLastRun.TotalHours < freqHours) - { - if (!ForceRefresh) return false; - } + var lastRan = DateTime.Now - schedule.LastUpdate; + if (lastRan.TotalHours < freqHours && !ForceRefresh) + return false; } - sched.LastUpdate = DateTime.Now; - RepoFactory.ScheduledUpdate.Save(sched); + schedule.LastUpdate = DateTime.Now; + RepoFactory.ScheduledUpdate.Save(schedule); return true; } @@ -313,13 +329,13 @@ private static bool TryGetFileID(ILookup localFiles, str return true; } - public SyncAniDBMyListJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, AnimeSeriesService seriesService, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService) + public SyncAniDBMyListJob(IRequestFactory requestFactory, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, AnimeSeriesService seriesService, VideoLocal_UserRepository vlUsers, IUserDataService userDataService) { _requestFactory = requestFactory; _schedulerFactory = schedulerFactory; _seriesService = seriesService; _vlUsers = vlUsers; - _watchedService = watchedService; + _userDataService = userDataService; _settings = settingsProvider.GetSettings(); } diff --git a/Shoko.Server/Scheduling/Jobs/Plex/SyncPlexWatchedStatesJob.cs b/Shoko.Server/Scheduling/Jobs/Plex/SyncPlexWatchedStatesJob.cs index 77bb4e178..15c70ade5 100644 --- a/Shoko.Server/Scheduling/Jobs/Plex/SyncPlexWatchedStatesJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Plex/SyncPlexWatchedStatesJob.cs @@ -5,7 +5,9 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Shoko.Commons.Extensions; -using Shoko.Models.Server; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Services; +using Shoko.Server.Models; using Shoko.Server.Plex; using Shoko.Server.Plex.Collection; using Shoko.Server.Plex.Libraries; @@ -15,7 +17,6 @@ using Shoko.Server.Scheduling.Attributes; using Shoko.Server.Scheduling.Concurrency; using Shoko.Server.Scheduling.Jobs.Trakt; -using Shoko.Server.Services; using Shoko.Server.Settings; namespace Shoko.Server.Scheduling.Jobs.Plex; @@ -28,8 +29,8 @@ public class SyncPlexWatchedStatesJob : BaseJob { private readonly ISettingsProvider _settingsProvider; private readonly VideoLocal_UserRepository _vlUsers; - private readonly WatchedStatusService _watchedService; - public JMMUser User { get; set; } + private readonly IUserDataService _userDataService; + public SVR_JMMUser User { get; set; } public override string TypeName => "Sync Plex States for User"; @@ -100,7 +101,7 @@ public override async Task Process() if (isWatched && !alreadyWatched) { _logger.LogInformation("Marking episode watched in Shoko"); - await _watchedService.SetWatchedStatus(video, true, true, lastWatched ?? DateTime.Now, true, User.JMMUserID, true, true); + await _userDataService.SaveVideoUserData(User, video, new() { LastPlayedAt = lastWatched ?? DateTime.Now }); } } } @@ -113,11 +114,11 @@ private DateTime FromUnixTime(long unixTime) .AddSeconds(unixTime); } - public SyncPlexWatchedStatesJob(ISettingsProvider settingsProvider, VideoLocal_UserRepository vlUsers, WatchedStatusService watchedService) + public SyncPlexWatchedStatesJob(ISettingsProvider settingsProvider, VideoLocal_UserRepository vlUsers, IUserDataService userDataService) { _settingsProvider = settingsProvider; _vlUsers = vlUsers; - _watchedService = watchedService; + _userDataService = userDataService; } protected SyncPlexWatchedStatesJob() { } diff --git a/Shoko.Server/Server/Startup.cs b/Shoko.Server/Server/Startup.cs index ecb25e6ec..b31e7dd50 100644 --- a/Shoko.Server/Server/Startup.cs +++ b/Shoko.Server/Server/Startup.cs @@ -68,13 +68,14 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(ShokoEventHandler.Instance); services.AddSingleton(AbstractApplicationPaths.Instance); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Shoko.Server/Services/AbstractUserDataService.cs b/Shoko.Server/Services/AbstractUserDataService.cs new file mode 100644 index 000000000..54869055d --- /dev/null +++ b/Shoko.Server/Services/AbstractUserDataService.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Quartz; +using Shoko.Commons.Extensions; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Events; +using Shoko.Plugin.Abstractions.Services; +using Shoko.Server.Models; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.AniDB; +using Shoko.Server.Scheduling.Jobs.Trakt; +using Shoko.Server.Server; +using Shoko.Server.Settings; + +#nullable enable +namespace Shoko.Server.Services; + +public class AbstractUserDataService( + ISettingsProvider settingsProvider, + ISchedulerFactory schedulerFactory, + AnimeGroupService groupService, + AnimeSeriesService seriesService, + VideoLocalService videoService, + VideoLocal_UserRepository userDataRepository, + AnimeEpisode_UserRepository userEpisodeDataRepository, + JMMUserRepository userRepository +) : IUserDataService +{ + public event EventHandler? VideoUserDataSaved; + + #region Video User Data + + public IVideoUserData? GetVideoUserData(int userID, int videoID) + => userDataRepository.GetByUserIDAndVideoLocalID(userID, videoID); + + public IReadOnlyList GetVideoUserDataForUser(int userID) + => userDataRepository.GetByUserID(userID); + + public IReadOnlyList GetVideoUserDataForVideo(int videoID) + => userDataRepository.GetByVideoLocalID(videoID); + + public Task SetVideoWatchedStatus(IShokoUser user, IVideo video, bool watched = true, DateTime? watchedAt = null, UserDataSaveReason reason = UserDataSaveReason.None, bool updateStatsNow = true) + => SaveVideoUserData(user, video, new() { ResumePosition = TimeSpan.Zero, LastPlayedAt = watched ? watchedAt ?? DateTime.Now : null, LastUpdatedAt = DateTime.Now }, reason: reason, updateStatsNow: updateStatsNow); + + public async Task SaveVideoUserData(IShokoUser user, IVideo video, VideoUserDataUpdate userDataUpdate, UserDataSaveReason reason = UserDataSaveReason.None, bool updateStatsNow = true) + { + ArgumentNullException.ThrowIfNull(user, nameof(user)); + ArgumentNullException.ThrowIfNull(video, nameof(video)); + ArgumentNullException.ThrowIfNull(userDataUpdate, nameof(userDataUpdate)); + + var settings = settingsProvider.GetSettings(); + var scheduler = await schedulerFactory.GetScheduler(); + var syncTrakt = ((SVR_JMMUser)user).IsTraktUser == 1 && settings.TraktTv.Enabled && !string.IsNullOrEmpty(settings.TraktTv.AuthToken); + var syncAnidb = reason is not UserDataSaveReason.AnidbImport && ((SVR_JMMUser)user).IsAniDBUser == 1 && ((userDataUpdate.LastPlayedAt.HasValue && settings.AniDb.MyList_SetWatched) || (!userDataUpdate.LastPlayedAt.HasValue && settings.AniDb.MyList_SetUnwatched)); + IReadOnlyList users = ((SVR_JMMUser)user).IsAniDBUser == 1 ? userRepository.GetAniDBUsers() : [user]; + var lastUpdatedAt = userDataUpdate.LastUpdatedAt ?? DateTime.Now; + foreach (var u in users) + SaveWatchedStatus(video, u.ID, userDataUpdate.LastPlayedAt, lastUpdatedAt, userDataUpdate.ResumePosition, userDataUpdate.PlaybackCount); + + // now find all the episode records associated with this video file, + // but we also need to check if there are any other files attached to this episode with a watched status + var xrefs = video.CrossReferences; + var toUpdateSeries = new Dictionary(); + if (userDataUpdate.LastPlayedAt.HasValue) + { + foreach (var episodeXref in xrefs) + { + // get the episodes for this file, may be more than one (One Piece x Toriko) + var episode = episodeXref.ShokoEpisode; + if (episode == null) + continue; + + // find the total watched percentage + // e.g. one file can have a % = 100 + // or if 2 files make up one episode they will each have a % = 50 + var epPercentWatched = 0; + foreach (var videoXref in episode.CrossReferences) + { + var otherVideo = videoXref.Video; + if (otherVideo == null) + continue; + + var videoUser = userDataRepository.GetByUserIDAndVideoLocalID(user.ID, otherVideo.ID); + if (videoUser?.WatchedDate != null) + epPercentWatched += videoXref.Percentage <= 0 ? 100 : videoXref.Percentage; + + if (epPercentWatched > 95) + break; + } + + if (epPercentWatched <= 95) + continue; + + if (updateStatsNow && episode.Series is SVR_AnimeSeries series) + toUpdateSeries.TryAdd(series.AnimeSeriesID, series); + + foreach (var u in users) + SaveWatchedStatus(episode, u.ID, true, userDataUpdate.LastPlayedAt); + + if (syncTrakt) + await scheduler.StartJob(c => + { + c.AnimeEpisodeID = episode.ID; + c.Action = TraktSyncAction.Add; + }); + } + } + else + { + // if setting a file to unwatched only set the episode unwatched, if ALL the files are unwatched + foreach (var episodeXref in xrefs) + { + var episode = episodeXref.ShokoEpisode; + if (episode == null) + continue; + + var epPercentWatched = 0; + foreach (var videoXref in episode.CrossReferences) + { + var otherVideo = videoXref.Video; + if (otherVideo == null) continue; + var videoUser = userDataRepository.GetByUserIDAndVideoLocalID(user.ID, otherVideo.ID); + if (videoUser?.WatchedDate != null) + epPercentWatched += videoXref.Percentage <= 0 ? 100 : videoXref.Percentage; + + if (epPercentWatched > 95) break; + } + + if (epPercentWatched < 95) + { + foreach (var u in users) + SaveWatchedStatus(episode, u.ID, false, null); + + if (updateStatsNow && episode.Series is SVR_AnimeSeries series) + toUpdateSeries.TryAdd(series.AnimeSeriesID, series); + + if (syncTrakt) + await scheduler.StartJob(c => + { + c.AnimeEpisodeID = episode.ID; + c.Action = TraktSyncAction.Remove; + }); + } + } + } + + if (syncAnidb) + await scheduler.StartJob(c => + { + c.Hash = video.Hashes.ED2K; + c.Watched = userDataUpdate.LastPlayedAt.HasValue; + c.UpdateSeriesStats = false; + c.WatchedDate = userDataUpdate.LastPlayedAt?.ToUniversalTime(); + }); + + if (updateStatsNow && toUpdateSeries.Count > 0) + { + foreach (var series in toUpdateSeries.Values) + seriesService.UpdateStats(series, true, true); + + var groups = toUpdateSeries.Values + .Select(a => a.TopLevelAnimeGroup) + .WhereNotNull() + .DistinctBy(a => a.AnimeGroupID); + foreach (var group in groups) + groupService.UpdateStatsFromTopLevel(group, true, true); + } + + // Invoke the event(s). Assume the user data is already created, but throw if it isn't. + foreach (var u in users) + { + var uD = userDataRepository.GetByUserIDAndVideoLocalID(u.ID, video.ID) ?? + throw new InvalidOperationException($"User data is null. (Video={video.ID},User={u.ID})"); + VideoUserDataSaved?.Invoke(this, new(reason, u, video, uD)); + } + + var userData = userDataRepository.GetByUserIDAndVideoLocalID(user.ID, video.ID) ?? + throw new InvalidOperationException($"User data is null. (Video={video.ID},User={user.ID})"); + return userData; + } + + #endregion + + #region Episode User Data + + public async Task SetEpisodeWatchedStatus(IShokoUser user, IShokoEpisode episode, bool watched = true, DateTime? watchedAt = null, UserDataSaveReason reason = UserDataSaveReason.None, bool updateStatsNow = true) + { + ArgumentNullException.ThrowIfNull(user, nameof(user)); + ArgumentNullException.ThrowIfNull(episode, nameof(episode)); + if (episode.VideoList is not { Count: > 0 } videoList) + return false; + + var now = DateTime.Now; + var userDataUpdate = new VideoUserDataUpdate() + { + LastPlayedAt = watched ? watchedAt?.ToLocalTime() ?? now : null, + ResumePosition = TimeSpan.Zero, + LastUpdatedAt = now, + }; + foreach (var video in videoList) + await SaveVideoUserData(user, video, userDataUpdate, reason, false); + + if (updateStatsNow && episode.Series is SVR_AnimeSeries series) + { + seriesService.UpdateStats(series, true, true); + if (series.TopLevelAnimeGroup is { } topLevelGroup) + groupService.UpdateStatsFromTopLevel(topLevelGroup, true, true); + } + + return true; + } + + #endregion + + #region Internals + + private void SaveWatchedStatus(IShokoEpisode ep, int userID, bool watched, DateTime? watchedDate) + { + var epUserRecord = userEpisodeDataRepository.GetByUserIDAndEpisodeID(userID, ep.ID); + if (watched) + { + // let's check if an update is actually required + if ((epUserRecord?.WatchedDate != null && watchedDate.HasValue && + epUserRecord.WatchedDate.Equals(watchedDate.Value)) || + (epUserRecord?.WatchedDate == null && !watchedDate.HasValue)) + return; + + epUserRecord ??= new(userID, ep.ID, ep.SeriesID); + epUserRecord.WatchedCount++; + epUserRecord.WatchedDate = watchedDate ?? epUserRecord.WatchedDate ?? DateTime.Now; + + userEpisodeDataRepository.Save(epUserRecord); + return; + } + + if (epUserRecord != null) + { + epUserRecord.WatchedDate = null; + userEpisodeDataRepository.Save(epUserRecord); + } + } + + private void SaveWatchedStatus(IVideo video, int userID, DateTime? watchedDate, DateTime lastUpdated, TimeSpan? resumePosition = null, int? watchedCount = null) + { + var userData = videoService.GetOrCreateUserRecord((SVR_VideoLocal)video, userID); + userData.WatchedDate = watchedDate; + if (watchedCount.HasValue) + { + if (watchedCount.Value < 0) + watchedCount = 0; + userData.WatchedCount = watchedCount.Value; + } + else if (watchedDate.HasValue) + userData.WatchedCount++; + + if (resumePosition.HasValue) + { + if (resumePosition.Value < TimeSpan.Zero) + resumePosition = TimeSpan.Zero; + else if (video.MediaInfo is { } mediaInfo && resumePosition.Value > mediaInfo.Duration) + resumePosition = mediaInfo.Duration; + userData.ResumePositionTimeSpan = resumePosition.Value; + } + + userData.LastUpdated = lastUpdated; + userDataRepository.Save(userData); + } + + #endregion +} diff --git a/Shoko.Server/Services/AbstractUserService.cs b/Shoko.Server/Services/AbstractUserService.cs new file mode 100644 index 000000000..c095f45f0 --- /dev/null +++ b/Shoko.Server/Services/AbstractUserService.cs @@ -0,0 +1,26 @@ + +using System.Linq; +using Shoko.Plugin.Abstractions.DataModels.Shoko; +using Shoko.Plugin.Abstractions.Services; +using Shoko.Server.Repositories.Cached; + +#nullable enable +namespace Shoko.Server.Services; + +public class AbstractUserService : IUserService +{ + private readonly JMMUserRepository _userRepository; + + public AbstractUserService(JMMUserRepository userRepository) + { + _userRepository = userRepository; + } + + /// + public IQueryable GetUsers() + => _userRepository.GetAll().AsQueryable(); + + /// + public IShokoUser? GetUserByID(int id) + => _userRepository.GetByID(id); +} diff --git a/Shoko.Server/Services/WatchedStatusService.cs b/Shoko.Server/Services/WatchedStatusService.cs deleted file mode 100644 index 4d0383103..000000000 --- a/Shoko.Server/Services/WatchedStatusService.cs +++ /dev/null @@ -1,288 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Quartz; -using Shoko.Server.Models; -using Shoko.Server.Repositories.Cached; -using Shoko.Server.Scheduling; -using Shoko.Server.Scheduling.Jobs.AniDB; -using Shoko.Server.Scheduling.Jobs.Trakt; -using Shoko.Server.Server; -using Shoko.Server.Settings; - -namespace Shoko.Server.Services; - -public class WatchedStatusService -{ - private readonly AnimeEpisodeRepository _episodes; - private readonly AnimeEpisode_UserRepository _epUsers; - private readonly AnimeGroupService _groupService; - private readonly AnimeSeriesService _seriesService; - private readonly CrossRef_File_EpisodeRepository _fileEpisodes; - private readonly ISchedulerFactory _schedulerFactory; - private readonly ISettingsProvider _settingsProvider; - private readonly JMMUserRepository _users; - private readonly VideoLocalService _vlService; - private readonly VideoLocal_UserRepository _vlUsers; - - public WatchedStatusService(AnimeEpisodeRepository episodes, AnimeEpisode_UserRepository epUsers, AnimeGroupService groupService, - AnimeSeriesService seriesService, CrossRef_File_EpisodeRepository fileEpisodes, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, - JMMUserRepository users, VideoLocalService vlService, VideoLocal_UserRepository vlUsers) - { - _episodes = episodes; - _epUsers = epUsers; - _groupService = groupService; - _seriesService = seriesService; - _fileEpisodes = fileEpisodes; - _schedulerFactory = schedulerFactory; - _settingsProvider = settingsProvider; - _users = users; - _vlService = vlService; - _vlUsers = vlUsers; - } - - - public async Task SetWatchedStatus(SVR_AnimeEpisode ep, bool watched, bool updateOnline, DateTime? watchedDate, bool updateStats, - int userID, bool syncTrakt) - { - foreach (var vid in ep.VideoLocals) - { - await SetWatchedStatus(vid, watched, updateOnline, watchedDate, updateStats, userID, - syncTrakt, true); - SetResumePosition(vid, 0, userID); - } - } - - public void SaveWatchedStatus(SVR_AnimeEpisode ep, bool watched, int userID, DateTime? watchedDate, bool updateWatchedDate) - { - - var epUserRecord = _epUsers.GetByUserIDAndEpisodeID(userID, ep.AnimeEpisodeID); - - if (watched) - { - // let's check if an update is actually required - if (epUserRecord?.WatchedDate != null && watchedDate.HasValue && - epUserRecord.WatchedDate.Equals(watchedDate.Value) || - (epUserRecord?.WatchedDate == null && !watchedDate.HasValue)) - return; - - epUserRecord ??= new SVR_AnimeEpisode_User(userID, ep.AnimeEpisodeID, ep.AnimeSeriesID); - epUserRecord.WatchedCount++; - - if (epUserRecord.WatchedDate.HasValue && updateWatchedDate || !epUserRecord.WatchedDate.HasValue) - epUserRecord.WatchedDate = watchedDate ?? DateTime.Now; - - _epUsers.Save(epUserRecord); - } - else if (epUserRecord != null && updateWatchedDate) - { - epUserRecord.WatchedDate = null; - _epUsers.Save(epUserRecord); - } - } - - public void SetResumePosition(SVR_VideoLocal vl, long resumeposition, int userID) - { - var userRecord = _vlService.GetOrCreateUserRecord(vl, userID); - userRecord.ResumePosition = resumeposition; - userRecord.LastUpdated = DateTime.Now; - _vlUsers.Save(userRecord); - } - - public async Task SetWatchedStatus(SVR_VideoLocal vl, bool watched, int userID) - { - await SetWatchedStatus(vl, watched, true, watched ? DateTime.Now : null, true, userID, true, true); - } - - public async Task SetWatchedStatus(SVR_VideoLocal vl, bool watched, bool updateOnline, DateTime? watchedDate, bool updateStats, int userID, - bool syncTrakt, bool updateWatchedDate, DateTime? lastUpdated = null) - { - var settings = _settingsProvider.GetSettings(); - var scheduler = await _schedulerFactory.GetScheduler(); - var user = _users.GetByID(userID); - if (user == null) return; - - var aniDBUsers = _users.GetAniDBUsers(); - - if (user.IsAniDBUser == 0) - SaveWatchedStatus(vl, watched, userID, watchedDate, updateWatchedDate, lastUpdated); - else - foreach (var juser in aniDBUsers.Where(juser => juser.IsAniDBUser == 1)) - SaveWatchedStatus(vl, watched, juser.JMMUserID, watchedDate, updateWatchedDate, lastUpdated); - - // now lets find all the associated AniDB_File record if there is one - if (user.IsAniDBUser == 1) - { - if (updateOnline) - if ((watched && settings.AniDb.MyList_SetWatched) || - (!watched && settings.AniDb.MyList_SetUnwatched)) - { - await scheduler.StartJob( - c => - { - c.Hash = vl.Hash; - c.Watched = watched; - c.UpdateSeriesStats = false; - c.Watched = watched; - c.WatchedDate = watchedDate?.ToUniversalTime(); - } - ); - } - } - - // now find all the episode records associated with this video file, - // but we also need to check if there are any other files attached to this episode with a watched status - - SVR_AnimeSeries ser; - // get all files associated with this episode - var xrefs = vl.EpisodeCrossReferences; - var toUpdateSeries = new Dictionary(); - if (watched) - { - // find the total watched percentage - // e.g. one file can have a % = 100 - // or if 2 files make up one episode they will each have a % = 50 - - foreach (var xref in xrefs) - { - // get the episodes for this file, may be more than one (One Piece x Toriko) - var ep = _episodes.GetByAniDBEpisodeID(xref.EpisodeID); - // a show we don't have - if (ep == null) continue; - - // get all the files for this episode - var epPercentWatched = 0; - foreach (var filexref in ep.FileCrossReferences) - { - var xrefVideoLocal = filexref.VideoLocal; - if (xrefVideoLocal == null) continue; - var vidUser = _vlUsers.GetByUserIDAndVideoLocalID(userID, xrefVideoLocal.VideoLocalID); - if (vidUser?.WatchedDate != null) - epPercentWatched += filexref.Percentage <= 0 ? 100 : filexref.Percentage; - - if (epPercentWatched > 95) break; - } - - if (epPercentWatched <= 95) continue; - - ser = ep.AnimeSeries; - // a problem - if (ser == null) continue; - toUpdateSeries.TryAdd(ser.AnimeSeriesID, ser); - if (user.IsAniDBUser == 0) - SaveWatchedStatus(ep, true, userID, watchedDate, updateWatchedDate); - else - foreach (var juser in aniDBUsers.Where(a => a.IsAniDBUser == 1)) - SaveWatchedStatus(ep, true, juser.JMMUserID, watchedDate, updateWatchedDate); - - if (syncTrakt && settings.TraktTv.Enabled && - !string.IsNullOrEmpty(settings.TraktTv.AuthToken)) - { - await scheduler.StartJob( - c => - { - c.AnimeEpisodeID = ep.AnimeEpisodeID; - c.Action = TraktSyncAction.Add; - } - ); - } - } - } - else - { - // if setting a file to unwatched only set the episode unwatched, if ALL the files are unwatched - foreach (var xrefEp in xrefs) - { - // get the episodes for this file, may be more than one (One Piece x Toriko) - var ep = _episodes.GetByAniDBEpisodeID(xrefEp.EpisodeID); - // a show we don't have - if (ep == null) continue; - - // get all the files for this episode - var epPercentWatched = 0; - foreach (var filexref in ep.FileCrossReferences) - { - var xrefVideoLocal = filexref.VideoLocal; - if (xrefVideoLocal == null) continue; - var vidUser = _vlUsers.GetByUserIDAndVideoLocalID(userID, xrefVideoLocal.VideoLocalID); - if (vidUser?.WatchedDate != null) - epPercentWatched += filexref.Percentage <= 0 ? 100 : filexref.Percentage; - - if (epPercentWatched > 95) break; - } - - if (epPercentWatched < 95) - { - if (user.IsAniDBUser == 0) - SaveWatchedStatus(ep, false, userID, watchedDate, true); - else - foreach (var juser in aniDBUsers.Where(juser => juser.IsAniDBUser == 1)) - SaveWatchedStatus(ep, false, juser.JMMUserID, watchedDate, true); - - ser = ep.AnimeSeries; - // a problem - if (ser == null) continue; - toUpdateSeries.TryAdd(ser.AnimeSeriesID, ser); - - if (syncTrakt && settings.TraktTv.Enabled && - !string.IsNullOrEmpty(settings.TraktTv.AuthToken)) - { - await scheduler.StartJob( - c => - { - c.AnimeEpisodeID = ep.AnimeEpisodeID; - c.Action = TraktSyncAction.Remove; - } - ); - } - } - } - } - - - // update stats for groups and series - if (toUpdateSeries.Count > 0 && updateStats) - { - foreach (var s in toUpdateSeries.Values) - { - // update all the groups above this series in the hierarchy - _seriesService.UpdateStats(s, true, true); - } - - var groups = toUpdateSeries.Values.Select(a => a.AnimeGroup?.TopLevelAnimeGroup).Where(a => a != null) - .DistinctBy(a => a.AnimeGroupID); - - foreach (var group in groups) - { - _groupService.UpdateStatsFromTopLevel(group, true, true); - } - } - } - - private void SaveWatchedStatus(SVR_VideoLocal vl, bool watched, int userID, DateTime? watchedDate, bool updateWatchedDate, DateTime? lastUpdated = null) - { - SVR_VideoLocal_User vidUserRecord; - // mark as watched - if (watched) - { - vidUserRecord = _vlService.GetOrCreateUserRecord(vl, userID); - vidUserRecord.WatchedDate = DateTime.Now; - vidUserRecord.WatchedCount++; - - if (watchedDate.HasValue && updateWatchedDate) - vidUserRecord.WatchedDate = watchedDate.Value; - - vidUserRecord.LastUpdated = lastUpdated ?? DateTime.Now; - _vlUsers.Save(vidUserRecord); - return; - } - - // unmark - vidUserRecord = _vlUsers.GetByUserIDAndVideoLocalID(userID, vl.VideoLocalID); - if (vidUserRecord == null) return; - - vidUserRecord.WatchedDate = null; - _vlUsers.Save(vidUserRecord); - } -}