10.9 support (#115)

This commit is contained in:
MBR-0001 2024-04-03 23:32:56 +02:00 committed by Cody Robibero
parent 6d8ee4eb7a
commit ecc270c915
28 changed files with 1221 additions and 1211 deletions

View File

@ -8,61 +8,60 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.OpenSubtitles.API
namespace Jellyfin.Plugin.OpenSubtitles.API;
/// <summary>
/// The open subtitles plugin controller.
/// </summary>
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Authorize]
public class OpenSubtitlesController : ControllerBase
{
/// <summary>
/// The open subtitles plugin controller.
/// Validates login info.
/// </summary>
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Authorize]
public class OpenSubtitlesController : ControllerBase
/// <remarks>
/// Accepts plugin configuration as JSON body.
/// </remarks>
/// <response code="200">Login info valid.</response>
/// <response code="400">Login info is missing data.</response>
/// <response code="401">Login info not valid.</response>
/// <param name="body">The request body.</param>
/// <returns>
/// An <see cref="NoContentResult"/> if the login info is valid, a <see cref="BadRequestResult"/> if the request body missing is data
/// or <see cref="UnauthorizedResult"/> if the login info is not valid.
/// </returns>
[HttpPost("Jellyfin.Plugin.OpenSubtitles/ValidateLoginInfo")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> ValidateLoginInfo([FromBody] LoginInfoInput body)
{
/// <summary>
/// Validates login info.
/// </summary>
/// <remarks>
/// Accepts plugin configuration as JSON body.
/// </remarks>
/// <response code="200">Login info valid.</response>
/// <response code="400">Login info is missing data.</response>
/// <response code="401">Login info not valid.</response>
/// <param name="body">The request body.</param>
/// <returns>
/// An <see cref="NoContentResult"/> if the login info is valid, a <see cref="BadRequestResult"/> if the request body missing is data
/// or <see cref="UnauthorizedResult"/> if the login info is not valid.
/// </returns>
[HttpPost("Jellyfin.Plugin.OpenSubtitles/ValidateLoginInfo")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> ValidateLoginInfo([FromBody] LoginInfoInput body)
var key = !string.IsNullOrWhiteSpace(body.CustomApiKey) ? body.CustomApiKey : OpenSubtitlesPlugin.ApiKey;
var response = await OpenSubtitlesHandler.OpenSubtitles.LogInAsync(body.Username, body.Password, key, CancellationToken.None).ConfigureAwait(false);
if (!response.Ok)
{
var key = !string.IsNullOrWhiteSpace(body.CustomApiKey) ? body.CustomApiKey : OpenSubtitlesPlugin.ApiKey;
var response = await OpenSubtitlesHandler.OpenSubtitles.LogInAsync(body.Username, body.Password, key, CancellationToken.None).ConfigureAwait(false);
var msg = $"{response.Code}{(response.Body.Length < 150 ? $" - {response.Body}" : string.Empty)}";
if (!response.Ok)
if (response.Body.Contains("message\":", StringComparison.Ordinal))
{
var msg = $"{response.Code}{(response.Body.Length < 150 ? $" - {response.Body}" : string.Empty)}";
if (response.Body.Contains("message\":", StringComparison.Ordinal))
var err = JsonSerializer.Deserialize<ErrorResponse>(response.Body);
if (err is not null)
{
var err = JsonSerializer.Deserialize<ErrorResponse>(response.Body);
if (err != null)
{
msg = string.Equals(err.Message, "You cannot consume this service", StringComparison.Ordinal) ? "Invalid API key provided" : err.Message;
}
msg = string.Equals(err.Message, "You cannot consume this service", StringComparison.Ordinal) ? "Invalid API key provided" : err.Message;
}
return Unauthorized(new { Message = msg });
}
if (response.Data != null)
{
await OpenSubtitlesHandler.OpenSubtitles.LogOutAsync(response.Data, key, CancellationToken.None).ConfigureAwait(false);
}
return Ok(new { Downloads = response.Data?.User?.AllowedDownloads ?? 0 });
return Unauthorized(new { Message = msg });
}
if (response.Data is not null)
{
await OpenSubtitlesHandler.OpenSubtitles.LogOutAsync(response.Data, key, CancellationToken.None).ConfigureAwait(false);
}
return Ok(new { Downloads = response.Data?.User?.AllowedDownloads ?? 0 });
}
}

View File

@ -1,30 +1,29 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.OpenSubtitles.Configuration
namespace Jellyfin.Plugin.OpenSubtitles.Configuration;
/// <summary>
/// The plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// The plugin configuration.
/// Gets or sets the username.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the custom API Key.
/// </summary>
public string CustomApiKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the custom API Key.
/// </summary>
public string CustomApiKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the credentials are invalid.
/// </summary>
public bool CredentialsInvalid { get; set; } = false;
}
/// <summary>
/// Gets or sets a value indicating whether the credentials are invalid.
/// </summary>
public bool CredentialsInvalid { get; set; } = false;
}

View File

@ -19,428 +19,430 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.OpenSubtitles
namespace Jellyfin.Plugin.OpenSubtitles;
/// <summary>
/// The open subtitle downloader.
/// </summary>
public class OpenSubtitleDownloader : ISubtitleProvider
{
private readonly ILogger<OpenSubtitleDownloader> _logger;
private LoginInfo? _login;
private DateTime? _limitReset;
private DateTime? _lastRatelimitLog;
private IReadOnlyList<string>? _languages;
private PluginConfiguration? _configuration;
/// <summary>
/// The open subtitle downloader.
/// Initializes a new instance of the <see cref="OpenSubtitleDownloader"/> class.
/// </summary>
public class OpenSubtitleDownloader : ISubtitleProvider
/// <param name="logger">Instance of the <see cref="ILogger{OpenSubtitleDownloader}"/> interface.</param>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for creating Http Clients.</param>
public OpenSubtitleDownloader(ILogger<OpenSubtitleDownloader> logger, IHttpClientFactory httpClientFactory)
{
private readonly ILogger<OpenSubtitleDownloader> _logger;
private LoginInfo? _login;
private DateTime? _limitReset;
private DateTime? _lastRatelimitLog;
private IReadOnlyList<string>? _languages;
private string _customApiKey;
Instance = this;
_logger = logger;
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString();
OpenSubtitlesRequestHelper.Instance = new OpenSubtitlesRequestHelper(httpClientFactory, version);
}
/// <summary>
/// Initializes a new instance of the <see cref="OpenSubtitleDownloader"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{OpenSubtitleDownloader}"/> interface.</param>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for creating Http Clients.</param>
public OpenSubtitleDownloader(ILogger<OpenSubtitleDownloader> logger, IHttpClientFactory httpClientFactory)
/// <summary>
/// Gets the downloader instance.
/// </summary>
public static OpenSubtitleDownloader? Instance { get; private set; }
/// <summary>
/// Gets the API key that will be used for requests.
/// </summary>
public string ApiKey
{
get
{
_logger = logger;
return !string.IsNullOrWhiteSpace(_configuration?.CustomApiKey) ? _configuration.CustomApiKey : OpenSubtitlesPlugin.ApiKey;
}
}
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString();
/// <inheritdoc />
public string Name
=> "Open Subtitles";
OpenSubtitlesRequestHelper.Instance = new OpenSubtitlesRequestHelper(httpClientFactory, version);
/// <inheritdoc />
public IEnumerable<VideoContentType> SupportedMediaTypes
=> new[] { VideoContentType.Episode, VideoContentType.Movie };
OpenSubtitlesPlugin.Instance!.ConfigurationChanged += (_, _) =>
{
_customApiKey = GetOptions().CustomApiKey;
// force a login next time a request is made
_login = null;
};
/// <inheritdoc />
public Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
=> GetSubtitlesInternal(id, cancellationToken);
_customApiKey = GetOptions().CustomApiKey;
/// <inheritdoc />
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
await Login(cancellationToken).ConfigureAwait(false);
if (request.IsAutomated && _login is null)
{
// Login attempt failed, since this is a task to download subtitles there's no point in continuing
_logger.LogDebug("Returning empty results because login failed");
return Enumerable.Empty<RemoteSubtitleInfo>();
}
/// <summary>
/// Gets the API key that will be used for requests.
/// </summary>
public string ApiKey
if (request.IsAutomated && _login?.User?.RemainingDownloads <= 0)
{
get
if (_lastRatelimitLog is null || DateTime.UtcNow.Subtract(_lastRatelimitLog.Value).TotalSeconds > 60)
{
return !string.IsNullOrWhiteSpace(_customApiKey) ? _customApiKey : OpenSubtitlesPlugin.ApiKey;
_logger.LogInformation("Daily download limit reached, returning no results for automated task");
_lastRatelimitLog = DateTime.UtcNow;
}
return Enumerable.Empty<RemoteSubtitleInfo>();
}
long.TryParse(request.GetProviderId(MetadataProvider.Imdb)?.TrimStart('t') ?? string.Empty, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbId);
if (request.ContentType == VideoContentType.Episode && (!request.IndexNumber.HasValue || !request.ParentIndexNumber.HasValue || string.IsNullOrEmpty(request.SeriesName)))
{
_logger.LogDebug("Episode information missing");
return Enumerable.Empty<RemoteSubtitleInfo>();
}
if (string.IsNullOrEmpty(request.MediaPath))
{
_logger.LogDebug("Path Missing");
return Enumerable.Empty<RemoteSubtitleInfo>();
}
var language = await GetLanguage(request.TwoLetterISOLanguageName, cancellationToken).ConfigureAwait(false);
string? hash = null;
if (!Path.GetExtension(request.MediaPath).Equals(".strm", StringComparison.OrdinalIgnoreCase))
{
try
{
#pragma warning disable CA2007
await using var fileStream = File.OpenRead(request.MediaPath);
#pragma warning restore CA2007
hash = OpenSubtitlesRequestHelper.ComputeHash(fileStream);
}
catch (IOException ex)
{
throw new IOException(string.Format(CultureInfo.InvariantCulture, "IOException while computing hash for {0}", request.MediaPath), ex);
}
}
/// <inheritdoc />
public string Name
=> "Open Subtitles";
/// <inheritdoc />
public IEnumerable<VideoContentType> SupportedMediaTypes
=> new[] { VideoContentType.Episode, VideoContentType.Movie };
/// <inheritdoc />
public Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
=> GetSubtitlesInternal(id, cancellationToken);
/// <inheritdoc />
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
var options = new Dictionary<string, string>
{
ArgumentNullException.ThrowIfNull(request);
{ "languages", language },
{ "type", request.ContentType == VideoContentType.Episode ? "episode" : "movie" }
};
await Login(cancellationToken).ConfigureAwait(false);
if (request.IsAutomated && _login is null)
{
// Login attempt failed, since this is a task to download subtitles there's no point in continuing
_logger.LogDebug("Returning empty results because login failed");
return Enumerable.Empty<RemoteSubtitleInfo>();
}
if (request.IsAutomated && _login?.User?.RemainingDownloads <= 0)
{
if (_lastRatelimitLog == null || DateTime.UtcNow.Subtract(_lastRatelimitLog.Value).TotalSeconds > 60)
{
_logger.LogInformation("Daily download limit reached, returning no results for automated task");
_lastRatelimitLog = DateTime.UtcNow;
}
return Enumerable.Empty<RemoteSubtitleInfo>();
}
long.TryParse(request.GetProviderId(MetadataProvider.Imdb)?.TrimStart('t') ?? string.Empty, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbId);
if (request.ContentType == VideoContentType.Episode && (!request.IndexNumber.HasValue || !request.ParentIndexNumber.HasValue || string.IsNullOrEmpty(request.SeriesName)))
{
_logger.LogDebug("Episode information missing");
return Enumerable.Empty<RemoteSubtitleInfo>();
}
if (string.IsNullOrEmpty(request.MediaPath))
{
_logger.LogDebug("Path Missing");
return Enumerable.Empty<RemoteSubtitleInfo>();
}
var language = await GetLanguage(request.TwoLetterISOLanguageName, cancellationToken).ConfigureAwait(false);
string? hash = null;
if (!Path.GetExtension(request.MediaPath).Equals(".strm", StringComparison.OrdinalIgnoreCase))
{
try
{
#pragma warning disable CA2007
await using var fileStream = File.OpenRead(request.MediaPath);
#pragma warning restore CA2007
hash = OpenSubtitlesRequestHelper.ComputeHash(fileStream);
}
catch (IOException ex)
{
throw new IOException(string.Format(CultureInfo.InvariantCulture, "IOException while computing hash for {0}", request.MediaPath), ex);
}
}
var options = new Dictionary<string, string>
{
{ "languages", language },
{ "type", request.ContentType == VideoContentType.Episode ? "episode" : "movie" }
};
if (hash is not null)
{
options.Add("moviehash", hash);
}
// If we have the IMDb ID we use that, otherwise query with the details
if (imdbId != 0)
{
options.Add("imdb_id", imdbId.ToString(CultureInfo.InvariantCulture));
}
else
{
options.Add("query", Path.GetFileName(request.MediaPath));
if (request.ContentType == VideoContentType.Episode)
{
if (request.ParentIndexNumber.HasValue)
{
options.Add("season_number", request.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture));
}
if (request.IndexNumber.HasValue)
{
options.Add("episode_number", request.IndexNumber.Value.ToString(CultureInfo.InvariantCulture));
}
}
}
if (request.IsPerfectMatch && hash is not null)
{
options.Add("moviehash_match", "only");
}
_logger.LogDebug("Search query: {Query}", options);
var searchResponse = await OpenSubtitlesHandler.OpenSubtitles.SearchSubtitlesAsync(options, ApiKey, cancellationToken).ConfigureAwait(false);
if (!searchResponse.Ok)
{
_logger.LogError("Invalid response: {Code} - {Body}", searchResponse.Code, searchResponse.Body);
return Enumerable.Empty<RemoteSubtitleInfo>();
}
bool MediaFilter(ResponseData x) =>
x.Attributes?.FeatureDetails?.FeatureType == (request.ContentType == VideoContentType.Episode ? "Episode" : "Movie")
&& request.ContentType == VideoContentType.Episode
? x.Attributes.FeatureDetails.SeasonNumber == request.ParentIndexNumber
&& x.Attributes.FeatureDetails.EpisodeNumber == request.IndexNumber
: x.Attributes?.FeatureDetails?.ImdbId == imdbId;
if (searchResponse.Data == null)
{
return Enumerable.Empty<RemoteSubtitleInfo>();
}
return searchResponse.Data
.Where(x => MediaFilter(x) && (!request.IsPerfectMatch || (x.Attributes?.MovieHashMatch ?? false)))
.OrderByDescending(x => x.Attributes?.MovieHashMatch ?? false)
.ThenByDescending(x => x.Attributes?.DownloadCount)
.ThenByDescending(x => x.Attributes?.Ratings)
.ThenByDescending(x => x.Attributes?.FromTrusted)
.Select(i => new RemoteSubtitleInfo
{
Author = i.Attributes?.Uploader?.Name,
Comment = i.Attributes?.Comments,
CommunityRating = i.Attributes?.Ratings,
DownloadCount = i.Attributes?.DownloadCount,
Format = "srt",
ProviderName = Name,
ThreeLetterISOLanguageName = request.Language,
Id = $"srt-{request.Language}-{i.Attributes?.Files[0].FileId}",
Name = i.Attributes?.Release,
DateCreated = i.Attributes?.UploadDate,
IsHashMatch = i.Attributes?.MovieHashMatch
})
.Where(i => !string.Equals(i.Format, "sub", StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Format, "idx", StringComparison.OrdinalIgnoreCase));
if (hash is not null)
{
options.Add("moviehash", hash);
}
private async Task<SubtitleResponse> GetSubtitlesInternal(string id, CancellationToken cancellationToken)
// If we have the IMDb ID we use that, otherwise query with the details
if (imdbId != 0)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Missing param", nameof(id));
}
options.Add("imdb_id", imdbId.ToString(CultureInfo.InvariantCulture));
}
else
{
options.Add("query", Path.GetFileName(request.MediaPath));
if (_login?.User?.RemainingDownloads <= 0)
if (request.ContentType == VideoContentType.Episode)
{
if (_limitReset < DateTime.UtcNow)
if (request.ParentIndexNumber.HasValue)
{
_logger.LogDebug("Reset time passed, updating user info");
await UpdateUserInfo(cancellationToken).ConfigureAwait(false);
// this shouldn't happen?
if (_login.User.RemainingDownloads <= 0)
{
_logger.LogError("OpenSubtitles download limit reached");
throw new RateLimitExceededException("OpenSubtitles download limit reached");
}
options.Add("season_number", request.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture));
}
else
if (request.IndexNumber.HasValue)
{
options.Add("episode_number", request.IndexNumber.Value.ToString(CultureInfo.InvariantCulture));
}
}
}
if (request.IsPerfectMatch && hash is not null)
{
options.Add("moviehash_match", "only");
}
_logger.LogDebug("Search query: {Query}", options);
var searchResponse = await OpenSubtitlesHandler.OpenSubtitles.SearchSubtitlesAsync(options, ApiKey, cancellationToken).ConfigureAwait(false);
if (!searchResponse.Ok)
{
_logger.LogError("Invalid response: {Code} - {Body}", searchResponse.Code, searchResponse.Body);
return Enumerable.Empty<RemoteSubtitleInfo>();
}
bool MediaFilter(ResponseData x) =>
x.Attributes?.FeatureDetails?.FeatureType == (request.ContentType == VideoContentType.Episode ? "Episode" : "Movie")
&& x.Attributes?.Files?.Count > 0 && x.Attributes.Files[0].FileId != null
&& (request.ContentType == VideoContentType.Episode
? x.Attributes.FeatureDetails.SeasonNumber == request.ParentIndexNumber
&& x.Attributes.FeatureDetails.EpisodeNumber == request.IndexNumber
: x.Attributes?.FeatureDetails?.ImdbId == imdbId);
if (searchResponse.Data is null)
{
return Enumerable.Empty<RemoteSubtitleInfo>();
}
return searchResponse.Data
.Where(x => MediaFilter(x) && (!request.IsPerfectMatch || (x.Attributes?.MovieHashMatch ?? false)))
.OrderByDescending(x => x.Attributes?.MovieHashMatch ?? false)
.ThenByDescending(x => x.Attributes?.DownloadCount)
.ThenByDescending(x => x.Attributes?.Ratings)
.ThenByDescending(x => x.Attributes?.FromTrusted)
.Select(i => new RemoteSubtitleInfo
{
Author = i.Attributes?.Uploader?.Name,
Comment = i.Attributes?.Comments,
CommunityRating = i.Attributes?.Ratings,
DownloadCount = i.Attributes?.DownloadCount,
Format = "srt",
ProviderName = Name,
ThreeLetterISOLanguageName = request.Language,
Id = $"srt-{request.Language}-{i.Attributes?.Files[0].FileId}",
Name = i.Attributes?.Release,
DateCreated = i.Attributes?.UploadDate,
IsHashMatch = i.Attributes?.MovieHashMatch
});
}
private async Task<SubtitleResponse> GetSubtitlesInternal(string id, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Missing param", nameof(id));
}
if (_login?.User?.RemainingDownloads <= 0)
{
if (_limitReset < DateTime.UtcNow)
{
_logger.LogDebug("Reset time passed, updating user info");
await UpdateUserInfo(cancellationToken).ConfigureAwait(false);
// this shouldn't happen?
if (_login.User.RemainingDownloads <= 0)
{
_logger.LogError("OpenSubtitles download limit reached");
throw new RateLimitExceededException("OpenSubtitles download limit reached");
}
}
await Login(cancellationToken).ConfigureAwait(false);
if (_login == null)
else
{
throw new AuthenticationException("Unable to login");
_logger.LogError("OpenSubtitles download limit reached");
throw new RateLimitExceededException("OpenSubtitles download limit reached");
}
}
var idParts = id.Split('-', 3);
var format = idParts[0];
var language = idParts[1];
var ossId = idParts[2];
await Login(cancellationToken).ConfigureAwait(false);
if (_login is null)
{
throw new AuthenticationException("Unable to login");
}
var fid = int.Parse(ossId, CultureInfo.InvariantCulture);
var idParts = id.Split('-');
if (idParts.Length != 3)
{
throw new FormatException(string.Format(CultureInfo.InvariantCulture, "Invalid subtitle id format: {0}", id));
}
var info = await OpenSubtitlesHandler.OpenSubtitles.GetSubtitleLinkAsync(fid, _login, ApiKey, cancellationToken).ConfigureAwait(false);
var format = idParts[0];
var language = idParts[1];
var fileId = int.Parse(idParts[2], CultureInfo.InvariantCulture);
if (info.Data?.ResetTime != null)
var info = await OpenSubtitlesHandler.OpenSubtitles
.GetSubtitleLinkAsync(fileId, format, _login, ApiKey, cancellationToken)
.ConfigureAwait(false);
if (info.Data?.ResetTime is not null)
{
_limitReset = info.Data.ResetTime;
_logger.LogDebug("Updated expiration time to {ResetTime}", _limitReset);
}
if (!info.Ok)
{
switch (info.Code)
{
_limitReset = info.Data.ResetTime;
_logger.LogDebug("Updated expiration time to {ResetTime}", _limitReset);
}
if (!info.Ok)
{
switch (info.Code)
case HttpStatusCode.NotAcceptable when info.Data?.Remaining <= 0:
{
case HttpStatusCode.NotAcceptable when info.Data?.Remaining <= 0:
if (_login.User is not null)
{
if (_login.User != null)
{
_login.User.RemainingDownloads = 0;
}
_logger.LogError("OpenSubtitles download limit reached");
throw new RateLimitExceededException("OpenSubtitles download limit reached");
_login.User.RemainingDownloads = 0;
}
case HttpStatusCode.Unauthorized:
// JWT token expired, obtain a new one and try again?
_login = null;
return await GetSubtitlesInternal(id, cancellationToken).ConfigureAwait(false);
_logger.LogError("OpenSubtitles download limit reached");
throw new RateLimitExceededException("OpenSubtitles download limit reached");
}
var msg = info.Body.Contains("<html", StringComparison.OrdinalIgnoreCase) ? "[html]" : info.Body;
msg = string.Format(
CultureInfo.InvariantCulture,
"Invalid response for file {0}: {1}\n\n{2}",
fid,
info.Code,
msg);
throw new HttpRequestException(msg);
case HttpStatusCode.Unauthorized:
// JWT token expired, obtain a new one and try again?
_login = null;
return await GetSubtitlesInternal(id, cancellationToken).ConfigureAwait(false);
}
if (_login.User != null)
{
_login.User.RemainingDownloads = info.Data?.Remaining;
_logger.LogInformation("Remaining downloads: {RemainingDownloads}", _login.User.RemainingDownloads);
}
var msg = info.Body.Contains("<html", StringComparison.OrdinalIgnoreCase) ? "[html]" : info.Body;
if (string.IsNullOrWhiteSpace(info.Data?.Link))
{
var msg = string.Format(
CultureInfo.InvariantCulture,
"Failed to obtain download link for file {0}: {1} (empty response)",
fid,
info.Code);
msg = string.Format(
CultureInfo.InvariantCulture,
"Invalid response for file {0}: {1}\n\n{2}",
fileId,
info.Code,
msg);
throw new HttpRequestException(msg);
}
var res = await OpenSubtitlesHandler.OpenSubtitles.DownloadSubtitleAsync(info.Data.Link, cancellationToken).ConfigureAwait(false);
if (res.Code != HttpStatusCode.OK || string.IsNullOrWhiteSpace(res.Body))
{
var msg = string.Format(
CultureInfo.InvariantCulture,
"Subtitle with Id {0} could not be downloaded: {1}",
ossId,
res.Code);
throw new HttpRequestException(msg);
}
return new SubtitleResponse { Format = format, Language = language, Stream = new MemoryStream(Encoding.UTF8.GetBytes(res.Body)) };
throw new HttpRequestException(msg);
}
private async Task Login(CancellationToken cancellationToken)
if (_login.User is not null)
{
if (_login != null && DateTime.UtcNow < _login.ExpirationDate)
{
return;
}
var options = GetOptions();
if (string.IsNullOrWhiteSpace(options.Username) || string.IsNullOrWhiteSpace(options.Password))
{
throw new AuthenticationException("Account username and/or password are not set up");
}
if (options.CredentialsInvalid)
{
_logger.LogDebug("Skipping login due to credentials being invalid");
return;
}
var loginResponse = await OpenSubtitlesHandler.OpenSubtitles.LogInAsync(
options.Username,
options.Password,
ApiKey,
cancellationToken).ConfigureAwait(false);
if (!loginResponse.Ok)
{
// 400 = Using email, 401 = invalid credentials, 403 = invalid api key
if ((loginResponse.Code == HttpStatusCode.BadRequest && options.Username.Contains('@', StringComparison.OrdinalIgnoreCase))
|| loginResponse.Code == HttpStatusCode.Unauthorized
|| (loginResponse.Code == HttpStatusCode.Forbidden && ApiKey == options.CustomApiKey))
{
_logger.LogError("Login failed due to invalid credentials/API key, invalidating them ({Code} - {Body})", loginResponse.Code, loginResponse.Body);
options.CredentialsInvalid = true;
OpenSubtitlesPlugin.Instance!.SaveConfiguration(options);
}
else
{
_logger.LogError("Login failed: {Code} - {Body}", loginResponse.Code, loginResponse.Body);
}
throw new AuthenticationException("Authentication to OpenSubtitles failed.");
}
_login = loginResponse.Data;
await UpdateUserInfo(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Logged in, download limit reset at {ResetTime}, token expiration at {ExpirationDate}", _limitReset, _login?.ExpirationDate);
_login.User.RemainingDownloads = info.Data?.Remaining;
_logger.LogInformation("Remaining downloads: {RemainingDownloads}", _login.User.RemainingDownloads);
}
private async Task UpdateUserInfo(CancellationToken cancellationToken)
if (string.IsNullOrWhiteSpace(info.Data?.Link))
{
if (_login == null)
{
return;
}
var msg = string.Format(
CultureInfo.InvariantCulture,
"Failed to obtain download link for file {0}: {1} (empty response)",
fileId,
info.Code);
var infoResponse = await OpenSubtitlesHandler.OpenSubtitles.GetUserInfo(_login, ApiKey, cancellationToken).ConfigureAwait(false);
if (infoResponse.Ok)
{
_login.User = infoResponse.Data?.Data;
_limitReset = _login.User?.ResetTime;
}
throw new HttpRequestException(msg);
}
private async Task<string> GetLanguage(string language, CancellationToken cancellationToken)
var res = await OpenSubtitlesHandler.OpenSubtitles.DownloadSubtitleAsync(info.Data.Link, cancellationToken).ConfigureAwait(false);
if (res.Code != HttpStatusCode.OK || string.IsNullOrWhiteSpace(res.Body))
{
if (language == "zh")
{
language = "zh-CN";
}
else if (language == "pt")
{
language = "pt-PT";
}
var msg = string.Format(
CultureInfo.InvariantCulture,
"Subtitle with Id {0} could not be downloaded: {1}",
fileId,
res.Code);
if (_languages == null || _languages.Count == 0)
{
var res = await OpenSubtitlesHandler.OpenSubtitles.GetLanguageList(ApiKey, cancellationToken).ConfigureAwait(false);
if (!res.Ok || res.Data?.Data == null)
{
throw new HttpRequestException(string.Format(CultureInfo.InvariantCulture, "Failed to get language list: {0}", res.Code));
}
_languages = res.Data.Data.Where(x => !string.IsNullOrWhiteSpace(x.Code)).Select(x => x.Code!).ToList();
}
var found = _languages.FirstOrDefault(x => string.Equals(x, language, StringComparison.OrdinalIgnoreCase));
if (found != null)
{
return found;
}
if (language.Contains('-', StringComparison.OrdinalIgnoreCase))
{
return await GetLanguage(language.Split('-')[0], cancellationToken).ConfigureAwait(false);
}
throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, "Language '{0}' is not supported", language));
throw new HttpRequestException(msg);
}
private PluginConfiguration GetOptions()
=> OpenSubtitlesPlugin.Instance!.Configuration;
return new SubtitleResponse { Format = format, Language = language, Stream = new MemoryStream(Encoding.UTF8.GetBytes(res.Body)) };
}
private async Task Login(CancellationToken cancellationToken)
{
if (_configuration is null || (_login is not null && DateTime.UtcNow < _login.ExpirationDate))
{
return;
}
if (string.IsNullOrWhiteSpace(_configuration.Username) || string.IsNullOrWhiteSpace(_configuration.Password))
{
throw new AuthenticationException("Account username and/or password are not set up");
}
if (_configuration.CredentialsInvalid)
{
_logger.LogDebug("Skipping login due to credentials being invalid");
return;
}
var loginResponse = await OpenSubtitlesHandler.OpenSubtitles.LogInAsync(
_configuration.Username,
_configuration.Password,
ApiKey,
cancellationToken).ConfigureAwait(false);
if (!loginResponse.Ok)
{
// 400 = Using email, 401 = invalid credentials, 403 = invalid api key
if ((loginResponse.Code == HttpStatusCode.BadRequest && _configuration.Username.Contains('@', StringComparison.OrdinalIgnoreCase))
|| loginResponse.Code == HttpStatusCode.Unauthorized
|| (loginResponse.Code == HttpStatusCode.Forbidden && ApiKey == _configuration.CustomApiKey))
{
_logger.LogError("Login failed due to invalid credentials/API key, invalidating them ({Code} - {Body})", loginResponse.Code, loginResponse.Body);
_configuration.CredentialsInvalid = true;
OpenSubtitlesPlugin.Instance!.SaveConfiguration(_configuration);
}
else
{
_logger.LogError("Login failed: {Code} - {Body}", loginResponse.Code, loginResponse.Body);
}
throw new AuthenticationException("Authentication to OpenSubtitles failed.");
}
_login = loginResponse.Data;
await UpdateUserInfo(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Logged in, download limit reset at {ResetTime}, token expiration at {ExpirationDate}", _limitReset, _login?.ExpirationDate);
}
private async Task UpdateUserInfo(CancellationToken cancellationToken)
{
if (_login is null)
{
return;
}
var infoResponse = await OpenSubtitlesHandler.OpenSubtitles.GetUserInfo(_login, ApiKey, cancellationToken).ConfigureAwait(false);
if (infoResponse.Ok)
{
_login.User = infoResponse.Data?.Data;
_limitReset = _login.User?.ResetTime;
}
}
private async Task<string> GetLanguage(string language, CancellationToken cancellationToken)
{
if (language == "zh")
{
language = "zh-CN";
}
else if (language == "pt")
{
language = "pt-PT";
}
if (_languages is null || _languages.Count == 0)
{
var res = await OpenSubtitlesHandler.OpenSubtitles.GetLanguageList(ApiKey, cancellationToken).ConfigureAwait(false);
if (!res.Ok || res.Data?.Data is null)
{
throw new HttpRequestException(string.Format(CultureInfo.InvariantCulture, "Failed to get language list: {0}", res.Code));
}
_languages = res.Data.Data.Where(x => !string.IsNullOrWhiteSpace(x.Code)).Select(x => x.Code!).ToList();
}
var found = _languages.FirstOrDefault(x => string.Equals(x, language, StringComparison.OrdinalIgnoreCase));
if (found is not null)
{
return found;
}
if (language.Contains('-', StringComparison.OrdinalIgnoreCase))
{
return await GetLanguage(language.Split('-')[0], cancellationToken).ConfigureAwait(false);
}
throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, "Language '{0}' is not supported", language));
}
internal void ConfigurationChanged(PluginConfiguration e)
{
_configuration = e;
// force a login next time a request is made
_login = null;
}
}

View File

@ -2,80 +2,79 @@
using System.Net;
using System.Text.Json;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models;
/// <summary>
/// The api response.
/// </summary>
/// <typeparam name="T">The type of response.</typeparam>
public class ApiResponse<T>
{
/// <summary>
/// The api response.
/// Initializes a new instance of the <see cref="ApiResponse{T}"/> class.
/// </summary>
/// <typeparam name="T">The type of response.</typeparam>
public class ApiResponse<T>
/// <param name="data">The data.</param>
/// <param name="response">The http response.</param>
public ApiResponse(T data, HttpResponse response)
{
/// <summary>
/// Initializes a new instance of the <see cref="ApiResponse{T}"/> class.
/// </summary>
/// <param name="data">The data.</param>
/// <param name="response">The http response.</param>
public ApiResponse(T data, HttpResponse response)
Data = data;
Code = response.Code;
Body = response.Body;
if (!Ok && string.IsNullOrWhiteSpace(Body) && !string.IsNullOrWhiteSpace(response.Reason))
{
Data = data;
Code = response.Code;
Body = response.Body;
if (!Ok && string.IsNullOrWhiteSpace(Body) && !string.IsNullOrWhiteSpace(response.Reason))
{
Body = response.Reason;
}
Body = response.Reason;
}
/// <summary>
/// Initializes a new instance of the <see cref="ApiResponse{T}"/> class.
/// </summary>
/// <param name="response">The http response.</param>
/// <param name="context">The request context.</param>
public ApiResponse(HttpResponse response, params string[] context)
{
Code = response.Code;
Body = response.Body;
if (!Ok && string.IsNullOrWhiteSpace(Body) && !string.IsNullOrWhiteSpace(response.Reason))
{
Body = response.Reason;
}
if (!Ok)
{
// don't bother parsing json if HTTP status code is bad
return;
}
try
{
Data = JsonSerializer.Deserialize<T>(Body) ?? default;
}
catch (Exception ex)
{
throw new JsonException($"Failed to parse response, code: {Code}, context: {string.Join(", ", context)}, body: \n{(string.IsNullOrWhiteSpace(Body) ? "\"\"" : Body)}", ex);
}
}
/// <summary>
/// Gets the status code.
/// </summary>
public HttpStatusCode Code { get; }
/// <summary>
/// Gets the response body.
/// </summary>
public string Body { get; } = string.Empty;
/// <summary>
/// Gets the deserialized data.
/// </summary>
public T? Data { get; }
/// <summary>
/// Gets a value indicating whether the request was successful.
/// </summary>
public bool Ok => (int)Code >= 200 && (int)Code <= 299;
}
/// <summary>
/// Initializes a new instance of the <see cref="ApiResponse{T}"/> class.
/// </summary>
/// <param name="response">The http response.</param>
/// <param name="context">The request context.</param>
public ApiResponse(HttpResponse response, params string[] context)
{
Code = response.Code;
Body = response.Body;
if (!Ok && string.IsNullOrWhiteSpace(Body) && !string.IsNullOrWhiteSpace(response.Reason))
{
Body = response.Reason;
}
if (!Ok)
{
// don't bother parsing json if HTTP status code is bad
return;
}
try
{
Data = JsonSerializer.Deserialize<T>(Body) ?? default;
}
catch (Exception ex)
{
throw new JsonException($"Failed to parse response, code: {Code}, context: {string.Join(", ", context)}, body: \n{(string.IsNullOrWhiteSpace(Body) ? "\"\"" : Body)}", ex);
}
}
/// <summary>
/// Gets the status code.
/// </summary>
public HttpStatusCode Code { get; }
/// <summary>
/// Gets the response body.
/// </summary>
public string Body { get; } = string.Empty;
/// <summary>
/// Gets the deserialized data.
/// </summary>
public T? Data { get; }
/// <summary>
/// Gets a value indicating whether the request was successful.
/// </summary>
public bool Ok => (int)Code >= 200 && (int)Code <= 299;
}

View File

@ -1,16 +1,15 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models;
/// <summary>
/// The error response.
/// </summary>
public class ErrorResponse
{
/// <summary>
/// The error response.
/// Gets or sets the error message.
/// </summary>
public class ErrorResponse
{
/// <summary>
/// Gets or sets the error message.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
}
[JsonPropertyName("message")]
public string? Message { get; set; }
}

View File

@ -1,32 +1,31 @@
using System.Net;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models;
/// <summary>
/// The http response.
/// </summary>
public class HttpResponse
{
/// <summary>
/// The http response.
/// Initializes a new instance of the <see cref="HttpResponse"/> class.
/// </summary>
public class HttpResponse
public HttpResponse()
{
/// <summary>
/// Initializes a new instance of the <see cref="HttpResponse"/> class.
/// </summary>
public HttpResponse()
{
}
/// <summary>
/// Gets the status code.
/// </summary>
public HttpStatusCode Code { get; init; }
/// <summary>
/// Gets the response body.
/// </summary>
public string Body { get; init; } = string.Empty;
/// <summary>
/// Gets the response fail reason.
/// </summary>
public string Reason { get; init; } = string.Empty;
}
/// <summary>
/// Gets the status code.
/// </summary>
public HttpStatusCode Code { get; init; }
/// <summary>
/// Gets the response body.
/// </summary>
public string Body { get; init; } = string.Empty;
/// <summary>
/// Gets the response fail reason.
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@ -1,16 +1,15 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models;
/// <summary>
/// The jwt payload.
/// </summary>
public class JWTPayload
{
/// <summary>
/// The jwt payload.
/// Gets or sets the expiration timestamp.
/// </summary>
public class JWTPayload
{
/// <summary>
/// Gets or sets the expiration timestamp.
/// </summary>
[JsonPropertyName("exp")]
public long Exp { get; set; }
}
[JsonPropertyName("exp")]
public long Exp { get; set; }
}

View File

@ -1,27 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models;
/// <summary>
/// The login model.
/// </summary>
public class LoginInfoInput
{
/// <summary>
/// The login model.
/// Gets or sets the username.
/// </summary>
public class LoginInfoInput
{
/// <summary>
/// Gets or sets the username.
/// </summary>
[Required]
public string Username { get; set; } = null!;
[Required]
public string Username { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the custom api key.
/// </summary>
public string CustomApiKey { get; set; } = null!;
}
/// <summary>
/// Gets or sets the custom api key.
/// </summary>
public string CustomApiKey { get; set; } = null!;
}

View File

@ -2,71 +2,70 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The attributes response.
/// </summary>
public class Attributes
{
/// <summary>
/// The attributes response.
/// Gets or sets the download count.
/// </summary>
public class Attributes
{
/// <summary>
/// Gets or sets the download count.
/// </summary>
[JsonPropertyName("download_count")]
public int DownloadCount { get; set; }
[JsonPropertyName("download_count")]
public int DownloadCount { get; set; }
/// <summary>
/// Gets or sets the subtitle rating.
/// </summary>
[JsonPropertyName("ratings")]
public float Ratings { get; set; }
/// <summary>
/// Gets or sets the subtitle rating.
/// </summary>
[JsonPropertyName("ratings")]
public float Ratings { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this subtitle was from a trusted uploader.
/// </summary>
[JsonPropertyName("from_trusted")]
public bool? FromTrusted { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this subtitle was from a trusted uploader.
/// </summary>
[JsonPropertyName("from_trusted")]
public bool? FromTrusted { get; set; }
/// <summary>
/// Gets or sets the subtitle upload date.
/// </summary>
[JsonPropertyName("upload_date")]
public DateTime UploadDate { get; set; }
/// <summary>
/// Gets or sets the subtitle upload date.
/// </summary>
[JsonPropertyName("upload_date")]
public DateTime UploadDate { get; set; }
/// <summary>
/// Gets or sets the release this subtitle is for.
/// </summary>
[JsonPropertyName("release")]
public string? Release { get; set; }
/// <summary>
/// Gets or sets the release this subtitle is for.
/// </summary>
[JsonPropertyName("release")]
public string? Release { get; set; }
/// <summary>
/// Gets or sets the comments for the subtitle.
/// </summary>
[JsonPropertyName("comments")]
public string? Comments { get; set; }
/// <summary>
/// Gets or sets the comments for the subtitle.
/// </summary>
[JsonPropertyName("comments")]
public string? Comments { get; set; }
/// <summary>
/// Gets or sets the uploader.
/// </summary>
[JsonPropertyName("uploader")]
public Uploader? Uploader { get; set; }
/// <summary>
/// Gets or sets the uploader.
/// </summary>
[JsonPropertyName("uploader")]
public Uploader? Uploader { get; set; }
/// <summary>
/// Gets or sets the feature details.
/// </summary>
[JsonPropertyName("feature_details")]
public FeatureDetails? FeatureDetails { get; set; }
/// <summary>
/// Gets or sets the feature details.
/// </summary>
[JsonPropertyName("feature_details")]
public FeatureDetails? FeatureDetails { get; set; }
/// <summary>
/// Gets or sets the list of files.
/// </summary>
[JsonPropertyName("files")]
public IReadOnlyList<SubFile> Files { get; set; } = Array.Empty<SubFile>();
/// <summary>
/// Gets or sets the list of files.
/// </summary>
[JsonPropertyName("files")]
public IReadOnlyList<SubFile> Files { get; set; } = Array.Empty<SubFile>();
/// <summary>
/// Gets or sets a value indicating whether this was a hash match.
/// </summary>
[JsonPropertyName("moviehash_match")]
public bool? MovieHashMatch { get; set; }
}
/// <summary>
/// Gets or sets a value indicating whether this was a hash match.
/// </summary>
[JsonPropertyName("moviehash_match")]
public bool? MovieHashMatch { get; set; }
}

View File

@ -1,17 +1,16 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The encapsulated language list.
/// </summary>
public class EncapsulatedLanguageList
{
/// <summary>
/// The encapsulated language list.
/// Gets or sets the language list.
/// </summary>
public class EncapsulatedLanguageList
{
/// <summary>
/// Gets or sets the language list.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<LanguageInfo>? Data { get; set; }
}
[JsonPropertyName("data")]
public IReadOnlyList<LanguageInfo>? Data { get; set; }
}

View File

@ -1,16 +1,15 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The encapsulated user info.
/// </summary>
public class EncapsulatedUserInfo
{
/// <summary>
/// The encapsulated user info.
/// Gets or sets the user info data.
/// </summary>
public class EncapsulatedUserInfo
{
/// <summary>
/// Gets or sets the user info data.
/// </summary>
[JsonPropertyName("data")]
public UserInfo? Data { get; set; }
}
[JsonPropertyName("data")]
public UserInfo? Data { get; set; }
}

View File

@ -1,34 +1,33 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The feature details.
/// </summary>
public class FeatureDetails
{
/// <summary>
/// The feature details.
/// Gets or sets the feature type.
/// </summary>
public class FeatureDetails
{
/// <summary>
/// Gets or sets the feature type.
/// </summary>
[JsonPropertyName("feature_type")]
public string? FeatureType { get; set; }
[JsonPropertyName("feature_type")]
public string? FeatureType { get; set; }
/// <summary>
/// Gets or sets the imdb id.
/// </summary>
[JsonPropertyName("imdb_id")]
public int ImdbId { get; set; }
/// <summary>
/// Gets or sets the imdb id.
/// </summary>
[JsonPropertyName("imdb_id")]
public int ImdbId { get; set; }
/// <summary>
/// Gets or sets the season number.
/// </summary>
[JsonPropertyName("season_number")]
public int? SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the season number.
/// </summary>
[JsonPropertyName("season_number")]
public int? SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the episode number.
/// </summary>
[JsonPropertyName("episode_number")]
public int? EpisodeNumber { get; set; }
}
/// <summary>
/// Gets or sets the episode number.
/// </summary>
[JsonPropertyName("episode_number")]
public int? EpisodeNumber { get; set; }
}

View File

@ -1,16 +1,15 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The language info.
/// </summary>
public class LanguageInfo
{
/// <summary>
/// The language info.
/// Gets or sets the language code.
/// </summary>
public class LanguageInfo
{
/// <summary>
/// Gets or sets the language code.
/// </summary>
[JsonPropertyName("language_code")]
public string? Code { get; set; }
}
[JsonPropertyName("language_code")]
public string? Code { get; set; }
}

View File

@ -3,53 +3,52 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The login info.
/// </summary>
public class LoginInfo
{
private DateTime? _expirationDate;
/// <summary>
/// The login info.
/// Gets or sets the user info.
/// </summary>
public class LoginInfo
[JsonPropertyName("user")]
public UserInfo? User { get; set; }
/// <summary>
/// Gets or sets the token.
/// </summary>
[JsonPropertyName("token")]
public string? Token { get; set; }
/// <summary>
/// Gets the expiration date.
/// </summary>
[JsonIgnore]
public DateTime ExpirationDate
{
private DateTime? _expirationDate;
/// <summary>
/// Gets or sets the user info.
/// </summary>
[JsonPropertyName("user")]
public UserInfo? User { get; set; }
/// <summary>
/// Gets or sets the token.
/// </summary>
[JsonPropertyName("token")]
public string? Token { get; set; }
/// <summary>
/// Gets the expiration date.
/// </summary>
[JsonIgnore]
public DateTime ExpirationDate
get
{
get
if (_expirationDate.HasValue)
{
if (_expirationDate.HasValue)
{
return _expirationDate.Value;
}
if (string.IsNullOrWhiteSpace(Token))
{
return DateTime.MinValue;
}
var part = Token.Split('.')[1];
part = part.PadRight(part.Length + ((4 - (part.Length % 4)) % 4), '=');
part = Encoding.UTF8.GetString(Convert.FromBase64String(part));
var sec = JsonSerializer.Deserialize<JWTPayload>(part)?.Exp ?? 0;
_expirationDate = DateTimeOffset.FromUnixTimeSeconds(sec).UtcDateTime;
return _expirationDate.Value;
}
if (string.IsNullOrWhiteSpace(Token))
{
return DateTime.MinValue;
}
var part = Token.Split('.')[1];
part = part.PadRight(part.Length + ((4 - (part.Length % 4)) % 4), '=');
part = Encoding.UTF8.GetString(Convert.FromBase64String(part));
var sec = JsonSerializer.Deserialize<JWTPayload>(part)?.Exp ?? 0;
_expirationDate = DateTimeOffset.FromUnixTimeSeconds(sec).UtcDateTime;
return _expirationDate.Value;
}
}
}

View File

@ -1,16 +1,15 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The response data.
/// </summary>
public class ResponseData
{
/// <summary>
/// The response data.
/// Gets or sets the response attributes.
/// </summary>
public class ResponseData
{
/// <summary>
/// Gets or sets the response attributes.
/// </summary>
[JsonPropertyName("attributes")]
public Attributes? Attributes { get; set; }
}
[JsonPropertyName("attributes")]
public Attributes? Attributes { get; set; }
}

View File

@ -2,29 +2,28 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The search result.
/// </summary>
public class SearchResult
{
/// <summary>
/// The search result.
/// Gets or sets the total page count.
/// </summary>
public class SearchResult
{
/// <summary>
/// Gets or sets the total page count.
/// </summary>
[JsonPropertyName("total_pages")]
public int TotalPages { get; set; }
[JsonPropertyName("total_pages")]
public int TotalPages { get; set; }
/// <summary>
/// Gets or sets the current page.
/// </summary>
[JsonPropertyName("page")]
public int Page { get; set; }
/// <summary>
/// Gets or sets the current page.
/// </summary>
[JsonPropertyName("page")]
public int Page { get; set; }
/// <summary>
/// Gets or sets the list of response data.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<ResponseData> Data { get; set; } = Array.Empty<ResponseData>();
}
/// <summary>
/// Gets or sets the list of response data.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<ResponseData> Data { get; set; } = Array.Empty<ResponseData>();
}

View File

@ -1,16 +1,15 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The sub file.
/// </summary>
public class SubFile
{
/// <summary>
/// The sub file.
/// Gets or sets the file id.
/// </summary>
public class SubFile
{
/// <summary>
/// Gets or sets the file id.
/// </summary>
[JsonPropertyName("file_id")]
public int FileId { get; set; }
}
[JsonPropertyName("file_id")]
public int? FileId { get; set; }
}

View File

@ -1,29 +1,28 @@
using System;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The subtitle download info.
/// </summary>
public class SubtitleDownloadInfo
{
/// <summary>
/// The subtitle download info.
/// Gets or sets the subtitle download link.
/// </summary>
public class SubtitleDownloadInfo
{
/// <summary>
/// Gets or sets the subtitle download link.
/// </summary>
[JsonPropertyName("link")]
public string? Link { get; set; }
[JsonPropertyName("link")]
public string? Link { get; set; }
/// <summary>
/// Gets or sets the remaining download count.
/// </summary>
[JsonPropertyName("remaining")]
public int Remaining { get; set; }
/// <summary>
/// Gets or sets the remaining download count.
/// </summary>
[JsonPropertyName("remaining")]
public int Remaining { get; set; }
/// <summary>
/// Gets or sets the reset time.
/// </summary>
[JsonPropertyName("reset_time_utc")]
public DateTime? ResetTime { get; set; }
}
/// <summary>
/// Gets or sets the reset time.
/// </summary>
[JsonPropertyName("reset_time_utc")]
public DateTime? ResetTime { get; set; }
}

View File

@ -1,16 +1,15 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The uploader.
/// </summary>
public class Uploader
{
/// <summary>
/// The uploader.
/// Gets or sets the uploader name.
/// </summary>
public class Uploader
{
/// <summary>
/// Gets or sets the uploader name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
}
[JsonPropertyName("name")]
public string? Name { get; set; }
}

View File

@ -1,29 +1,28 @@
using System;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
/// <summary>
/// The user info.
/// </summary>
public class UserInfo
{
/// <summary>
/// The user info.
/// Gets or sets the allowed downloads count.
/// </summary>
public class UserInfo
{
/// <summary>
/// Gets or sets the allowed downloads count.
/// </summary>
[JsonPropertyName("allowed_downloads")]
public int AllowedDownloads { get; set; }
[JsonPropertyName("allowed_downloads")]
public int AllowedDownloads { get; set; }
/// <summary>
/// Gets or sets the remaining download count.
/// </summary>
[JsonPropertyName("remaining_downloads")]
public int? RemainingDownloads { get; set; }
/// <summary>
/// Gets or sets the remaining download count.
/// </summary>
[JsonPropertyName("remaining_downloads")]
public int? RemainingDownloads { get; set; }
/// <summary>
/// Gets or sets the timestamp in which the download count resets.
/// </summary>
[JsonPropertyName("reset_time_utc")]
public DateTime ResetTime { get; set; }
}
/// <summary>
/// Gets or sets the timestamp in which the download count resets.
/// </summary>
[JsonPropertyName("reset_time_utc")]
public DateTime ResetTime { get; set; }
}

View File

@ -7,176 +7,181 @@ using System.Threading.Tasks;
using Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models;
using Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models.Responses;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler;
/// <summary>
/// The open subtitles helper class.
/// </summary>
public static class OpenSubtitles
{
/// <summary>
/// The open subtitles helper class.
/// Login.
/// </summary>
public static class OpenSubtitles
/// <param name="username">The username.</param>
/// <param name="password">The password.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The api response.</returns>
public static async Task<ApiResponse<LoginInfo>> LogInAsync(string username, string password, string apiKey, CancellationToken cancellationToken)
{
/// <summary>
/// Login.
/// </summary>
/// <param name="username">The username.</param>
/// <param name="password">The password.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The api response.</returns>
public static async Task<ApiResponse<LoginInfo>> LogInAsync(string username, string password, string apiKey, CancellationToken cancellationToken)
{
var body = new { username, password };
var response = await RequestHandler.SendRequestAsync("/login", HttpMethod.Post, body, null, apiKey, 1, cancellationToken).ConfigureAwait(false);
var body = new { username, password };
var response = await RequestHandler.SendRequestAsync("/login", HttpMethod.Post, body, null, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<LoginInfo>(response);
return new ApiResponse<LoginInfo>(response);
}
/// <summary>
/// Logout.
/// </summary>
/// <param name="user">The user information.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>logout status.</returns>
public static async Task<bool> LogOutAsync(LoginInfo user, string apiKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(user.Token);
var headers = new Dictionary<string, string> { { "Authorization", user.Token } };
var response = await RequestHandler.SendRequestAsync("/logout", HttpMethod.Delete, null, headers, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<object>(response).Ok;
}
/// <summary>
/// Get user info.
/// </summary>
/// <param name="user">The user information.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The encapsulated user info.</returns>
public static async Task<ApiResponse<EncapsulatedUserInfo>> GetUserInfo(LoginInfo user, string apiKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(user.Token);
var headers = new Dictionary<string, string> { { "Authorization", user.Token } };
var response = await RequestHandler.SendRequestAsync("/infos/user", HttpMethod.Get, null, headers, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<EncapsulatedUserInfo>(response);
}
/// <summary>
/// Get the subtitle link.
/// </summary>
/// <param name="file">The subtitle file.</param>
/// <param name="format">The subtitle format.</param>
/// <param name="user">The user information.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The subtitle download info.</returns>
public static async Task<ApiResponse<SubtitleDownloadInfo>> GetSubtitleLinkAsync(
int file,
string format,
LoginInfo user,
string apiKey,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(user.Token);
var headers = new Dictionary<string, string> { { "Authorization", user.Token } };
var body = new { file_id = file, sub_format = format };
var response = await RequestHandler.SendRequestAsync("/download", HttpMethod.Post, body, headers, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<SubtitleDownloadInfo>(response, $"file id: {file}");
}
/// <summary>
/// Download subtitle.
/// </summary>
/// <param name="url">the subtitle url.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The Http response.</returns>
public static async Task<HttpResponse> DownloadSubtitleAsync(string url, CancellationToken cancellationToken)
{
var response = await OpenSubtitlesRequestHelper.Instance!.SendRequestAsync(
url,
HttpMethod.Get,
null,
new Dictionary<string, string>(),
cancellationToken).ConfigureAwait(false);
return new HttpResponse
{
Body = response.body,
Code = response.statusCode
};
}
/// <summary>
/// Search for subtitle.
/// </summary>
/// <param name="options">The search options.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The list of response data.</returns>
public static async Task<ApiResponse<IReadOnlyList<ResponseData>>> SearchSubtitlesAsync(Dictionary<string, string> options, string apiKey, CancellationToken cancellationToken)
{
var opts = new Dictionary<string, string>();
foreach (var (key, value) in options)
{
opts.Add(key.ToLowerInvariant(), value.ToLowerInvariant());
}
/// <summary>
/// Logout.
/// </summary>
/// <param name="user">The user information.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>logout status.</returns>
public static async Task<bool> LogOutAsync(LoginInfo user, string apiKey, CancellationToken cancellationToken)
var max = -1;
var current = 1;
List<ResponseData> final = new ();
ApiResponse<SearchResult> last;
HttpResponse response;
do
{
ArgumentException.ThrowIfNullOrEmpty(user.Token);
var headers = new Dictionary<string, string> { { "Authorization", user.Token } };
var response = await RequestHandler.SendRequestAsync("/logout", HttpMethod.Delete, null, headers, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<object>(response).Ok;
}
/// <summary>
/// Get user info.
/// </summary>
/// <param name="user">The user information.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The encapsulated user info.</returns>
public static async Task<ApiResponse<EncapsulatedUserInfo>> GetUserInfo(LoginInfo user, string apiKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(user.Token);
var headers = new Dictionary<string, string> { { "Authorization", user.Token } };
var response = await RequestHandler.SendRequestAsync("/infos/user", HttpMethod.Get, null, headers, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<EncapsulatedUserInfo>(response);
}
/// <summary>
/// Get the subtitle link.
/// </summary>
/// <param name="file">The subtitle file.</param>
/// <param name="user">The user information.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The subtitle download info.</returns>
public static async Task<ApiResponse<SubtitleDownloadInfo>> GetSubtitleLinkAsync(int file, LoginInfo user, string apiKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(user.Token);
var headers = new Dictionary<string, string> { { "Authorization", user.Token } };
var body = new { file_id = file };
var response = await RequestHandler.SendRequestAsync("/download", HttpMethod.Post, body, headers, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<SubtitleDownloadInfo>(response, $"file id: {file}");
}
/// <summary>
/// Download subtitle.
/// </summary>
/// <param name="url">the subtitle url.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The Http response.</returns>
public static async Task<HttpResponse> DownloadSubtitleAsync(string url, CancellationToken cancellationToken)
{
var response = await OpenSubtitlesRequestHelper.Instance!.SendRequestAsync(
url,
HttpMethod.Get,
null,
new Dictionary<string, string>(),
cancellationToken).ConfigureAwait(false);
return new HttpResponse
if (current > 1)
{
Body = response.body,
Code = response.statusCode
};
}
/// <summary>
/// Search for subtitle.
/// </summary>
/// <param name="options">The search options.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The list of response data.</returns>
public static async Task<ApiResponse<IReadOnlyList<ResponseData>>> SearchSubtitlesAsync(Dictionary<string, string> options, string apiKey, CancellationToken cancellationToken)
{
var opts = new Dictionary<string, string>();
foreach (var (key, value) in options)
{
opts.Add(key.ToLowerInvariant(), value.ToLowerInvariant());
options["page"] = current.ToString(CultureInfo.InvariantCulture);
}
var max = -1;
var current = 1;
var url = RequestHandler.AddQueryString("/subtitles", opts);
response = await RequestHandler.SendRequestAsync(url, HttpMethod.Get, null, null, apiKey, 1, cancellationToken).ConfigureAwait(false);
List<ResponseData> final = new ();
ApiResponse<SearchResult> last;
HttpResponse response;
last = new ApiResponse<SearchResult>(response, $"url: {url}", $"page: {current}");
do
if (!last.Ok || last.Data is null)
{
if (current > 1)
{
options["page"] = current.ToString(CultureInfo.InvariantCulture);
}
var url = RequestHandler.AddQueryString("/subtitles", opts);
response = await RequestHandler.SendRequestAsync(url, HttpMethod.Get, null, null, apiKey, 1, cancellationToken).ConfigureAwait(false);
last = new ApiResponse<SearchResult>(response, $"url: {url}", $"page: {current}");
if (!last.Ok || last.Data == null)
{
break;
}
if (last.Data.TotalPages == 0)
{
break;
}
if (max == -1)
{
max = last.Data.TotalPages;
}
current = last.Data.Page + 1;
final.AddRange(last.Data.Data);
break;
}
while (current <= max);
return new ApiResponse<IReadOnlyList<ResponseData>>(final, response);
if (last.Data.TotalPages == 0)
{
break;
}
if (max == -1)
{
max = last.Data.TotalPages;
}
current = last.Data.Page + 1;
final.AddRange(last.Data.Data);
}
while (current <= max);
/// <summary>
/// Get language list.
/// </summary>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The list of languages.</returns>
public static async Task<ApiResponse<EncapsulatedLanguageList>> GetLanguageList(string apiKey, CancellationToken cancellationToken)
{
var response = await RequestHandler.SendRequestAsync("/infos/languages", HttpMethod.Get, null, null, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<IReadOnlyList<ResponseData>>(final, response);
}
return new ApiResponse<EncapsulatedLanguageList>(response);
}
/// <summary>
/// Get language list.
/// </summary>
/// <param name="apiKey">The api key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The list of languages.</returns>
public static async Task<ApiResponse<EncapsulatedLanguageList>> GetLanguageList(string apiKey, CancellationToken cancellationToken)
{
var response = await RequestHandler.SendRequestAsync("/infos/languages", HttpMethod.Get, null, null, apiKey, 1, cancellationToken).ConfigureAwait(false);
return new ApiResponse<EncapsulatedLanguageList>(response);
}
}

View File

@ -10,105 +10,104 @@ using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler;
/// <summary>
/// Http util helper.
/// </summary>
public class OpenSubtitlesRequestHelper
{
private readonly IHttpClientFactory _clientFactory;
private readonly string _version;
/// <summary>
/// Http util helper.
/// Initializes a new instance of the <see cref="OpenSubtitlesRequestHelper"/> class.
/// </summary>
public class OpenSubtitlesRequestHelper
/// <param name="factory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="version">The plugin version.</param>
public OpenSubtitlesRequestHelper(IHttpClientFactory factory, string version)
{
private readonly IHttpClientFactory _clientFactory;
private readonly string _version;
_clientFactory = factory;
_version = version;
}
/// <summary>
/// Initializes a new instance of the <see cref="OpenSubtitlesRequestHelper"/> class.
/// </summary>
/// <param name="factory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="version">The plugin version.</param>
public OpenSubtitlesRequestHelper(IHttpClientFactory factory, string version)
/// <summary>
/// Gets or sets the current instance.
/// </summary>
public static OpenSubtitlesRequestHelper? Instance { get; set; }
/// <summary>
/// Calculates: size + 64bit chksum of the first and last 64k (even if they overlap because the file is smaller than 128k).
/// </summary>
/// <param name="input">The input stream.</param>
/// <returns>The hash as Hexadecimal string.</returns>
public static string ComputeHash(Stream input)
{
const int HashLength = 8; // 64 bit hash
const long HashPos = 64 * 1024; // 64k
long streamsize = input.Length;
ulong hash = (ulong)streamsize;
Span<byte> buffer = stackalloc byte[HashLength];
while (input.Position < HashPos && input.Read(buffer) > 0)
{
_clientFactory = factory;
_version = version;
hash += BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
/// <summary>
/// Gets or sets the current instance.
/// </summary>
public static OpenSubtitlesRequestHelper? Instance { get; set; }
/// <summary>
/// Calculates: size + 64bit chksum of the first and last 64k (even if they overlap because the file is smaller than 128k).
/// </summary>
/// <param name="input">The input stream.</param>
/// <returns>The hash as Hexadecimal string.</returns>
public static string ComputeHash(Stream input)
input.Seek(-HashPos, SeekOrigin.End);
while (input.Read(buffer) > 0)
{
const int HashLength = 8; // 64 bit hash
const long HashPos = 64 * 1024; // 64k
long streamsize = input.Length;
ulong hash = (ulong)streamsize;
Span<byte> buffer = stackalloc byte[HashLength];
while (input.Position < HashPos && input.Read(buffer) > 0)
{
hash += BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
input.Seek(-HashPos, SeekOrigin.End);
while (input.Read(buffer) > 0)
{
hash += BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
BinaryPrimitives.WriteUInt64BigEndian(buffer, hash);
return Convert.ToHexString(buffer).ToLowerInvariant();
hash += BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
internal async Task<(string body, Dictionary<string, string> headers, HttpStatusCode statusCode)> SendRequestAsync(
string url,
HttpMethod method,
object? body,
Dictionary<string, string> headers,
CancellationToken cancellationToken)
BinaryPrimitives.WriteUInt64BigEndian(buffer, hash);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
internal async Task<(string body, Dictionary<string, string> headers, HttpStatusCode statusCode)> SendRequestAsync(
string url,
HttpMethod method,
object? body,
Dictionary<string, string> headers,
CancellationToken cancellationToken)
{
var client = _clientFactory.CreateClient("Default");
HttpContent? content = null;
if (method != HttpMethod.Get && body is not null)
{
var client = _clientFactory.CreateClient("Default");
HttpContent? content = null;
if (method != HttpMethod.Get && body != null)
{
content = JsonContent.Create(body);
}
using var request = new HttpRequestMessage
{
Method = method,
RequestUri = new Uri(url),
Content = content,
Headers =
{
UserAgent = { new ProductInfoHeaderValue("Jellyfin-Plugin-OpenSubtitles", _version) },
Accept = { new MediaTypeWithQualityHeaderValue("*/*") }
}
};
foreach (var (key, value) in headers)
{
if (string.Equals(key, "authorization", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", value);
}
else
{
request.Headers.Add(key, value);
}
}
var result = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
var resHeaders = result.Headers.ToDictionary(x => x.Key.ToLowerInvariant(), x => x.Value.First());
var resBody = await result.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return (resBody, resHeaders, result.StatusCode);
content = JsonContent.Create(body);
}
using var request = new HttpRequestMessage
{
Method = method,
RequestUri = new Uri(url),
Content = content,
Headers =
{
UserAgent = { new ProductInfoHeaderValue("Jellyfin-Plugin-OpenSubtitles", _version) },
Accept = { new MediaTypeWithQualityHeaderValue("*/*") },
}
};
foreach (var (key, value) in headers)
{
if (string.Equals(key, "authorization", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", value);
}
else
{
request.Headers.Add(key, value);
}
}
var result = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
var resHeaders = result.Headers.ToDictionary(x => x.Key.ToLowerInvariant(), x => x.Value.First());
var resBody = await result.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return (resBody, resHeaders, result.StatusCode);
}
}

View File

@ -9,149 +9,148 @@ using System.Threading.Tasks;
using System.Web;
using Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler.Models;
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler
namespace Jellyfin.Plugin.OpenSubtitles.OpenSubtitlesHandler;
/// <summary>
/// The request handler.
/// </summary>
public static class RequestHandler
{
private const string BaseApiUrl = "https://api.opensubtitles.com/api/v1";
// header rate limits (5/1s & 240/1 min)
private static int _hRemaining = -1;
private static int _hReset = -1;
// 40/10s limits
private static DateTime _windowStart = DateTime.MinValue;
private static int _requestCount;
/// <summary>
/// The request handler.
/// Send the request.
/// </summary>
public static class RequestHandler
/// <param name="endpoint">The endpoint to send request to.</param>
/// <param name="method">The method.</param>
/// <param name="body">The request body.</param>
/// <param name="headers">The headers.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="attempt">The request attempt key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The response.</returns>
/// <exception cref="ArgumentException">API Key is empty.</exception>
public static async Task<HttpResponse> SendRequestAsync(
string endpoint,
HttpMethod method,
object? body,
Dictionary<string, string>? headers,
string? apiKey,
int attempt,
CancellationToken cancellationToken)
{
private const string BaseApiUrl = "https://api.opensubtitles.com/api/v1";
headers ??= new Dictionary<string, string>();
// header rate limits (5/1s & 240/1 min)
private static int _hRemaining = -1;
private static int _hReset = -1;
// 40/10s limits
private static DateTime _windowStart = DateTime.MinValue;
private static int _requestCount;
/// <summary>
/// Send the request.
/// </summary>
/// <param name="endpoint">The endpoint to send request to.</param>
/// <param name="method">The method.</param>
/// <param name="body">The request body.</param>
/// <param name="headers">The headers.</param>
/// <param name="apiKey">The api key.</param>
/// <param name="attempt">The request attempt key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The response.</returns>
/// <exception cref="ArgumentException">API Key is empty.</exception>
public static async Task<HttpResponse> SendRequestAsync(
string endpoint,
HttpMethod method,
object? body,
Dictionary<string, string>? headers,
string? apiKey,
int attempt,
CancellationToken cancellationToken)
if (string.IsNullOrWhiteSpace(apiKey))
{
headers ??= new Dictionary<string, string>();
throw new ArgumentException("Provided API key is blank", nameof(apiKey));
}
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new ArgumentException("Provided API key is blank", nameof(apiKey));
}
headers.TryAdd("Api-Key", apiKey);
if (_hRemaining == 0)
{
await Task.Delay(1000 * _hReset, cancellationToken).ConfigureAwait(false);
_hRemaining = -1;
_hReset = -1;
}
headers.TryAdd("Api-Key", apiKey);
if (_hRemaining == 0)
if (_requestCount == 40)
{
var diff = DateTime.UtcNow.Subtract(_windowStart).TotalSeconds;
if (diff <= 10)
{
await Task.Delay(1000 * _hReset, cancellationToken).ConfigureAwait(false);
await Task.Delay(1000 * (int)Math.Ceiling(10 - diff), cancellationToken).ConfigureAwait(false);
_hRemaining = -1;
_hReset = -1;
}
if (_requestCount == 40)
{
var diff = DateTime.UtcNow.Subtract(_windowStart).TotalSeconds;
if (diff <= 10)
{
await Task.Delay(1000 * (int)Math.Ceiling(10 - diff), cancellationToken).ConfigureAwait(false);
_hRemaining = -1;
_hReset = -1;
}
}
if (DateTime.UtcNow.Subtract(_windowStart).TotalSeconds >= 10)
{
_windowStart = DateTime.UtcNow;
_requestCount = 0;
}
var response = await OpenSubtitlesRequestHelper.Instance!.SendRequestAsync(BaseApiUrl + endpoint, method, body, headers, cancellationToken).ConfigureAwait(false);
_requestCount++;
if (response.headers.TryGetValue("x-ratelimit-remaining-second", out var value))
{
_ = int.TryParse(value, out _hRemaining);
}
if (response.headers.TryGetValue("ratelimit-reset", out value))
{
_ = int.TryParse(value, out _hReset);
}
if (response.statusCode == HttpStatusCode.TooManyRequests && attempt <= 4)
{
var time = _hReset == -1 ? 5 : _hReset;
await Task.Delay(time * 1000, cancellationToken).ConfigureAwait(false);
return await SendRequestAsync(endpoint, method, body, headers, apiKey, attempt + 1, cancellationToken).ConfigureAwait(false);
}
if (response.statusCode == HttpStatusCode.BadGateway && attempt <= 3)
{
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
return await SendRequestAsync(endpoint, method, body, headers, apiKey, attempt + 1, cancellationToken).ConfigureAwait(false);
}
if (!response.headers.TryGetValue("x-reason", out value))
{
value = string.Empty;
}
if ((int)response.statusCode >= 400 && (int)response.statusCode <= 499)
{
// Wait 1s after a 4xx response
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
return new HttpResponse
{
Body = response.body,
Code = response.statusCode,
Reason = value
};
}
/// <summary>
/// Append the given query keys and values to the URI.
/// </summary>
/// <param name="path">The base URI.</param>
/// <param name="param">A dictionary of query keys and values to append.</param>
/// <returns>The combined result.</returns>
public static string AddQueryString(string path, Dictionary<string, string> param)
if (DateTime.UtcNow.Subtract(_windowStart).TotalSeconds >= 10)
{
if (param.Count == 0)
{
return path;
}
var url = new StringBuilder(path);
url.Append('?');
foreach (var (key, value) in param.OrderBy(x => x.Key))
{
url.Append(HttpUtility.UrlEncode(key))
.Append('=')
.Append(HttpUtility.UrlEncode(value))
.Append('&');
}
url.Length -= 1; // Remove last &
return url.ToString();
_windowStart = DateTime.UtcNow;
_requestCount = 0;
}
var response = await OpenSubtitlesRequestHelper.Instance!.SendRequestAsync(BaseApiUrl + endpoint, method, body, headers, cancellationToken).ConfigureAwait(false);
_requestCount++;
if (response.headers.TryGetValue("x-ratelimit-remaining-second", out var value))
{
_ = int.TryParse(value, out _hRemaining);
}
if (response.headers.TryGetValue("ratelimit-reset", out value))
{
_ = int.TryParse(value, out _hReset);
}
if (response.statusCode == HttpStatusCode.TooManyRequests && attempt <= 4)
{
var time = _hReset == -1 ? 5 : _hReset;
await Task.Delay(time * 1000, cancellationToken).ConfigureAwait(false);
return await SendRequestAsync(endpoint, method, body, headers, apiKey, attempt + 1, cancellationToken).ConfigureAwait(false);
}
if (response.statusCode == HttpStatusCode.BadGateway && attempt <= 3)
{
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
return await SendRequestAsync(endpoint, method, body, headers, apiKey, attempt + 1, cancellationToken).ConfigureAwait(false);
}
if (!response.headers.TryGetValue("x-reason", out value))
{
value = string.Empty;
}
if ((int)response.statusCode >= 400 && (int)response.statusCode <= 499)
{
// Wait 1s after a 4xx response
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
return new HttpResponse
{
Body = response.body,
Code = response.statusCode,
Reason = value
};
}
/// <summary>
/// Append the given query keys and values to the URI.
/// </summary>
/// <param name="path">The base URI.</param>
/// <param name="param">A dictionary of query keys and values to append.</param>
/// <returns>The combined result.</returns>
public static string AddQueryString(string path, Dictionary<string, string> param)
{
if (param.Count == 0)
{
return path;
}
var url = new StringBuilder(path);
url.Append('?');
foreach (var (key, value) in param.OrderBy(x => x.Key))
{
url.Append(HttpUtility.UrlEncode(key))
.Append('=')
.Append(HttpUtility.UrlEncode(value))
.Append('&');
}
url.Length -= 1; // Remove last &
return url.ToString();
}
}

View File

@ -6,58 +6,64 @@ using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.OpenSubtitles
namespace Jellyfin.Plugin.OpenSubtitles;
/// <summary>
/// The open subtitles plugin.
/// </summary>
public class OpenSubtitlesPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// The open subtitles plugin.
/// Default API key to use when performing an API call.
/// </summary>
public class OpenSubtitlesPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
public const string ApiKey = "gUCLWGoAg2PmyseoTM0INFFVPcDCeDlT";
/// <summary>
/// Initializes a new instance of the <see cref="OpenSubtitlesPlugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public OpenSubtitlesPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
/// <summary>
/// Default API key to use when performing an API call.
/// </summary>
public const string ApiKey = "gUCLWGoAg2PmyseoTM0INFFVPcDCeDlT";
Instance = this;
/// <summary>
/// Initializes a new instance of the <see cref="OpenSubtitlesPlugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public OpenSubtitlesPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
ConfigurationChanged += (_, _) =>
{
Instance = this;
}
OpenSubtitleDownloader.Instance?.ConfigurationChanged(this.Configuration);
};
/// <inheritdoc />
public override string Name
=> "Open Subtitles";
OpenSubtitleDownloader.Instance?.ConfigurationChanged(this.Configuration);
}
/// <inheritdoc />
public override Guid Id
=> Guid.Parse("4b9ed42f-5185-48b5-9803-6ff2989014c4");
/// <inheritdoc />
public override string Name
=> "Open Subtitles";
/// <summary>
/// Gets the plugin instance.
/// </summary>
public static OpenSubtitlesPlugin? Instance { get; private set; }
/// <inheritdoc />
public override Guid Id
=> Guid.Parse("4b9ed42f-5185-48b5-9803-6ff2989014c4");
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
/// <summary>
/// Gets the plugin instance.
/// </summary>
public static OpenSubtitlesPlugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
return new[]
new PluginPageInfo
{
new PluginPageInfo
{
Name = "opensubtitles",
EmbeddedResourcePath = GetType().Namespace + ".Web.opensubtitles.html",
},
new PluginPageInfo
{
Name = "opensubtitlesjs",
EmbeddedResourcePath = GetType().Namespace + ".Web.opensubtitles.js"
}
};
}
Name = "opensubtitles",
EmbeddedResourcePath = GetType().Namespace + ".Web.opensubtitles.html",
},
new PluginPageInfo
{
Name = "opensubtitlesjs",
EmbeddedResourcePath = GetType().Namespace + ".Web.opensubtitles.js"
}
};
}
}

View File

@ -0,0 +1,18 @@
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Subtitles;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.OpenSubtitles;
/// <summary>
/// Register subtitle provider.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<ISubtitleProvider, OpenSubtitleDownloader>();
}
}

View File

@ -2,9 +2,9 @@
<div data-role="content">
<div class="content-primary">
<form id="OpenSubtitlesConfigForm">
<div style="display: none;" id="expiredCredentialsWarning" class="padded-left padded-right padded-bottom emby-select-withcolor">
<h2 style="color: #ff2828;"><strong>Warning!</strong></h2>
<p>Your login credentials seem to have changed, please update them in order for the plugin to work.</p>
<div style="display: none;" id="expiredCredentialsWarning" class="padded-left padded-right padded-bottom padded-top emby-select-withcolor">
<h2 style="color: #ff2828; margin: 0;"><strong>Warning!</strong></h2>
<p style="margin: 0;">Your login credentials seem to have changed, please update them in order for the plugin to work.</p>
</div>
<h3 class="sectionTitle sectionTitle-cards"><b>In order for this plugin to work you need to enter your OpenSubtitles.com account info below</b></h3>
<div class="inputContainer">

View File

@ -26,7 +26,7 @@ This is a plugin allows you to download subtitles from [Open Subtitles](https://
## Build
1. To build this plugin you will need [.Net 6.x](https://dotnet.microsoft.com/download/dotnet/6.0).
1. To build this plugin you will need [.Net 8.x](https://dotnet.microsoft.com/download/dotnet/8.0).
2. Build plugin with following command
```

View File

@ -6,7 +6,7 @@ targetAbi: "10.9.0.0"
framework: "net8.0"
owner: "jellyfin"
overview: "Download subtitles for your media"
description: "Download subtitles from the internet to use with your media files."
description: "Download subtitles from the internet to use with your media files. (Requires configuration)"
category: "Metadata"
artifacts:
- "Jellyfin.Plugin.OpenSubtitles.dll"