diff --git a/Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs b/Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs index 8f2736c..c103fc1 100644 --- a/Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs +++ b/Jellyfin.Plugin.AnilistSync/API/AnilistApi.cs @@ -24,19 +24,20 @@ namespace Jellyfin.Plugin.AnilistSync.API private readonly IHttpClientFactory _httpClientFactory; private readonly JsonSerializerOptions _jsonSerializerOptions; - private const string listIdQuery = @"mutation ($mediaId: Int) {SaveMediaListEntry(mediaId: $mediaId) {id, status, repeat, progress}}&variables={""mediaId"": ""{0}""}"; - private const string listUpdateQuery = @"mutation ($id: Int, $progress: Int, $status: MediaListStatus, $repeat: Int) {SaveMediaListEntry(id: $id, progress: $progress, status: $status, repeat: $repeat) {id, progress, status, repeat}}"; - private const string listStatusUpdateQuery = @"mutation ($id: Int, $status: MediaListStatus) {SaveMediaListEntry(id: $id, status: $status) {id, 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}"", ""status"":""{1}""}"; - 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 GraphQLUrl = @"https://graphql.anilist.co"; + + public const string QueryString = @" +mutation ($id: Int, $mediaId: Int, $status: MediaListStatus, $progress: Int,) { + SaveMediaListEntry(id: $id, mediaId: $mediaId, status: $status, progress: $progress ) { + id, + mediaId, + status, + progress + } +}"; + + public const int ClientId = 5659; public const string Secret = @"h7ym2GZ6OjrdJ9sygDP7kDnQWsBdTwp4U8s7pt4X"; public AnilistApi(ILogger logger, IHttpClientFactory httpClientFactory) @@ -49,112 +50,128 @@ namespace Jellyfin.Plugin.AnilistSync.API 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"); - + string uri = @"/oauth/token"; + string payload = JsonSerializer.Serialize(new OAuth + { + GrantType = "authorization_code", + ClientId = ClientId, + ClientSecret = Secret, + RedirectUri = $"{ BaseOauthUrl }/oauth/pin", + Code = code + }); + using 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 GetEpisodes(int? anilistId) + { + GraphQLBody content = new GraphQLBody { + Query = @"query ($mediaId: Int) { Media (id: $mediaId) { episodes }}", + Variables = new ListEntry + { + MediaId = anilistId, + Id = null, + Progress = null, + Status = null + } + }; + return await Post(content); + } + 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; + GraphQLBody content = new GraphQLBody { + Query = @"query { Viewer { id, name }}", + Variables = null + }; + return await PostWithAuth(userToken, content); } - public async Task GetListEntry(string anilistId, string? userToken) + public async Task GetListEntry(string? userToken, int? anilistId) { - 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) + GraphQLBody content = new GraphQLBody { - throw new AnilistAPIException(data.Errors); - } - return data; + Query = QueryString, + Variables = new ListEntry + { + MediaId = anilistId, + Id = null, + Progress = null, + Status = null + } + }; + return await PostWithAuth(userToken, content); } - public async Task GetEpisodes(string anilistId) + public async Task PostListStatusUpdate(string? userToken, int? listId, MediaListStatus? status) { - 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) + GraphQLBody content = new GraphQLBody { - throw new AnilistAPIException(data.Errors); - } - return data; + Query = QueryString, + Variables = new ListEntry + { + MediaId = null, + Id = listId, + Progress = null, + Status = status + } + }; + return await PostWithAuth(userToken, content); } - public async Task PostListStatusUpdate(string? anilistMediaId, string? userToken, MediaListStatus? status) + public async Task PostListProgressUpdate(string? userToken, int? listId, int? progress) { - var requestMessage = new HttpRequestMessage(); - requestMessage.RequestUri = new Uri( - BaseGraphQLUrl + - listStatusUpdateQuery + - listUpdateVars2 - .Replace("{0}", anilistMediaId) - .Replace("{1}", 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) + GraphQLBody content = new GraphQLBody { - throw new AnilistAPIException(data.Errors); - } - return data; + Query = QueryString, + Variables = new ListEntry + { + MediaId = null, + Id = listId, + Progress = progress, + Status = null + } + }; + return await PostWithAuth(userToken, content); } - public async Task PostListProgressUpdate(string? anilistMediaId, string? userToken, int? progress) + public async Task Post(GraphQLBody graphQL) { - var requestMessage = new HttpRequestMessage(); - requestMessage.RequestUri = new Uri( - BaseGraphQLUrl + - listUpdateQuery + - listUpdateVars1 - .Replace("{0}", anilistMediaId) - .Replace("{1}", progress.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) + var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default).PostAsJsonAsync(GraphQLUrl, graphQL, _jsonSerializerOptions); + var root = await responseMessage.Content.ReadFromJsonAsync(_jsonSerializerOptions); + if (root?.Errors != null) { - throw new AnilistAPIException(data.Errors); + foreach (AnilistError error in root.Errors) + { + throw new AnilistAPIException(error.ErrorMessage, error.ErrorStatus, error.Locations); + } } - return data; + return root; + } + + public async Task PostWithAuth(string? userToken, GraphQLBody graphQL) + { + using var requestMessage = new HttpRequestMessage + { + RequestUri = new Uri(GraphQLUrl), + Method = HttpMethod.Post + }; + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken); + + string content = JsonSerializer.Serialize(graphQL, _jsonSerializerOptions); + requestMessage.Content = new StringContent(content, Encoding.UTF8, "application/json"); + + var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(requestMessage); + var root = await responseMessage.Content.ReadFromJsonAsync(_jsonSerializerOptions); + if (root?.Errors != null) + { + foreach (AnilistError error in root.Errors) + { + throw new AnilistAPIException(error.ErrorMessage, error.ErrorStatus, error.Locations); + } + } + return root; } } } \ No newline at end of file diff --git a/Jellyfin.Plugin.AnilistSync/API/ApiModel.cs b/Jellyfin.Plugin.AnilistSync/API/ApiModel.cs index 1970595..a76a787 100644 --- a/Jellyfin.Plugin.AnilistSync/API/ApiModel.cs +++ b/Jellyfin.Plugin.AnilistSync/API/ApiModel.cs @@ -10,7 +10,7 @@ namespace Jellyfin.Plugin.AnilistSync.API public Data? Data { get; set; } [JsonPropertyName("errors")] - public Error[]? Errors { get; set; } + public AnilistError[]? Errors { get; set; } } public class Data @@ -25,7 +25,7 @@ namespace Jellyfin.Plugin.AnilistSync.API public Media? Media { get; set; } } - public class Error + public class AnilistError { [JsonPropertyName("message")] public string? ErrorMessage { get; set; } @@ -63,6 +63,9 @@ namespace Jellyfin.Plugin.AnilistSync.API public class ListEntry { + [JsonPropertyName("mediaId")] + public int? MediaId { get; set; } + [JsonPropertyName("id")] public int? Id { get; set; } @@ -71,9 +74,33 @@ namespace Jellyfin.Plugin.AnilistSync.API [JsonPropertyName("status")] public MediaListStatus? Status { get; set; } + } - [JsonPropertyName("repeat")] - public int? TimesRewatched { get; set; } + public class GraphQLBody + { + [JsonPropertyName("query")] + public string? Query { get; set; } + + [JsonPropertyName("variables")] + public ListEntry? Variables { get; set; } + } + + public class OAuth + { + [JsonPropertyName("grant_type")] + public string? GrantType { get; set; } + + [JsonPropertyName("client_id")] + public int? ClientId { get; set; } + + [JsonPropertyName("client_secret")] + public string? ClientSecret { get; set; } + + [JsonPropertyName("redirect_uri")] + public string? RedirectUri { get; set; } + + [JsonPropertyName("code")] + public string? Code { get; set; } } public enum MediaListStatus @@ -99,14 +126,13 @@ namespace Jellyfin.Plugin.AnilistSync.API [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } - } - public class AnilistAPIException : Exception { - public Error[]? errors; + public int? statusCode; + public Location[]? locations; public AnilistAPIException() { @@ -120,14 +146,16 @@ namespace Jellyfin.Plugin.AnilistSync.API } public AnilistAPIException(string message, Exception inner) - : base(message, inner) + : base(message, inner) { } - public AnilistAPIException(Error[] errors) + public AnilistAPIException(string? message, int? statusCode, Location[]? locations) + : base(message) { - this.errors = errors; + this.statusCode = statusCode; + this.locations = locations; } } } diff --git a/Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs b/Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs index 3f12896..053dd6d 100644 --- a/Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs +++ b/Jellyfin.Plugin.AnilistSync/Services/PlaybackScrobbler.cs @@ -170,11 +170,10 @@ namespace Jellyfin.Plugin.AnilistSync.Services // Get the list entry for current watching show from Anilist - var listEntry = _anilistApi.GetListEntry(anilistId, userConfig.UserToken).Result?.Data?.ListEntry; + var listEntry = _anilistApi.GetListEntry(userConfig.UserToken, Int32.Parse(anilistId)).Result?.Data?.ListEntry; int? currentIndex = eventArgs.Item.IndexNumber; int? currentRemoteIndex = listEntry?.Progress; - int? timesRewatched = listEntry?.TimesRewatched; MediaListStatus? status = listEntry?.Status; switch (status) @@ -185,7 +184,7 @@ namespace Jellyfin.Plugin.AnilistSync.Services if (currentIndex == 1) // Only initialize a rewatch if watching first episode { status = MediaListStatus.REPEATING; - await _anilistApi.PostListStatusUpdate(listEntry?.Id.ToString(), userConfig.UserToken, status); + await _anilistApi.PostListStatusUpdate(userConfig.UserToken, listEntry?.Id, status); _logger.LogInformation("Rewatch started"); } else @@ -219,7 +218,7 @@ namespace Jellyfin.Plugin.AnilistSync.Services case MediaListStatus.DROPPED: case MediaListStatus.PAUSED: status = MediaListStatus.CURRENT; - await _anilistApi.PostListStatusUpdate(listEntry?.Id.ToString(), userConfig.UserToken, status); + await _anilistApi.PostListStatusUpdate(userConfig.UserToken, listEntry?.Id, status); break; case MediaListStatus.CURRENT: if (currentIndex <= currentRemoteIndex) @@ -233,36 +232,27 @@ namespace Jellyfin.Plugin.AnilistSync.Services } // Get total number of episodes of current item from Anilist - int? episodes = (await _anilistApi.GetEpisodes(anilistId))?.Data?.Media?.Episodes; - _logger.LogInformation("Total Episodes: {totalEpisodes}", episodes); - _logger.LogInformation("Current Episode: {currentEpisode}", currentIndex); + int? totalEpisodes = (await _anilistApi.GetEpisodes(Int32.Parse(anilistId)))?.Data?.Media?.Episodes; // If watching LAST episode change status to completed - if (currentIndex == episodes) + if (currentIndex == totalEpisodes) { status = MediaListStatus.COMPLETED; - var statusResponse = await _anilistApi.PostListStatusUpdate(listEntry?.Id.ToString(), userConfig.UserToken, status); + var statusResponse = await _anilistApi.PostListStatusUpdate(userConfig.UserToken, listEntry?.Id, status); } else { - var response = await _anilistApi.PostListProgressUpdate(listEntry?.Id.ToString(), userConfig.UserToken, currentIndex); + var response = await _anilistApi.PostListProgressUpdate(userConfig.UserToken, listEntry?.Id, currentIndex); } - _logger.LogInformation("Current watch status: {status}", status); + _logger.LogInformation("Scrobbled episode: ({currentIndex} of {totalEpisodes}) for Anilist ID: ({anilistId})", currentIndex, totalEpisodes, anilistId); + _logger.LogInformation("Watch status: {status}", status); _logger.LogInformation("Scrobbled without errors"); _lastScrobbled[eventArgs.Session.Id] = eventArgs.MediaInfo.Id; } - //catch (InvalidTokenException) - //{ - // _logger.LogDebug("Deleted user token"); - //} catch (AnilistAPIException aniAPIEx) { - for (int i = 0; i < aniAPIEx.errors?.Length; i++) - { - Error? error = aniAPIEx.errors[i]; - _logger.LogError(error.ErrorMessage, "API response code " + error.ErrorStatus); - } + _logger.LogError(aniAPIEx, "Error status: {aniAPIEx.statusCode}", aniAPIEx.statusCode); } catch (InvalidDataException ex) {