diff --git a/src/NzbDrone.Core/Datastore/Migration/022_orpheus_api.cs b/src/NzbDrone.Core/Datastore/Migration/022_orpheus_api.cs new file mode 100644 index 000000000..3907af367 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/022_orpheus_api.cs @@ -0,0 +1,63 @@ +using System.Data; +using FluentMigrator; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(22)] + public class orpheus_api : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(MigrateToRedactedApi); + } + + private void MigrateToRedactedApi(IDbConnection conn, IDbTransaction tran) + { + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Indexers\" WHERE \"Implementation\" = 'Orpheus'"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var settings = reader.GetString(1); + if (!string.IsNullOrWhiteSpace(settings)) + { + var jsonObject = Json.Deserialize(settings); + + // Remove username + if (jsonObject.ContainsKey("username")) + { + jsonObject.Remove("username"); + } + + // Remove password + if (jsonObject.ContainsKey("password")) + { + jsonObject.Remove("password"); + } + + // write new json back to db, switch to new ConfigContract, and disable the indexer + settings = jsonObject.ToJson(); + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "UPDATE \"Indexers\" SET \"Settings\" = ?, \"ConfigContract\" = ?, \"Enable\" = 0 WHERE \"Id\" = ?"; + updateCmd.AddParameter(settings); + updateCmd.AddParameter("OrpheusSettings"); + updateCmd.AddParameter(id); + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Definitions/Gazelle/GazelleSettings.cs b/src/NzbDrone.Core/Indexers/Definitions/Gazelle/GazelleSettings.cs index 3461543d8..8f3dc85ea 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/Gazelle/GazelleSettings.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/Gazelle/GazelleSettings.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Core.Indexers.Gazelle public string AuthKey; public string PassKey; - [FieldDefinition(4, Type = FieldType.Checkbox, Label = "Use Freeleech Token", HelpText = "Use Freeleech Token")] + [FieldDefinition(4, Type = FieldType.Checkbox, Label = "Use Freeleech Token", HelpText = "Use freeleech tokens when available")] public bool UseFreeleechToken { get; set; } } } diff --git a/src/NzbDrone.Core/Indexers/Definitions/Orpheus.cs b/src/NzbDrone.Core/Indexers/Definitions/Orpheus.cs index cd7286542..b52287137 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/Orpheus.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/Orpheus.cs @@ -1,25 +1,51 @@ +using System; using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using FluentValidation; using NLog; using NzbDrone.Common.Http; +using NzbDrone.Core.Annotations; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers.Exceptions; using NzbDrone.Core.Indexers.Gazelle; +using NzbDrone.Core.Indexers.Settings; +using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Definitions { - public class Orpheus : Gazelle.Gazelle + public class Orpheus : TorrentIndexerBase { public override string Name => "Orpheus"; public override string[] IndexerUrls => new string[] { "https://orpheus.network/" }; public override string Description => "Orpheus (APOLLO) is a Private Torrent Tracker for MUSIC"; + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; public override IndexerPrivacy Privacy => IndexerPrivacy.Private; + public override IndexerCapabilities Capabilities => SetCapabilities(); + public override bool SupportsRedirect => true; public Orpheus(IIndexerHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) - : base(httpClient, eventAggregator, indexerStatusService, configService, logger) + : base(httpClient, eventAggregator, indexerStatusService, configService, logger) { } - protected override IndexerCapabilities SetCapabilities() + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new OrpheusRequestGenerator() { Settings = Settings, Capabilities = Capabilities, HttpClient = _httpClient }; + } + + public override IParseIndexerResponse GetParser() + { + return new OrpheusParser(Settings, Capabilities.Categories); + } + + private IndexerCapabilities SetCapabilities() { var caps = new IndexerCapabilities { @@ -44,23 +70,252 @@ namespace NzbDrone.Core.Indexers.Definitions return caps; } - public override IParseIndexerResponse GetParser() + public override async Task Download(Uri link) + { + var request = new HttpRequestBuilder(link.AbsoluteUri) + .SetHeader("Authorization", $"token {Settings.Apikey}") + .Build(); + + var downloadBytes = Array.Empty(); + + try + { + var response = await _httpClient.ExecuteProxiedAsync(request, Definition); + downloadBytes = response.ResponseData; + + if (downloadBytes.Length >= 1 + && downloadBytes[0] != 'd' // simple test for torrent vs HTML content + && link.Query.Contains("usetoken=1")) + { + var html = Encoding.GetString(downloadBytes); + if (html.Contains("You do not have any freeleech tokens left.") + || html.Contains("You do not have enough freeleech tokens") + || html.Contains("This torrent is too large.") + || html.Contains("You cannot use tokens here")) + { + // download again without usetoken + request.Url = new HttpUri(link.ToString().Replace("&usetoken=1", "")); + + response = await _httpClient.ExecuteProxiedAsync(request, Definition); + downloadBytes = response.ResponseData; + } + } + } + catch (Exception) + { + _indexerStatusService.RecordFailure(Definition.Id); + _logger.Error("Download failed"); + } + + return downloadBytes; + } + } + + public class OrpheusRequestGenerator : IIndexerRequestGenerator + { + public OrpheusSettings Settings { get; set; } + public IndexerCapabilities Capabilities { get; set; } + public Func> GetCookies { get; set; } + public Action, DateTime?> CookiesUpdater { get; set; } + public IIndexerHttpClient HttpClient { get; set; } + + public OrpheusRequestGenerator() + { + } + + public IndexerPageableRequestChain GetSearchRequests(MusicSearchCriteria searchCriteria) { - return new OrpheusParser(Settings, Capabilities); + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetRequest(string.Format("&artistname={0}&groupname={1}", searchCriteria.Artist, searchCriteria.Album))); + + return pageableRequests; + } + + public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetRequest(searchCriteria.SanitizedSearchTerm)); + + return pageableRequests; + } + + public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + + public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + + public IndexerPageableRequestChain GetSearchRequests(BasicSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetRequest(searchCriteria.SanitizedSearchTerm)); + + return pageableRequests; + } + + private IEnumerable GetRequest(string searchParameters) + { + var req = RequestBuilder() + .Resource($"ajax.php?action=browse&searchstr={searchParameters}") + .Build(); + + yield return new IndexerRequest(req); + } + + private HttpRequestBuilder RequestBuilder() + { + return new HttpRequestBuilder($"{Settings.BaseUrl.Trim().TrimEnd('/')}") + .Accept(HttpAccept.Json) + .SetHeader("Authorization", $"token {Settings.Apikey}"); } } - public class OrpheusParser : GazelleParser + public class OrpheusParser : IParseIndexerResponse { - public OrpheusParser(GazelleSettings settings, IndexerCapabilities capabilities) - : base(settings, capabilities) + private readonly OrpheusSettings _settings; + private readonly IndexerCapabilitiesCategories _categories; + public Action, DateTime?> CookiesUpdater { get; set; } + + public OrpheusParser(OrpheusSettings settings, IndexerCapabilitiesCategories categories) { + _settings = settings; + _categories = categories; } - protected override string GetDownloadUrl(int torrentId) + public IList ParseResponse(IndexerResponse indexerResponse) { + var torrentInfos = new List(); + + if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new IndexerException(indexerResponse, $"Unexpected response status {indexerResponse.HttpResponse.StatusCode} code from API request"); + } + + if (!indexerResponse.HttpResponse.Headers.ContentType.Contains(HttpAccept.Json.Value)) + { + throw new IndexerException(indexerResponse, $"Unexpected response header {indexerResponse.HttpResponse.Headers.ContentType} from API request, expected {HttpAccept.Json.Value}"); + } + + var jsonResponse = new HttpResponse(indexerResponse.HttpResponse); + if (jsonResponse.Resource.Status != "success" || + string.IsNullOrWhiteSpace(jsonResponse.Resource.Status) || + jsonResponse.Resource.Response == null) + { + return torrentInfos; + } + + foreach (var result in jsonResponse.Resource.Response.Results) + { + if (result.Torrents != null) + { + foreach (var torrent in result.Torrents) + { + var id = torrent.TorrentId; + var artist = WebUtility.HtmlDecode(result.Artist); + var album = WebUtility.HtmlDecode(result.GroupName); + + var title = $"{result.Artist} - {result.GroupName} ({result.GroupYear}) [{torrent.Format} {torrent.Encoding}] [{torrent.Media}]"; + if (torrent.HasCue) + { + title += " [Cue]"; + } + + var infoUrl = GetInfoUrl(result.GroupId, id); + + GazelleInfo release = new GazelleInfo() + { + Guid = infoUrl, + + // Splice Title from info to avoid calling API again for every torrent. + Title = WebUtility.HtmlDecode(title), + + Container = torrent.Encoding, + Codec = torrent.Format, + Size = long.Parse(torrent.Size), + DownloadUrl = GetDownloadUrl(id, torrent.CanUseToken), + InfoUrl = infoUrl, + Seeders = int.Parse(torrent.Seeders), + Peers = int.Parse(torrent.Leechers) + int.Parse(torrent.Seeders), + PublishDate = torrent.Time.ToUniversalTime(), + Scene = torrent.Scene, + Freeleech = torrent.IsFreeLeech || torrent.IsPersonalFreeLeech, + Files = torrent.FileCount, + Grabs = torrent.Snatches, + DownloadVolumeFactor = torrent.IsFreeLeech || torrent.IsNeutralLeech || torrent.IsPersonalFreeLeech ? 0 : 1, + UploadVolumeFactor = torrent.IsNeutralLeech ? 0 : 1 + }; + + var category = torrent.Category; + if (category == null || category.Contains("Select Category")) + { + release.Categories = _categories.MapTrackerCatToNewznab("1"); + } + else + { + release.Categories = _categories.MapTrackerCatDescToNewznab(category); + } + + torrentInfos.Add(release); + } + } + + // Non-Audio files are formatted a little differently (1:1 for group and torrents) + else + { + var id = result.TorrentId; + var infoUrl = GetInfoUrl(result.GroupId, id); + + GazelleInfo release = new GazelleInfo() + { + Guid = infoUrl, + Title = WebUtility.HtmlDecode(result.GroupName), + Size = long.Parse(result.Size), + DownloadUrl = GetDownloadUrl(id, result.CanUseToken), + InfoUrl = infoUrl, + Seeders = int.Parse(result.Seeders), + Peers = int.Parse(result.Leechers) + int.Parse(result.Seeders), + PublishDate = DateTimeOffset.FromUnixTimeSeconds(ParseUtil.CoerceLong(result.GroupTime)).UtcDateTime, + Freeleech = result.IsFreeLeech || result.IsPersonalFreeLeech, + Files = result.FileCount, + Grabs = result.Snatches, + DownloadVolumeFactor = result.IsFreeLeech || result.IsNeutralLeech || result.IsPersonalFreeLeech ? 0 : 1, + UploadVolumeFactor = result.IsNeutralLeech ? 0 : 1 + }; + + var category = result.Category; + if (category == null || category.Contains("Select Category")) + { + release.Categories = _categories.MapTrackerCatToNewznab("1"); + } + else + { + release.Categories = _categories.MapTrackerCatDescToNewznab(category); + } + + torrentInfos.Add(release); + } + } + + // order by date + return + torrentInfos + .OrderByDescending(o => o.PublishDate) + .ToArray(); + } + + private string GetDownloadUrl(int torrentId, bool canUseToken) + { + // AuthKey is required but not checked, just pass in a dummy variable + // to avoid having to track authkey, which is randomly cycled var url = new HttpUri(_settings.BaseUrl) - .CombinePath("/torrents.php") + .CombinePath("/ajax.php") .AddQueryParam("action", "download") .AddQueryParam("id", torrentId); @@ -72,5 +327,45 @@ namespace NzbDrone.Core.Indexers.Definitions return url.FullUri; } + + private string GetInfoUrl(string groupId, int torrentId) + { + var url = new HttpUri(_settings.BaseUrl) + .CombinePath("/torrents.php") + .AddQueryParam("id", groupId) + .AddQueryParam("torrentid", torrentId); + + return url.FullUri; + } + } + + public class OrpheusSettingsValidator : AbstractValidator + { + public OrpheusSettingsValidator() + { + RuleFor(c => c.Apikey).NotEmpty(); + } + } + + public class OrpheusSettings : NoAuthTorrentBaseSettings + { + private static readonly OrpheusSettingsValidator Validator = new OrpheusSettingsValidator(); + + public OrpheusSettings() + { + Apikey = ""; + UseFreeleechToken = false; + } + + [FieldDefinition(2, Label = "API Key", HelpText = "API Key from the Site (Found in Settings => Access Settings)", Privacy = PrivacyLevel.ApiKey)] + public string Apikey { get; set; } + + [FieldDefinition(3, Label = "Use Freeleech Tokens", HelpText = "Use freeleech tokens when available", Type = FieldType.Checkbox)] + public bool UseFreeleechToken { get; set; } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } } } diff --git a/src/NzbDrone.Core/Indexers/Definitions/Redacted.cs b/src/NzbDrone.Core/Indexers/Definitions/Redacted.cs index 9fe615e10..6fe62739e 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/Redacted.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/Redacted.cs @@ -27,8 +27,6 @@ namespace NzbDrone.Core.Indexers.Definitions public override string Name => "Redacted"; public override string[] IndexerUrls => new string[] { "https://redacted.ch/" }; public override string Description => "REDActed (Aka.PassTheHeadPhones) is one of the most well-known music trackers."; - public override string Language => "en-US"; - public override Encoding Encoding => Encoding.UTF8; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override IndexerCapabilities Capabilities => SetCapabilities(); @@ -82,10 +80,26 @@ namespace NzbDrone.Core.Indexers.Definitions return caps; } - protected override async Task Test(List failures) + public override async Task Download(Uri link) { - ((RedactedRequestGenerator)GetRequestGenerator()).FetchPasskey(); - await base.Test(failures); + var request = new HttpRequestBuilder(link.AbsoluteUri) + .SetHeader("Authorization", Settings.Apikey) + .Build(); + + var downloadBytes = Array.Empty(); + + try + { + var response = await _httpClient.ExecuteProxiedAsync(request, Definition); + downloadBytes = response.ResponseData; + } + catch (Exception) + { + _indexerStatusService.RecordFailure(Definition.Id); + _logger.Error("Download failed"); + } + + return downloadBytes; } } @@ -138,24 +152,6 @@ namespace NzbDrone.Core.Indexers.Definitions return pageableRequests; } - public void FetchPasskey() - { - // GET on index for the passkey - var request = RequestBuilder().Resource("ajax.php?action=index").Build(); - var indexResponse = HttpClient.Execute(request); - var index = Json.Deserialize(indexResponse.Content); - if (index == null || - string.IsNullOrWhiteSpace(index.Status) || - index.Status != "success" || - string.IsNullOrWhiteSpace(index.Response.Passkey)) - { - throw new Exception("Failed to authenticate with Redacted."); - } - - // Set passkey on settings so it can be used to generate the download URL - Settings.Passkey = index.Response.Passkey; - } - private IEnumerable GetRequest(string searchParameters) { var req = RequestBuilder() @@ -311,11 +307,9 @@ namespace NzbDrone.Core.Indexers.Definitions // AuthKey is required but not checked, just pass in a dummy variable // to avoid having to track authkey, which is randomly cycled var url = new HttpUri(_settings.BaseUrl) - .CombinePath("/torrents.php") + .CombinePath("/ajax.php") .AddQueryParam("action", "download") .AddQueryParam("id", torrentId) - .AddQueryParam("authkey", "prowlarr") - .AddQueryParam("torrent_pass", _settings.Passkey) .AddQueryParam("usetoken", (_settings.UseFreeleechToken && canUseToken) ? 1 : 0); return url.FullUri; @@ -347,7 +341,6 @@ namespace NzbDrone.Core.Indexers.Definitions public RedactedSettings() { Apikey = ""; - Passkey = ""; UseFreeleechToken = false; } @@ -357,8 +350,6 @@ namespace NzbDrone.Core.Indexers.Definitions [FieldDefinition(3, Label = "Use Freeleech Tokens", HelpText = "Use freeleech tokens when available", Type = FieldType.Checkbox)] public bool UseFreeleechToken { get; set; } - public string Passkey { get; set; } - public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this));