Files
AnilistSync/Jellyfin.Plugin.Simkl/API/SimklApi.cs
T
2021-02-24 12:39:47 -07:00

294 lines
12 KiB
C#

using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Net.Mime;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Plugin.Simkl.API.Exceptions;
using Jellyfin.Plugin.Simkl.API.Objects;
using Jellyfin.Plugin.Simkl.API.Responses;
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.Simkl.API
{
/// <summary>
/// Simkl Api.
/// </summary>
public class SimklApi
{
/* INTERFACES */
private readonly ILogger<SimklApi> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/* BASIC API THINGS */
/// <summary>
/// Base url.
/// </summary>
public const string Baseurl = @"https://api.simkl.com";
/// <summary>
/// Redirect uri.
/// </summary>
public const string RedirectUri = @"https://simkl.com/apps/jellyfin/connected/";
/// <summary>
/// Api key.
/// </summary>
public const string Apikey = @"c721b22482097722a84a20ccc579cf9d232be85b9befe7b7805484d0ddbc6781";
/// <summary>
/// Secret.
/// </summary>
public const string Secret = @"87893fc73cdbd2e51a7c63975c6f941ac1c6155c0e20ffa76b83202dd10a507e";
/// <summary>
/// Initializes a new instance of the <see cref="SimklApi"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{SimklApi}"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
public SimklApi(ILogger<SimklApi> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_jsonSerializerOptions = JsonDefaults.GetOptions();
}
/// <summary>
/// Get code.
/// </summary>
/// <returns>Code response.</returns>
public async Task<CodeResponse?> GetCode()
{
var uri = $"/oauth/pin?client_id={Apikey}&redirect={RedirectUri}";
return await Get<CodeResponse>(uri);
}
/// <summary>
/// Get code status.
/// </summary>
/// <param name="userCode">User code.</param>
/// <returns>Code status.</returns>
public async Task<CodeStatusResponse?> GetCodeStatus(string userCode)
{
var uri = $"/oauth/pin/{userCode}?client_id={Apikey}";
return await Get<CodeStatusResponse>(uri);
}
/// <summary>
/// Get user settings.
/// </summary>
/// <param name="userToken">User token.</param>
/// <returns>User settings.</returns>
public async Task<UserSettings?> GetUserSettings(string userToken)
{
try
{
return await Post<UserSettings, object>("/users/settings/", userToken);
}
catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// Wontfix: Custom status codes
// "You don't get to pick your response code" - Luke (System Architect of Emby)
// https://emby.media/community/index.php?/topic/61889-wiki-issue-resultfactorythrowerror/
return new UserSettings { Error = "user_token_failed" };
}
}
/// <summary>
/// Mark as watched.
/// </summary>
/// <param name="item">Item.</param>
/// <param name="userToken">User token.</param>
/// <returns>Status.</returns>
public async Task<(bool Success, BaseItemDto Item)> MarkAsWatched(BaseItemDto item, string userToken)
{
var history = CreateHistoryFromItem(item);
var r = await SyncHistoryAsync(history, userToken);
_logger.LogDebug("Response: {@Response}", r);
if (r != null && history.Movies.Count == r.Added.Movies && history.Shows.Count == r.Added.Shows)
{
return (true, item);
}
// If we are here, is because the item has not been found
// let's try scrobbling from full path
try
{
(history, item) = await GetHistoryFromFileName(item);
}
catch (InvalidDataException)
{
// Let's try again but this time using only the FILE name
_logger.LogDebug("Couldn't scrobble using full path, trying using only filename");
(history, item) = await GetHistoryFromFileName(item, false);
}
r = await SyncHistoryAsync(history, userToken);
return r == null
? (false, item)
: (history.Movies.Count == r.Added.Movies && history.Shows.Count == r.Added.Shows, item);
}
/// <summary>
/// Get from file.
/// </summary>
/// <param name="filename">Filename.</param>
/// <returns>Search file response.</returns>
private async Task<SearchFileResponse?> GetFromFile(string filename)
{
var f = new SimklFile { File = filename };
_logger.LogInformation("Posting: {@File}", f);
return await Post<SearchFileResponse, SimklFile>("/search/file/", null, f);
}
/// <summary>
/// Get history from file name.
/// </summary>
/// <param name="item">Item.</param>
/// <param name="fullpath">Full path.</param>
/// <returns>Srobble history.</returns>
private async Task<(SimklHistory history, BaseItemDto item)> GetHistoryFromFileName(BaseItemDto item, bool fullpath = true)
{
var fname = fullpath ? item.Path : Path.GetFileName(item.Path);
var mo = await GetFromFile(fname);
if (mo == null)
{
throw new InvalidDataException("Search file response is null");
}
var history = new SimklHistory();
if (mo.Movie != null &&
(item.IsMovie == true || string.Equals(item.Type, nameof(Movie), StringComparison.OrdinalIgnoreCase)))
{
if (!string.Equals(mo.Type, "movie", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException("type != movie (" + mo.Type + ")");
}
item.Name = mo.Movie.Title;
item.ProductionYear = mo.Movie.Year;
history.Movies.Add(mo.Movie);
}
else if (mo.Episode != null
&& mo.Show != null
&& (item.IsSeries == true || string.Equals(item.Type, nameof(Episode), StringComparison.OrdinalIgnoreCase)))
{
if (!string.Equals(mo.Type, "episode", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException("type != episode (" + mo.Type + ")");
}
item.Name = mo.Episode.Title;
item.SeriesName = mo.Show.Title;
item.IndexNumber = mo.Episode.Episode;
item.ParentIndexNumber = mo.Episode.Season;
item.ProductionYear = mo.Show.Year;
history.Episodes.Add(mo.Episode);
}
return (history, item);
}
private static HttpRequestMessage GetOptions(string? userToken = null)
{
var requestMessage = new HttpRequestMessage();
requestMessage.Headers.TryAddWithoutValidation("simkl-api-key", Apikey);
if (!string.IsNullOrEmpty(userToken))
{
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken);
}
return requestMessage;
}
private static SimklHistory CreateHistoryFromItem(BaseItemDto item)
{
var history = new SimklHistory();
if (item.IsMovie == true || string.Equals(item.Type, nameof(Movie), StringComparison.OrdinalIgnoreCase))
{
history.Movies.Add(new SimklMovie(item));
}
else if (item.IsSeries == true || string.Equals(item.Type, nameof(Episode), StringComparison.OrdinalIgnoreCase))
{
// TODO: TV Shows scrobbling (WIP)
history.Shows.Add(new SimklShow(item));
}
return history;
}
/// <summary>
/// Implements /sync/history method from simkl.
/// </summary>
/// <param name="history">History object.</param>
/// <param name="userToken">User token.</param>
/// <returns>The sync history response.</returns>
private async Task<SyncHistoryResponse?> SyncHistoryAsync(SimklHistory history, string userToken)
{
try
{
_logger.LogInformation("Syncing History");
return await Post<SyncHistoryResponse, SimklHistory>("/sync/history", userToken, history);
}
catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogError(e, "Invalid user token {UserToken}, deleting", userToken);
SimklPlugin.Instance?.Configuration.DeleteUserToken(userToken);
throw new InvalidTokenException("Invalid user token " + userToken);
}
}
/// <summary>
/// API's private get method, given RELATIVE url and headers.
/// </summary>
/// <param name="url">Relative url.</param>
/// <param name="userToken">Authentication token.</param>
/// <returns>HTTP(s) Stream to be used.</returns>
private async Task<T?> Get<T>(string url, string? userToken = null)
{
// Todo: If string is not null neither empty
using var options = GetOptions(userToken);
options.RequestUri = new Uri(Baseurl + url);
options.Method = HttpMethod.Get;
var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(options);
return await responseMessage.Content.ReadFromJsonAsync<T>(_jsonSerializerOptions);
}
/// <summary>
/// API's private post method.
/// </summary>
/// <param name="url">Relative post url.</param>
/// <param name="userToken">Authentication token.</param>
/// <param name="data">Object to serialize.</param>
private async Task<T1?> Post<T1, T2>(string url, string? userToken = null, T2? data = null)
where T2 : class
{
using var options = GetOptions(userToken);
options.RequestUri = new Uri(Baseurl + url);
options.Method = HttpMethod.Post;
if (data != null)
{
options.Content = new StringContent(
JsonSerializer.Serialize(data, _jsonSerializerOptions),
Encoding.UTF8,
MediaTypeNames.Application.Json);
}
var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(options);
return await responseMessage.Content.ReadFromJsonAsync<T1>(_jsonSerializerOptions);
}
}
}