From 895a92290f11aabb48d26e62ef52ac541383b294 Mon Sep 17 00:00:00 2001 From: Alexander Rufenach Date: Tue, 1 Jun 2021 18:24:50 -0400 Subject: [PATCH] Adding additional files --- .editorconfig | 10 + Jellyfin.Plugin.AnilistSync.sln | 30 ++ Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs | 128 ++++++++ Jellyfin.Plugin.AnilistSync/API/ApiModel.cs | 130 ++++++++ Jellyfin.Plugin.AnilistSync/API/Endpoints.cs | 38 +++ .../Configuration/PluginConfiguration.cs | 70 +++++ .../Configuration/UserConfig.cs | 70 +++++ .../Configuration/configPage.html | 286 ++++++++++++++++++ .../Jellyfin.Plugin.AnilistSync.csproj | 45 +++ Jellyfin.Plugin.AnilistSync/Plugin.cs | 51 ++++ .../PluginServiceRegistrator.cs | 16 + .../Services/PlaybackScrobbler.cs | 208 +++++++++++++ 12 files changed, 1082 insertions(+) create mode 100644 .editorconfig create mode 100644 Jellyfin.Plugin.AnilistSync.sln create mode 100644 Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs create mode 100644 Jellyfin.Plugin.AnilistSync/API/ApiModel.cs create mode 100644 Jellyfin.Plugin.AnilistSync/API/Endpoints.cs create mode 100644 Jellyfin.Plugin.AnilistSync/Configuration/PluginConfiguration.cs create mode 100644 Jellyfin.Plugin.AnilistSync/Configuration/UserConfig.cs create mode 100644 Jellyfin.Plugin.AnilistSync/Configuration/configPage.html create mode 100644 Jellyfin.Plugin.AnilistSync/Jellyfin.Plugin.AnilistSync.csproj create mode 100644 Jellyfin.Plugin.AnilistSync/Plugin.cs create mode 100644 Jellyfin.Plugin.AnilistSync/PluginServiceRegistrator.cs create mode 100644 Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0384ecf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +[*.cs] + +# CS8618: Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +dotnet_diagnostic.CS8618.severity = warning + +# CS8603: Possible null reference return. +dotnet_diagnostic.CS8603.severity = warning + +# CS8602: Dereference of a possibly null reference. +dotnet_diagnostic.CS8602.severity = warning diff --git a/Jellyfin.Plugin.AnilistSync.sln b/Jellyfin.Plugin.AnilistSync.sln new file mode 100644 index 0000000..cf16231 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync.sln @@ -0,0 +1,30 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.AnilistSync", "Jellyfin.Plugin.AnilistSync\Jellyfin.Plugin.AnilistSync.csproj", "{2C1895EC-A7E1-48B6-A2E0-3E847D1FB748}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{785517C0-5B12-4228-9D64-2495680C537A}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2C1895EC-A7E1-48B6-A2E0-3E847D1FB748}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C1895EC-A7E1-48B6-A2E0-3E847D1FB748}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C1895EC-A7E1-48B6-A2E0-3E847D1FB748}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C1895EC-A7E1-48B6-A2E0-3E847D1FB748}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {87680E98-9FDC-4691-8096-1E3A901004A7} + EndGlobalSection +EndGlobal diff --git a/Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs b/Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs new file mode 100644 index 0000000..3cfed15 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.Mime; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using MediaBrowser.Common.Json; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Dto; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.AnilistSync.API +{ + public class AnilistApi + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly JsonSerializerOptions _jsonSerializerOptions; + + private const string listIdQuery = @"mutation ($mediaId: Int) {SaveMediaListEntry(mediaId: $mediaId) {id, status}}&variables={""mediaId"": ""{0}""}"; + private const string listUpdateQuery = @"mutation ($id: Int, $progress: Int, $status: MediaListStatus) {SaveMediaListEntry(id: $id, progress: $progress, status: $status) {id, progress, status}}"; + private const string episodeQuery = @"query ($id: Int) {Media (id: $id) {episodes}}"; + private const string currentUserQuery = @"query {Viewer {id, name}}"; + + private const string listUpdateVars1 = @"&variables={""id"":""{0}"", ""progress"":""{1}""}"; + private const string listUpdateVars2 = @"&variables={""id"":""{0}"", ""progress"":""{1}"", ""status"":""{2}""}"; + + public const string BaseOauthUrl = @"https://anilist.co/api/v2"; + public const string BaseGraphQLUrl = @"https://graphql.anilist.co/api/v2?query="; + public const string RedirectUri = BaseOauthUrl + @"/oauth/pin"; + public const string ClientId = @"5659"; + public const string Secret = @"h7ym2GZ6OjrdJ9sygDP7kDnQWsBdTwp4U8s7pt4X"; + + public AnilistApi(ILogger logger, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _jsonSerializerOptions = JsonDefaults.GetOptions(); + } + + + public async Task GetToken(string? code) + { + var uri = $"/oauth/token"; + + var payload = $"{{\"grant_type\": \"authorization_code\",\"client_id\": {ClientId}, \"client_secret\": \"{Secret}\", \"redirect_uri\": \"{BaseOauthUrl}/oauth/pin\",\"code\": \"{code}\"}}"; + HttpContent content = new StringContent(payload, Encoding.UTF8, "application/json"); + + var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default).PostAsync(BaseOauthUrl + uri, content); + return await responseMessage.Content.ReadFromJsonAsync(_jsonSerializerOptions); + } + + public async Task GetUser(string? userToken) + { + var requestMessage = new HttpRequestMessage(); + requestMessage.RequestUri = new Uri(BaseGraphQLUrl + currentUserQuery); + requestMessage.Method = HttpMethod.Post; + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken); + requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + requestMessage.Content = new StringContent("", Encoding.UTF8, "application/json"); + + var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(requestMessage); + + var data = await responseMessage.Content.ReadFromJsonAsync(_jsonSerializerOptions); + if (data?.Errors != null) + { + throw new AnilistAPIException(data.Errors); + } + return data; + } + + public async Task GetListId(string anilistId, string? userToken) + { + var requestMessage = new HttpRequestMessage(); + requestMessage.RequestUri = new Uri(BaseGraphQLUrl + listIdQuery.Replace("{0}", anilistId)); + requestMessage.Method = HttpMethod.Post; + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken); + requestMessage.Content = new StringContent("", Encoding.UTF8, "application/json"); + var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(requestMessage); + var data = await responseMessage.Content.ReadFromJsonAsync(_jsonSerializerOptions); + if (data?.Errors != null) + { + throw new AnilistAPIException(data.Errors); + } + return data; + } + + public async Task GetEpisodes(string anilistId) + { + var requestMessage = new HttpRequestMessage(); + requestMessage.RequestUri = new Uri(BaseGraphQLUrl + episodeQuery + $"&variables={{\"id\":{anilistId}}}"); + requestMessage.Method = HttpMethod.Post; + requestMessage.Content = new StringContent("", Encoding.UTF8, "application/json"); + var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(requestMessage); + var data = await responseMessage.Content.ReadFromJsonAsync(_jsonSerializerOptions); + if (data?.Errors != null) + { + throw new AnilistAPIException(data.Errors); + } + return data; + } + + public async Task PostListUpdate(string anilistId, string? userToken, int? progress, MediaListStatus status) + { + var listEntry = GetListId(anilistId, userToken).Result?.Data?.ListEntry; + + var requestMessage = new HttpRequestMessage(); + requestMessage.RequestUri = new Uri(BaseGraphQLUrl + listUpdateQuery + listUpdateVars2.Replace("{0}", listEntry?.Id.ToString()).Replace("{1}", progress.ToString()).Replace("{2}", status.ToString())); + requestMessage.Method = HttpMethod.Post; + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken); + requestMessage.Content = new StringContent("", Encoding.UTF8, "application/json"); + var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(requestMessage); + var data = await responseMessage.Content.ReadFromJsonAsync(_jsonSerializerOptions); + if (data?.Errors != null) + { + throw new AnilistAPIException(data.Errors); + } + return data; + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/API/ApiModel.cs b/Jellyfin.Plugin.AnilistSync/API/ApiModel.cs new file mode 100644 index 0000000..75c42ea --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/API/ApiModel.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.AnilistSync.API +{ + public class RootObject + { + [JsonPropertyName("data")] + public Data? Data { get; set; } + + [JsonPropertyName("errors")] + public Error[]? Errors { get; set; } + } + + public class Data + { + [JsonPropertyName("Viewer")] + public User? User { get; set; } + + [JsonPropertyName("SaveMediaListEntry")] + public ListEntry? ListEntry { get; set; } + + [JsonPropertyName("Media")] + public Media? Media { get; set; } + } + + public class Error + { + [JsonPropertyName("message")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("status")] + public int? ErrorStatus { get; set; } + + [JsonPropertyName("locations")] + public Location[]? Locations { get; set; } + } + + public class Location + { + [JsonPropertyName("line")] + public int? Line { get; set; } + + [JsonPropertyName("column")] + public int? column { get; set; } + } + + public class Media + { + [JsonPropertyName("episodes")] + public int? Episodes { get; set; } + } + + public class User + { + [JsonPropertyName("id")] + public int? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + } + + public class ListEntry + { + [JsonPropertyName("id")] + public int? Id { get; set; } + + [JsonPropertyName("progress")] + public int? Progress { get; set; } + + [JsonPropertyName("status")] + public MediaListStatus? Status { get; set; } + } + + public enum MediaListStatus + { + CURRENT, + PLANNING, + COMPLETED, + DROPPED, + PAUSED, + REPEATING + } + + public class CodeResponse + { + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + } + + + + public class AnilistAPIException : Exception + { + public Error[]? errors; + + public AnilistAPIException() + { + + } + + public AnilistAPIException(string message) + : base(message) + { + + } + + public AnilistAPIException(string message, Exception inner) + : base(message, inner) + { + + } + + public AnilistAPIException(Error[] errors) + { + this.errors = errors; + } + } +} diff --git a/Jellyfin.Plugin.AnilistSync/API/Endpoints.cs b/Jellyfin.Plugin.AnilistSync/API/Endpoints.cs new file mode 100644 index 0000000..cedb085 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/API/Endpoints.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Plugin.AnilistSync.API +{ + [ApiController] + [Authorize(Policy = "DefaultAuthorization")] + [Route("AnilistSync")] + public class Endpoints : ControllerBase + { + private readonly AnilistApi _anilistApi; + + public Endpoints(AnilistApi anilistApi) + { + _anilistApi = anilistApi; + } + + [HttpGet("oauth/token/{userCode}")] + public async Task> GetToken([FromRoute] string userCode) + { + return await _anilistApi.GetToken(userCode); + } + + [HttpGet("users/settings/{userId}")] + public async Task> GetUser([FromRoute] Guid userId) + { + var userConfiguration = Plugin.Instance?.Configuration.GetByGuid(userId); + if (userConfiguration == null) + { + return NotFound(); + } + return await _anilistApi.GetUser(userConfiguration.UserToken); + } + + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.AnilistSync/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..c2c13dd --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/Configuration/PluginConfiguration.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.AnilistSync.Configuration +{ + public enum TitlePreferenceType + { + /// + /// Use titles in the local metadata language. + /// + Localized, + + /// + /// Use titles in Japanese. + /// + Japanese, + + /// + /// Use titles in Japanese romaji. + /// + JapaneseRomaji + } + + /// + /// Class needed to create a Plugin and configure it. + /// + public class PluginConfiguration : BasePluginConfiguration + { + /// + /// Initializes a new instance of the class. + /// + public PluginConfiguration() + { + UserConfigs = Array.Empty(); + } + + /// + /// Gets or sets the list of user configs. + /// + public UserConfig[] UserConfigs { get; set; } + + /// + /// Get config by id. + /// + /// The user id. + /// Stored user config. + public UserConfig? GetByGuid(Guid id) + { + return UserConfigs.FirstOrDefault(c => c.Id == id); + } + + /// + /// Delete user token. + /// + /// User token. + public void DeleteUserToken(string userToken) + { + foreach (var config in UserConfigs) + { + if (config.UserToken == userToken) + { + config.UserToken = string.Empty; + } + } + + Plugin.Instance?.SaveConfiguration(); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/Configuration/UserConfig.cs b/Jellyfin.Plugin.AnilistSync/Configuration/UserConfig.cs new file mode 100644 index 0000000..bf31efd --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/Configuration/UserConfig.cs @@ -0,0 +1,70 @@ +using System; + +namespace Jellyfin.Plugin.AnilistSync.Configuration +{ + /// + /// User config. + /// + public class UserConfig + { + /// + /// Initializes a new instance of the class. + /// + public UserConfig() + { + ScrobbleMovies = true; + ScrobbleShows = true; + ScrobblePercentage = 70; + ScrobbleNowWatchingPercentage = 5; + MinLength = 5; + UserToken = string.Empty; // Todo: check if token is still valid + ScrobbleTimeout = 30; + } + + /// + /// Gets or sets a value indicating whether scrobble movies. + /// + public bool ScrobbleMovies { get; set; } + + /// + /// Gets or sets a value indicating whether scrobble shows. + /// + public bool ScrobbleShows { get; set; } + + /// + /// Gets or sets scrobble percentage. + /// + public int ScrobblePercentage { get; set; } + + /// + /// Gets or sets scrobble now watching percentage. + /// + public int ScrobbleNowWatchingPercentage { get; set; } + + /// + /// Gets or sets min length. + /// + /// + /// Minimum length for scrobbling (in minutes). + /// + public int MinLength { get; set; } + + /// + /// Gets or sets user token. + /// + public string UserToken { get; set; } // Is the user logged in + + /// + /// Gets or sets scrobble timeout. + /// + /// + /// Time between scrobbling tries. + /// + public int ScrobbleTimeout { get; set; } + + /// + /// Gets or sets user id. + /// + public Guid Id { get; set; } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/Configuration/configPage.html b/Jellyfin.Plugin.AnilistSync/Configuration/configPage.html new file mode 100644 index 0000000..8deb0a4 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/Configuration/configPage.html @@ -0,0 +1,286 @@ + + + + AnilistSync Srobbler Settings + + +
+
+
+

AnilistSync Srobbler Settings

+
+
+ +
+ + + +
+
+
+ +
+ + \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/Jellyfin.Plugin.AnilistSync.csproj b/Jellyfin.Plugin.AnilistSync/Jellyfin.Plugin.AnilistSync.csproj new file mode 100644 index 0000000..4bbbc21 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/Jellyfin.Plugin.AnilistSync.csproj @@ -0,0 +1,45 @@ + + + + net5.0 + 1.0.0.0 + 1.0.0.0 + true + true + enable + + + + + + + + + + + + + + + + + + + + ../jellyfin.ruleset + + + + 1701;1702;1591;1300 + 4 + + + + + + + + + + + diff --git a/Jellyfin.Plugin.AnilistSync/Plugin.cs b/Jellyfin.Plugin.AnilistSync/Plugin.cs new file mode 100644 index 0000000..6986a40 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/Plugin.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using Jellyfin.Plugin.AnilistSync.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.AnilistSync +{ + + public class Plugin : BasePlugin, IHasWebPages + { + public override string Name => "AnilistSync"; + public override Guid Id => Guid.Parse("18c2a8ea-afa0-4a0b-aa94-072b492ab80b"); + public override string Description => "Description"; + IHttpClientFactory _httpClientFactory; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IHttpClientFactory htppClientFactory) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + _httpClientFactory = htppClientFactory; + } + + public static Plugin? Instance { get; private set; } + + public HttpClient GetHttpClient() { + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue(Name, Version.ToString())); + + return httpClient; + } + + public IEnumerable GetPages() + { + return new[] + { + new PluginPageInfo + { + Name = this.Name, + EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace) + } + }; + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/PluginServiceRegistrator.cs b/Jellyfin.Plugin.AnilistSync/PluginServiceRegistrator.cs new file mode 100644 index 0000000..aeef4e7 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/PluginServiceRegistrator.cs @@ -0,0 +1,16 @@ +using Jellyfin.Plugin.AnilistSync.API; +using MediaBrowser.Common.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.AnilistSync +{ + /// + public class PluginServiceRegistrator : IPluginServiceRegistrator + { + /// + public void RegisterServices(IServiceCollection serviceCollection) + { + serviceCollection.AddScoped(); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs b/Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs new file mode 100644 index 0000000..b7fb3d8 --- /dev/null +++ b/Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Jellyfin.Plugin.AnilistSync.API; +using Jellyfin.Plugin.AnilistSync.Configuration; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.AnilistSync.Services +{ + + public class PlaybackScrobbler : IServerEntryPoint + { + private readonly ISessionManager _sessionManager; // Needed to set up de startPlayBack and endPlayBack functions + private readonly ILogger _logger; + private readonly Dictionary _lastScrobbled; // Library ID of last scrobbled item + private readonly AnilistApi _anilistApi; + private DateTime _nextTry; + + public PlaybackScrobbler(ISessionManager sessionManager, ILogger logger, AnilistApi anilistApi) + { + _sessionManager = sessionManager; + _logger = logger; + _anilistApi = anilistApi; + _lastScrobbled = new Dictionary(); + _nextTry = DateTime.UtcNow; + } + + public Task RunAsync() + { + _sessionManager.PlaybackProgress += OnPlaybackProgress; + _sessionManager.PlaybackStopped += OnPlaybackStopped; + return Task.CompletedTask; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _sessionManager.PlaybackProgress -= OnPlaybackProgress; + _sessionManager.PlaybackStopped -= OnPlaybackStopped; + } + } + + // UserConfig config, + private static bool CanBeScrobbled(UserConfig userConfig, PlaybackProgressEventArgs playbackProgress) + { + var position = playbackProgress.PlaybackPositionTicks; + var runtime = playbackProgress.MediaInfo.RunTimeTicks; + + if (runtime != null) + { + var percentageWatched = position / (float)runtime * 100f; + + // Check if percentageWatched is greater than threshold + if (percentageWatched < userConfig.ScrobblePercentage) + { + return false; + } + } + + // Checks if runtime is greater than min length to be scrobbled + // TODO: chan 5 to configurable value + if (runtime < 60 * 10000 * userConfig.MinLength) + { + return false; + } + return true; + } + + private async void OnPlaybackProgress(object? sessions, PlaybackProgressEventArgs eventArgs) + { + if (DateTime.UtcNow < _nextTry) + { + return; + } + + // Scrobble every 30s + _nextTry = DateTime.UtcNow.AddSeconds(30); + await ScrobbleSession(eventArgs); + } + + private async void OnPlaybackStopped(object? sessions, PlaybackStopEventArgs eventArgs) + { + await ScrobbleSession(eventArgs); + } + + private static string? GetAnilistId(PlaybackProgressEventArgs eventArgs) + { + string? id = null; + if (eventArgs.Item is Episode episode) + { + id = episode.Series.GetProviderId("AniList"); + } + else if (eventArgs.Item is Movie movie) + { + id = movie.GetProviderId("AniList"); + } + return id; + } + + private async Task ScrobbleSession(PlaybackProgressEventArgs eventArgs) + { + try + { + var userId = eventArgs.Session.UserId; + + //Get user config + var userConfig = Plugin.Instance?.Configuration.GetByGuid(userId); + + // Check if logged in + if (userConfig == null || string.IsNullOrEmpty(userConfig.UserToken)) + { + _logger.LogError( + "Can't scrobble: User {UserName} not logged in ({UserConfigStatus})", + eventArgs.Session.UserName, + userConfig == null); + return; + } + + // Scrobble code + if (!CanBeScrobbled(userConfig, eventArgs)) + { + return; + } + + // Check if already scrobbled + if (_lastScrobbled.ContainsKey(eventArgs.Session.Id) && _lastScrobbled[eventArgs.Session.Id] == eventArgs.MediaInfo.Id) + { + _logger.LogDebug("Already scrobbled {ItemName} for {UserName}", eventArgs.MediaInfo.Name, eventArgs.Session.UserName); + return; + } + + // Get AniList Id and check if exists in Jellyfin + string? anilistId = GetAnilistId(eventArgs); + if (anilistId == null) + { + _logger.LogDebug("Cannot Scrobble {ItemName}, unknown AniList Id."); + return; + } + + _logger.LogInformation( + "Trying to scrobble {Name} ({NowPlayingId}) for {UserName} ({UserId}) - {PlayingItemPath} on {SessionId} - AniList ID {AnilistId}", + eventArgs.MediaInfo.Name, + eventArgs.MediaInfo.Id, + eventArgs.Session.UserName, + userId, + eventArgs.MediaInfo.Path, + eventArgs.Session.Id, + anilistId); + + + // Send post request to API to update list + int? episodes = (await _anilistApi.GetEpisodes(anilistId))?.Data?.Media?.Episodes; + int? currentIndex = eventArgs.Item.IndexNumber; + + _logger.LogInformation("Total Episodes: " + episodes.ToString()); + _logger.LogInformation("Current Episode: " + currentIndex.ToString()); + + MediaListStatus status = MediaListStatus.CURRENT; + if (currentIndex == episodes) + { + status = MediaListStatus.COMPLETED; + } + _logger.LogInformation(status.ToString()); + + var response = await _anilistApi.PostListUpdate(anilistId, userConfig.UserToken, currentIndex, status); + _logger.LogDebug("Scrobbled without errors"); + _lastScrobbled[eventArgs.Session.Id] = eventArgs.MediaInfo.Id; + + } + //catch (InvalidTokenException) + //{ + // _logger.LogDebug("Deleted user token"); + //} + catch (AnilistAPIException alEx) + { + for (int i = 0; i < alEx.errors?.Length; i++) + { + Error? error = alEx.errors[i]; + _logger.LogError(error.ErrorMessage, "API response code " + error.ErrorStatus); + } + } + catch (InvalidDataException ex) + { + _logger.LogError(ex, "Couldn't scrobble"); + _lastScrobbled[eventArgs.Session.Id] = eventArgs.MediaInfo.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Caught unknown exception while trying to scrobble"); + } + } + } +} \ No newline at end of file