Support Jellyfin 10.7

This commit is contained in:
crobibero
2021-02-24 10:29:09 -07:00
parent 1458fd7534
commit 84a1d0934e
35 changed files with 699 additions and 751 deletions
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Plugin.Simkl.API.Objects;
using Jellyfin.Plugin.Simkl.API.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.Simkl.API
{
/// <summary>
/// The simkl endpoints.
/// </summary>
[ApiController]
[Authorize(Policy = "DefaultAuthorization")]
[Route("Simkl")]
public class Endpoints : ControllerBase
{
private readonly SimklApi _simklApi;
/// <summary>
/// Initializes a new instance of the <see cref="Endpoints"/> class.
/// </summary>
/// <param name="simklApi">Instance of the <see cref="SimklApi"/>.</param>
public Endpoints(SimklApi simklApi)
{
_simklApi = simklApi;
}
/// <summary>
/// Gets the oauth pin.
/// </summary>
/// <returns>The oauth pin.</returns>
[HttpGet("oauth/pin")]
public async Task<ActionResult<CodeResponse?>> GetPin()
{
return await _simklApi.GetCode();
}
/// <summary>
/// Gets the status for the code.
/// </summary>
/// <param name="userCode">The user auth code.</param>
/// <returns>The code status response.</returns>
[HttpGet("oauth/pin/{userCode}")]
public async Task<ActionResult<CodeStatusResponse?>> GetPinStatus([FromRoute] string userCode)
{
return await _simklApi.GetCodeStatus(userCode);
}
/// <summary>
/// Gets the settings for the user.
/// </summary>
/// <param name="userId">The user id.</param>
/// <returns>The user settings.</returns>
[HttpGet("users/settings/{userId}")]
public async Task<ActionResult<UserSettings?>> GetUserSettings([FromRoute] Guid userId)
{
var userConfiguration = SimklPlugin.Instance?.Configuration.GetByGuid(userId);
if (userConfiguration == null)
{
return NotFound();
}
return await _simklApi.GetUserSettings(userConfiguration.UserToken);
}
}
}
@@ -16,7 +16,8 @@ namespace Jellyfin.Plugin.Simkl.API.Exceptions
/// Initializes a new instance of the <see cref="InvalidTokenException"/> class.
/// </summary>
/// <param name="msg">The message.</param>
public InvalidTokenException(string msg) : base(msg)
public InvalidTokenException(string msg)
: base(msg)
{
}
@@ -25,7 +26,8 @@ namespace Jellyfin.Plugin.Simkl.API.Exceptions
/// </summary>
/// <param name="msg">The message.</param>
/// <param name="inner">The inner exception.</param>
public InvalidTokenException(string msg, Exception inner) : base(msg, inner)
public InvalidTokenException(string msg, Exception inner)
: base(msg, inner)
{
}
}
-14
View File
@@ -1,14 +0,0 @@
using Jellyfin.Plugin.Simkl.API.Responses;
using MediaBrowser.Model.Services;
namespace Jellyfin.Plugin.Simkl.API
{
/// <summary>
/// Get oauth pin.
/// </summary>
[Route("/Simkl/oauth/pin", "GET")]
public class GetPin : IReturn<CodeResponse>
{
// Doesn't receive anything
}
}
-19
View File
@@ -1,19 +0,0 @@
using Jellyfin.Plugin.Simkl.API.Responses;
using MediaBrowser.Model.Services;
#pragma warning disable SA1300
namespace Jellyfin.Plugin.Simkl.API
{
/// <summary>
/// Get pin status.
/// </summary>
[Route("/Simkl/oauth/pin/{user_code}", "GET")]
public class GetPinStatus : IReturn<CodeStatusResponse>
{
/// <summary>
/// Gets or sets user code.
/// </summary>
[ApiMember(Name = "user_code", Description = "pin to be introduced by the user", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
public string user_code { get; set; }
}
}
@@ -1,22 +0,0 @@
using System;
using Jellyfin.Plugin.Simkl.API.Objects;
using MediaBrowser.Model.Services;
namespace Jellyfin.Plugin.Simkl.API
{
/// <summary>
/// Get user settings.
/// </summary>
[Route("/Simkl/users/settings/{userId}", "GET")]
public class GetUserSettings : IReturn<UserSettings>
{
/// <summary>
/// Gets or sets user id.
/// </summary>
/// <remarks>
/// Note: In the future, when we'll have config for more than one user, we'll use a parameter.
/// </remarks>
[ApiMember(Name = "id", Description = "user id", IsRequired = true, DataType = "Guid", ParameterType = "path", Verb = "GET")]
public Guid UserId { get; set; }
}
}
+9 -3
View File
@@ -1,4 +1,8 @@
namespace Jellyfin.Plugin.Simkl.API.Objects
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
/// <summary>
/// Season.
@@ -8,11 +12,13 @@
/// <summary>
/// Gets or sets the season number.
/// </summary>
public int? number { get; set; }
[JsonPropertyName("number")]
public int? Number { get; set; }
/// <summary>
/// Gets or sets the episodes.
/// </summary>
public ShowEpisode[] episodes { get; set; }
[JsonPropertyName("episodes")]
public IReadOnlyList<ShowEpisode> Episodes { get; set; } = Array.Empty<ShowEpisode>();
}
}
@@ -1,4 +1,6 @@
namespace Jellyfin.Plugin.Simkl.API.Objects
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
/// <summary>
/// Show episode.
@@ -8,7 +10,8 @@
/// <summary>
/// Gets or sets episode number.
/// </summary>
public int? number { get; set; }
[JsonPropertyName("number")]
public int? Number { get; set; }
// TODO: watched_at
}
}
@@ -1,3 +1,4 @@
using System;
using System.Text.Json.Serialization;
#pragma warning disable SA1300
@@ -12,31 +13,30 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// Gets or sets watched at.
/// </summary>
[JsonPropertyName("watched_at")]
public string watched_at { get; set; }
/// <summary>
/// Gets or sets ids.
/// </summary>
public override SimklIds ids { get; set; }
public DateTime? WatchedAt { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
public string title { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the season.
/// </summary>
public int season { get; set; }
[JsonPropertyName("season")]
public int? Season { get; set; }
/// <summary>
/// Gets or sets the episode.
/// </summary>
public int episode { get; set; }
[JsonPropertyName("episode")]
public int? Episode { get; set; }
/// <summary>
/// Gets or sets multipart.
/// </summary>
public bool? multipart { get; set; }
[JsonPropertyName("multipart")]
public bool? Multipart { get; set; }
}
}
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
/// <summary>
@@ -8,16 +10,19 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary>
/// Gets or sets the file.
/// </summary>
public string file { get; set; }
[JsonPropertyName("file")]
public string? File { get; set; }
/// <summary>
/// Gets or sets the part.
/// </summary>
public int? part { get; set; }
[JsonPropertyName("part")]
public int? Part { get; set; }
/// <summary>
/// Gets or sets the hash.
/// </summary>
public string hash { get; set; }
[JsonPropertyName("hash")]
public string? Hash { get; set; }
}
}
@@ -1,6 +1,7 @@
#pragma warning disable CA2227
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
@@ -14,24 +15,27 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// </summary>
public SimklHistory()
{
movies = new List<SimklMovie>();
shows = new List<SimklShow>();
episodes = new List<SimklEpisode>();
Movies = new List<SimklMovie>();
Shows = new List<SimklShow>();
Episodes = new List<SimklEpisode>();
}
/// <summary>
/// Gets or sets list of movies.
/// </summary>
public List<SimklMovie> movies { get; set; }
[JsonPropertyName("movies")]
public List<SimklMovie> Movies { get; set; }
/// <summary>
/// Gets or sets the list of shows.
/// </summary>
public List<SimklShow> shows { get; set; }
[JsonPropertyName("shows")]
public List<SimklShow> Shows { get; set; }
/// <summary>
/// Gets or sets the list of episodes.
/// </summary>
public List<SimklEpisode> episodes { get; set; }
[JsonPropertyName("episodes")]
public List<SimklEpisode> Episodes { get; set; }
}
}
+22 -16
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
@@ -17,52 +18,57 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
{
foreach (var (key, value) in providerIds)
{
if (key.Equals(nameof(simkl), StringComparison.OrdinalIgnoreCase))
if (key.Equals(nameof(Simkl), StringComparison.OrdinalIgnoreCase))
{
simkl = Convert.ToInt32(value, CultureInfo.InvariantCulture);
Simkl = Convert.ToInt32(value, CultureInfo.InvariantCulture);
}
else if (key.Equals(nameof(imdb), StringComparison.OrdinalIgnoreCase))
else if (key.Equals(nameof(Imdb), StringComparison.OrdinalIgnoreCase))
{
imdb = value;
Imdb = value;
}
else if (key.Equals(nameof(slug), StringComparison.OrdinalIgnoreCase))
else if (key.Equals(nameof(Slug), StringComparison.OrdinalIgnoreCase))
{
slug = value;
Slug = value;
}
else if (key.Equals(nameof(netflix), StringComparison.OrdinalIgnoreCase))
else if (key.Equals(nameof(Netflix), StringComparison.OrdinalIgnoreCase))
{
netflix = value;
Netflix = value;
}
else if (key.Equals(nameof(tmdb), StringComparison.OrdinalIgnoreCase))
else if (key.Equals(nameof(Tmdb), StringComparison.OrdinalIgnoreCase))
{
tmdb = value;
Tmdb = value;
}
}
}
/// <summary>
/// Gets or sets simkl.
/// Gets or sets the simkl id.
/// </summary>
public int? simkl { get; set; }
[JsonPropertyName("simkl")]
public int? Simkl { get; set; }
/// <summary>
/// Gets or sets the imdb id.
/// </summary>
public string imdb { get; set; }
[JsonPropertyName("imdb")]
public string? Imdb { get; set; }
/// <summary>
/// Gets or sets the slug.
/// </summary>
public string slug { get; set; }
[JsonPropertyName("slug")]
public string? Slug { get; set; }
/// <summary>
/// Gets or sets the netflix id.
/// </summary>
public string netflix { get; set; }
[JsonPropertyName("netflix")]
public string? Netflix { get; set; }
/// <summary>
/// Gets or sets the TMDb id.
/// </summary>
public string tmdb { get; set; }
[JsonPropertyName("tmdb")]
public string? Tmdb { get; set; }
}
}
@@ -1,13 +1,16 @@
namespace Jellyfin.Plugin.Simkl.API.Objects
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
/// <summary>
/// Simkl media object.
/// </summary>
public abstract class SimklMediaObject
public class SimklMediaObject
{
/// <summary>
/// Gets or sets ids.
/// </summary>
public abstract SimklIds ids { get; set; }
[JsonPropertyName("ids")]
public SimklIds? Ids { get; set; }
}
}
+12 -12
View File
@@ -1,5 +1,5 @@
using System;
using System.Globalization;
using System.Text.Json.Serialization;
using MediaBrowser.Model.Dto;
namespace Jellyfin.Plugin.Simkl.API.Objects
@@ -15,28 +15,28 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <param name="item">The base item dto.</param>
public SimklMovie(BaseItemDto item)
{
title = item.OriginalTitle;
year = item.ProductionYear;
ids = new SimklMovieIds(item.ProviderIds);
watched_at = DateTime.UtcNow.ToString("yyyy-MM-dd HH\\:mm\\:ss", CultureInfo.InvariantCulture);
Title = item.OriginalTitle;
Year = item.ProductionYear;
Ids = new SimklMovieIds(item.ProviderIds);
WatchedAt = DateTime.UtcNow;
}
/// <summary>
/// Gets or sets the movie title.
/// </summary>
public string title { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the year.
/// </summary>
public int? year { get; set; }
/// <inheritdoc />
public override SimklIds ids { get; set; }
[JsonPropertyName("year")]
public int? Year { get; set; }
/// <summary>
/// Gets watched at.
/// Gets or sets watched at.
/// </summary>
public string watched_at { get; }
[JsonPropertyName("watched_at")]
public DateTime? WatchedAt { get; set; }
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
@@ -19,31 +20,37 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary>
/// Gets or sets the tvdb id.
/// </summary>
public int? tvdb { get; set; }
[JsonPropertyName("tvdb")]
public int? Tvdb { get; set; }
/// <summary>
/// Gets or sets the mal id.
/// </summary>
public int? mal { get; set; }
[JsonPropertyName("mal")]
public int? Mal { get; set; }
/// <summary>
/// Gets or sets the anidb id.
/// </summary>
public int? anidb { get; set; }
[JsonPropertyName("anidb")]
public int? Anidb { get; set; }
/// <summary>
/// Gets or sets the hulu id.
/// </summary>
public int? hulu { get; set; }
[JsonPropertyName("hulu")]
public int? Hulu { get; set; }
/// <summary>
/// Gets or sets the crunchyroll id.
/// </summary>
public int? crunchyroll { get; set; }
[JsonPropertyName("crunchyroll")]
public int? Crunchyroll { get; set; }
/// <summary>
/// Gets or sets the movie db id.
/// </summary>
public string moviedb { get; set; }
[JsonPropertyName("moviedb")]
public string? Moviedb { get; set; }
}
}
+16 -16
View File
@@ -1,4 +1,6 @@
using MediaBrowser.Model.Dto;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using MediaBrowser.Model.Dto;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
@@ -13,19 +15,19 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <param name="mediaInfo">The media info.</param>
public SimklShow(BaseItemDto mediaInfo)
{
title = mediaInfo.SeriesName;
ids = new SimklShowIds(mediaInfo.ProviderIds);
year = mediaInfo.ProductionYear;
seasons = new[]
Title = mediaInfo.SeriesName;
Ids = new SimklShowIds(mediaInfo.ProviderIds);
Year = mediaInfo.ProductionYear;
Seasons = new[]
{
new Season
{
number = mediaInfo.ParentIndexNumber,
episodes = new[]
Number = mediaInfo.ParentIndexNumber,
Episodes = new[]
{
new ShowEpisode
{
number = mediaInfo.IndexNumber
Number = mediaInfo.IndexNumber
}
}
}
@@ -35,21 +37,19 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary>
/// Gets or sets title.
/// </summary>
public string title { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
/// <summary>
/// Gets or sets year.
/// </summary>
public int? year { get; set; }
[JsonPropertyName("year")]
public int? Year { get; set; }
/// <summary>
/// Gets or sets seasons.
/// </summary>
public Season[] seasons { get; set; }
/// <summary>
/// Gets or sets ids.
/// </summary>
public override SimklIds ids { get; set; }
[JsonPropertyName("seasons")]
public IReadOnlyList<Season> Seasons { get; set; }
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
@@ -19,31 +20,37 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary>
/// Gets or sets tvdb.
/// </summary>
public int? tvdb { get; set; }
[JsonPropertyName("tvdb")]
public int? Tvdb { get; set; }
/// <summary>
/// Gets or sets mal.
/// </summary>
public int? mal { get; set; }
[JsonPropertyName("mal")]
public int? Mal { get; set; }
/// <summary>
/// Gets or sets anidb.
/// </summary>
public int? anidb { get; set; }
[JsonPropertyName("anidb")]
public int? Anidb { get; set; }
/// <summary>
/// Gets or sets hulu.
/// </summary>
public int? hulu { get; set; }
[JsonPropertyName("hulu")]
public int? Hulu { get; set; }
/// <summary>
/// Gets or sets crunchyroll.
/// </summary>
public int? crunchyroll { get; set; }
[JsonPropertyName("crunchyroll")]
public int? Crunchyroll { get; set; }
/// <summary>
/// Gets or sets zap2it.
/// </summary>
public string zap2It { get; set; }
[JsonPropertyName("zap2It")]
public string? Zap2It { get; set; }
}
}
+5 -2
View File
@@ -1,4 +1,6 @@
#pragma warning disable SA1300
using System.Text.Json.Serialization;
#pragma warning disable SA1300
namespace Jellyfin.Plugin.Simkl.API.Objects
{
@@ -10,6 +12,7 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary>
/// Gets or sets name.
/// </summary>
public string name { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
}
}
@@ -1,4 +1,6 @@
namespace Jellyfin.Plugin.Simkl.API.Objects
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects
{
/// <summary>
/// User settings.
@@ -8,11 +10,13 @@
/// <summary>
/// Gets or sets user.
/// </summary>
public User user { get; set; }
[JsonPropertyName("user")]
public User? User { get; set; }
/// <summary>
/// Gets or sets error.
/// </summary>
public string error { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
}
}
@@ -11,35 +11,35 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary>
/// Gets or sets result.
/// </summary>
public string Result { get; set; }
public string? Result { get; set; }
/// <summary>
/// Gets or sets device code.
/// </summary>
[JsonPropertyName("device_code")]
public string device_code { get; set; }
public string? DeviceCode { get; set; }
/// <summary>
/// Gets or sets user code.
/// </summary>
[JsonPropertyName("user_code")]
public string user_code { get; set; }
public string? UserCode { get; set; }
/// <summary>
/// Gets or sets verification url.
/// </summary>
[JsonPropertyName("verification_url")]
public string verification_url { get; set; }
public string? VerificationUrl { get; set; }
/// <summary>
/// Gets or sets expires in.
/// </summary>
[JsonPropertyName("expires_in")]
public int expires_in { get; set; }
public int? ExpiresIn { get; set; }
/// <summary>
/// Gets or sets interval.
/// </summary>
public int Interval { get; set; }
public int? Interval { get; set; }
}
}
@@ -11,17 +11,17 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary>
/// Gets or sets result.
/// </summary>
public string Result { get; set; }
public string? Result { get; set; }
/// <summary>
/// Gets or sets message.
/// </summary>
public string Message { get; set; }
public string? Message { get; set; }
/// <summary>
/// Gets or sets access token.
/// </summary>
[JsonPropertyName("access_token")]
public string access_token { get; set; }
public string? AccessToken { get; set; }
}
}
@@ -10,21 +10,21 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary>
/// Gets or sets type.
/// </summary>
public string Type { get; set; }
public string? Type { get; set; }
/// <summary>
/// Gets or sets episode.
/// </summary>
public SimklEpisode Episode { get; set; }
public SimklEpisode? Episode { get; set; }
/// <summary>
/// Gets or sets movie.
/// </summary>
public SimklMovie Movie { get; set; }
public SimklMovie? Movie { get; set; }
/// <summary>
/// Gets or sets show.
/// </summary>
public SimklShow Show { get; set; }
public SimklShow? Show { get; set; }
}
}
@@ -1,4 +1,5 @@
using Jellyfin.Plugin.Simkl.API.Objects;
using System;
using Jellyfin.Plugin.Simkl.API.Objects;
namespace Jellyfin.Plugin.Simkl.API.Responses
{
@@ -10,16 +11,16 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary>
/// Gets or sets movies.
/// </summary>
public SimklMovie[] Movies { get; set; }
public SimklMovie[] Movies { get; set; } = Array.Empty<SimklMovie>();
/// <summary>
/// Gets or sets shows.
/// </summary>
public SimklShow[] Shows { get; set; }
public SimklShow[] Shows { get; set; } = Array.Empty<SimklShow>();
/// <summary>
/// Gets or sets episodes.
/// </summary>
public SimklEpisode[] Episodes { get; set; }
public SimklEpisode[] Episodes { get; set; } = Array.Empty<SimklEpisode>();
}
}
@@ -11,12 +11,12 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary>
/// Gets or sets added.
/// </summary>
public SyncHistoryResponseCount Added { get; set; }
public SyncHistoryResponseCount Added { get; set; } = new SyncHistoryResponseCount();
/// <summary>
/// Gets or sets not found.
/// </summary>
[JsonPropertyName("not_found")]
public SyncHistoryNotFound not_found { get; set; }
public SyncHistoryNotFound? NotFound { get; set; }
}
}
@@ -1,75 +0,0 @@
using Jellyfin.Plugin.Simkl.API.Objects;
using Jellyfin.Plugin.Simkl.API.Responses;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Services;
using Microsoft.Extensions.Logging;
#pragma warning disable CA1801
namespace Jellyfin.Plugin.Simkl.API
{
/// <summary>
/// Server endpoints.
/// </summary>
public class ServerEndpoint : IService, IHasResultFactory
{
private readonly SimklApi _api;
private readonly ILogger<ServerEndpoint> _logger;
private readonly IJsonSerializer _json;
/// <summary>
/// Initializes a new instance of the <see cref="ServerEndpoint"/> class.
/// </summary>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
/// <param name="json">Instance of the <see cref="IJsonSerializer"/> interface.</param>
/// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
public ServerEndpoint(ILoggerFactory loggerFactory, IJsonSerializer json, IHttpClient httpClient)
{
_logger = loggerFactory.CreateLogger<ServerEndpoint>();
_json = json;
_api = new SimklApi(json, loggerFactory.CreateLogger<SimklApi>(), httpClient);
}
/// <summary>
/// Gets or sets result factory.
/// </summary>
public IHttpResultFactory ResultFactory { get; set; }
/// <summary>
/// Gets or sets request.
/// </summary>
public IRequest Request { get; set; }
/// <summary>
/// Get pin request.
/// </summary>
/// <param name="request">The request.</param>
/// <returns>The code response.</returns>
public CodeResponse Get(GetPin request)
{
return _api.GetCode().GetAwaiter().GetResult();
}
/// <summary>
/// Get pin status.
/// </summary>
/// <param name="request">The request.</param>
/// <returns>Code status response.</returns>
public CodeStatusResponse Get(GetPinStatus request)
{
return _api.GetCodeStatus(request.user_code).GetAwaiter().GetResult();
}
/// <summary>
/// Get user settings.
/// </summary>
/// <param name="request">The request.</param>
/// <returns>User settings.</returns>
public UserSettings Get(GetUserSettings request)
{
_logger.LogDebug(_json.SerializeToString(request));
return _api.GetUserSettings(SimklPlugin.Instance.Configuration.GetByGuid(request.UserId).UserToken).GetAwaiter().GetResult();
}
}
}
+99 -84
View File
@@ -1,12 +1,21 @@
using System.IO;
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 MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging; // using System.Threading;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Simkl.API
{
@@ -16,9 +25,9 @@ namespace Jellyfin.Plugin.Simkl.API
public class SimklApi
{
/* INTERFACES */
private readonly IJsonSerializer _json;
private readonly ILogger<SimklApi> _logger;
private readonly IHttpClient _httpClient;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/* BASIC API THINGS */
@@ -26,44 +35,43 @@ namespace Jellyfin.Plugin.Simkl.API
/// Base url.
/// </summary>
public const string Baseurl = @"https://api.simkl.com";
// public const string BASE_URL = @"http://private-9c39b-simkl.apiary-proxy.com";
/// <summary>
/// Redirect uri.
/// </summary>
public const string RedirectUri = @"https://simkl.com/apps/emby/connected/";
/// public const string RedirectUri = @"https://simkl.com/apps/jellyfin/connected/";
public const string RedirectUri = @"https://jellyfin.org";
/// <summary>
/// Api key.
/// </summary>
public const string Apikey = @"27dd5d6adc24aa1ad9f95ef913244cbaf6df5696036af577ed41670473dc97d0";
public const string Apikey = @"c721b22482097722a84a20ccc579cf9d232be85b9befe7b7805484d0ddbc6781";
/// <summary>
/// Secret.
/// </summary>
public const string Secret = @"d7b9feb9d48bbaa69dbabaca21ba4671acaa89198637e9e136a4d69ec97ab68b";
public const string Secret = @"87893fc73cdbd2e51a7c63975c6f941ac1c6155c0e20ffa76b83202dd10a507e";
/// <summary>
/// Initializes a new instance of the <see cref="SimklApi"/> class.
/// </summary>
/// <param name="json">Instance of the <see cref="IJsonSerializer"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{SimklApi}"/> interface.</param>
/// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
public SimklApi(IJsonSerializer json, ILogger<SimklApi> logger, IHttpClient httpClient)
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
public SimklApi(ILogger<SimklApi> logger, IHttpClientFactory httpClientFactory)
{
_json = json;
_logger = logger;
_httpClient = httpClient;
_httpClientFactory = httpClientFactory;
_jsonSerializerOptions = JsonDefaults.GetOptions();
}
/// <summary>
/// Get code.
/// </summary>
/// <returns>Code response.</returns>
public async Task<CodeResponse> GetCode()
public async Task<CodeResponse?> GetCode()
{
var uri = $"/oauth/pin?client_id={Apikey}&redirect={RedirectUri}";
return _json.DeserializeFromStream<CodeResponse>(await Get(uri).ConfigureAwait(false));
return await Get<CodeResponse>(uri);
}
/// <summary>
@@ -71,10 +79,10 @@ namespace Jellyfin.Plugin.Simkl.API
/// </summary>
/// <param name="userCode">User code.</param>
/// <returns>Code status.</returns>
public async Task<CodeStatusResponse> GetCodeStatus(string userCode)
public async Task<CodeStatusResponse?> GetCodeStatus(string userCode)
{
var uri = $"/oauth/pin/{userCode}?client_id={Apikey}";
return _json.DeserializeFromStream<CodeStatusResponse>(await Get(uri).ConfigureAwait(false));
return await Get<CodeStatusResponse>(uri);
}
/// <summary>
@@ -82,18 +90,18 @@ namespace Jellyfin.Plugin.Simkl.API
/// </summary>
/// <param name="userToken">User token.</param>
/// <returns>User settings.</returns>
public async Task<UserSettings> GetUserSettings(string userToken)
public async Task<UserSettings?> GetUserSettings(string userToken)
{
try
{
return _json.DeserializeFromStream<UserSettings>(await Post("/users/settings/", userToken).ConfigureAwait(false));
return await Post<UserSettings, object>("/users/settings/", userToken);
}
catch (MediaBrowser.Model.Net.HttpException e) when (e.StatusCode == System.Net.HttpStatusCode.Unauthorized)
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" };
return new UserSettings { Error = "user_token_failed" };
}
}
@@ -103,12 +111,12 @@ namespace Jellyfin.Plugin.Simkl.API
/// <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)
public async Task<(bool Success, BaseItemDto Item)> MarkAsWatched(BaseItemDto item, string userToken)
{
var history = CreateHistoryFromItem(item);
var r = await SyncHistoryAsync(history, userToken).ConfigureAwait(false);
_logger.LogDebug("Response: " + _json.SerializeToString(r));
if (history.movies.Count == r.Added.Movies && history.shows.Count == r.Added.Shows)
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);
}
@@ -117,19 +125,19 @@ namespace Jellyfin.Plugin.Simkl.API
// let's try scrobbling from full path
try
{
(history, item) = await GetHistoryFromFileName(item).ConfigureAwait(false);
(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).ConfigureAwait(false);
(history, item) = await GetHistoryFromFileName(item, false);
}
r = await SyncHistoryAsync(history, userToken).ConfigureAwait(false);
_logger.LogDebug("Response: " + _json.SerializeToString(r));
return (history.movies.Count == r.Added.Movies && history.shows.Count == r.Added.Shows, item);
r = await SyncHistoryAsync(history, userToken);
return r == null
? (false, item)
: (history.Movies.Count == r.Added.Movies && history.Shows.Count == r.Added.Shows, item);
}
/// <summary>
@@ -137,14 +145,11 @@ namespace Jellyfin.Plugin.Simkl.API
/// </summary>
/// <param name="filename">Filename.</param>
/// <returns>Search file response.</returns>
private async Task<SearchFileResponse> GetFromFile(string filename)
private async Task<SearchFileResponse?> GetFromFile(string filename)
{
var f = new SimklFile { file = filename };
_logger.LogInformation("Posting: " + _json.SerializeToString(f));
using var r = new StreamReader(await Post("/search/file/", null, f).ConfigureAwait(false));
var t = await r.ReadToEndAsync().ConfigureAwait(false);
_logger.LogDebug("Response: " + t);
return _json.DeserializeFromString<SearchFileResponse>(t);
var f = new SimklFile { File = filename };
_logger.LogInformation("Posting: {@File}", f);
return await Post<SearchFileResponse, SimklFile>("/search/file/", null, f);
}
/// <summary>
@@ -156,68 +161,69 @@ namespace Jellyfin.Plugin.Simkl.API
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).ConfigureAwait(false);
var mo = await GetFromFile(fname);
if (mo == null)
{
throw new InvalidDataException("Search file response is null");
}
var history = new SimklHistory();
if (item.IsMovie == true || item.Type == "Movie")
if (mo.Movie != null &&
(item.IsMovie == true || string.Equals(item.Type, nameof(Movie), StringComparison.OrdinalIgnoreCase)))
{
if (mo.Type != "movie")
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);
item.Name = mo.Movie.Title;
item.ProductionYear = mo.Movie.Year;
history.Movies.Add(mo.Movie);
}
else if (item.IsSeries == true || item.Type == "Episode")
else if (mo.Episode != null
&& mo.Show != null
&& (item.IsSeries == true || string.Equals(item.Type, nameof(Episode), StringComparison.OrdinalIgnoreCase)))
{
if (mo.Type != "episode")
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);
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 HttpRequestOptions GetOptions(string userToken = null)
private static HttpRequestMessage GetOptions(string? userToken = null)
{
var options = new HttpRequestOptions
{
RequestContentType = "application/json",
LogErrorResponseBody = true,
EnableDefaultUserAgent = true
};
options.RequestHeaders.Add("simkl-api-key", Apikey);
// options.RequestHeaders.Add("Content-Type", "application/json");
var requestMessage = new HttpRequestMessage();
requestMessage.Headers.TryAddWithoutValidation("simkl-api-key", Apikey);
if (!string.IsNullOrEmpty(userToken))
{
options.RequestHeaders.Add("Authorization", "Bearer " + userToken);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken);
}
return options;
return requestMessage;
}
private static SimklHistory CreateHistoryFromItem(BaseItemDto item)
{
var history = new SimklHistory();
if (item.IsMovie == true || item.Type == "Movie")
if (item.IsMovie == true || string.Equals(item.Type, nameof(Movie), StringComparison.OrdinalIgnoreCase))
{
history.movies.Add(new SimklMovie(item));
history.Movies.Add(new SimklMovie(item));
}
else if (item.IsSeries == true || item.Type == "Episode")
else if (item.IsSeries == true || string.Equals(item.Type, nameof(Episode), StringComparison.OrdinalIgnoreCase))
{
// TODO: TV Shows scrobbling (WIP)
history.shows.Add(new SimklShow(item));
history.Shows.Add(new SimklShow(item));
}
return history;
@@ -229,17 +235,17 @@ namespace Jellyfin.Plugin.Simkl.API
/// <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)
private async Task<SyncHistoryResponse?> SyncHistoryAsync(SimklHistory history, string userToken)
{
try
{
_logger.LogInformation("Syncing History: " + _json.SerializeToString(history));
return _json.DeserializeFromStream<SyncHistoryResponse>(await Post("/sync/history", userToken, history).ConfigureAwait(false));
_logger.LogInformation("Syncing History");
return await Post<SyncHistoryResponse, SimklHistory>("/sync/history", userToken, history);
}
catch (MediaBrowser.Model.Net.HttpException e) when (e.StatusCode == System.Net.HttpStatusCode.Unauthorized)
catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogError("Invalid user token " + userToken + ", deleting");
SimklPlugin.Instance.Configuration.DeleteUserToken(userToken);
_logger.LogError(e, "Invalid user token {UserToken}, deleting", userToken);
SimklPlugin.Instance?.Configuration.DeleteUserToken(userToken);
throw new InvalidTokenException("Invalid user token " + userToken);
}
}
@@ -250,13 +256,15 @@ namespace Jellyfin.Plugin.Simkl.API
/// <param name="url">Relative url.</param>
/// <param name="userToken">Authentication token.</param>
/// <returns>HTTP(s) Stream to be used.</returns>
private async Task<Stream> Get(string url, string userToken = null)
private async Task<T?> Get<T>(string url, string? userToken = null)
{
// Todo: If string is not null neither empty
var options = GetOptions(userToken);
options.Url = Baseurl + url;
return await _httpClient.Get(options).ConfigureAwait(false);
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>
@@ -265,16 +273,23 @@ namespace Jellyfin.Plugin.Simkl.API
/// <param name="url">Relative post url.</param>
/// <param name="userToken">Authentication token.</param>
/// <param name="data">Object to serialize.</param>
private async Task<Stream> Post(string url, string userToken = null, object data = null)
private async Task<T1?> Post<T1, T2>(string url, string? userToken = null, T2? data = null)
where T2 : class
{
var options = GetOptions(userToken);
options.Url = Baseurl + url;
using var options = GetOptions(userToken);
options.RequestUri = new Uri(Baseurl + url);
options.Method = HttpMethod.Post;
if (data != null)
{
options.RequestContent = _json.SerializeToString(data);
options.Content = new StringContent(
JsonSerializer.Serialize(data, _jsonSerializerOptions),
Encoding.UTF8,
MediaTypeNames.Application.Json);
}
return (await _httpClient.Post(options).ConfigureAwait(false)).Content;
var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(options);
return await responseMessage.Content.ReadFromJsonAsync<T1>(_jsonSerializerOptions);
}
}
}
@@ -27,7 +27,7 @@ namespace Jellyfin.Plugin.Simkl.Configuration
/// </summary>
/// <param name="id">The user id.</param>
/// <returns>Stored user config.</returns>
public UserConfig GetByGuid(Guid id)
public UserConfig? GetByGuid(Guid id)
{
return UserConfigs.FirstOrDefault(c => c.Id == id);
}
@@ -46,7 +46,7 @@ namespace Jellyfin.Plugin.Simkl.Configuration
}
}
SimklPlugin.Instance.SaveConfiguration();
SimklPlugin.Instance?.SaveConfiguration();
}
}
}
@@ -4,52 +4,61 @@
<title>Simkl's TV Tracker</title>
</head>
<body>
<div data-role="page" class="page type-interior pluginConfigurationPage" id="SimklConfigurationPage" data-require="emby-button,emby-checkbox,emby-input,emby-select">
<div data-role="page" class="page type-interior pluginConfigurationPage" id="SimklConfigurationPage"
data-require="emby-button,emby-checkbox,emby-input,emby-select">
<div data-role="content">
<div class="content-primary">
<h1>Simkl's TV Tracker</h1>
<form id="SimklConfigurationForm">
<div id="selectContainer">
<select onchange="SimklConfig.onSelectorChange();" is="emby-select" id="user-selector" label="Showing plugin settings for...">
<select onchange="SimklConfig.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="SimklConfig.startLoginProcess();" is="emby-button" type="button" class="raised button-submit block"><span>Log In</span></button>
<button onclick="location.href='https://simkl.com/';" is="emby-button" type="button" class="raised block"><span>Create an account</span></button>
<button onclick="SimklConfig.startLoginProcess();" is="emby-button" type="button"
class="raised button-submit block"><span>Log In</span></button>
<button onclick="location.href='https://simkl.com/';" 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>
<h3 id="loginPin"></h3>
<span id="loginSecondsRemaining">900</span> seconds remaining
<button onclick="SimklConfig.stopLoginProcess();" is="emby-button" type="button" class="raised button-cancel block"><span>Cancel</span></button>
<button onclick="SimklConfig.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="SimklConfig.logOut();" is="emby-button" type="button" class="raised button block"><span>Log Out</span></button>
<button onclick="SimklConfig.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" />
<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" />
<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:" />
<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>${ButtonSave}</span></button>
<button is="emby-button" type="button" class="raised block" onclick="history.back();"><span>${Cancel}</span></button>
<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>
@@ -57,42 +66,57 @@
<script type="text/javascript">
var SimklConfig = {
guid: "07CAEF58-A94B-4211-A62C-F9774E04EBDB",
onLoginProccess: false,
onLoginProcess: false,
configCache: [],
loginTimer: null,
remainingTimer: null,
finish: null,
populateUsers : async function (users) {
users.forEach(function(user) {
$("#user-selector").append(new Option(user.Name, user.Id));
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) {
SimklConfig.userSelector.append(new Option(user.Name, user.Id));
});
},
loadConfig : async function(user, config) {
if (config != null) this.configCache = config;
else config = this.configCache;
loadConfig: async function (user, config) {
if (config != null) {
this.configCache = config;
} else {
config = this.configCache;
}
console.log("Simkl: Loading config for user " + user);
console.log(config);
$("#loginButtonContainer").hide();
$("#configOptionsContainer").hide();
SimklConfig.loginButtonContainer.setAttribute('hidden', '');
SimklConfig.configOptionsContainer.setAttribute('hidden', '')
if (config.UserConfigs.some(e => e.Id === user && e.UserToken != null && e.UserToken !== "")) {
$("#configOptionsContainer").show();
this.populateOptionsContainer(config.UserConfigs.filter(e => e.Id === user)[0]);
SimklConfig.configOptionsContainer.removeAttribute('hidden');
await this.populateOptionsContainer(config.UserConfigs.filter(e => e.Id === user)[0]);
} else {
$("#loginButtonContainer").show();
SimklConfig.loginButtonContainer.removeAttribute('hidden');
}
},
saveConfig : async function(guid) {
var uconfig = this.configCache.UserConfigs.filter(e => e.Id === guid)[0];
saveConfig: async function (guid) {
const uconfig = this.configCache.UserConfigs.filter(e => e.Id === guid)[0];
for (var key in uconfig) {
var element = $("#configOptionsContainer #"+key);
if (element.is(":checkbox")) {
uconfig[key] = element.is(':checked');
for (const key in uconfig) {
const element = document.querySelector("#configOptionsContainer #" + key);
if (element.type === 'checkbox') {
uconfig[key] = element.checked;
} else {
if (element.val() != null) uconfig[key] = element.val();
if (element.value != null) {
uconfig[key] = element.value;
}
}
}
@@ -100,51 +124,59 @@
console.log(this.configCache);
ApiClient.updatePluginConfiguration(this.guid, this.configCache).then(Dashboard.processPluginConfigurationUpdateResult);
},
populateOptionsContainer : async function(userConfig) {
$("#simklName").html(SimklAPI.getUserSettings(userConfig.Id).user.name);
populateOptionsContainer: async function (userConfig) {
SimklConfig.simklName.innerText = (await SimklAPI.getUserSettings(userConfig.Id)).user.name;
for (var key in userConfig) {
for (const key in userConfig) {
console.log(key, userConfig[key]);
$("#configOptionsContainer input[type=checkbox]#"+key).attr("checked", userConfig[key]);
$("#configOptionsContainer input[type=number]#"+key).val(userConfig[key]);
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 () {
startLoginProcess: async function () {
this.onLoginProcess = true;
var code = SimklAPI.getCode();
const code = await SimklAPI.getCode();
console.log(code);
this.finish = new Date();
this.finish.setSeconds(this.finish.getSeconds() + code.expires_in);
this.nextInterval = new Date();
this.loginTimer = window.setTimeout(this.checkLoginProcess.bind(this,code), code.Interval*1000);
this.remainingTimer = window.setInterval(function() {
$("#loginSecondsRemaining").html(Math.round((SimklConfig.finish.getTime() - (new Date().getTime()))/1000));
} ,1000);
this.loginTimer = window.setTimeout(this.checkLoginProcess.bind(this, code), code.Interval * 1000);
this.remainingTimer = window.setInterval(function () {
SimklConfig.loginSecondsRemaining.innerText = Math.round((SimklConfig.finish.getTime() - (new Date().getTime())) / 1000);
}, 1000);
$("#loginText").html("Please visit <a href='" + code.verification_url + "/" + code.user_code +
"' target='_blank'>" + code.verification_url + "</a> on your phone or computer and enter the following code:");
$("#loginPin").html(code.user_code);
SimklConfig.loginText.innerHTML = 'Please visit <a href="' + code.verification_url + '/' + code.user_code + '" target="_blank">' + code.verification_url + '</a> on your phone or compouter and enter the following code:';
$("#loginButtonContainer").hide();
await $("#loggingIn").show();
SimklConfig.loginPin.innerText = code.user_code;
SimklConfig.loginButtonContainer.setAttribute('hidden', '');
SimklConfig.loggingIn.removeAttribute('hidden');
},
checkLoginProcess : function (code) {
var response = SimklAPI.checkCode(code.user_code);
checkLoginProcess: async function (code) {
const response = await SimklAPI.checkCode(code.user_code);
console.log("Response:");
console.log(response);
if (new Date() > this.finish) {
Dashboard.alert("Timed out!");
this.stopLoginProcess();
await this.stopLoginProcess();
} else if (response.Result === "KO") {
this.loginTimer = window.setTimeout(this.checkLoginProcess.bind(this,code), code.Interval*1000);
this.loginTimer = window.setTimeout(this.checkLoginProcess.bind(this, code), code.Interval * 1000);
} else if (response.Result === "OK") {
this.stopLoginProcess();
await this.stopLoginProcess();
// Save key on settings
var uguid = $("#user-selector").val();
var filter = this.configCache.UserConfigs.filter(function(c) {
const uguid = SimklConfig.userSelector.value;
const filter = this.configCache.UserConfigs.filter(function (c) {
return c.Id === uguid;
});
if (filter.length > 0) {
@@ -159,26 +191,29 @@
console.log(this.configCache);
ApiClient.updatePluginConfiguration(this.guid, this.configCache);
this.loadConfig(uguid);
await this.loadConfig(uguid);
} else {
Dashboard.alert("Error logging in");
}
},
stopLoginProcess : async function () {
stopLoginProcess: async function () {
this.onLoginProcess = false;
window.clearTimeout(this.loginTimer);
window.clearInterval(this.remainingTimer);
$("#loginButtonContainer").show();
$("#loggingIn").hide();
SimklConfig.loginButtonContainer.removeAttribute('hidden');
SimklConfig.loggingIn.setAttribute('hidden', '');
},
onSelectorChange : async function () {
if (this.onLoginProcess) this.stopLoginProcess();
this.loadConfig($("#user-selector").val(), null);
onSelectorChange: async function () {
if (this.onLoginProcess) {
await this.stopLoginProcess();
}
await this.loadConfig(SimklConfig.userSelector.value, null);
},
logOut : function(uguid) {
if (uguid == null) uguid = $("#user-selector").val();
logOut: function (uguid) {
if (uguid == null) {
uguid = SimklConfig.userSelector.value;
}
// e1f34a57cb3c4767ab6f29cc5c7c0566
var filter = this.configCache.UserConfigs.filter(function (c) {
return c.Id === uguid;
});
@@ -191,70 +226,87 @@
}
console.log(this.configCache);
ApiClient.updatePluginConfiguration(this.guid, this.configCache);
window.ApiClient.updatePluginConfiguration(this.guid, this.configCache);
this.loadConfig(uguid);
}
}
var SimklAPI = {
getCode: function () {
var uri = "/Simkl/oauth/pin";
var request = new XMLHttpRequest();
request.open("GET", uri, false);
request.send();
if (request.status === 200) {
return $.parseJSON(request.response);
} else {
console.log(request);
Dashboard.alert("Some error occurred, see browser log for more details");
SimklConfig.stopLoginProcess();
const request = {
url: window.ApiClient.getUrl('Simkl/oauth/pin'),
type: 'GET',
headers: {
accept: 'application/json'
}
}
return window.ApiClient.fetch(request)
.then(function (result) {
return result;
})
.catch(function (result) {
console.log(result);
Dashboard.alert("Some error occurred, see browser log for more details");
SimklConfig.stopLoginProcess();
});
},
checkCode : function (user_code) {
var uri = "/Simkl/oauth/pin/" + user_code;
var request = new XMLHttpRequest();
request.open("GET", uri, false);
request.send();
if (request.status === 200) {
return $.parseJSON(request.response);
} else {
console.log(request);
Dashboard.alert("Some error occurred, see browser log for more details");
SimklConfig.stopLoginProcess();
checkCode: function (user_code) {
const request = {
url: window.ApiClient.getUrl('Simkl/oauth/pin/' + user_code),
type: 'GET',
headers: {
accept: 'application/json'
}
}
return window.ApiClient.fetch(request)
.then(function (result) {
return result;
})
.catch(function (result) {
console.log(result);
Dashboard.alert("Some error occurred, see browser log for more details");
SimklConfig.stopLoginProcess();
});
},
getUserSettings: function (secret) {
var uri = "/Simkl/users/settings/" + secret;
var request = new XMLHttpRequest();
request.open("GET", uri, false);
request.send();
if (request.status === 200) {
return $.parseJSON(request.response);
} else {
console.log(request);
Dashboard.alert("Something went wrong, see logs for more details");
const request = {
url: window.ApiClient.getUrl('Simkl/users/settings/' + secret),
type: 'GET',
headers: {
accept: 'application/json'
}
}
return window.ApiClient.fetch(request)
.then(function (result) {
return result;
})
.catch(function (result) {
console.log(result);
Dashboard.alert("Something went wrong, see logs for more details");
});
}
}
$("#SimklConfigurationPage").on("pageshow", async function(e) {
Dashboard.showLoadingMsg();
await Promise.all([
ApiClient.getUsers().then(SimklConfig.populateUsers),
ApiClient.getPluginConfiguration(SimklConfig.guid).then(SimklConfig.loadConfig.bind(SimklConfig, ApiClient.getCurrentUserId()))]);
Dashboard.hideLoadingMsg();
});
document.querySelector('#SimklConfigurationPage')
.addEventListener('pageshow', async function () {
Dashboard.showLoadingMsg();
await Promise.all([
window.ApiClient.getUsers().then(SimklConfig.populateUsers),
window.ApiClient.getPluginConfiguration(SimklConfig.guid).then(SimklConfig.loadConfig.bind(SimklConfig, ApiClient.getCurrentUserId()))]);
Dashboard.hideLoadingMsg();
});
$("#SimklConfigurationForm").on("submit", function(e) {
Dashboard.showLoadingMsg();
SimklConfig.saveConfig($("#user-selector").val());
Dashboard.hideLoadingMsg();
document.querySelector('#SimklConfigurationForm')
.addEventListener('submit', function (e) {
e.preventDefault();
return false;
});
Dashboard.showLoadingMsg();
SimklConfig.saveConfig(SimklConfig.userSelector.value);
Dashboard.hideLoadingMsg();
});
</script>
</div>
</body>
@@ -1,21 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>CA1707;CA1819;SA1300</NoWarn>
<nullable>enable</nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.6-*" />
<PackageReference Include="Jellyfin.Controller" Version="10.7-*" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.*" />
</ItemGroup>
<!-- Code Analyzers-->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="5.0.3" PrivateAssets="All" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
@@ -28,5 +29,9 @@
<PropertyGroup>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
using Jellyfin.Plugin.Simkl.API;
using MediaBrowser.Common.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Simkl
{
/// <inheritdoc />
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection)
{
serviceCollection.AddScoped<SimklApi>();
}
}
}
@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Jellyfin.Plugin.Simkl.API;
using Jellyfin.Plugin.Simkl.API.Exceptions;
using Jellyfin.Plugin.Simkl.Configuration;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Simkl.Services
{
/// <summary>
/// Playback progress scrobbler.
/// </summary>
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 SimklApi _simklApi;
private DateTime _nextTry;
/// <summary>
/// Initializes a new instance of the <see cref="PlaybackScrobbler"/> class.
/// </summary>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{Scrobbler}"/> interface.</param>
/// <param name="simklApi">Instance of the <see cref="SimklApi"/>.</param>
public PlaybackScrobbler(
ISessionManager sessionManager,
ILogger<PlaybackScrobbler> logger,
SimklApi simklApi)
{
_sessionManager = sessionManager;
_logger = logger;
_simklApi = simklApi;
_lastScrobbled = new Dictionary<string, Guid>();
_nextTry = DateTime.UtcNow;
}
/// <inheritdoc />
public Task RunAsync()
{
_sessionManager.PlaybackProgress += OnPlaybackProgress;
_sessionManager.PlaybackStopped += OnPlaybackStopped;
return Task.CompletedTask;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose.
/// </summary>
/// <param name="disposing">Dispose all resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
}
}
private static bool CanBeScrobbled(UserConfig config, SessionInfo session, BaseItemDto mediaInfo, bool playedToCompletion)
{
if (!playedToCompletion)
{
if (session.NowPlayingItem.RunTimeTicks != null)
{
var percentageWatched = session.PlayState.PositionTicks / (float)session.NowPlayingItem.RunTimeTicks * 100f;
// If percentage watched is below minimum, can't scrobble
if (percentageWatched < config.ScrobblePercentage)
{
return false;
}
}
// If it's below minimum length, can't scrobble
if (session.NowPlayingItem.RunTimeTicks < 60 * 10000 * config.MinLength)
{
return false;
}
}
return mediaInfo.Type switch
{
nameof(Movie) => config.ScrobbleMovies,
nameof(Episode) => config.ScrobbleShows,
_ => false
};
}
private async void OnPlaybackProgress(object? sessions, PlaybackProgressEventArgs e)
{
if (DateTime.UtcNow < _nextTry)
{
return;
}
_nextTry = DateTime.UtcNow.AddSeconds(30);
await ScrobbleSession(e, false);
}
private async void OnPlaybackStopped(object? sessions, PlaybackStopEventArgs e)
{
await ScrobbleSession(e, e.PlayedToCompletion);
}
private async Task ScrobbleSession(PlaybackProgressEventArgs eventArgs, bool playedToCompletion)
{
try
{
var userId = eventArgs.Session.UserId;
var userConfig = SimklPlugin.Instance?.Configuration.GetByGuid(userId);
if (userConfig == null || string.IsNullOrEmpty(userConfig.UserToken))
{
_logger.LogError(
"Can't scrobble: User {UserName} not logged in ({UserConfigStatus})",
eventArgs.Session.UserName,
userConfig == null);
return;
}
if (!CanBeScrobbled(userConfig, eventArgs.Session, eventArgs.MediaInfo, playedToCompletion))
{
return;
}
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;
}
_logger.LogInformation(
"Trying to scrobble {Name} ({NowPlayingId}) for {UserName} ({UserId}) - {PlayingItemPath} on {SessionId}",
eventArgs.MediaInfo.Name,
eventArgs.MediaInfo.Id,
eventArgs.Session.UserName,
userId,
eventArgs.MediaInfo.Path,
eventArgs.Session.Id);
var response = await _simklApi.MarkAsWatched(eventArgs.MediaInfo, userConfig.UserToken);
if (response.Success)
{
_logger.LogDebug("Scrobbled without errors");
_lastScrobbled[eventArgs.Session.Id] = eventArgs.MediaInfo.Id;
}
}
catch (InvalidTokenException)
{
_logger.LogDebug("Deleted user token");
}
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");
}
}
}
}
-206
View File
@@ -1,206 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Simkl.API;
using Jellyfin.Plugin.Simkl.API.Exceptions;
using Jellyfin.Plugin.Simkl.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Notifications;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Simkl.Services
{
/// <inheritdoc />
public class Scrobbler : IServerEntryPoint
{
private readonly ISessionManager _sessionManager; // Needed to set up de startPlayBack and endPlayBack functions
private readonly ILogger<Scrobbler> _logger;
private readonly IJsonSerializer _json;
private readonly INotificationManager _notifications;
private readonly Dictionary<string, Guid> _lastScrobbled; // Library ID of last scrobbled item
private SimklApi _api;
private DateTime _nextTry;
/// <summary>
/// Initializes a new instance of the <see cref="Scrobbler"/> class.
/// </summary>
/// <param name="json">Instance of the <see cref="IJsonSerializer"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="loggerFactory">Instance of the <see cref="ILogger{Scrobbler}"/> interface.</param>
/// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
/// <param name="notifications">Instance of the <see cref="INotificationManager"/> interface.</param>
public Scrobbler(
IJsonSerializer json,
ISessionManager sessionManager,
ILoggerFactory loggerFactory,
IHttpClient httpClient,
INotificationManager notifications)
{
_json = json;
_sessionManager = sessionManager;
_logger = loggerFactory.CreateLogger<Scrobbler>();
_notifications = notifications;
_api = new SimklApi(json, loggerFactory.CreateLogger<SimklApi>(), httpClient);
_lastScrobbled = new Dictionary<string, Guid>();
_nextTry = DateTime.UtcNow;
}
/// <inheritdoc />
public Task RunAsync()
{
_sessionManager.PlaybackProgress += OnPlaybackProgress;
return Task.CompletedTask;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose.
/// </summary>
/// <param name="disposing">Dispose all resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
_api = null;
}
}
/// <summary>
/// Session can be scrobbled.
/// </summary>
/// <param name="config">The plugin configuration.</param>
/// <param name="session">The session.</param>
/// <returns>If session can be scrobbled.</returns>
private static bool CanBeScrobbled(UserConfig config, SessionInfo session)
{
if (session.NowPlayingItem.RunTimeTicks != null)
{
var percentageWatched = session.PlayState.PositionTicks / (float)session.NowPlayingItem.RunTimeTicks * 100f;
// If percentage watched is below minimum, can't scrobble
if (percentageWatched < config.ScrobblePercentage)
{
return false;
}
}
// If it's below minimum length, can't scrobble
if (session.NowPlayingItem.RunTimeTicks < 60 * 10000 * config.MinLength)
{
return false;
}
var item = session.FullNowPlayingItem;
return item switch
{
Movie _ => config.ScrobbleMovies,
Episode _ => config.ScrobbleShows,
_ => false
};
}
private bool CanSendNotification(BaseItemDto item)
{
if (item.IsMovie == true || item.Type == "Movie")
{
return _notifications.GetNotificationTypes().Any(t => t.Type == SimklNotificationsFactory.NotificationMovieType && t.Enabled);
}
if (item.IsSeries == true || item.Type == "Episode")
{
return _notifications.GetNotificationTypes().Any(t => t.Type == SimklNotificationsFactory.NotificationShowType && t.Enabled);
}
return false;
}
private async void OnPlaybackProgress(object sessions, PlaybackProgressEventArgs e)
{
var sid = e.PlaySessionId;
Guid uid = e.Session.UserId, npid = e.Session.NowPlayingItem.Id;
try
{
if (DateTime.UtcNow < _nextTry)
{
return;
}
_nextTry = DateTime.UtcNow.AddSeconds(30);
var userConfig = SimklPlugin.Instance.Configuration.GetByGuid(uid);
if (userConfig == null || string.IsNullOrEmpty(userConfig.UserToken))
{
_logger.LogError("Can't scrobble: User " + e.Session.UserName + " not logged in (" + (userConfig == null) + ")");
return;
}
if (!CanBeScrobbled(userConfig, e.Session))
{
return;
}
if (_lastScrobbled.ContainsKey(sid) && _lastScrobbled[sid] == npid)
{
_logger.LogDebug("Already scrobbled {0} for {1}", e.Session.NowPlayingItem.Name, e.Session.UserName);
return;
}
_logger.LogDebug(_json.SerializeToString(e.Session.NowPlayingItem));
_logger.LogInformation(
"Trying to scrobble {0} ({1}) for {2} ({3}) - {4} on {5}",
e.Session.NowPlayingItem.Name,
npid,
e.Session.UserName,
uid,
e.Session.NowPlayingItem.Path,
sid);
var response = await _api.MarkAsWatched(e.MediaInfo, userConfig.UserToken).ConfigureAwait(false);
if (response.success)
{
_logger.LogDebug("Scrobbled without errors");
_lastScrobbled[sid] = npid;
if (CanSendNotification(response.item))
{
await _notifications.SendNotification(
SimklNotificationsFactory.GetNotificationRequest(response.item, e.Session.UserId),
e.Session.FullNowPlayingItem,
CancellationToken.None)
.ConfigureAwait(false);
}
}
}
catch (InvalidTokenException)
{
_logger.LogDebug("Deleted user token");
}
catch (InvalidDataException ex)
{
_logger.LogError(ex, "Couldn't scrobble.");
_lastScrobbled[sid] = npid;
}
catch (Exception ex)
{
_logger.LogError(ex, "Caught unknown exception while trying to scrobble.");
}
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Notifications;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Notifications;
namespace Jellyfin.Plugin.Simkl.Services
{
/// <inheritdoc />
public class SimklNotificationsFactory : INotificationTypeFactory
{
/// <summary>
/// Notification category.
/// </summary>
public const string NotificationCategory = "Simkl Scrobbling";
/// <summary>
/// Notification movie type.
/// </summary>
public const string NotificationMovieType = "SimklScrobblingMovie";
/// <summary>
/// Notification show type.
/// </summary>
public const string NotificationShowType = "SimklScrobblingShow";
/// <inheritdoc />
public IEnumerable<NotificationTypeInfo> GetNotificationTypes()
{
yield return new NotificationTypeInfo
{
Type = NotificationMovieType,
Name = "Scrobbling Movie",
Category = NotificationCategory,
Enabled = true,
IsBasedOnUserEvent = false
};
yield return new NotificationTypeInfo
{
Type = NotificationShowType,
Name = "Scrobbling TV Show",
Category = NotificationCategory,
Enabled = true,
IsBasedOnUserEvent = false
};
}
/// <summary>
/// Get notification request.
/// </summary>
/// <param name="item">Item.</param>
/// <param name="userId">USer id.</param>
/// <returns>The notification request.</returns>
public static NotificationRequest GetNotificationRequest(BaseItemDto item, Guid userId)
{
var nr = new NotificationRequest
{
Date = DateTime.UtcNow,
UserIds = new[] { userId },
SendToUserMode = SendToUserType.Custom
};
// TODO: Set url parameter to simkl's movie url
if (item.IsMovie == true || item.Type == "Movie")
{
nr.NotificationType = NotificationMovieType;
nr.Name = "Movie Scrobbled to Simkl";
nr.Description = "The movie " + item.Name;
nr.Description += " has been scrobbled to your account";
}
if (item.IsSeries == true || item.Type == "Episode")
{
nr.NotificationType = NotificationShowType;
nr.Name = "Episode Scrobbled to Simkl";
nr.Description = item.SeriesName;
nr.Description += " S" + item.ParentIndexNumber + ":E" + item.IndexNumber;
nr.Description += " - " + item.Name;
nr.Description += " has been scrobbled to your account";
}
return nr;
}
}
}
+2 -2
View File
@@ -27,13 +27,13 @@ namespace Jellyfin.Plugin.Simkl
/// <summary>
/// Gets the current instance of the plugin.
/// </summary>
public static SimklPlugin Instance { get; private set; }
public static SimklPlugin? Instance { get; private set; }
/// <inheritdoc />
public override Guid Id => new Guid("07CAEF58-A94B-4211-A62C-F9774E04EBDB");
/// <inheritdoc />
public override string Name => "Simkl TV Tracker";
public override string Name => "Simkl";
/// <inheritdoc />
public override string Description => "Scrobble your watched Movies, TV Shows and Anime to Simkl and share your progress with friends!";
+3 -17
View File
@@ -5,25 +5,11 @@
Repository Url:
https://repo.codyrobibero.dev/manifest.json
## How to enable notifications when something is marked as watched
1. On Jellyfin's dashboard, you'll have to go to the bottom and, on expert options, select "Notifications"
2. There, on section "Simkl Scrobbling", enable both notifications
## How to enable debugging
To report a bug or an error, we'll need more info to know how to fix it. To send us the needed reports you'll need to
first enable debug logging.
1. On Jellyfin's dashboard, scroll to the bottom and, on expert options, select "Logs" (right above "Notifications")
2. Click on "Enable debug logging"
3. Restart the server and reproduce the error
Now you can contact us to try and fix the problem
## Current features
- Multi-user support
- Auto scrobble Movies and TV Shows at given percentage to Simkl
- Easy login using pin (no more putting passwords with the TV remote)
- Easy login using pin
- If scrobbling fails, search it using filename using Simkl's API and then scrobble it
- Send notifications about scrobbling
Modified for Jellyfin from https://github.com/SIMKL/Emby/
## Future features
- Sync all watch status with Simkl
+1 -1
View File
@@ -2,7 +2,7 @@
name: "Simkl"
guid: "07CAEF58-A94B-4211-A62C-F9774E04EBDB"
version: "1.0.0.0"
targetAbi: "10.6.0.0"
targetAbi: "10.7.0.0"
owner: "crobibero"
overview: "Scrobble to Simkl"
description: >