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 { /// /// Simkl Api. /// public class SimklApi { /* INTERFACES */ private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly JsonSerializerOptions _jsonSerializerOptions; /* BASIC API THINGS */ /// /// Base url. /// public const string Baseurl = @"https://api.simkl.com"; /// /// Redirect uri. /// public const string RedirectUri = @"https://simkl.com/apps/jellyfin/connected/"; /// /// Api key. /// public const string Apikey = @"c721b22482097722a84a20ccc579cf9d232be85b9befe7b7805484d0ddbc6781"; /// /// Secret. /// public const string Secret = @"87893fc73cdbd2e51a7c63975c6f941ac1c6155c0e20ffa76b83202dd10a507e"; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. public SimklApi(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; _httpClientFactory = httpClientFactory; _jsonSerializerOptions = JsonDefaults.GetOptions(); } /// /// Get code. /// /// Code response. public async Task GetCode() { var uri = $"/oauth/pin?client_id={Apikey}&redirect={RedirectUri}"; return await Get(uri); } /// /// Get code status. /// /// User code. /// Code status. public async Task GetCodeStatus(string userCode) { var uri = $"/oauth/pin/{userCode}?client_id={Apikey}"; return await Get(uri); } /// /// Get user settings. /// /// User token. /// User settings. public async Task GetUserSettings(string userToken) { try { return await Post("/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" }; } } /// /// Mark as watched. /// /// Item. /// User token. /// Status. 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); } /// /// Get from file. /// /// Filename. /// Search file response. private async Task GetFromFile(string filename) { var f = new SimklFile { File = filename }; _logger.LogInformation("Posting: {@File}", f); return await Post("/search/file/", null, f); } /// /// Get history from file name. /// /// Item. /// Full path. /// Srobble history. 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; } /// /// Implements /sync/history method from simkl. /// /// History object. /// User token. /// The sync history response. private async Task SyncHistoryAsync(SimklHistory history, string userToken) { try { _logger.LogInformation("Syncing History"); return await Post("/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); } } /// /// API's private get method, given RELATIVE url and headers. /// /// Relative url. /// Authentication token. /// HTTP(s) Stream to be used. private async Task Get(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(_jsonSerializerOptions); } /// /// API's private post method. /// /// Relative post url. /// Authentication token. /// Object to serialize. private async Task Post(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(_jsonSerializerOptions); } } }