Merge pull request #29 from cheir-mneme/feature/indexer-management

feat: Sprint 3 - Multi-media indexer support
This commit is contained in:
Cody Kickertz 2025-12-18 15:13:59 -06:00 committed by GitHub
commit 1bfa716745
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1073 additions and 10 deletions

View file

@ -55,6 +55,7 @@ dotnet_diagnostic.IDE0018.severity = error
# Stylecop Rules
dotnet_diagnostic.SA0001.severity = none
dotnet_diagnostic.SA1200.severity = none
dotnet_diagnostic.SA1025.severity = none
dotnet_diagnostic.SA1101.severity = none
dotnet_diagnostic.SA1116.severity = none

View file

@ -6,7 +6,7 @@ All-in-one media manager for movies, books, and audiobooks.
Aletheia (from Greek ἀλήθεια - "truth, disclosure") is a unified media management system forked from Radarr. It provides automated monitoring, downloading, and library management for multiple media types through a single interface.
**Current Status:** Early development. Movie functionality inherited from Radarr is working. Book and audiobook support is planned.
**Current Status:** Active development. Movie functionality inherited from Radarr is working. Multi-media foundation being implemented.
## Features
@ -15,20 +15,21 @@ Aletheia (from Greek ἀλήθεια - "truth, disclosure") is a unified media m
- Metadata and artwork management
- Integration with download clients and indexers
**Books (planned):**
**Books (in development):**
- EPUB, MOBI, PDF quality tracking
- Author and series hierarchy
- Goodreads/Hardcover metadata
**Audiobooks (planned):**
**Audiobooks (in development):**
- M4B, MP3, FLAC support
- Narrator tracking and duration metadata
- Audible metadata integration
- Narrator tracking (competitive differentiator)
- Duration metadata and Audible integration
**General:**
- Usenet and BitTorrent support
- SABnzbd, NZBGet, qBittorrent, Deluge, rTorrent, Transmission integration
- Plex and Kodi integration
- Built-in archive extraction (Unpackerr functionality)
## Privacy
@ -69,13 +70,40 @@ dotnet run --project src/Radarr
## Roadmap
1. **Foundation** - Generalize database schema, implement hierarchical monitoring (Author → Series → Item)
2. **Multi-Media** - Add book and audiobook quality profiles, port metadata providers
3. **Interface** - Unified dashboard with type filters, type-specific detail views
See [ROADMAP.md](../ROADMAP.md) for detailed phase planning.
**Completed:**
- Phase 0-1: Privacy & security fixes
- Phase 2: Foundation (fork, CI/CD, branding)
- Phase 2.5: Community standards, quality gates, Unpackerr absorption
**Current:**
- Phase 3: Multi-media foundation (database generalization, indexer management)
**Planned:**
- Phase 4: Books & audiobooks support
- Phase 5: TV shows
- Phase 6: Music (with fingerprinting and quality analysis)
- Phase 7: Subtitles (Bazarr replacement), podcasts, comics
## Key Differences from Radarr
| Feature | Radarr | Aletheia |
|---------|--------|----------|
| Media types | Movies only | Movies, books, audiobooks (planned: TV, music, podcasts) |
| Telemetry | Enabled by default | Disabled by default |
| Indexer management | External (Prowlarr) | Built-in (planned) |
| Archive extraction | External (Unpackerr) | Built-in |
| Narrator tracking | N/A | Native support for audiobooks |
## Contributing
Early development phase. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and code guidelines.
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code guidelines, and PR process.
**Development standards:**
- Conventional commits (`feat:`, `fix:`, `docs:`, etc.)
- Feature branches + PRs to `develop`
- Pre-commit hooks for linting
## License

View file

@ -0,0 +1,48 @@
import React from 'react';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import { MediaType } from 'Movie/Movie';
import translate from 'Utilities/String/translate';
interface MediaTypeBadgeProps {
mediaType?: MediaType;
className?: string;
}
function getKindForMediaType(mediaType?: MediaType) {
switch (mediaType) {
case 'book':
return kinds.INFO;
case 'audiobook':
return kinds.SUCCESS;
case 'movie':
default:
return kinds.PRIMARY;
}
}
function getLabelForMediaType(mediaType?: MediaType) {
switch (mediaType) {
case 'book':
return translate('Book');
case 'audiobook':
return translate('Audiobook');
case 'movie':
default:
return translate('Movie');
}
}
function MediaTypeBadge({ mediaType, className }: MediaTypeBadgeProps) {
return (
<Label
className={className}
kind={getKindForMediaType(mediaType)}
size={sizes.SMALL}
>
{getLabelForMediaType(mediaType)}
</Label>
);
}
export default MediaTypeBadge;

View file

@ -7,6 +7,7 @@ import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import MediaTypeBadge from 'Components/MediaTypeBadge';
import MovieTagList from 'Components/MovieTagList';
import RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
@ -90,6 +91,7 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
originalTitle,
originalLanguage,
tags = [],
mediaType,
} = movie;
const { sizeOnDisk = 0 } = statistics;
@ -234,6 +236,12 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
</div>
) : null}
{mediaType && mediaType !== 'movie' ? (
<div className={styles.title}>
<MediaTypeBadge mediaType={mediaType} />
</div>
) : null}
{showMonitored ? (
<div className={styles.title}>
{monitored ? translate('Monitored') : translate('Unmonitored')}

View file

@ -4,6 +4,8 @@ import { MovieFile } from 'MovieFile/MovieFile';
export type MovieMonitor = 'movieOnly' | 'movieAndCollection' | 'none';
export type MediaType = 'movie' | 'book' | 'audiobook';
export type MovieStatus =
| 'tba'
| 'announced'
@ -58,6 +60,7 @@ export interface MovieAddOptions {
interface Movie extends ModelBase {
tmdbId: number;
imdbId?: string;
mediaType?: MediaType;
sortTitle: string;
overview: string;
youTubeTrailerId?: string;

View file

@ -0,0 +1,14 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(243)]
public class add_mediatype_to_indexers : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("Indexers").AddColumn("SupportedMediaTypes").AsString().WithDefaultValue("[\"Movie\"]");
}
}
}

View file

@ -26,6 +26,7 @@
using NzbDrone.Core.Jobs;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaTypes;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Movies.AlternativeTitles;
@ -202,6 +203,7 @@ private static void RegisterMappers()
SqlMapper.AddTypeHandler(new DapperLanguageIntConverter());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<Language>>(new LanguageIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<string>>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<MediaType>>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<ParsedMovieInfo>(new QualityIntConverter(), new LanguageIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<ReleaseInfo>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<PendingReleaseAdditionalInfo>());

View file

@ -0,0 +1,34 @@
namespace NzbDrone.Core.IndexerSearch.Definitions
{
public class AudiobookSearchCriteria : SearchCriteriaBase
{
public string Author { get; set; }
public string Title { get; set; }
public string Narrator { get; set; }
public string ASIN { get; set; }
public string ISBN { get; set; }
public int? Year { get; set; }
public bool? Abridged { get; set; }
public override string ToString()
{
if (!string.IsNullOrWhiteSpace(Author) && !string.IsNullOrWhiteSpace(Title))
{
var result = $"[{Author} - {Title}";
if (!string.IsNullOrWhiteSpace(Narrator))
{
result += $" (narrated by {Narrator})";
}
return result + "]";
}
if (!string.IsNullOrWhiteSpace(ASIN))
{
return $"[ASIN: {ASIN}]";
}
return $"[{Title ?? Author ?? "Unknown"}]";
}
}
}

View file

@ -0,0 +1,26 @@
namespace NzbDrone.Core.IndexerSearch.Definitions
{
public class BookSearchCriteria : SearchCriteriaBase
{
public string Author { get; set; }
public string Title { get; set; }
public string ISBN { get; set; }
public string Publisher { get; set; }
public int? Year { get; set; }
public override string ToString()
{
if (!string.IsNullOrWhiteSpace(Author) && !string.IsNullOrWhiteSpace(Title))
{
return $"[{Author} - {Title}]";
}
if (!string.IsNullOrWhiteSpace(ISBN))
{
return $"[ISBN: {ISBN}]";
}
return $"[{Title ?? Author ?? "Unknown"}]";
}
}
}

View file

@ -40,6 +40,16 @@ public virtual IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
private IEnumerable<IndexerRequest> GetRequest(string searchType, string parameters)
{
if (Settings.Categories.Empty())

View file

@ -34,6 +34,16 @@ public virtual IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
private bool TryAddSearchParameters(TorrentQuery query, SearchCriteriaBase searchCriteria)
{
if (searchCriteria.Movie.MovieMetadata.Value.ImdbId.IsNullOrWhiteSpace())

View file

@ -2,6 +2,7 @@
using System.Threading.Tasks;
using NzbDrone.Common.Http;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.MediaTypes;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider;
@ -12,6 +13,7 @@ public interface IIndexer : IProvider
bool SupportsRss { get; }
bool SupportsSearch { get; }
DownloadProtocol Protocol { get; }
IEnumerable<MediaType> SupportedMediaTypes { get; }
Task<IList<ReleaseInfo>> FetchRecent();
Task<IList<ReleaseInfo>> Fetch(MovieSearchCriteria searchCriteria);

View file

@ -8,6 +8,8 @@ public interface IIndexerRequestGenerator
{
IndexerPageableRequestChain GetRecentRequests();
IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria);
IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria);
IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria);
Func<IDictionary<string, string>> GetCookies { get; set; }
Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}

View file

@ -23,6 +23,16 @@ public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchC
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
private IEnumerable<IndexerRequest> GetRssRequests()
{
yield return new IndexerRequest(Settings.BaseUrl, HttpAccept.Rss);

View file

@ -9,6 +9,7 @@
using NzbDrone.Core.Configuration;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaTypes;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider;
@ -29,6 +30,7 @@ public abstract class IndexerBase<TSettings> : IIndexer
public abstract bool SupportsRss { get; }
public abstract bool SupportsSearch { get; }
public virtual IEnumerable<MediaType> SupportedMediaTypes => new[] { MediaType.Movie };
public IndexerBase(IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger)
{

View file

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using Equ;
using NzbDrone.Core.MediaTypes;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.Indexers
@ -13,6 +15,7 @@ public class IndexerDefinition : ProviderDefinition, IEquatable<IndexerDefinitio
public IndexerDefinition()
{
Priority = DefaultPriority;
SupportedMediaTypes = new List<MediaType> { MediaType.Movie };
}
[MemberwiseEqualityIgnore]
@ -29,6 +32,7 @@ public IndexerDefinition()
public bool EnableInteractiveSearch { get; set; }
public int DownloadClientId { get; set; }
public int Priority { get; set; }
public List<MediaType> SupportedMediaTypes { get; set; }
[MemberwiseEqualityIgnore]
public override bool Enable => EnableRss || EnableAutomaticSearch || EnableInteractiveSearch;

View file

@ -0,0 +1,35 @@
using System.Collections.Generic;
using NLog;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaTypes;
using NzbDrone.Core.Parser;
namespace NzbDrone.Core.Indexers.MyAnonamouse
{
public class MyAnonamouse : HttpIndexerBase<MyAnonamouseSettings>
{
public override string Name => "MyAnonamouse";
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
public override bool SupportsRss => true;
public override bool SupportsSearch => true;
public override int PageSize => 100;
public override IEnumerable<MediaType> SupportedMediaTypes => new[] { MediaType.Book, MediaType.Audiobook };
public MyAnonamouse(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger)
: base(httpClient, indexerStatusService, configService, parsingService, logger)
{
}
public override IIndexerRequestGenerator GetRequestGenerator()
{
return new MyAnonamouseRequestGenerator(Settings, _logger);
}
public override IParseIndexerResponse GetParser()
{
return new MyAnonamouseParser(Settings, _logger);
}
}
}

View file

@ -0,0 +1,59 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace NzbDrone.Core.Indexers.MyAnonamouse
{
public class MyAnonamouseResponse
{
public string Error { get; set; }
public IReadOnlyCollection<MyAnonamouseTorrent> Data { get; set; }
public string Message { get; set; }
}
public class MyAnonamouseTorrent
{
public int Id { get; set; }
public string Title { get; set; }
[JsonProperty(PropertyName = "author_info")]
public string AuthorInfo { get; set; }
public string Description { get; set; }
[JsonProperty(PropertyName = "lang_code")]
public string LanguageCode { get; set; }
public string Filetype { get; set; }
public bool Vip { get; set; }
public bool Free { get; set; }
[JsonProperty(PropertyName = "personal_freeleech")]
public bool PersonalFreeLeech { get; set; }
[JsonProperty(PropertyName = "fl_vip")]
public bool FreeVip { get; set; }
public string Category { get; set; }
public string Added { get; set; }
[JsonProperty(PropertyName = "times_completed")]
public int Grabs { get; set; }
public int Seeders { get; set; }
public int Leechers { get; set; }
public int NumFiles { get; set; }
public string Size { get; set; }
}
public class MyAnonamouseBuyFreeleechResponse
{
public bool Success { get; set; }
public string Error { get; set; }
}
public class MyAnonamouseUserDataResponse
{
[JsonProperty(PropertyName = "classname")]
public string UserClass { get; set; }
}
}

View file

@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using Newtonsoft.Json;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Indexers.Exceptions;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.Indexers.MyAnonamouse
{
public class MyAnonamouseParser : IParseIndexerResponse
{
private readonly MyAnonamouseSettings _settings;
private readonly Logger _logger;
public MyAnonamouseParser(MyAnonamouseSettings settings, Logger logger = null)
{
_settings = settings;
_logger = logger ?? LogManager.GetCurrentClassLogger();
}
public IList<ReleaseInfo> ParseResponse(IndexerResponse indexerResponse)
{
var torrentInfos = new List<ReleaseInfo>();
if (indexerResponse.HttpResponse.StatusCode == HttpStatusCode.Forbidden)
{
throw new IndexerException(indexerResponse, "[403 Forbidden] - mam_id expired or invalid");
}
if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK)
{
throw new IndexerException(indexerResponse,
"Unexpected response status {0} code from API request",
indexerResponse.HttpResponse.StatusCode);
}
MyAnonamouseResponse jsonResponse;
try
{
jsonResponse = JsonConvert.DeserializeObject<MyAnonamouseResponse>(indexerResponse.Content);
}
catch (JsonException ex)
{
throw new IndexerException(indexerResponse, "Failed to parse JSON response from MyAnonamouse: {0}", ex.Message);
}
if (jsonResponse == null)
{
throw new IndexerException(indexerResponse, "Empty response from MyAnonamouse");
}
if (jsonResponse.Error.IsNotNullOrWhiteSpace() && jsonResponse.Error.StartsWithIgnoreCase("Nothing returned, out of"))
{
return torrentInfos;
}
if (jsonResponse.Data == null)
{
throw new IndexerException(indexerResponse,
"Unexpected response content: {0}",
jsonResponse.Message ?? "Check the logs for more information");
}
foreach (var item in jsonResponse.Data)
{
var id = item.Id;
var title = item.Title;
if (item.AuthorInfo != null)
{
try
{
var authorInfo = JsonConvert.DeserializeObject<Dictionary<string, string>>(item.AuthorInfo);
var author = authorInfo?.Take(5).Select(v => v.Value).Join(", ");
if (author.IsNotNullOrWhiteSpace())
{
title += " by " + author;
}
}
catch (JsonException ex)
{
_logger.Debug(ex, "Failed to parse author info for torrent {0}", id);
}
}
var flags = new List<string>();
if (item.LanguageCode.IsNotNullOrWhiteSpace())
{
flags.Add(item.LanguageCode);
}
if (item.Filetype.IsNotNullOrWhiteSpace())
{
flags.Add(item.Filetype.ToUpper());
}
if (flags.Count > 0)
{
title += " [" + flags.Join(" / ") + "]";
}
if (item.Vip)
{
title += " [VIP]";
}
var isFreeLeech = item.Free || item.PersonalFreeLeech || item.FreeVip;
torrentInfos.Add(new TorrentInfo
{
Guid = $"MyAnonamouse-{id}",
Title = title,
Size = ParseSize(item.Size),
DownloadUrl = GetDownloadUrl(id),
InfoUrl = GetInfoUrl(id),
Seeders = item.Seeders,
Peers = item.Leechers + item.Seeders,
PublishDate = ParseDate(item.Added),
IndexerFlags = GetIndexerFlags(isFreeLeech)
});
}
return torrentInfos;
}
private static IndexerFlags GetIndexerFlags(bool isFreeLeech)
{
IndexerFlags flags = 0;
if (isFreeLeech)
{
flags |= IndexerFlags.G_Freeleech;
}
return flags;
}
private static long ParseSize(string sizeString)
{
if (sizeString.IsNullOrWhiteSpace())
{
return 0;
}
if (long.TryParse(sizeString, out var size))
{
return size;
}
var parts = sizeString.Trim().Split(' ');
if (parts.Length != 2 || !double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
{
return 0;
}
var unit = parts[1].ToUpperInvariant();
return unit switch
{
"B" => (long)value,
"KB" => (long)(value * 1024),
"MB" => (long)(value * 1024 * 1024),
"GB" => (long)(value * 1024 * 1024 * 1024),
"TB" => (long)(value * 1024 * 1024 * 1024 * 1024),
_ => 0
};
}
private static DateTime ParseDate(string dateString)
{
if (DateTime.TryParseExact(dateString, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date))
{
return date.ToLocalTime();
}
return DateTime.UtcNow;
}
private string GetDownloadUrl(int torrentId)
{
var url = new HttpUri(_settings.BaseUrl)
.CombinePath("/tor/download.php")
.AddQueryParam("tid", torrentId);
return url.FullUri;
}
private string GetInfoUrl(int torrentId)
{
var url = new HttpUri(_settings.BaseUrl)
.CombinePath("/t/")
.CombinePath(torrentId.ToString());
return url.FullUri;
}
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}
}

View file

@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text.RegularExpressions;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.IndexerSearch.Definitions;
namespace NzbDrone.Core.Indexers.MyAnonamouse
{
public class MyAnonamouseRequestGenerator : IIndexerRequestGenerator
{
private static readonly Regex SanitizeSearchQueryRegex = new ("[^\\w]+", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
public MyAnonamouseSettings Settings { get; set; }
private readonly Logger _logger;
public MyAnonamouseRequestGenerator(MyAnonamouseSettings settings, Logger logger)
{
Settings = settings;
_logger = logger;
}
public virtual IndexerPageableRequestChain GetRecentRequests()
{
var pageableRequests = new IndexerPageableRequestChain();
pageableRequests.Add(GetPagedRequests(string.Empty));
return pageableRequests;
}
public virtual IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
var pageableRequests = new IndexerPageableRequestChain();
var searchTerm = BuildSearchTerm(searchCriteria);
if (searchTerm.IsNotNullOrWhiteSpace())
{
pageableRequests.Add(GetPagedRequests(searchTerm));
}
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
var pageableRequests = new IndexerPageableRequestChain();
var searchTerm = BuildSearchTerm(searchCriteria);
if (searchTerm.IsNotNullOrWhiteSpace())
{
pageableRequests.Add(GetPagedRequests(searchTerm));
}
return pageableRequests;
}
private string BuildSearchTerm(SearchCriteriaBase searchCriteria)
{
var terms = new List<string>();
if (searchCriteria is BookSearchCriteria bookCriteria)
{
if (bookCriteria.Author.IsNotNullOrWhiteSpace())
{
terms.Add(bookCriteria.Author);
}
if (bookCriteria.Title.IsNotNullOrWhiteSpace())
{
terms.Add(bookCriteria.Title);
}
}
else if (searchCriteria is AudiobookSearchCriteria audiobookCriteria)
{
if (audiobookCriteria.Author.IsNotNullOrWhiteSpace())
{
terms.Add(audiobookCriteria.Author);
}
if (audiobookCriteria.Title.IsNotNullOrWhiteSpace())
{
terms.Add(audiobookCriteria.Title);
}
}
return string.Join(" ", terms);
}
private IEnumerable<IndexerRequest> GetPagedRequests(string term)
{
var sanitizedTerm = SanitizeSearchQueryRegex.Replace(term, " ").Trim();
if (term.IsNotNullOrWhiteSpace() && sanitizedTerm.IsNullOrWhiteSpace())
{
_logger.Debug("Search term is empty after sanitization, skipping. Original: '{0}'", term);
yield break;
}
var searchType = Settings.SearchType switch
{
(int)MyAnonamouseSearchType.Active => "active",
(int)MyAnonamouseSearchType.Freeleech => "fl",
(int)MyAnonamouseSearchType.FreeleechOrVip => "fl-VIP",
(int)MyAnonamouseSearchType.Vip => "VIP",
(int)MyAnonamouseSearchType.NotVip => "nVIP",
_ => "all"
};
var parameters = new NameValueCollection
{
{ "tor[text]", sanitizedTerm },
{ "tor[searchType]", searchType },
{ "tor[srchIn][title]", "true" },
{ "tor[srchIn][author]", "true" },
{ "tor[srchIn][narrator]", "true" },
{ "tor[searchIn]", "torrents" },
{ "tor[sortType]", "default" },
{ "tor[perpage]", "100" },
{ "tor[startNumber]", "0" },
{ "thumbnails", "1" },
{ "description", "1" }
};
if (Settings.SearchInDescription)
{
parameters.Set("tor[srchIn][description]", "true");
}
if (Settings.SearchInSeries)
{
parameters.Set("tor[srchIn][series]", "true");
}
if (Settings.SearchInFilenames)
{
parameters.Set("tor[srchIn][filenames]", "true");
}
parameters.Set("tor[cat][]", "0");
var searchUrl = Settings.BaseUrl.TrimEnd('/') + "/tor/js/loadSearchJSONbasic.php";
if (parameters.Count > 0)
{
searchUrl += "?" + parameters.ToQueryString();
}
var requestBuilder = new HttpRequestBuilder(searchUrl)
.Accept(HttpAccept.Json);
var cookies = GetCookies?.Invoke();
if (cookies != null && cookies.TryGetValue("mam_id", out var mamId) && mamId.IsNotNullOrWhiteSpace())
{
requestBuilder.SetCookies(cookies);
}
else if (Settings.MamId.IsNotNullOrWhiteSpace())
{
requestBuilder.SetCookies(new Dictionary<string, string> { { "mam_id", Settings.MamId } });
}
yield return new IndexerRequest(requestBuilder.Build());
}
public Func<IDictionary<string, string>> GetCookies { get; set; }
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}
internal static class NameValueCollectionExtensions
{
public static string ToQueryString(this NameValueCollection nvc)
{
var items = new List<string>();
foreach (var key in nvc.AllKeys)
{
items.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(nvc[key])}");
}
return string.Join("&", items);
}
}
}

View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using Equ;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Indexers.MyAnonamouse
{
public class MyAnonamouseSettingsValidator : AbstractValidator<MyAnonamouseSettings>
{
public MyAnonamouseSettingsValidator()
{
RuleFor(c => c.BaseUrl).ValidRootUrl();
RuleFor(c => c.MamId).NotEmpty();
RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator());
}
}
public class MyAnonamouseSettings : PropertywiseEquatable<MyAnonamouseSettings>, ITorrentIndexerSettings
{
private static readonly MyAnonamouseSettingsValidator Validator = new ();
public MyAnonamouseSettings()
{
BaseUrl = "https://www.myanonamouse.net";
MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS;
SearchType = (int)MyAnonamouseSearchType.All;
SearchInDescription = false;
SearchInSeries = false;
SearchInFilenames = false;
MultiLanguages = Array.Empty<int>();
FailDownloads = Array.Empty<int>();
RequiredFlags = Array.Empty<int>();
}
[FieldDefinition(0, Label = "IndexerSettingsApiUrl", Advanced = true, HelpText = "IndexerSettingsApiUrlHelpText")]
public string BaseUrl { get; set; }
[FieldDefinition(1, Label = "IndexerMyAnonamouseMamId", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey, HelpText = "IndexerMyAnonamouseMamIdHelpText")]
public string MamId { get; set; }
[FieldDefinition(2, Label = "IndexerMyAnonamouseSearchType", Type = FieldType.Select, SelectOptions = typeof(MyAnonamouseSearchType), HelpText = "IndexerMyAnonamouseSearchTypeHelpText")]
public int SearchType { get; set; }
[FieldDefinition(3, Label = "IndexerMyAnonamouseSearchInDescription", Type = FieldType.Checkbox, HelpText = "IndexerMyAnonamouseSearchInDescriptionHelpText", Advanced = true)]
public bool SearchInDescription { get; set; }
[FieldDefinition(4, Label = "IndexerMyAnonamouseSearchInSeries", Type = FieldType.Checkbox, HelpText = "IndexerMyAnonamouseSearchInSeriesHelpText", Advanced = true)]
public bool SearchInSeries { get; set; }
[FieldDefinition(5, Label = "IndexerMyAnonamouseSearchInFilenames", Type = FieldType.Checkbox, HelpText = "IndexerMyAnonamouseSearchInFilenamesHelpText", Advanced = true)]
public bool SearchInFilenames { get; set; }
[FieldDefinition(6, Type = FieldType.Number, Label = "IndexerSettingsMinimumSeeders", HelpText = "IndexerSettingsMinimumSeedersHelpText", Advanced = true)]
public int MinimumSeeders { get; set; }
[FieldDefinition(7)]
public SeedCriteriaSettings SeedCriteria { get; set; } = new ();
[FieldDefinition(8, Type = FieldType.Checkbox, Label = "IndexerSettingsRejectBlocklistedTorrentHashes", HelpText = "IndexerSettingsRejectBlocklistedTorrentHashesHelpText", Advanced = true)]
public bool RejectBlocklistedTorrentHashesWhileGrabbing { get; set; }
[FieldDefinition(9, Type = FieldType.Select, SelectOptions = typeof(RealLanguageFieldConverter), Label = "IndexerSettingsMultiLanguageRelease", HelpText = "IndexerSettingsMultiLanguageReleaseHelpText", Advanced = true)]
public IEnumerable<int> MultiLanguages { get; set; }
[FieldDefinition(10, Type = FieldType.Select, SelectOptions = typeof(FailDownloads), Label = "IndexerSettingsFailDownloads", HelpText = "IndexerSettingsFailDownloadsHelpText", Advanced = true)]
public IEnumerable<int> FailDownloads { get; set; }
[FieldDefinition(11, Type = FieldType.Select, SelectOptions = typeof(IndexerFlags), Label = "IndexerSettingsRequiredFlags", HelpText = "IndexerSettingsRequiredFlagsHelpText", HelpLink = "https://wiki.servarr.com/radarr/settings#indexer-flags", Advanced = true)]
public IEnumerable<int> RequiredFlags { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
public enum MyAnonamouseSearchType
{
[FieldOption("All Torrents")]
All = 0,
[FieldOption("Only Active")]
Active = 1,
[FieldOption("Freeleech")]
Freeleech = 2,
[FieldOption("Freeleech or VIP")]
FreeleechOrVip = 3,
[FieldOption("VIP")]
Vip = 4,
[FieldOption("Not VIP")]
NotVip = 5
}
}

View file

@ -7,6 +7,7 @@
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaTypes;
using NzbDrone.Core.Parser;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation;
@ -21,6 +22,7 @@ public class Newznab : HttpIndexerBase<NewznabSettings>
public override DownloadProtocol Protocol => DownloadProtocol.Usenet;
public override int PageSize => GetProviderPageSize();
public override IEnumerable<MediaType> SupportedMediaTypes => new[] { MediaType.Movie, MediaType.Book, MediaType.Audiobook };
public Newznab(INewznabCapabilitiesProvider capabilitiesProvider, IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger)
: base(httpClient, indexerStatusService, configService, parsingService, logger)

View file

@ -113,6 +113,66 @@ public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchC
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
var pageableRequests = new IndexerPageableRequestChain();
var categories = Settings.Categories.Any()
? Settings.Categories
: new[] { NewznabStandardCategory.Books, NewznabStandardCategory.BooksEBook };
if (!string.IsNullOrWhiteSpace(searchCriteria.ISBN))
{
pageableRequests.Add(GetPagedRequests(MaxPages, categories, "book", $"&q={Uri.EscapeDataString(searchCriteria.ISBN)}"));
}
else if (!string.IsNullOrWhiteSpace(searchCriteria.Author) && !string.IsNullOrWhiteSpace(searchCriteria.Title))
{
var query = $"{searchCriteria.Author} {searchCriteria.Title}";
if (searchCriteria.Year.HasValue)
{
query += $" {searchCriteria.Year}";
}
pageableRequests.Add(GetPagedRequests(MaxPages, categories, "book", $"&q={Uri.EscapeDataString(query)}"));
}
else if (!string.IsNullOrWhiteSpace(searchCriteria.Title))
{
pageableRequests.Add(GetPagedRequests(MaxPages, categories, "book", $"&q={Uri.EscapeDataString(searchCriteria.Title)}"));
}
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
var pageableRequests = new IndexerPageableRequestChain();
var categories = Settings.Categories.Any()
? Settings.Categories
: new[] { NewznabStandardCategory.AudioAudiobook, NewznabStandardCategory.Audio };
if (!string.IsNullOrWhiteSpace(searchCriteria.ASIN))
{
pageableRequests.Add(GetPagedRequests(MaxPages, categories, "search", $"&q={Uri.EscapeDataString(searchCriteria.ASIN)}"));
}
else if (!string.IsNullOrWhiteSpace(searchCriteria.Author) && !string.IsNullOrWhiteSpace(searchCriteria.Title))
{
var query = $"{searchCriteria.Author} {searchCriteria.Title}";
if (!string.IsNullOrWhiteSpace(searchCriteria.Narrator))
{
query += $" {searchCriteria.Narrator}";
}
pageableRequests.Add(GetPagedRequests(MaxPages, categories, "search", $"&q={Uri.EscapeDataString(query)}"));
}
else if (!string.IsNullOrWhiteSpace(searchCriteria.Title))
{
pageableRequests.Add(GetPagedRequests(MaxPages, categories, "search", $"&q={Uri.EscapeDataString(searchCriteria.Title)}"));
}
return pageableRequests;
}
private void AddMovieIdPageableRequests(IndexerPageableRequestChain chain, int maxPages, IEnumerable<int> categories, SearchCriteriaBase searchCriteria)
{
var includeTmdbSearch = SupportsTmdbSearch && searchCriteria.Movie.MovieMetadata.Value.TmdbId > 0;

View file

@ -0,0 +1,128 @@
using System.Collections.Generic;
using NzbDrone.Core.MediaTypes;
namespace NzbDrone.Core.Indexers
{
public static class NewznabStandardCategory
{
public static readonly int Console = 1000;
public static readonly int ConsoleNDS = 1010;
public static readonly int ConsolePSP = 1020;
public static readonly int ConsoleWii = 1030;
public static readonly int ConsoleXbox = 1040;
public static readonly int ConsoleXbox360 = 1050;
public static readonly int ConsoleWiiware = 1060;
public static readonly int ConsoleXbox360DLC = 1070;
public static readonly int ConsolePS3 = 1080;
public static readonly int ConsoleOther = 1090;
public static readonly int Console3DS = 1110;
public static readonly int ConsolePSVita = 1120;
public static readonly int ConsoleWiiU = 1130;
public static readonly int ConsoleXboxOne = 1140;
public static readonly int ConsolePS4 = 1180;
public static readonly int Movies = 2000;
public static readonly int MoviesForeign = 2010;
public static readonly int MoviesOther = 2020;
public static readonly int MoviesSD = 2030;
public static readonly int MoviesHD = 2040;
public static readonly int MoviesUHD = 2045;
public static readonly int MoviesBluRay = 2050;
public static readonly int Movies3D = 2060;
public static readonly int MoviesDVD = 2070;
public static readonly int MoviesWEBDL = 2080;
public static readonly int Audio = 3000;
public static readonly int AudioMP3 = 3010;
public static readonly int AudioVideo = 3020;
public static readonly int AudioAudiobook = 3030;
public static readonly int AudioLossless = 3040;
public static readonly int AudioOther = 3050;
public static readonly int AudioForeign = 3060;
public static readonly int PC = 4000;
public static readonly int PC0day = 4010;
public static readonly int PCISO = 4020;
public static readonly int PCMac = 4030;
public static readonly int PCMobileOther = 4040;
public static readonly int PCGames = 4050;
public static readonly int PCMobileiOS = 4060;
public static readonly int PCMobileAndroid = 4070;
public static readonly int TV = 5000;
public static readonly int TVWEBDL = 5010;
public static readonly int TVForeign = 5020;
public static readonly int TVSD = 5030;
public static readonly int TVHD = 5040;
public static readonly int TVUHD = 5045;
public static readonly int TVOther = 5050;
public static readonly int TVSport = 5060;
public static readonly int TVAnime = 5070;
public static readonly int TVDocumentary = 5080;
public static readonly int XXX = 6000;
public static readonly int XXXDVD = 6010;
public static readonly int XXXWMV = 6020;
public static readonly int XXXXviD = 6030;
public static readonly int XXXx264 = 6040;
public static readonly int XXXOther = 6050;
public static readonly int XXXImageset = 6060;
public static readonly int XXXPacks = 6070;
public static readonly int Books = 7000;
public static readonly int BooksMags = 7010;
public static readonly int BooksEBook = 7020;
public static readonly int BooksComics = 7030;
public static readonly int BooksTechnical = 7040;
public static readonly int BooksOther = 7050;
public static readonly int BooksForeign = 7060;
public static readonly int Other = 8000;
public static readonly int OtherMisc = 8010;
public static readonly int OtherHashed = 8020;
public static IReadOnlyList<int> GetCategoriesForMediaType(MediaType mediaType)
{
return mediaType switch
{
MediaType.Movie => new[]
{
Movies, MoviesForeign, MoviesOther, MoviesSD, MoviesHD,
MoviesUHD, MoviesBluRay, Movies3D, MoviesDVD, MoviesWEBDL
},
MediaType.TV => new[]
{
TV, TVWEBDL, TVForeign, TVSD, TVHD, TVUHD,
TVOther, TVSport, TVAnime, TVDocumentary
},
MediaType.Music => new[]
{
Audio, AudioMP3, AudioVideo, AudioLossless, AudioOther, AudioForeign
},
MediaType.Audiobook => new[] { AudioAudiobook, Audio },
MediaType.Book => new[]
{
Books, BooksMags, BooksEBook, BooksTechnical, BooksOther, BooksForeign
},
MediaType.Comic => new[] { BooksComics, Books },
MediaType.Podcast => new[] { Audio, AudioOther },
_ => new[] { Movies }
};
}
public static IReadOnlyList<int> GetIgnoredCategoriesForMediaType(MediaType mediaType)
{
return mediaType switch
{
MediaType.Movie => new[] { Console, Audio, PC, XXX, Books },
MediaType.TV => new[] { Console, Audio, PC, Movies, XXX, Books },
MediaType.Music => new[] { Console, PC, Movies, XXX, Books, TV },
MediaType.Audiobook => new[] { Console, PC, Movies, XXX, TV },
MediaType.Book => new[] { Console, Audio, PC, Movies, XXX, TV },
MediaType.Comic => new[] { Console, Audio, PC, Movies, XXX, TV },
MediaType.Podcast => new[] { Console, PC, Movies, XXX, Books, TV },
_ => new[] { Console, Audio, PC, XXX, Books }
};
}
}
}

View file

@ -30,6 +30,16 @@ public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchC
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
private IEnumerable<IndexerRequest> GetPagedRequests(string term)
{
var baseUrl = $"{Settings.BaseUrl.TrimEnd('/')}/?page=rss{Settings.AdditionalParameters}";

View file

@ -43,6 +43,16 @@ public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchC
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
private IEnumerable<IndexerRequest> GetRequest(string searchParameters)
{
var request =

View file

@ -28,6 +28,16 @@ public virtual IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria
return new IndexerPageableRequestChain();
}
public virtual IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public virtual IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public Func<IDictionary<string, string>> GetCookies { get; set; }
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}

View file

@ -74,6 +74,16 @@ public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchC
return pageableRequests;
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public Func<IDictionary<string, string>> GetCookies { get; set; }
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}

View file

@ -24,6 +24,16 @@ public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchC
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria)
{
return new IndexerPageableRequestChain();
}
private IEnumerable<IndexerRequest> GetRssRequests(string searchParameters)
{
var request = new IndexerRequest(Settings.BaseUrl.Trim().TrimEnd('/'), HttpAccept.Rss);

View file

@ -8,6 +8,7 @@
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Indexers.Newznab;
using NzbDrone.Core.MediaTypes;
using NzbDrone.Core.Parser;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation;
@ -22,6 +23,7 @@ public class Torznab : HttpIndexerBase<TorznabSettings>
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
public override int PageSize => GetProviderPageSize();
public override IEnumerable<MediaType> SupportedMediaTypes => new[] { MediaType.Movie, MediaType.Book, MediaType.Audiobook };
public Torznab(INewznabCapabilitiesProvider capabilitiesProvider, IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger)
: base(httpClient, indexerStatusService, configService, parsingService, logger)
@ -61,7 +63,7 @@ private IndexerDefinition GetDefinition(string name, TorznabSettings settings)
Name = name,
Implementation = GetType().Name,
Settings = settings,
Protocol = DownloadProtocol.Usenet,
Protocol = DownloadProtocol.Torrent,
SupportsRss = SupportsRss,
SupportsSearch = SupportsSearch
};

View file

@ -97,6 +97,7 @@
"AptUpdater": "Use apt to install the update",
"AudioInfo": "Audio Info",
"AudioLanguages": "Audio Languages",
"Audiobook": "Audiobook",
"AuthBasic": "Basic (Browser Popup)",
"AuthForm": "Forms (Login Page)",
"Authentication": "Authentication",
@ -163,6 +164,7 @@
"BlocklistReleases": "Blocklist Releases",
"Blocklisted": "Blocklisted",
"BlocklistedAt": "Blocklisted at {date}",
"Book": "Book",
"Branch": "Branch",
"BranchUpdate": "Branch to use to update {appName}",
"BranchUpdateMechanism": "Branch used by external update mechanism",
@ -879,6 +881,16 @@
"IndexerJackettAll": "Indexers using the unsupported Jackett 'all' endpoint: {indexerNames}",
"IndexerLongTermStatusCheckAllClientMessage": "All indexers are unavailable due to failures for more than 6 hours",
"IndexerLongTermStatusCheckSingleClientMessage": "Indexers unavailable due to failures for more than 6 hours: {indexerNames}",
"IndexerMyAnonamouseMamId": "Mam Id",
"IndexerMyAnonamouseMamIdHelpText": "Mam Session Id (found in Preferences > Security)",
"IndexerMyAnonamouseSearchInDescription": "Search in Description",
"IndexerMyAnonamouseSearchInDescriptionHelpText": "Include torrent description in search",
"IndexerMyAnonamouseSearchInFilenames": "Search in Filenames",
"IndexerMyAnonamouseSearchInFilenamesHelpText": "Include filenames in search",
"IndexerMyAnonamouseSearchInSeries": "Search in Series",
"IndexerMyAnonamouseSearchInSeriesHelpText": "Include series information in search",
"IndexerMyAnonamouseSearchType": "Search Type",
"IndexerMyAnonamouseSearchTypeHelpText": "Specify the desired search type",
"IndexerNewznabSettingsAdditionalParametersHelpText": "Additional Newznab parameters",
"IndexerNewznabSettingsCategoriesHelpText": "Drop down list, at least one category must be selected.",
"IndexerNyaaSettingsAdditionalParametersHelpText": "Please note if you change the category you will have to add required/restricted rules about the subgroups to avoid foreign language releases.",

View file

@ -0,0 +1,14 @@
namespace NzbDrone.Core.MediaTypes
{
public enum MediaType
{
Unknown = 0,
Movie = 1,
TV = 2,
Music = 3,
Book = 4,
Audiobook = 5,
Podcast = 6,
Comic = 7
}
}