Adding additional files
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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<AnilistApi> _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<AnilistApi> logger, IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_jsonSerializerOptions = JsonDefaults.GetOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<CodeResponse?> 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<CodeResponse>(_jsonSerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RootObject?> 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<RootObject>(_jsonSerializerOptions);
|
||||||
|
if (data?.Errors != null)
|
||||||
|
{
|
||||||
|
throw new AnilistAPIException(data.Errors);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RootObject?> 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<RootObject>(_jsonSerializerOptions);
|
||||||
|
if (data?.Errors != null)
|
||||||
|
{
|
||||||
|
throw new AnilistAPIException(data.Errors);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RootObject?> 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<RootObject>(_jsonSerializerOptions);
|
||||||
|
if (data?.Errors != null)
|
||||||
|
{
|
||||||
|
throw new AnilistAPIException(data.Errors);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RootObject?> 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<RootObject>(_jsonSerializerOptions);
|
||||||
|
if (data?.Errors != null)
|
||||||
|
{
|
||||||
|
throw new AnilistAPIException(data.Errors);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ActionResult<CodeResponse?>> GetToken([FromRoute] string userCode)
|
||||||
|
{
|
||||||
|
return await _anilistApi.GetToken(userCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("users/settings/{userId}")]
|
||||||
|
public async Task<ActionResult<RootObject?>> GetUser([FromRoute] Guid userId)
|
||||||
|
{
|
||||||
|
var userConfiguration = Plugin.Instance?.Configuration.GetByGuid(userId);
|
||||||
|
if (userConfiguration == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
return await _anilistApi.GetUser(userConfiguration.UserToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.AnilistSync.Configuration
|
||||||
|
{
|
||||||
|
public enum TitlePreferenceType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Use titles in the local metadata language.
|
||||||
|
/// </summary>
|
||||||
|
Localized,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use titles in Japanese.
|
||||||
|
/// </summary>
|
||||||
|
Japanese,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use titles in Japanese romaji.
|
||||||
|
/// </summary>
|
||||||
|
JapaneseRomaji
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Class needed to create a Plugin and configure it.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public PluginConfiguration()
|
||||||
|
{
|
||||||
|
UserConfigs = Array.Empty<UserConfig>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of user configs.
|
||||||
|
/// </summary>
|
||||||
|
public UserConfig[] UserConfigs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get config by id.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The user id.</param>
|
||||||
|
/// <returns>Stored user config.</returns>
|
||||||
|
public UserConfig? GetByGuid(Guid id)
|
||||||
|
{
|
||||||
|
return UserConfigs.FirstOrDefault(c => c.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete user token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userToken">User token.</param>
|
||||||
|
public void DeleteUserToken(string userToken)
|
||||||
|
{
|
||||||
|
foreach (var config in UserConfigs)
|
||||||
|
{
|
||||||
|
if (config.UserToken == userToken)
|
||||||
|
{
|
||||||
|
config.UserToken = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Instance?.SaveConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.AnilistSync.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// User config.
|
||||||
|
/// </summary>
|
||||||
|
public class UserConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="UserConfig"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public UserConfig()
|
||||||
|
{
|
||||||
|
ScrobbleMovies = true;
|
||||||
|
ScrobbleShows = true;
|
||||||
|
ScrobblePercentage = 70;
|
||||||
|
ScrobbleNowWatchingPercentage = 5;
|
||||||
|
MinLength = 5;
|
||||||
|
UserToken = string.Empty; // Todo: check if token is still valid
|
||||||
|
ScrobbleTimeout = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether scrobble movies.
|
||||||
|
/// </summary>
|
||||||
|
public bool ScrobbleMovies { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether scrobble shows.
|
||||||
|
/// </summary>
|
||||||
|
public bool ScrobbleShows { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets scrobble percentage.
|
||||||
|
/// </summary>
|
||||||
|
public int ScrobblePercentage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets scrobble now watching percentage.
|
||||||
|
/// </summary>
|
||||||
|
public int ScrobbleNowWatchingPercentage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets min length.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Minimum length for scrobbling (in minutes).
|
||||||
|
/// </remarks>
|
||||||
|
public int MinLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets user token.
|
||||||
|
/// </summary>
|
||||||
|
public string UserToken { get; set; } // Is the user logged in
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets scrobble timeout.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Time between scrobbling tries.
|
||||||
|
/// </remarks>
|
||||||
|
public int ScrobbleTimeout { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets user id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>AnilistSync Srobbler Settings</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div data-role="page" class="page type-interior pluginConfigurationPage" id="AnilistSyncConfigurationPage"
|
||||||
|
data-require="emby-button,emby-checkbox,emby-input,emby-select">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<h1>AnilistSync Srobbler Settings</h1>
|
||||||
|
<form id="AnilistSyncConfigurationForm">
|
||||||
|
<div id="selectContainer">
|
||||||
|
<select onchange="AnilistSyncConfig.onSelectorChange();" is="emby-select" id="user-selector"
|
||||||
|
label="Showing plugin settings for...">
|
||||||
|
<!-- This will be populated by SimklConfig.populateUsers -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="loginButtonContainer" hidden>
|
||||||
|
<h3>It seems you are not logged in, do you wish to log in?</h3>
|
||||||
|
<button onclick="AnilistSyncConfig.startLoginProcess();" is="emby-button" type="button"
|
||||||
|
class="raised button-submit block"><span>Log In</span></button>
|
||||||
|
<button onclick="location.href='https://anilist.co/signup';" is="emby-button" type="button"
|
||||||
|
class="raised block"><span>Create an account</span></button>
|
||||||
|
</div>
|
||||||
|
<div id="loggingIn" hidden>
|
||||||
|
<h2>Logging In</h2>
|
||||||
|
<div id="loginText"></div>
|
||||||
|
<div id="inputCodeContainer">
|
||||||
|
<input is="emby-input" id="AnilistAuthCodeInput" label="Anilist Authorization Code:" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Anilist authorization code from redirect
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="AnilistSyncConfig.submitAuthCode();" is="emby-button" type="button"
|
||||||
|
class="raised button-submit block"><span>Submit</span></button>
|
||||||
|
<button onclick="AnilistSyncConfig.stopLoginProcess();" is="emby-button" type="button"
|
||||||
|
class="raised button-cancel block"><span>Cancel</span></button>
|
||||||
|
</div>
|
||||||
|
<div id="configOptionsContainer" hidden>
|
||||||
|
<h3>Hello again <span id="simklName">USERNAME</span>!</h3>
|
||||||
|
<button onclick="AnilistSyncConfig.logOut();" is="emby-button" type="button" class="raised button block">
|
||||||
|
<span>Log Out</span></button>
|
||||||
|
<h2>Scrobbling options:</h2>
|
||||||
|
<div class="checkboxcontainer">
|
||||||
|
<label>
|
||||||
|
<input is="emby-checkbox" type="checkbox" id="ScrobbleMovies"/>
|
||||||
|
<span>Autoscrobbling Movies</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxcontainer">
|
||||||
|
<label>
|
||||||
|
<input is="emby-checkbox" type="checkbox" id="ScrobbleShows"/>
|
||||||
|
<span>Autoscrobbling TV Shows</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<input is="emby-input" id="ScrobblePercentage" type="number" min="0" max="100" pattern="[0-9]*"
|
||||||
|
label="Scrobbling percentage:"/>
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Percentage watched needed to scrobble
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block"><span>${Save}</span>
|
||||||
|
</button>
|
||||||
|
<button is="emby-button" type="button" class="raised block" onclick="history.back();"><span>${Cancel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var AnilistSyncConfig = {
|
||||||
|
guid: "18c2a8ea-afa0-4a0b-aa94-072b492ab80b",
|
||||||
|
onLoginProcess: false,
|
||||||
|
configCache: [],
|
||||||
|
loginTimer: null,
|
||||||
|
remainingTimer: null,
|
||||||
|
finish: null,
|
||||||
|
|
||||||
|
userSelector: document.querySelector('#user-selector'),
|
||||||
|
loginButtonContainer: document.querySelector('#loginButtonContainer'),
|
||||||
|
configOptionsContainer: document.querySelector('#configOptionsContainer'),
|
||||||
|
simklName: document.querySelector('#simklName'),
|
||||||
|
loginSecondsRemaining: document.querySelector('#loginSecondsRemaining'),
|
||||||
|
loginText: document.querySelector('#loginText'),
|
||||||
|
loginPin: document.querySelector('#loginPin'),
|
||||||
|
loggingIn: document.querySelector('#loggingIn'),
|
||||||
|
|
||||||
|
populateUsers: async function (users) {
|
||||||
|
users.forEach(function (user) {
|
||||||
|
AnilistSyncConfig.userSelector.append(new Option(user.Name, user.Id));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadConfig: async function (user, config) {
|
||||||
|
if (config != null) {
|
||||||
|
this.configCache = config;
|
||||||
|
} else {
|
||||||
|
config = this.configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("AnilistSync: Loading config for user " + user);
|
||||||
|
console.log(config);
|
||||||
|
|
||||||
|
AnilistSyncConfig.loginButtonContainer.setAttribute('hidden', '');
|
||||||
|
AnilistSyncConfig.configOptionsContainer.setAttribute('hidden', '')
|
||||||
|
|
||||||
|
if (config.UserConfigs.some(e => e.Id === user && e.UserToken != null && e.UserToken !== "")) {
|
||||||
|
AnilistSyncConfig.configOptionsContainer.removeAttribute('hidden');
|
||||||
|
await this.populateOptionsContainer(config.UserConfigs.filter(e => e.Id === user)[0]);
|
||||||
|
} else {
|
||||||
|
AnilistSyncConfig.loginButtonContainer.removeAttribute('hidden');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveConfig: async function (guid) {
|
||||||
|
const uconfig = this.configCache.UserConfigs.filter(e => e.Id === guid)[0];
|
||||||
|
|
||||||
|
for (const key in uconfig) {
|
||||||
|
const element = document.querySelector("#configOptionsContainer #" + key);
|
||||||
|
if (element) {
|
||||||
|
if (element.type === 'checkbox') {
|
||||||
|
uconfig[key] = element.checked;
|
||||||
|
} else {
|
||||||
|
if (element.value != null) {
|
||||||
|
uconfig[key] = element.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Saving config:");
|
||||||
|
console.log(this.configCache);
|
||||||
|
ApiClient.updatePluginConfiguration(this.guid, this.configCache).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||||
|
},
|
||||||
|
populateOptionsContainer: async function (userConfig) {
|
||||||
|
const userSettings = await AnilistAPI.getUserSettings(userConfig.Id);
|
||||||
|
console.log("User");
|
||||||
|
console.log(userSettings);
|
||||||
|
AnilistSyncConfig.simklName.innerText = userSettings.data.Viewer.name;
|
||||||
|
|
||||||
|
for (const key in userConfig) {
|
||||||
|
const chk = document.querySelector("#configOptionsContainer input[type=checkbox]#" + key);
|
||||||
|
if (chk) {
|
||||||
|
chk.checked = userConfig[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.querySelector("#configOptionsContainer input[type=number]#" + key);
|
||||||
|
if (input) {
|
||||||
|
input.value = userConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startLoginProcess: async function () {
|
||||||
|
this.onLoginProcess = true;
|
||||||
|
AnilistAPI.openAnilistAuth();
|
||||||
|
|
||||||
|
AnilistSyncConfig.loginText.innerHTML = 'Please log in and authorize the application.';
|
||||||
|
|
||||||
|
AnilistSyncConfig.loginButtonContainer.setAttribute('hidden', '');
|
||||||
|
AnilistSyncConfig.loggingIn.removeAttribute('hidden');
|
||||||
|
},
|
||||||
|
stopLoginProcess: async function () {
|
||||||
|
this.onLoginProcess = false;
|
||||||
|
document.getElementById("AnilistAuthCodeInput").value = "";
|
||||||
|
AnilistSyncConfig.loginButtonContainer.removeAttribute('hidden');
|
||||||
|
AnilistSyncConfig.loggingIn.setAttribute('hidden', '');
|
||||||
|
},
|
||||||
|
submitAuthCode: async function () {
|
||||||
|
const code = document.getElementById("AnilistAuthCodeInput").value;
|
||||||
|
const response = await AnilistAPI.getToken(code)
|
||||||
|
console.log("Response:");
|
||||||
|
console.log(response);
|
||||||
|
|
||||||
|
await this.stopLoginProcess();
|
||||||
|
|
||||||
|
// Save key to plugin config
|
||||||
|
const uguid = AnilistSyncConfig.userSelector.value;
|
||||||
|
const filter = this.configCache.UserConfigs.filter(function (c) {
|
||||||
|
return c.Id === uguid;
|
||||||
|
});
|
||||||
|
if (filter.length > 0) {
|
||||||
|
filter[0].UserToken = response.access_token;
|
||||||
|
} else {
|
||||||
|
this.configCache.UserConfigs.push({
|
||||||
|
Id: uguid,
|
||||||
|
UserToken: response.access_token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(this.configCache);
|
||||||
|
|
||||||
|
ApiClient.updatePluginConfiguration(this.guid, this.configCache);
|
||||||
|
await this.loadConfig(uguid);
|
||||||
|
},
|
||||||
|
onSelectorChange: async function () {
|
||||||
|
if (this.onLoginProcess) {
|
||||||
|
await this.stopLoginProcess();
|
||||||
|
}
|
||||||
|
await this.loadConfig(AnilistSyncConfig.userSelector.value, null);
|
||||||
|
},
|
||||||
|
logOut: function (uguid) {
|
||||||
|
if (uguid == null) {
|
||||||
|
uguid = AnilistSyncConfig.userSelector.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter = this.configCache.UserConfigs.filter(function (c) {
|
||||||
|
return c.Id === uguid;
|
||||||
|
});
|
||||||
|
console.log(filter);
|
||||||
|
|
||||||
|
if (filter.length > 0) {
|
||||||
|
filter[0].UserToken = "";
|
||||||
|
} else {
|
||||||
|
console.log("User not found " + uguid);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(this.configCache);
|
||||||
|
window.ApiClient.updatePluginConfiguration(this.guid, this.configCache);
|
||||||
|
this.loadConfig(uguid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var AnilistAPI = {
|
||||||
|
openAnilistAuth: function () {
|
||||||
|
window.open('https://anilist.co/api/v2/oauth/authorize?client_id=5659&redirect_uri=https://anilist.co/api/v2/oauth/pin&response_type=code')
|
||||||
|
},
|
||||||
|
getToken: function (user_code) {
|
||||||
|
const request = {
|
||||||
|
url: window.ApiClient.getUrl('AnilistSync/oauth/token/' + user_code),
|
||||||
|
type: 'GET',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.ApiClient.fetch(request)
|
||||||
|
.then(function (result) {
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch(function (result) {
|
||||||
|
console.error(result);
|
||||||
|
Dashboard.alert("Some error orccurred, see browser log for more details");
|
||||||
|
AnilistSyncConfig.stopLoginProcess();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getUserSettings: function (secret) {
|
||||||
|
const request = {
|
||||||
|
url: window.ApiClient.getUrl('AnilistSync/users/settings/' + secret),
|
||||||
|
type: 'GET',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.ApiClient.fetch(request)
|
||||||
|
.then(function (result) {
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch(function (result) {
|
||||||
|
console.error(result);
|
||||||
|
Dashboard.alert("Something went wrong, see logs for more details");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('#AnilistSyncConfigurationPage')
|
||||||
|
.addEventListener('pageshow', async function () {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
await Promise.all([
|
||||||
|
window.ApiClient.getUsers().then(AnilistSyncConfig.populateUsers),
|
||||||
|
window.ApiClient.getPluginConfiguration(AnilistSyncConfig.guid).then(AnilistSyncConfig.loadConfig.bind(AnilistSyncConfig, ApiClient.getCurrentUserId()))]);
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#AnilistSyncConfigurationForm')
|
||||||
|
.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
AnilistSyncConfig.saveConfig(AnilistSyncConfig.userSelector.value);
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net5.0</TargetFramework>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<nullable>enable</nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="10.7-*" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="5.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Code Analyzers-->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="5.0.3" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Configuration\configPage.html" />
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<NoWarn>1701;1702;1591;1300</NoWarn>
|
||||||
|
<WarningLevel>4</WarningLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\.editorconfig" Link=".editorconfig" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<PluginConfiguration>, 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<PluginPageInfo> GetPages()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = this.Name,
|
||||||
|
EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Jellyfin.Plugin.AnilistSync.API;
|
||||||
|
using MediaBrowser.Common.Plugins;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.AnilistSync
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RegisterServices(IServiceCollection serviceCollection)
|
||||||
|
{
|
||||||
|
serviceCollection.AddScoped<AnilistApi>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PlaybackScrobbler> _logger;
|
||||||
|
private readonly Dictionary<string, Guid> _lastScrobbled; // Library ID of last scrobbled item
|
||||||
|
private readonly AnilistApi _anilistApi;
|
||||||
|
private DateTime _nextTry;
|
||||||
|
|
||||||
|
public PlaybackScrobbler(ISessionManager sessionManager, ILogger<PlaybackScrobbler> logger, AnilistApi anilistApi)
|
||||||
|
{
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_logger = logger;
|
||||||
|
_anilistApi = anilistApi;
|
||||||
|
_lastScrobbled = new Dictionary<string, Guid>();
|
||||||
|
_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user