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. /// Initializes a new instance of the <see cref="InvalidTokenException"/> class.
/// </summary> /// </summary>
/// <param name="msg">The message.</param> /// <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> /// </summary>
/// <param name="msg">The message.</param> /// <param name="msg">The message.</param>
/// <param name="inner">The inner exception.</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> /// <summary>
/// Season. /// Season.
@@ -8,11 +12,13 @@
/// <summary> /// <summary>
/// Gets or sets the season number. /// Gets or sets the season number.
/// </summary> /// </summary>
public int? number { get; set; } [JsonPropertyName("number")]
public int? Number { get; set; }
/// <summary> /// <summary>
/// Gets or sets the episodes. /// Gets or sets the episodes.
/// </summary> /// </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> /// <summary>
/// Show episode. /// Show episode.
@@ -8,7 +10,8 @@
/// <summary> /// <summary>
/// Gets or sets episode number. /// Gets or sets episode number.
/// </summary> /// </summary>
public int? number { get; set; } [JsonPropertyName("number")]
public int? Number { get; set; }
// TODO: watched_at // TODO: watched_at
} }
} }
@@ -1,3 +1,4 @@
using System;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
#pragma warning disable SA1300 #pragma warning disable SA1300
@@ -12,31 +13,30 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// Gets or sets watched at. /// Gets or sets watched at.
/// </summary> /// </summary>
[JsonPropertyName("watched_at")] [JsonPropertyName("watched_at")]
public string watched_at { get; set; } public DateTime? WatchedAt { get; set; }
/// <summary>
/// Gets or sets ids.
/// </summary>
public override SimklIds ids { get; set; }
/// <summary> /// <summary>
/// Gets or sets the title. /// Gets or sets the title.
/// </summary> /// </summary>
public string title { get; set; } [JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary> /// <summary>
/// Gets or sets the season. /// Gets or sets the season.
/// </summary> /// </summary>
public int season { get; set; } [JsonPropertyName("season")]
public int? Season { get; set; }
/// <summary> /// <summary>
/// Gets or sets the episode. /// Gets or sets the episode.
/// </summary> /// </summary>
public int episode { get; set; } [JsonPropertyName("episode")]
public int? Episode { get; set; }
/// <summary> /// <summary>
/// Gets or sets multipart. /// Gets or sets multipart.
/// </summary> /// </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 namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
/// <summary> /// <summary>
@@ -8,16 +10,19 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary> /// <summary>
/// Gets or sets the file. /// Gets or sets the file.
/// </summary> /// </summary>
public string file { get; set; } [JsonPropertyName("file")]
public string? File { get; set; }
/// <summary> /// <summary>
/// Gets or sets the part. /// Gets or sets the part.
/// </summary> /// </summary>
public int? part { get; set; } [JsonPropertyName("part")]
public int? Part { get; set; }
/// <summary> /// <summary>
/// Gets or sets the hash. /// Gets or sets the hash.
/// </summary> /// </summary>
public string hash { get; set; } [JsonPropertyName("hash")]
public string? Hash { get; set; }
} }
} }
@@ -1,6 +1,7 @@
#pragma warning disable CA2227 #pragma warning disable CA2227
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
@@ -14,24 +15,27 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// </summary> /// </summary>
public SimklHistory() public SimklHistory()
{ {
movies = new List<SimklMovie>(); Movies = new List<SimklMovie>();
shows = new List<SimklShow>(); Shows = new List<SimklShow>();
episodes = new List<SimklEpisode>(); Episodes = new List<SimklEpisode>();
} }
/// <summary> /// <summary>
/// Gets or sets list of movies. /// Gets or sets list of movies.
/// </summary> /// </summary>
public List<SimklMovie> movies { get; set; } [JsonPropertyName("movies")]
public List<SimklMovie> Movies { get; set; }
/// <summary> /// <summary>
/// Gets or sets the list of shows. /// Gets or sets the list of shows.
/// </summary> /// </summary>
public List<SimklShow> shows { get; set; } [JsonPropertyName("shows")]
public List<SimklShow> Shows { get; set; }
/// <summary> /// <summary>
/// Gets or sets the list of episodes. /// Gets or sets the list of episodes.
/// </summary> /// </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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
@@ -17,52 +18,57 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
foreach (var (key, value) in providerIds) 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> /// <summary>
/// Gets or sets simkl. /// Gets or sets the simkl id.
/// </summary> /// </summary>
public int? simkl { get; set; } [JsonPropertyName("simkl")]
public int? Simkl { get; set; }
/// <summary> /// <summary>
/// Gets or sets the imdb id. /// Gets or sets the imdb id.
/// </summary> /// </summary>
public string imdb { get; set; } [JsonPropertyName("imdb")]
public string? Imdb { get; set; }
/// <summary> /// <summary>
/// Gets or sets the slug. /// Gets or sets the slug.
/// </summary> /// </summary>
public string slug { get; set; } [JsonPropertyName("slug")]
public string? Slug { get; set; }
/// <summary> /// <summary>
/// Gets or sets the netflix id. /// Gets or sets the netflix id.
/// </summary> /// </summary>
public string netflix { get; set; } [JsonPropertyName("netflix")]
public string? Netflix { get; set; }
/// <summary> /// <summary>
/// Gets or sets the TMDb id. /// Gets or sets the TMDb id.
/// </summary> /// </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> /// <summary>
/// Simkl media object. /// Simkl media object.
/// </summary> /// </summary>
public abstract class SimklMediaObject public class SimklMediaObject
{ {
/// <summary> /// <summary>
/// Gets or sets ids. /// Gets or sets ids.
/// </summary> /// </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;
using System.Globalization; using System.Text.Json.Serialization;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
namespace Jellyfin.Plugin.Simkl.API.Objects namespace Jellyfin.Plugin.Simkl.API.Objects
@@ -15,28 +15,28 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <param name="item">The base item dto.</param> /// <param name="item">The base item dto.</param>
public SimklMovie(BaseItemDto item) public SimklMovie(BaseItemDto item)
{ {
title = item.OriginalTitle; Title = item.OriginalTitle;
year = item.ProductionYear; Year = item.ProductionYear;
ids = new SimklMovieIds(item.ProviderIds); Ids = new SimklMovieIds(item.ProviderIds);
watched_at = DateTime.UtcNow.ToString("yyyy-MM-dd HH\\:mm\\:ss", CultureInfo.InvariantCulture); WatchedAt = DateTime.UtcNow;
} }
/// <summary> /// <summary>
/// Gets or sets the movie title. /// Gets or sets the movie title.
/// </summary> /// </summary>
public string title { get; set; } [JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary> /// <summary>
/// Gets or sets the year. /// Gets or sets the year.
/// </summary> /// </summary>
public int? year { get; set; } [JsonPropertyName("year")]
public int? Year { get; set; }
/// <inheritdoc />
public override SimklIds ids { get; set; }
/// <summary> /// <summary>
/// Gets watched at. /// Gets or sets watched at.
/// </summary> /// </summary>
public string watched_at { get; } [JsonPropertyName("watched_at")]
public DateTime? WatchedAt { get; set; }
} }
} }
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
@@ -19,31 +20,37 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary> /// <summary>
/// Gets or sets the tvdb id. /// Gets or sets the tvdb id.
/// </summary> /// </summary>
public int? tvdb { get; set; } [JsonPropertyName("tvdb")]
public int? Tvdb { get; set; }
/// <summary> /// <summary>
/// Gets or sets the mal id. /// Gets or sets the mal id.
/// </summary> /// </summary>
public int? mal { get; set; } [JsonPropertyName("mal")]
public int? Mal { get; set; }
/// <summary> /// <summary>
/// Gets or sets the anidb id. /// Gets or sets the anidb id.
/// </summary> /// </summary>
public int? anidb { get; set; } [JsonPropertyName("anidb")]
public int? Anidb { get; set; }
/// <summary> /// <summary>
/// Gets or sets the hulu id. /// Gets or sets the hulu id.
/// </summary> /// </summary>
public int? hulu { get; set; } [JsonPropertyName("hulu")]
public int? Hulu { get; set; }
/// <summary> /// <summary>
/// Gets or sets the crunchyroll id. /// Gets or sets the crunchyroll id.
/// </summary> /// </summary>
public int? crunchyroll { get; set; } [JsonPropertyName("crunchyroll")]
public int? Crunchyroll { get; set; }
/// <summary> /// <summary>
/// Gets or sets the movie db id. /// Gets or sets the movie db id.
/// </summary> /// </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 namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
@@ -13,19 +15,19 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <param name="mediaInfo">The media info.</param> /// <param name="mediaInfo">The media info.</param>
public SimklShow(BaseItemDto mediaInfo) public SimklShow(BaseItemDto mediaInfo)
{ {
title = mediaInfo.SeriesName; Title = mediaInfo.SeriesName;
ids = new SimklShowIds(mediaInfo.ProviderIds); Ids = new SimklShowIds(mediaInfo.ProviderIds);
year = mediaInfo.ProductionYear; Year = mediaInfo.ProductionYear;
seasons = new[] Seasons = new[]
{ {
new Season new Season
{ {
number = mediaInfo.ParentIndexNumber, Number = mediaInfo.ParentIndexNumber,
episodes = new[] Episodes = new[]
{ {
new ShowEpisode new ShowEpisode
{ {
number = mediaInfo.IndexNumber Number = mediaInfo.IndexNumber
} }
} }
} }
@@ -35,21 +37,19 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary> /// <summary>
/// Gets or sets title. /// Gets or sets title.
/// </summary> /// </summary>
public string title { get; set; } [JsonPropertyName("title")]
public string Title { get; set; }
/// <summary> /// <summary>
/// Gets or sets year. /// Gets or sets year.
/// </summary> /// </summary>
public int? year { get; set; } [JsonPropertyName("year")]
public int? Year { get; set; }
/// <summary> /// <summary>
/// Gets or sets seasons. /// Gets or sets seasons.
/// </summary> /// </summary>
public Season[] seasons { get; set; } [JsonPropertyName("seasons")]
public IReadOnlyList<Season> Seasons { get; set; }
/// <summary>
/// Gets or sets ids.
/// </summary>
public override SimklIds ids { get; set; }
} }
} }
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Simkl.API.Objects namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
@@ -19,31 +20,37 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary> /// <summary>
/// Gets or sets tvdb. /// Gets or sets tvdb.
/// </summary> /// </summary>
public int? tvdb { get; set; } [JsonPropertyName("tvdb")]
public int? Tvdb { get; set; }
/// <summary> /// <summary>
/// Gets or sets mal. /// Gets or sets mal.
/// </summary> /// </summary>
public int? mal { get; set; } [JsonPropertyName("mal")]
public int? Mal { get; set; }
/// <summary> /// <summary>
/// Gets or sets anidb. /// Gets or sets anidb.
/// </summary> /// </summary>
public int? anidb { get; set; } [JsonPropertyName("anidb")]
public int? Anidb { get; set; }
/// <summary> /// <summary>
/// Gets or sets hulu. /// Gets or sets hulu.
/// </summary> /// </summary>
public int? hulu { get; set; } [JsonPropertyName("hulu")]
public int? Hulu { get; set; }
/// <summary> /// <summary>
/// Gets or sets crunchyroll. /// Gets or sets crunchyroll.
/// </summary> /// </summary>
public int? crunchyroll { get; set; } [JsonPropertyName("crunchyroll")]
public int? Crunchyroll { get; set; }
/// <summary> /// <summary>
/// Gets or sets zap2it. /// Gets or sets zap2it.
/// </summary> /// </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 namespace Jellyfin.Plugin.Simkl.API.Objects
{ {
@@ -10,6 +12,7 @@ namespace Jellyfin.Plugin.Simkl.API.Objects
/// <summary> /// <summary>
/// Gets or sets name. /// Gets or sets name.
/// </summary> /// </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> /// <summary>
/// User settings. /// User settings.
@@ -8,11 +10,13 @@
/// <summary> /// <summary>
/// Gets or sets user. /// Gets or sets user.
/// </summary> /// </summary>
public User user { get; set; } [JsonPropertyName("user")]
public User? User { get; set; }
/// <summary> /// <summary>
/// Gets or sets error. /// Gets or sets error.
/// </summary> /// </summary>
public string error { get; set; } [JsonPropertyName("error")]
public string? Error { get; set; }
} }
} }
@@ -11,35 +11,35 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary> /// <summary>
/// Gets or sets result. /// Gets or sets result.
/// </summary> /// </summary>
public string Result { get; set; } public string? Result { get; set; }
/// <summary> /// <summary>
/// Gets or sets device code. /// Gets or sets device code.
/// </summary> /// </summary>
[JsonPropertyName("device_code")] [JsonPropertyName("device_code")]
public string device_code { get; set; } public string? DeviceCode { get; set; }
/// <summary> /// <summary>
/// Gets or sets user code. /// Gets or sets user code.
/// </summary> /// </summary>
[JsonPropertyName("user_code")] [JsonPropertyName("user_code")]
public string user_code { get; set; } public string? UserCode { get; set; }
/// <summary> /// <summary>
/// Gets or sets verification url. /// Gets or sets verification url.
/// </summary> /// </summary>
[JsonPropertyName("verification_url")] [JsonPropertyName("verification_url")]
public string verification_url { get; set; } public string? VerificationUrl { get; set; }
/// <summary> /// <summary>
/// Gets or sets expires in. /// Gets or sets expires in.
/// </summary> /// </summary>
[JsonPropertyName("expires_in")] [JsonPropertyName("expires_in")]
public int expires_in { get; set; } public int? ExpiresIn { get; set; }
/// <summary> /// <summary>
/// Gets or sets interval. /// Gets or sets interval.
/// </summary> /// </summary>
public int Interval { get; set; } public int? Interval { get; set; }
} }
} }
@@ -11,17 +11,17 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary> /// <summary>
/// Gets or sets result. /// Gets or sets result.
/// </summary> /// </summary>
public string Result { get; set; } public string? Result { get; set; }
/// <summary> /// <summary>
/// Gets or sets message. /// Gets or sets message.
/// </summary> /// </summary>
public string Message { get; set; } public string? Message { get; set; }
/// <summary> /// <summary>
/// Gets or sets access token. /// Gets or sets access token.
/// </summary> /// </summary>
[JsonPropertyName("access_token")] [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> /// <summary>
/// Gets or sets type. /// Gets or sets type.
/// </summary> /// </summary>
public string Type { get; set; } public string? Type { get; set; }
/// <summary> /// <summary>
/// Gets or sets episode. /// Gets or sets episode.
/// </summary> /// </summary>
public SimklEpisode Episode { get; set; } public SimklEpisode? Episode { get; set; }
/// <summary> /// <summary>
/// Gets or sets movie. /// Gets or sets movie.
/// </summary> /// </summary>
public SimklMovie Movie { get; set; } public SimklMovie? Movie { get; set; }
/// <summary> /// <summary>
/// Gets or sets show. /// Gets or sets show.
/// </summary> /// </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 namespace Jellyfin.Plugin.Simkl.API.Responses
{ {
@@ -10,16 +11,16 @@ namespace Jellyfin.Plugin.Simkl.API.Responses
/// <summary> /// <summary>
/// Gets or sets movies. /// Gets or sets movies.
/// </summary> /// </summary>
public SimklMovie[] Movies { get; set; } public SimklMovie[] Movies { get; set; } = Array.Empty<SimklMovie>();
/// <summary> /// <summary>
/// Gets or sets shows. /// Gets or sets shows.
/// </summary> /// </summary>
public SimklShow[] Shows { get; set; } public SimklShow[] Shows { get; set; } = Array.Empty<SimklShow>();
/// <summary> /// <summary>
/// Gets or sets episodes. /// Gets or sets episodes.
/// </summary> /// </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> /// <summary>
/// Gets or sets added. /// Gets or sets added.
/// </summary> /// </summary>
public SyncHistoryResponseCount Added { get; set; } public SyncHistoryResponseCount Added { get; set; } = new SyncHistoryResponseCount();
/// <summary> /// <summary>
/// Gets or sets not found. /// Gets or sets not found.
/// </summary> /// </summary>
[JsonPropertyName("not_found")] [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 System.Threading.Tasks;
using Jellyfin.Plugin.Simkl.API.Exceptions; using Jellyfin.Plugin.Simkl.API.Exceptions;
using Jellyfin.Plugin.Simkl.API.Objects; using Jellyfin.Plugin.Simkl.API.Objects;
using Jellyfin.Plugin.Simkl.API.Responses; using Jellyfin.Plugin.Simkl.API.Responses;
using MediaBrowser.Common.Json;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging; // using System.Threading;
namespace Jellyfin.Plugin.Simkl.API namespace Jellyfin.Plugin.Simkl.API
{ {
@@ -16,9 +25,9 @@ namespace Jellyfin.Plugin.Simkl.API
public class SimklApi public class SimklApi
{ {
/* INTERFACES */ /* INTERFACES */
private readonly IJsonSerializer _json;
private readonly ILogger<SimklApi> _logger; private readonly ILogger<SimklApi> _logger;
private readonly IHttpClient _httpClient; private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/* BASIC API THINGS */ /* BASIC API THINGS */
@@ -26,44 +35,43 @@ namespace Jellyfin.Plugin.Simkl.API
/// Base url. /// Base url.
/// </summary> /// </summary>
public const string Baseurl = @"https://api.simkl.com"; public const string Baseurl = @"https://api.simkl.com";
// public const string BASE_URL = @"http://private-9c39b-simkl.apiary-proxy.com";
/// <summary> /// <summary>
/// Redirect uri. /// Redirect uri.
/// </summary> /// </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> /// <summary>
/// Api key. /// Api key.
/// </summary> /// </summary>
public const string Apikey = @"27dd5d6adc24aa1ad9f95ef913244cbaf6df5696036af577ed41670473dc97d0"; public const string Apikey = @"c721b22482097722a84a20ccc579cf9d232be85b9befe7b7805484d0ddbc6781";
/// <summary> /// <summary>
/// Secret. /// Secret.
/// </summary> /// </summary>
public const string Secret = @"d7b9feb9d48bbaa69dbabaca21ba4671acaa89198637e9e136a4d69ec97ab68b"; public const string Secret = @"87893fc73cdbd2e51a7c63975c6f941ac1c6155c0e20ffa76b83202dd10a507e";
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SimklApi"/> class. /// Initializes a new instance of the <see cref="SimklApi"/> class.
/// </summary> /// </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="logger">Instance of the <see cref="ILogger{SimklApi}"/> interface.</param>
/// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
public SimklApi(IJsonSerializer json, ILogger<SimklApi> logger, IHttpClient httpClient) public SimklApi(ILogger<SimklApi> logger, IHttpClientFactory httpClientFactory)
{ {
_json = json;
_logger = logger; _logger = logger;
_httpClient = httpClient; _httpClientFactory = httpClientFactory;
_jsonSerializerOptions = JsonDefaults.GetOptions();
} }
/// <summary> /// <summary>
/// Get code. /// Get code.
/// </summary> /// </summary>
/// <returns>Code response.</returns> /// <returns>Code response.</returns>
public async Task<CodeResponse> GetCode() public async Task<CodeResponse?> GetCode()
{ {
var uri = $"/oauth/pin?client_id={Apikey}&redirect={RedirectUri}"; var uri = $"/oauth/pin?client_id={Apikey}&redirect={RedirectUri}";
return _json.DeserializeFromStream<CodeResponse>(await Get(uri).ConfigureAwait(false)); return await Get<CodeResponse>(uri);
} }
/// <summary> /// <summary>
@@ -71,10 +79,10 @@ namespace Jellyfin.Plugin.Simkl.API
/// </summary> /// </summary>
/// <param name="userCode">User code.</param> /// <param name="userCode">User code.</param>
/// <returns>Code status.</returns> /// <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}"; var uri = $"/oauth/pin/{userCode}?client_id={Apikey}";
return _json.DeserializeFromStream<CodeStatusResponse>(await Get(uri).ConfigureAwait(false)); return await Get<CodeStatusResponse>(uri);
} }
/// <summary> /// <summary>
@@ -82,18 +90,18 @@ namespace Jellyfin.Plugin.Simkl.API
/// </summary> /// </summary>
/// <param name="userToken">User token.</param> /// <param name="userToken">User token.</param>
/// <returns>User settings.</returns> /// <returns>User settings.</returns>
public async Task<UserSettings> GetUserSettings(string userToken) public async Task<UserSettings?> GetUserSettings(string userToken)
{ {
try 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 // Wontfix: Custom status codes
// "You don't get to pick your response code" - Luke (System Architect of Emby) // "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/ // 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="item">Item.</param>
/// <param name="userToken">User token.</param> /// <param name="userToken">User token.</param>
/// <returns>Status.</returns> /// <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 history = CreateHistoryFromItem(item);
var r = await SyncHistoryAsync(history, userToken).ConfigureAwait(false); var r = await SyncHistoryAsync(history, userToken);
_logger.LogDebug("Response: " + _json.SerializeToString(r)); _logger.LogDebug("Response: {@Response}", r);
if (history.movies.Count == r.Added.Movies && history.shows.Count == r.Added.Shows) if (r != null && history.Movies.Count == r.Added.Movies && history.Shows.Count == r.Added.Shows)
{ {
return (true, item); return (true, item);
} }
@@ -117,19 +125,19 @@ namespace Jellyfin.Plugin.Simkl.API
// let's try scrobbling from full path // let's try scrobbling from full path
try try
{ {
(history, item) = await GetHistoryFromFileName(item).ConfigureAwait(false); (history, item) = await GetHistoryFromFileName(item);
} }
catch (InvalidDataException) catch (InvalidDataException)
{ {
// Let's try again but this time using only the FILE name // Let's try again but this time using only the FILE name
_logger.LogDebug("Couldn't scrobble using full path, trying using only filename"); _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); r = await SyncHistoryAsync(history, userToken);
_logger.LogDebug("Response: " + _json.SerializeToString(r)); return r == null
? (false, item)
return (history.movies.Count == r.Added.Movies && history.shows.Count == r.Added.Shows, item); : (history.Movies.Count == r.Added.Movies && history.Shows.Count == r.Added.Shows, item);
} }
/// <summary> /// <summary>
@@ -137,14 +145,11 @@ namespace Jellyfin.Plugin.Simkl.API
/// </summary> /// </summary>
/// <param name="filename">Filename.</param> /// <param name="filename">Filename.</param>
/// <returns>Search file response.</returns> /// <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 }; var f = new SimklFile { File = filename };
_logger.LogInformation("Posting: " + _json.SerializeToString(f)); _logger.LogInformation("Posting: {@File}", f);
using var r = new StreamReader(await Post("/search/file/", null, f).ConfigureAwait(false)); return await Post<SearchFileResponse, SimklFile>("/search/file/", null, f);
var t = await r.ReadToEndAsync().ConfigureAwait(false);
_logger.LogDebug("Response: " + t);
return _json.DeserializeFromString<SearchFileResponse>(t);
} }
/// <summary> /// <summary>
@@ -156,68 +161,69 @@ namespace Jellyfin.Plugin.Simkl.API
private async Task<(SimklHistory history, BaseItemDto item)> GetHistoryFromFileName(BaseItemDto item, bool fullpath = true) private async Task<(SimklHistory history, BaseItemDto item)> GetHistoryFromFileName(BaseItemDto item, bool fullpath = true)
{ {
var fname = fullpath ? item.Path : Path.GetFileName(item.Path); 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(); 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 + ")"); throw new InvalidDataException("type != movie (" + mo.Type + ")");
} }
item.Name = mo.Movie.title; item.Name = mo.Movie.Title;
item.ProductionYear = mo.Movie.year; item.ProductionYear = mo.Movie.Year;
history.movies.Add(mo.Movie); 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 + ")"); throw new InvalidDataException("type != episode (" + mo.Type + ")");
} }
item.Name = mo.Episode.title; item.Name = mo.Episode.Title;
item.SeriesName = mo.Show.title; item.SeriesName = mo.Show.Title;
item.IndexNumber = mo.Episode.episode; item.IndexNumber = mo.Episode.Episode;
item.ParentIndexNumber = mo.Episode.season; item.ParentIndexNumber = mo.Episode.Season;
item.ProductionYear = mo.Show.year; item.ProductionYear = mo.Show.Year;
history.episodes.Add(mo.Episode); history.Episodes.Add(mo.Episode);
} }
return (history, item); return (history, item);
} }
private static HttpRequestOptions GetOptions(string userToken = null) private static HttpRequestMessage GetOptions(string? userToken = null)
{ {
var options = new HttpRequestOptions var requestMessage = new HttpRequestMessage();
{ requestMessage.Headers.TryAddWithoutValidation("simkl-api-key", Apikey);
RequestContentType = "application/json",
LogErrorResponseBody = true,
EnableDefaultUserAgent = true
};
options.RequestHeaders.Add("simkl-api-key", Apikey);
// options.RequestHeaders.Add("Content-Type", "application/json");
if (!string.IsNullOrEmpty(userToken)) 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) private static SimklHistory CreateHistoryFromItem(BaseItemDto item)
{ {
var history = new SimklHistory(); 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) // TODO: TV Shows scrobbling (WIP)
history.shows.Add(new SimklShow(item)); history.Shows.Add(new SimklShow(item));
} }
return history; return history;
@@ -229,17 +235,17 @@ namespace Jellyfin.Plugin.Simkl.API
/// <param name="history">History object.</param> /// <param name="history">History object.</param>
/// <param name="userToken">User token.</param> /// <param name="userToken">User token.</param>
/// <returns>The sync history response.</returns> /// <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 try
{ {
_logger.LogInformation("Syncing History: " + _json.SerializeToString(history)); _logger.LogInformation("Syncing History");
return _json.DeserializeFromStream<SyncHistoryResponse>(await Post("/sync/history", userToken, history).ConfigureAwait(false)); 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"); _logger.LogError(e, "Invalid user token {UserToken}, deleting", userToken);
SimklPlugin.Instance.Configuration.DeleteUserToken(userToken); SimklPlugin.Instance?.Configuration.DeleteUserToken(userToken);
throw new InvalidTokenException("Invalid user token " + 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="url">Relative url.</param>
/// <param name="userToken">Authentication token.</param> /// <param name="userToken">Authentication token.</param>
/// <returns>HTTP(s) Stream to be used.</returns> /// <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 // Todo: If string is not null neither empty
var options = GetOptions(userToken); using var options = GetOptions(userToken);
options.Url = Baseurl + url; options.RequestUri = new Uri(Baseurl + url);
options.Method = HttpMethod.Get;
return await _httpClient.Get(options).ConfigureAwait(false); var responseMessage = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(options);
return await responseMessage.Content.ReadFromJsonAsync<T>(_jsonSerializerOptions);
} }
/// <summary> /// <summary>
@@ -265,16 +273,23 @@ namespace Jellyfin.Plugin.Simkl.API
/// <param name="url">Relative post url.</param> /// <param name="url">Relative post url.</param>
/// <param name="userToken">Authentication token.</param> /// <param name="userToken">Authentication token.</param>
/// <param name="data">Object to serialize.</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); using var options = GetOptions(userToken);
options.Url = Baseurl + url; options.RequestUri = new Uri(Baseurl + url);
options.Method = HttpMethod.Post;
if (data != null) 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> /// </summary>
/// <param name="id">The user id.</param> /// <param name="id">The user id.</param>
/// <returns>Stored user config.</returns> /// <returns>Stored user config.</returns>
public UserConfig GetByGuid(Guid id) public UserConfig? GetByGuid(Guid id)
{ {
return UserConfigs.FirstOrDefault(c => c.Id == 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> <title>Simkl's TV Tracker</title>
</head> </head>
<body> <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 data-role="content">
<div class="content-primary"> <div class="content-primary">
<h1>Simkl's TV Tracker</h1> <h1>Simkl's TV Tracker</h1>
<form id="SimklConfigurationForm"> <form id="SimklConfigurationForm">
<div id="selectContainer"> <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 --> <!-- This will be populated by SimklConfig.populateUsers -->
</select> </select>
</div> </div>
<div id="loginButtonContainer" hidden> <div id="loginButtonContainer" hidden>
<h3>It seems you are not logged in, do you wish to log in?</h3> <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="SimklConfig.startLoginProcess();" is="emby-button" type="button"
<button onclick="location.href='https://simkl.com/';" is="emby-button" type="button" class="raised block"><span>Create an account</span></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>
<div id="loggingIn" hidden> <div id="loggingIn" hidden>
<h2>Logging In</h2> <h2>Logging In</h2>
<div id="loginText"></div> <div id="loginText"></div>
<h3 id="loginPin"></h3> <h3 id="loginPin"></h3>
<span id="loginSecondsRemaining">900</span> seconds remaining <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>
<div id="configOptionsContainer" hidden> <div id="configOptionsContainer" hidden>
<h3>Hello again <span id="simklName">USERNAME</span>!</h3> <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> <h2>Scrobbling options:</h2>
<div class="checkboxcontainer"> <div class="checkboxcontainer">
<label> <label>
<input is="emby-checkbox" type="checkbox" id="ScrobbleMovies" /> <input is="emby-checkbox" type="checkbox" id="ScrobbleMovies"/>
<span>Autoscrobbling Movies</span> <span>Autoscrobbling Movies</span>
</label> </label>
</div> </div>
<div class="checkboxcontainer"> <div class="checkboxcontainer">
<label> <label>
<input is="emby-checkbox" type="checkbox" id="ScrobbleShows" /> <input is="emby-checkbox" type="checkbox" id="ScrobbleShows"/>
<span>Autoscrobbling TV Shows</span> <span>Autoscrobbling TV Shows</span>
</label> </label>
</div> </div>
<div class="inputContainer"> <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"> <div class="fieldDescription">
Percentage watched needed to scrobble Percentage watched needed to scrobble
</div> </div>
</div> </div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>${ButtonSave}</span></button> <button is="emby-button" type="submit" class="raised button-submit block"><span>${Save}</span>
<button is="emby-button" type="button" class="raised block" onclick="history.back();"><span>${Cancel}</span></button> </button>
<button is="emby-button" type="button" class="raised block" onclick="history.back();"><span>${Cancel}</span>
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -57,42 +66,57 @@
<script type="text/javascript"> <script type="text/javascript">
var SimklConfig = { var SimklConfig = {
guid: "07CAEF58-A94B-4211-A62C-F9774E04EBDB", guid: "07CAEF58-A94B-4211-A62C-F9774E04EBDB",
onLoginProccess: false, onLoginProcess: false,
configCache: [], configCache: [],
loginTimer: null, loginTimer: null,
remainingTimer: null, remainingTimer: null,
finish: null, finish: null,
populateUsers : async function (users) {
users.forEach(function(user) { userSelector: document.querySelector('#user-selector'),
$("#user-selector").append(new Option(user.Name, user.Id)); 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) { loadConfig: async function (user, config) {
if (config != null) this.configCache = config; if (config != null) {
else config = this.configCache; this.configCache = config;
} else {
config = this.configCache;
}
console.log("Simkl: Loading config for user " + user); console.log("Simkl: Loading config for user " + user);
console.log(config); console.log(config);
$("#loginButtonContainer").hide(); SimklConfig.loginButtonContainer.setAttribute('hidden', '');
$("#configOptionsContainer").hide(); SimklConfig.configOptionsContainer.setAttribute('hidden', '')
if (config.UserConfigs.some(e => e.Id === user && e.UserToken != null && e.UserToken !== "")) { if (config.UserConfigs.some(e => e.Id === user && e.UserToken != null && e.UserToken !== "")) {
$("#configOptionsContainer").show(); SimklConfig.configOptionsContainer.removeAttribute('hidden');
this.populateOptionsContainer(config.UserConfigs.filter(e => e.Id === user)[0]); await this.populateOptionsContainer(config.UserConfigs.filter(e => e.Id === user)[0]);
} else { } else {
$("#loginButtonContainer").show(); SimklConfig.loginButtonContainer.removeAttribute('hidden');
} }
}, },
saveConfig : async function(guid) { saveConfig: async function (guid) {
var uconfig = this.configCache.UserConfigs.filter(e => e.Id === guid)[0]; const uconfig = this.configCache.UserConfigs.filter(e => e.Id === guid)[0];
for (var key in uconfig) { for (const key in uconfig) {
var element = $("#configOptionsContainer #"+key); const element = document.querySelector("#configOptionsContainer #" + key);
if (element.is(":checkbox")) { if (element.type === 'checkbox') {
uconfig[key] = element.is(':checked'); uconfig[key] = element.checked;
} else { } 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); console.log(this.configCache);
ApiClient.updatePluginConfiguration(this.guid, this.configCache).then(Dashboard.processPluginConfigurationUpdateResult); ApiClient.updatePluginConfiguration(this.guid, this.configCache).then(Dashboard.processPluginConfigurationUpdateResult);
}, },
populateOptionsContainer : async function(userConfig) { populateOptionsContainer: async function (userConfig) {
$("#simklName").html(SimklAPI.getUserSettings(userConfig.Id).user.name); 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]); console.log(key, userConfig[key]);
$("#configOptionsContainer input[type=checkbox]#"+key).attr("checked", userConfig[key]); const chk = document.querySelector("#configOptionsContainer input[type=checkbox]#" + key);
$("#configOptionsContainer input[type=number]#"+key).val(userConfig[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; this.onLoginProcess = true;
var code = SimklAPI.getCode(); const code = await SimklAPI.getCode();
console.log(code);
this.finish = new Date(); this.finish = new Date();
this.finish.setSeconds(this.finish.getSeconds() + code.expires_in); this.finish.setSeconds(this.finish.getSeconds() + code.expires_in);
this.nextInterval = new Date(); this.nextInterval = new Date();
this.loginTimer = window.setTimeout(this.checkLoginProcess.bind(this,code), code.Interval*1000); this.loginTimer = window.setTimeout(this.checkLoginProcess.bind(this, code), code.Interval * 1000);
this.remainingTimer = window.setInterval(function() { this.remainingTimer = window.setInterval(function () {
$("#loginSecondsRemaining").html(Math.round((SimklConfig.finish.getTime() - (new Date().getTime()))/1000)); SimklConfig.loginSecondsRemaining.innerText = Math.round((SimklConfig.finish.getTime() - (new Date().getTime())) / 1000);
} ,1000); }, 1000);
$("#loginText").html("Please visit <a href='" + code.verification_url + "/" + 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:';
"' target='_blank'>" + code.verification_url + "</a> on your phone or computer and enter the following code:");
$("#loginPin").html(code.user_code);
$("#loginButtonContainer").hide(); SimklConfig.loginPin.innerText = code.user_code;
await $("#loggingIn").show();
SimklConfig.loginButtonContainer.setAttribute('hidden', '');
SimklConfig.loggingIn.removeAttribute('hidden');
}, },
checkLoginProcess : function (code) { checkLoginProcess: async function (code) {
var response = SimklAPI.checkCode(code.user_code); const response = await SimklAPI.checkCode(code.user_code);
console.log("Response:"); console.log("Response:");
console.log(response); console.log(response);
if (new Date() > this.finish) { if (new Date() > this.finish) {
Dashboard.alert("Timed out!"); Dashboard.alert("Timed out!");
this.stopLoginProcess(); await this.stopLoginProcess();
} else if (response.Result === "KO") { } 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") { } else if (response.Result === "OK") {
this.stopLoginProcess(); await this.stopLoginProcess();
// Save key on settings // Save key on settings
var uguid = $("#user-selector").val(); const uguid = SimklConfig.userSelector.value;
var filter = this.configCache.UserConfigs.filter(function(c) { const filter = this.configCache.UserConfigs.filter(function (c) {
return c.Id === uguid; return c.Id === uguid;
}); });
if (filter.length > 0) { if (filter.length > 0) {
@@ -159,26 +191,29 @@
console.log(this.configCache); console.log(this.configCache);
ApiClient.updatePluginConfiguration(this.guid, this.configCache); ApiClient.updatePluginConfiguration(this.guid, this.configCache);
this.loadConfig(uguid); await this.loadConfig(uguid);
} else { } else {
Dashboard.alert("Error logging in"); Dashboard.alert("Error logging in");
} }
}, },
stopLoginProcess : async function () { stopLoginProcess: async function () {
this.onLoginProcess = false; this.onLoginProcess = false;
window.clearTimeout(this.loginTimer); window.clearTimeout(this.loginTimer);
window.clearInterval(this.remainingTimer); window.clearInterval(this.remainingTimer);
$("#loginButtonContainer").show(); SimklConfig.loginButtonContainer.removeAttribute('hidden');
$("#loggingIn").hide(); SimklConfig.loggingIn.setAttribute('hidden', '');
}, },
onSelectorChange : async function () { onSelectorChange: async function () {
if (this.onLoginProcess) this.stopLoginProcess(); if (this.onLoginProcess) {
this.loadConfig($("#user-selector").val(), null); await this.stopLoginProcess();
}
await this.loadConfig(SimklConfig.userSelector.value, null);
}, },
logOut : function(uguid) { logOut: function (uguid) {
if (uguid == null) uguid = $("#user-selector").val(); if (uguid == null) {
uguid = SimklConfig.userSelector.value;
}
// e1f34a57cb3c4767ab6f29cc5c7c0566
var filter = this.configCache.UserConfigs.filter(function (c) { var filter = this.configCache.UserConfigs.filter(function (c) {
return c.Id === uguid; return c.Id === uguid;
}); });
@@ -191,70 +226,87 @@
} }
console.log(this.configCache); console.log(this.configCache);
ApiClient.updatePluginConfiguration(this.guid, this.configCache); window.ApiClient.updatePluginConfiguration(this.guid, this.configCache);
this.loadConfig(uguid); this.loadConfig(uguid);
} }
} }
var SimklAPI = { var SimklAPI = {
getCode: function () { getCode: function () {
var uri = "/Simkl/oauth/pin"; const request = {
var request = new XMLHttpRequest(); url: window.ApiClient.getUrl('Simkl/oauth/pin'),
request.open("GET", uri, false); type: 'GET',
request.send(); headers: {
accept: 'application/json'
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();
} }
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) { checkCode: function (user_code) {
var uri = "/Simkl/oauth/pin/" + user_code; const request = {
var request = new XMLHttpRequest(); url: window.ApiClient.getUrl('Simkl/oauth/pin/' + user_code),
request.open("GET", uri, false); type: 'GET',
request.send(); headers: {
accept: 'application/json'
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();
} }
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) { getUserSettings: function (secret) {
var uri = "/Simkl/users/settings/" + secret; const request = {
var request = new XMLHttpRequest(); url: window.ApiClient.getUrl('Simkl/users/settings/' + secret),
request.open("GET", uri, false); type: 'GET',
request.send(); headers: {
accept: 'application/json'
if (request.status === 200) { }
return $.parseJSON(request.response);
} else {
console.log(request);
Dashboard.alert("Something went wrong, see logs for more details");
} }
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) { document.querySelector('#SimklConfigurationPage')
Dashboard.showLoadingMsg(); .addEventListener('pageshow', async function () {
await Promise.all([ Dashboard.showLoadingMsg();
ApiClient.getUsers().then(SimklConfig.populateUsers), await Promise.all([
ApiClient.getPluginConfiguration(SimklConfig.guid).then(SimklConfig.loadConfig.bind(SimklConfig, ApiClient.getCurrentUserId()))]); window.ApiClient.getUsers().then(SimklConfig.populateUsers),
Dashboard.hideLoadingMsg(); window.ApiClient.getPluginConfiguration(SimklConfig.guid).then(SimklConfig.loadConfig.bind(SimklConfig, ApiClient.getCurrentUserId()))]);
}); Dashboard.hideLoadingMsg();
});
$("#SimklConfigurationForm").on("submit", function(e) { document.querySelector('#SimklConfigurationForm')
Dashboard.showLoadingMsg(); .addEventListener('submit', function (e) {
SimklConfig.saveConfig($("#user-selector").val()); e.preventDefault();
Dashboard.hideLoadingMsg();
return false; Dashboard.showLoadingMsg();
}); SimklConfig.saveConfig(SimklConfig.userSelector.value);
Dashboard.hideLoadingMsg();
});
</script> </script>
</div> </div>
</body> </body>
@@ -1,21 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework> <TargetFramework>net5.0</TargetFramework>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.0.0</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>CA1707;CA1819;SA1300</NoWarn> <nullable>enable</nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.6-*" /> <PackageReference Include="Jellyfin.Controller" Version="10.7-*" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.*" />
</ItemGroup> </ItemGroup>
<!-- Code Analyzers--> <!-- Code Analyzers-->
<ItemGroup> <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="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
@@ -29,4 +30,8 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project> </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> /// <summary>
/// Gets the current instance of the plugin. /// Gets the current instance of the plugin.
/// </summary> /// </summary>
public static SimklPlugin Instance { get; private set; } public static SimklPlugin? Instance { get; private set; }
/// <inheritdoc /> /// <inheritdoc />
public override Guid Id => new Guid("07CAEF58-A94B-4211-A62C-F9774E04EBDB"); public override Guid Id => new Guid("07CAEF58-A94B-4211-A62C-F9774E04EBDB");
/// <inheritdoc /> /// <inheritdoc />
public override string Name => "Simkl TV Tracker"; public override string Name => "Simkl";
/// <inheritdoc /> /// <inheritdoc />
public override string Description => "Scrobble your watched Movies, TV Shows and Anime to Simkl and share your progress with friends!"; 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: Repository Url:
https://repo.codyrobibero.dev/manifest.json 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 ## Current features
- Multi-user support - Multi-user support
- Auto scrobble Movies and TV Shows at given percentage to Simkl - 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 - 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" name: "Simkl"
guid: "07CAEF58-A94B-4211-A62C-F9774E04EBDB" guid: "07CAEF58-A94B-4211-A62C-F9774E04EBDB"
version: "1.0.0.0" version: "1.0.0.0"
targetAbi: "10.6.0.0" targetAbi: "10.7.0.0"
owner: "crobibero" owner: "crobibero"
overview: "Scrobble to Simkl" overview: "Scrobble to Simkl"
description: > description: >