mirror of
https://github.com/jellyfin/jellyfin-plugin-opensubtitles.git
synced 2024-11-22 21:59:52 +00:00
10.9 support (#115)
This commit is contained in:
parent
6d8ee4eb7a
commit
ecc270c915
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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!;
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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>();
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
18
Jellyfin.Plugin.OpenSubtitles/PluginServiceRegistrator.cs
Normal file
18
Jellyfin.Plugin.OpenSubtitles/PluginServiceRegistrator.cs
Normal 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>();
|
||||
}
|
||||
}
|
@ -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">
|
||||
|
@ -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
|
||||
```
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user