using System ;
using System.Collections.Generic ;
using System.Linq ;
using System.Net ;
using System.Text ;
using System.Text.RegularExpressions ;
using System.Threading.Tasks ;
using AngleSharp.Dom ;
using AngleSharp.Html.Parser ;
using FluentValidation ;
using NLog ;
using NzbDrone.Common.Extensions ;
using NzbDrone.Common.Http ;
using NzbDrone.Core.Annotations ;
using NzbDrone.Core.Configuration ;
using NzbDrone.Core.Indexers.Exceptions ;
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 Shazbat : TorrentIndexerBase < ShazbatSettings >
{
public override string Name = > "Shazbat" ;
public override string [ ] IndexerUrls = > new [ ] { "https://www.shazbat.tv/" } ;
public override string Description = > "Shazbat is a PRIVATE Torrent Tracker with highly curated TV content" ;
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 ( ) ;
public override TimeSpan RateLimit = > TimeSpan . FromSeconds ( 5.1 ) ;
public Shazbat ( IIndexerHttpClient httpClient ,
IEventAggregator eventAggregator ,
IIndexerStatusService indexerStatusService ,
IConfigService configService ,
Logger logger )
: base ( httpClient , eventAggregator , indexerStatusService , configService , logger )
{
}
public override IIndexerRequestGenerator GetRequestGenerator ( )
{
return new ShazbatRequestGenerator ( Settings ) ;
}
public override IParseIndexerResponse GetParser ( )
{
return new ShazbatParser ( Settings , RateLimit , _httpClient , _logger ) ;
}
protected override async Task DoLogin ( )
{
var loginUrl = Settings . BaseUrl + "login" ;
var requestBuilder = new HttpRequestBuilder ( loginUrl )
{
LogResponseContent = true ,
AllowAutoRedirect = true
} ;
var authLoginRequest = requestBuilder . Post ( )
. AddFormParameter ( "referer" , "" )
. AddFormParameter ( "query" , "" )
. AddFormParameter ( "tv_timezone" , "0" )
. AddFormParameter ( "tv_login" , Settings . Username )
. AddFormParameter ( "tv_password" , Settings . Password )
. SetHeader ( "Content-Type" , "application/x-www-form-urlencoded" )
. SetHeader ( "Referer" , loginUrl )
. Build ( ) ;
var response = await ExecuteAuth ( authLoginRequest ) ;
if ( CheckIfLoginNeeded ( response ) )
{
var parser = new HtmlParser ( ) ;
var dom = parser . ParseDocument ( response . Content ) ;
var errorMessage = dom . QuerySelector ( "div#fail .modal-body" ) ? . TextContent . Trim ( ) ;
throw new IndexerAuthException ( errorMessage ? ? "Unknown error message, please report." ) ;
}
var cookies = response . GetCookies ( ) ;
UpdateCookies ( cookies , DateTime . Now . AddDays ( 30 ) ) ;
_logger . Debug ( "Authentication succeeded." ) ;
}
protected override bool CheckIfLoginNeeded ( HttpResponse response )
{
return response . Content . ContainsIgnoreCase ( "sign in now" ) ;
}
private IndexerCapabilities SetCapabilities ( )
{
var caps = new IndexerCapabilities
{
TvSearchParams = new List < TvSearchParam >
{
TvSearchParam . Q
}
} ;
caps . Categories . AddCategoryMapping ( "1" , NewznabStandardCategory . TV ) ;
caps . Categories . AddCategoryMapping ( "2" , NewznabStandardCategory . TVSD ) ;
caps . Categories . AddCategoryMapping ( "3" , NewznabStandardCategory . TVHD ) ;
caps . Categories . AddCategoryMapping ( "4" , NewznabStandardCategory . TVUHD ) ;
return caps ;
}
}
public class ShazbatRequestGenerator : IIndexerRequestGenerator
{
private readonly ShazbatSettings _settings ;
public ShazbatRequestGenerator ( ShazbatSettings settings )
{
_settings = settings ;
}
public IndexerPageableRequestChain GetSearchRequests ( MovieSearchCriteria searchCriteria )
{
return new IndexerPageableRequestChain ( ) ;
}
public IndexerPageableRequestChain GetSearchRequests ( MusicSearchCriteria searchCriteria )
{
return new IndexerPageableRequestChain ( ) ;
}
public IndexerPageableRequestChain GetSearchRequests ( TvSearchCriteria searchCriteria )
{
var pageableRequests = new IndexerPageableRequestChain ( ) ;
pageableRequests . Add ( GetPagedRequests ( $"{searchCriteria.SanitizedSearchTerm}" ) ) ;
return pageableRequests ;
}
public IndexerPageableRequestChain GetSearchRequests ( BookSearchCriteria searchCriteria )
{
return new IndexerPageableRequestChain ( ) ;
}
public IndexerPageableRequestChain GetSearchRequests ( BasicSearchCriteria searchCriteria )
{
var pageableRequests = new IndexerPageableRequestChain ( ) ;
pageableRequests . Add ( GetPagedRequests ( $"{searchCriteria.SanitizedSearchTerm}" ) ) ;
return pageableRequests ;
}
private IEnumerable < IndexerRequest > GetPagedRequests ( string term )
{
term = FixSearchTerm ( term ) ;
if ( term . IsNotNullOrWhiteSpace ( ) )
{
var request = new HttpRequestBuilder ( _settings . BaseUrl + "search" ) . Post ( )
. AddFormParameter ( "search" , term )
. SetHeader ( "Content-Type" , "application/x-www-form-urlencoded" )
. SetHeader ( "X-Requested-With" , "XMLHttpRequest" )
. SetHeader ( "Referer" , _settings . BaseUrl )
. Accept ( HttpAccept . Html )
. Build ( ) ;
yield return new IndexerRequest ( request ) ;
}
else
{
var request = new HttpRequestBuilder ( _settings . BaseUrl + "torrents" )
. SetHeader ( "Referer" , _settings . BaseUrl )
. Accept ( HttpAccept . Html )
. Build ( ) ;
yield return new IndexerRequest ( request ) ;
}
}
private static string FixSearchTerm ( string term )
{
term = Regex . Replace ( term , @"\b[S|E]\d+\b" , string . Empty , RegexOptions . IgnoreCase ) ;
term = Regex . Replace ( term , @"(.+)\b\d{4}(\.\d{2}\.\d{2})?\b" , "$1" ) ;
term = Regex . Replace ( term , @"[\.\s\(\)\[\]]+" , " " ) ;
return term . ToLower ( ) . Trim ( ) ;
}
public Func < IDictionary < string , string > > GetCookies { get ; set ; }
public Action < IDictionary < string , string > , DateTime ? > CookiesUpdater { get ; set ; }
}
public class ShazbatParser : IParseIndexerResponse
{
private readonly ShazbatSettings _settings ;
private readonly TimeSpan _rateLimit ;
private readonly IIndexerHttpClient _httpClient ;
private readonly Logger _logger ;
private readonly Regex _torrentInfoRegex = new ( @"\((?<size>\d+)\):(?<seeders>\d+) \/ :(?<leechers>\d+)$" , RegexOptions . Compiled ) ;
private readonly HashSet < string > _hdResolutions = new ( ) { "1080p" , "1080i" , "720p" } ;
public ShazbatParser ( ShazbatSettings settings , TimeSpan rateLimit , IIndexerHttpClient httpClient , Logger logger )
{
_settings = settings ;
_rateLimit = rateLimit ;
_httpClient = httpClient ;
_logger = logger ;
}
public IList < ReleaseInfo > ParseResponse ( IndexerResponse indexerResponse )
{
var releaseInfos = new List < ReleaseInfo > ( ) ;
var parser = new HtmlParser ( ) ;
var dom = parser . ParseDocument ( indexerResponse . Content ) ;
var hasGlobalFreeleech = dom . QuerySelector ( "span:contains(\"Freeleech until:\"):has(span.datetime)" ) ! = null ;
releaseInfos . AddRange ( ParseResults ( indexerResponse , hasGlobalFreeleech ) ) ;
var shows = dom . QuerySelectorAll ( "div.show[data-id]" ) ;
if ( shows . Any ( ) )
{
var showPagesFetchLimit = _settings . ShowPagesFetchLimit ? ? 2 ;
if ( showPagesFetchLimit is < 1 or > 5 )
{
throw new IndexerException ( indexerResponse , "Value for Show Pages Fetch Limit should be between 1 and 5. Current value: {0}." , showPagesFetchLimit ) ;
}
if ( shows . Length > showPagesFetchLimit )
{
_logger . Debug ( $"Your search returned {shows.Length} shows. Use a more specific search term for more relevant results." ) ;
}
if ( indexerResponse . HttpResponse . GetCookies ( ) = = null | | ! indexerResponse . HttpResponse . GetCookies ( ) . Any ( ) )
{
throw new IndexerException ( indexerResponse , "Invalid cookies. Most likely your session expired or was killed." ) ;
}
foreach ( var show in shows . Take ( showPagesFetchLimit ) )
{
var showPageUrl = new HttpRequestBuilder ( _settings . BaseUrl + "show" )
. AddQueryParam ( "id" , show . GetAttribute ( "data-id" ) )
. Build ( )
. Url . FullUri ;
var showRequest = new HttpRequestBuilder ( _settings . BaseUrl + "show" ) . Post ( )
. SetCookies ( indexerResponse . HttpResponse . GetCookies ( ) ? ? new Dictionary < string , string > ( ) )
. AddQueryParam ( "id" , show . GetAttribute ( "data-id" ) )
. AddQueryParam ( "show_mode" , "torrents" )
. AddFormParameter ( "portlet" , "true" )
. AddFormParameter ( "tab" , "true" )
. SetHeader ( "Content-Type" , "application/x-www-form-urlencoded" )
. SetHeader ( "X-Requested-With" , "XMLHttpRequest" )
. SetHeader ( "Referer" , showPageUrl )
. Accept ( HttpAccept . Html )
. WithRateLimit ( _rateLimit . TotalSeconds )
. Build ( ) ;
_logger . Debug ( "Downloading Feed " + showRequest . ToString ( ) ) ;
var releaseRequest = new IndexerRequest ( showRequest ) ;
var releaseResponse = new IndexerResponse ( releaseRequest , _httpClient . Execute ( releaseRequest . HttpRequest ) ) ;
if ( releaseResponse . HttpResponse . Content . ContainsIgnoreCase ( "sign in now" ) )
{
// Remove cookie cache
CookiesUpdater ( null , null ) ;
throw new IndexerAuthException ( "We are being redirected to the Shazbat login page. Most likely your session expired or was killed." ) ;
}
if ( releaseResponse . HttpResponse . HasHttpError )
{
if ( releaseResponse . HttpResponse . StatusCode = = HttpStatusCode . TooManyRequests )
{
throw new TooManyRequestsException ( releaseRequest . HttpRequest , releaseResponse . HttpResponse ) ;
}
throw new IndexerException ( releaseResponse , $"HTTP Error - {releaseResponse.HttpResponse.StatusCode}. {showRequest.Url.FullUri}" ) ;
}
releaseInfos . AddRange ( ParseResults ( releaseResponse , hasGlobalFreeleech ) ) ;
}
}
return releaseInfos . ToArray ( ) ;
}
private IList < ReleaseInfo > ParseResults ( IndexerResponse indexerResponse , bool hasGlobalFreeleech = false )
{
var releaseInfos = new List < ReleaseInfo > ( ) ;
var parser = new HtmlParser ( ) ;
var dom = parser . ParseDocument ( indexerResponse . Content ) ;
if ( ! hasGlobalFreeleech )
{
hasGlobalFreeleech = dom . QuerySelector ( "span:contains(\"Freeleech until:\"):has(span.datetime)" ) ! = null ;
}
var publishDate = DateTime . Now ;
var rows = dom . QuerySelectorAll ( "#torrent-table tr.eprow, table tr.eprow" ) ;
foreach ( var row in rows )
{
var downloadUrl = _settings . BaseUrl + row . QuerySelector ( "td:nth-of-type(5) a[href^=\"load_torrent?\"]" ) ? . GetAttribute ( "href" ) ;
var infoUrl = _settings . BaseUrl + row . QuerySelector ( "td:nth-of-type(5) [href^=\"torrent_info?\"]" ) ? . GetAttribute ( "href" ) ;
var title = ParseTitle ( row . QuerySelector ( "td:nth-of-type(3)" ) ) ;
var infoString = row . QuerySelector ( "td:nth-of-type(4)" ) ? . TextContent . Trim ( ) ? ? string . Empty ;
var matchInfo = _torrentInfoRegex . Match ( infoString ) ;
var size = matchInfo . Groups [ "size" ] . Success & & long . TryParse ( matchInfo . Groups [ "size" ] . Value , out var outSize ) ? outSize : 0 ;
var seeders = matchInfo . Groups [ "seeders" ] . Success & & int . TryParse ( matchInfo . Groups [ "seeders" ] . Value , out var outSeeders ) ? outSeeders : 0 ;
var leechers = matchInfo . Groups [ "leechers" ] . Success & & int . TryParse ( matchInfo . Groups [ "leechers" ] . Value , out var outLeechers ) ? outLeechers : 0 ;
var dateTimestamp = row . QuerySelector ( ".datetime[data-timestamp]" ) ? . GetAttribute ( "data-timestamp" ) ;
publishDate = dateTimestamp ! = null & & ParseUtil . TryCoerceDouble ( dateTimestamp , out var timestamp ) ? DateTimeUtil . UnixTimestampToDateTime ( timestamp ) : publishDate . AddMinutes ( - 1 ) ;
var release = new TorrentInfo
{
Guid = infoUrl ,
InfoUrl = infoUrl ,
DownloadUrl = downloadUrl ,
Title = title ,
Categories = ParseCategories ( title ) ,
Size = size ,
Seeders = seeders ,
Peers = seeders + leechers ,
PublishDate = publishDate ,
Genres = row . QuerySelectorAll ( "label.label-tag" ) . Select ( t = > t . TextContent . Trim ( ) ) . ToList ( ) ,
DownloadVolumeFactor = hasGlobalFreeleech ? 0 : 1 ,
UploadVolumeFactor = 1 ,
MinimumRatio = 1 ,
MinimumSeedTime = 172800 , // 48 hours
} ;
releaseInfos . Add ( release ) ;
}
return releaseInfos ;
}
private static string ParseTitle ( IElement titleRow )
{
var title = titleRow ? . ChildNodes . First ( n = > n . NodeType = = NodeType . Text & & n . TextContent . Trim ( ) . IsNotNullOrWhiteSpace ( ) ) ;
return title ? . TextContent . Trim ( ) ;
}
protected virtual List < IndexerCategory > ParseCategories ( string title )
{
var categories = new List < IndexerCategory >
{
NewznabStandardCategory . TV ,
title switch
{
_ when _hdResolutions . Any ( title . Contains ) = > NewznabStandardCategory . TVHD ,
_ when title . Contains ( "2160p" ) = > NewznabStandardCategory . TVUHD ,
_ = > NewznabStandardCategory . TVSD
}
} ;
return categories ;
}
public Action < IDictionary < string , string > , DateTime ? > CookiesUpdater { get ; set ; }
}
public class ShazbatSettingsValidator : UserPassBaseSettingsValidator < ShazbatSettings >
{
public ShazbatSettingsValidator ( )
{
RuleFor ( c = > c . ShowPagesFetchLimit ) . GreaterThan ( 0 ) . When ( c = > c . ShowPagesFetchLimit . HasValue ) . WithMessage ( "Should be greater than zero" ) ;
RuleFor ( c = > c . ShowPagesFetchLimit ) . LessThanOrEqualTo ( 5 ) . When ( c = > c . ShowPagesFetchLimit . HasValue ) . WithMessage ( "Should be less than or equal to 5" ) ;
}
}
public class ShazbatSettings : UserPassTorrentBaseSettings
{
private static readonly ShazbatSettingsValidator Validator = new ( ) ;
[FieldDefinition(4, Type = FieldType.Number, Label = "Show Pages Fetch Limit", HelpText = "The number of show pages should Prowlarr fetch when searching. Default: 2.")]
public int? ShowPagesFetchLimit { get ; set ; }
public override NzbDroneValidationResult Validate ( )
{
return new NzbDroneValidationResult ( Validator . Validate ( this ) ) ;
}
}