diff --git a/src/NzbDrone.Core/Indexers/Definitions/Nostr.cs b/src/NzbDrone.Core/Indexers/Definitions/Nostr.cs new file mode 100644 index 000000000..7364e77e2 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Definitions/Nostr.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentValidation.Results; +using MonoTorrent; +using Newtonsoft.Json; +using NLog; +using Nostr.Client.Client; +using Nostr.Client.Communicator; +using Nostr.Client.Messages; +using Nostr.Client.Requests; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers.Settings; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers.Definitions; + +public class Nostr : IndexerBase +{ + public Nostr(IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) + : base(indexerStatusService, configService, logger) + { + } + + public override string Name => "nostr"; + + public override IndexerCapabilities Capabilities + { + get => GetCapabilities(); + protected set { } + } + + public override string[] IndexerUrls => new[] { "wss://nos.lol", "wss://relay.nostr.band", "wss://relay.damus.io" }; + public override string[] LegacyUrls => Array.Empty(); + public override string Description => "nostr torrent index"; + public override Encoding Encoding => Encoding.UTF8; + public override string Language => "en"; + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Public; + + public override Task Fetch(MovieSearchCriteria searchCriteria) + { + var filter = new NostrTorrentFilter() + { + Kinds = new[] { (NostrKind)2003 }, + HashTags = new[] { "movie" }, + Search = searchCriteria.SearchTerm + }; + if (searchCriteria.Limit.HasValue) + { + filter.Limit = searchCriteria.Limit.Value; + } + + return FetchFilter(filter); + } + + public override Task Fetch(MusicSearchCriteria searchCriteria) + { + var filter = new NostrTorrentFilter() + { + Kinds = new[] { (NostrKind)2003 }, + HashTags = new[] { "music" }, + Search = searchCriteria.SearchTerm + }; + if (searchCriteria.Limit.HasValue) + { + filter.Limit = searchCriteria.Limit.Value; + } + + return FetchFilter(filter); + } + + public override Task Fetch(TvSearchCriteria searchCriteria) + { + var filter = new NostrTorrentFilter() + { + Kinds = new[] { (NostrKind)2003 }, + HashTags = new[] { "tv" }, + Search = searchCriteria.SearchTerm + }; + if (searchCriteria.Limit.HasValue) + { + filter.Limit = searchCriteria.Limit.Value; + } + + return FetchFilter(filter); + } + + public override Task Fetch(BookSearchCriteria searchCriteria) + { + var filter = new NostrTorrentFilter() + { + Kinds = new[] { (NostrKind)2003 }, + HashTags = new[] { "book" }, + Search = searchCriteria.SearchTerm + }; + if (searchCriteria.Limit.HasValue) + { + filter.Limit = searchCriteria.Limit.Value; + } + + return FetchFilter(filter); + } + + public override Task Fetch(BasicSearchCriteria searchCriteria) + { + var filter = new NostrTorrentFilter() + { + Kinds = new[] { (NostrKind)2003 }, + Search = searchCriteria.SearchTerm + }; + if (searchCriteria.Limit.HasValue) + { + filter.Limit = searchCriteria.Limit.Value; + } + + return FetchFilter(filter); + } + + public override Task Download(Uri link) + { + if (link.Scheme == "magnet") + { + return Task.FromResult(Encoding.GetBytes(link.AbsoluteUri)); + } + + throw new Exception("Link format not supported"); + } + + public override IndexerCapabilities GetCapabilities() + { + var caps = new IndexerCapabilities + { + TvSearchParams = new List + { + TvSearchParam.Q, TvSearchParam.ImdbId + }, + MovieSearchParams = new List + { + MovieSearchParam.Q, MovieSearchParam.ImdbId + } + }; + + caps.Categories.AddCategoryMapping("video,movie", NewznabStandardCategory.Movies, "Movies"); + caps.Categories.AddCategoryMapping("video,movie,dvdr", NewznabStandardCategory.MoviesDVD, "DVDR Movies"); + caps.Categories.AddCategoryMapping("video,movie,4k", NewznabStandardCategory.MoviesUHD, "4K Movies"); + caps.Categories.AddCategoryMapping("video,movie,hd", NewznabStandardCategory.MoviesHD, "HD Movies"); + + caps.Categories.AddCategoryMapping("video,tv", NewznabStandardCategory.TV, "TV"); + caps.Categories.AddCategoryMapping("video,tv,4k", NewznabStandardCategory.TVUHD, "4K TV"); + caps.Categories.AddCategoryMapping("video,tv,hd", NewznabStandardCategory.TVHD, "HD TV"); + + caps.Categories.AddCategoryMapping("audio", NewznabStandardCategory.Audio, "Audio"); + caps.Categories.AddCategoryMapping("audio,music,flac", NewznabStandardCategory.AudioLossless, "FLAC Audio"); + caps.Categories.AddCategoryMapping("audio,audio-book", NewznabStandardCategory.AudioAudiobook, "Audio Book"); + + caps.Categories.AddCategoryMapping("game,pc", NewznabStandardCategory.PCGames, "Games"); + caps.Categories.AddCategoryMapping("game,mac", NewznabStandardCategory.PCMac, "Mac Games"); + caps.Categories.AddCategoryMapping("game,unix", NewznabStandardCategory.PCGames, "Unix Games"); + caps.Categories.AddCategoryMapping("game,ios", NewznabStandardCategory.PCMobileiOS, "iOS Games"); + caps.Categories.AddCategoryMapping("game,android", NewznabStandardCategory.PCMobileiOS, "Android Games"); + + caps.Categories.AddCategoryMapping("game,psx", NewznabStandardCategory.Console, "PSx Games"); + caps.Categories.AddCategoryMapping("game,xbox", NewznabStandardCategory.ConsoleXBox, "XBOX Games"); + caps.Categories.AddCategoryMapping("game,wii", NewznabStandardCategory.ConsoleWii, "Wii Games"); + + caps.Categories.AddCategoryMapping("porn", NewznabStandardCategory.XXX, "Porn"); + caps.Categories.AddCategoryMapping("porn,movie,dvdr", NewznabStandardCategory.XXXDVD, "DVDR Porn"); + caps.Categories.AddCategoryMapping("porn,movie,hd", NewznabStandardCategory.XXXx264, "HD Porn"); + caps.Categories.AddCategoryMapping("porn,movie,4k", NewznabStandardCategory.XXXUHD, "4K Porn"); + caps.Categories.AddCategoryMapping("porn,picture", NewznabStandardCategory.XXXImageSet, "Porn Images"); + + caps.Categories.AddCategoryMapping("other", NewznabStandardCategory.Other, "Other"); + caps.Categories.AddCategoryMapping("other,comic", NewznabStandardCategory.BooksComics, "Comics"); + caps.Categories.AddCategoryMapping("other,e-book", NewznabStandardCategory.BooksEBook, "E-Books"); + + return caps; + } + + protected override Task Test(List failures) + { + // nothing to test + return Task.CompletedTask; + } + + public override bool SupportsRss => true; + public override bool SupportsSearch => true; + public override bool SupportsRedirect => true; + public override bool SupportsPagination => true; + public override bool FollowRedirect => false; + + private async Task FetchFilter(NostrFilter filter) + { + using var client = new NostrWebsocketClient(new NostrWebsocketCommunicator(new Uri(Settings.BaseUrl)), null); + var id = Guid.NewGuid().ToString(); + var req = new NostrRequest(id, filter); + var results = new IndexerPageableQueryResult(); + var tcs = new TaskCompletionSource(); + var eoseSub = client.Streams.EoseStream.Subscribe(eoseEvent => + { + if (eoseEvent.Subscription?.Equals(id) ?? false) + { + tcs.SetResult(); + } + }); + var eventsSub = client.Streams.EventStream.Subscribe(subEvent => + { + if (!(subEvent.Subscription?.Equals(id) ?? false) || subEvent.Event == default) + { + return; + } + + var ev = subEvent.Event; + try + { + var btih = ev.Tags?.FindFirstTagValue("btih"); + var title = ev.Tags?.FindFirstTagValue("title"); + if (string.IsNullOrEmpty(btih) || string.IsNullOrEmpty(title) || btih.Length != 40) + { + return; + } + + var files = ev.Tags?.Where(a => a.TagIdentifier == "file").ToArray() ?? Array.Empty(); + var totalSize = files.Sum(a => long.TryParse(a.AdditionalData[1], out var s) ? s : 0); + var catTags = + ev.Tags?.Where(a => a.TagIdentifier == "t").Select(a => a.AdditionalData[0].ToLowerInvariant()) + .ToArray() ?? + Array.Empty(); + var cat = Capabilities.Categories.MapTrackerCatToNewznab(string.Join(",", catTags)); + if (cat.Count == 0) + { + cat = Capabilities.Categories.MapTrackerCatToNewznab(string.Join(",", catTags.SkipLast(1))); + } + + if (cat.Count == 0) + { + cat = Capabilities.Categories.MapTrackerCatToNewznab(string.Join(",", catTags.SkipLast(2))); + } + + if (cat.Count == 0) + { + cat = Capabilities.Categories.MapTrackerCatToNewznab("other"); + } + + results.Releases.Add(new TorrentInfo() + { + Guid = $"nostr-{ev.Id}", + Title = title, + Description = ev.Content, + Files = files.Length, + IndexerId = 1, + Categories = cat, + Size = totalSize, + MagnetUrl = new MagnetLink(InfoHash.FromHex(btih), title, null, null, totalSize).ToV1String(), + InfoHash = btih, + ImdbId = int.TryParse(ev.Tags?.FindFirstTagValue("imdb"), out var x) ? x : default, + PublishDate = ev.CreatedAt!.Value, + DownloadProtocol = DownloadProtocol.Torrent + }); + } + catch (Exception ex) + { + _logger.Warn("Failed to parse event {}", ex.Message); + } + }); + + await client.Communicator.Start(); + client.Send(req); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + cts.Token.Register(() => { tcs.TrySetException(new TimeoutException()); }); + await tcs.Task; + eoseSub.Dispose(); + eventsSub.Dispose(); + await client.Communicator.Stop(WebSocketCloseStatus.NormalClosure, string.Empty); + + return results; + } +} + +public class NostrSettings : NoAuthTorrentBaseSettings +{ +} + +public class NostrTorrentFilter : NostrFilter +{ + [JsonProperty("#t")] + public string[] HashTags { get; init; } + + [JsonProperty("search")] + public string Search { get; init; } +} diff --git a/src/NzbDrone.Core/Prowlarr.Core.csproj b/src/NzbDrone.Core/Prowlarr.Core.csproj index 50fdb89e8..e7811a714 100644 --- a/src/NzbDrone.Core/Prowlarr.Core.csproj +++ b/src/NzbDrone.Core/Prowlarr.Core.csproj @@ -9,6 +9,7 @@ +