@@ -41,58 +41,77 @@ public AllAnimeCatalog(HttpClient httpClient, IOptions<AllAnimeOptions> options,
4141
4242 public async Task < IReadOnlyCollection < Anime > > SearchAsync ( string query , CancellationToken cancellationToken = default )
4343 {
44- if ( ! _options . IsConfigured )
44+ try
4545 {
46- _logger . LogWarning ( "AllAnime source not configured. Add configuration to ~/.config/koware/appsettings.user.json" ) ;
47- return Array . Empty < Anime > ( ) ;
48- }
46+ _logger . LogDebug ( "SearchAsync called. IsConfigured={IsConfigured}, ApiBase='{ApiBase}', BaseHost='{BaseHost}'" ,
47+ _options . IsConfigured , _options . ApiBase ?? "(null)" , _options . BaseHost ?? "(null)" ) ;
4948
50- // Note: Removed translationType from search to show all anime regardless of translation availability.
51- // Translation type is still used when fetching episodes and streams.
52- var gql = "query( $search: SearchInput $limit: Int $page: Int $countryOrigin: VaildCountryOriginEnumType ) { shows( search: $search limit: $limit page: $page countryOrigin: $countryOrigin ) { edges { _id name thumbnail description availableEpisodes __typename } }}" ;
53- var variables = new
54- {
55- search = new { allowAdult = false , allowUnknown = false , query } ,
56- limit = _options . SearchLimit ,
57- page = 1 ,
58- countryOrigin = "ALL"
59- } ;
49+ if ( ! _options . IsConfigured )
50+ {
51+ _logger . LogWarning ( "AllAnime source not configured. Add configuration to ~/.config/koware/appsettings.user.json" ) ;
52+ return Array . Empty < Anime > ( ) ;
53+ }
6054
61- var uri = BuildApiUri ( gql , variables ) ;
62- using var response = await SendWithRetryAsync ( uri , cancellationToken ) ;
63- response . EnsureSuccessStatusCode ( ) ;
55+ // Note: Removed translationType from search to show all anime regardless of translation availability.
56+ // Translation type is still used when fetching episodes and streams.
57+ var gql = "query( $search: SearchInput $limit: Int $page: Int $countryOrigin: VaildCountryOriginEnumType ) { shows( search: $search limit: $limit page: $page countryOrigin: $countryOrigin ) { edges { _id name thumbnail description availableEpisodes __typename } }}" ;
58+ var variables = new
59+ {
60+ search = new { allowAdult = false , allowUnknown = false , query } ,
61+ limit = _options . SearchLimit ,
62+ page = 1 ,
63+ countryOrigin = "ALL"
64+ } ;
65+
66+ var uri = BuildApiUri ( gql , variables ) ;
67+ using var response = await SendWithRetryAsync ( uri , cancellationToken ) ;
68+ response . EnsureSuccessStatusCode ( ) ;
6469
65- using var json = await JsonDocument . ParseAsync ( await response . Content . ReadAsStreamAsync ( cancellationToken ) , cancellationToken : cancellationToken ) ;
66- var edges = json . RootElement
67- . GetProperty ( "data" )
68- . GetProperty ( "shows" )
69- . GetProperty ( "edges" ) ;
70+ using var json = await JsonDocument . ParseAsync ( await response . Content . ReadAsStreamAsync ( cancellationToken ) , cancellationToken : cancellationToken ) ;
71+ var edges = json . RootElement
72+ . GetProperty ( "data" )
73+ . GetProperty ( "shows" )
74+ . GetProperty ( "edges" ) ;
7075
71- var results = new List < Anime > ( ) ;
72- foreach ( var edge in edges . EnumerateArray ( ) )
73- {
74- var id = edge . GetProperty ( "_id" ) . GetString ( ) ! ;
75- var title = edge . GetProperty ( "name" ) . GetString ( ) ?? id ;
76- var synopsis = edge . TryGetProperty ( "description" , out var desc ) ? desc . GetString ( ) : null ;
77- Uri ? coverImage = null ;
78- if ( edge . TryGetProperty ( "thumbnail" , out var thumb ) && thumb . ValueKind == JsonValueKind . String )
76+ var results = new List < Anime > ( ) ;
77+ foreach ( var edge in edges . EnumerateArray ( ) )
7978 {
80- var thumbUrl = thumb . GetString ( ) ;
81- if ( ! string . IsNullOrWhiteSpace ( thumbUrl ) )
79+ var id = edge . GetProperty ( "_id" ) . GetString ( ) ! ;
80+ var title = edge . GetProperty ( "name" ) . GetString ( ) ?? id ;
81+ var synopsis = edge . TryGetProperty ( "description" , out var desc ) ? desc . GetString ( ) : null ;
82+ Uri ? coverImage = null ;
83+ if ( edge . TryGetProperty ( "thumbnail" , out var thumb ) && thumb . ValueKind == JsonValueKind . String )
8284 {
83- coverImage = new Uri ( thumbUrl ) ;
85+ var thumbUrl = thumb . GetString ( ) ;
86+ if ( ! string . IsNullOrWhiteSpace ( thumbUrl ) )
87+ {
88+ var absoluteThumb = EnsureAbsolute ( thumbUrl ) ;
89+ if ( Uri . TryCreate ( absoluteThumb , UriKind . Absolute , out var parsedThumb ) )
90+ {
91+ coverImage = parsedThumb ;
92+ }
93+ else
94+ {
95+ _logger . LogDebug ( "Skipping invalid thumbnail '{Thumb}' for anime {AnimeId}" , thumbUrl , id ) ;
96+ }
97+ }
8498 }
99+ results . Add ( new Anime (
100+ new AnimeId ( id ) ,
101+ title ,
102+ synopsis : synopsis ,
103+ coverImage : coverImage ,
104+ detailPage : BuildDetailUri ( id ) ,
105+ episodes : Array . Empty < Episode > ( ) ) ) ;
85106 }
86- results . Add ( new Anime (
87- new AnimeId ( id ) ,
88- title ,
89- synopsis : synopsis ,
90- coverImage : coverImage ,
91- detailPage : BuildDetailUri ( id ) ,
92- episodes : Array . Empty < Episode > ( ) ) ) ;
93- }
94107
95- return results ;
108+ return results ;
109+ }
110+ catch ( UriFormatException ex )
111+ {
112+ _logger . LogError ( ex , "Invalid URI during AllAnime search. ApiBase={ApiBase}, BaseHost={BaseHost}, Referer={Referer}" , _options . ApiBase , _options . BaseHost , _options . Referer ) ;
113+ return Array . Empty < Anime > ( ) ;
114+ }
96115 }
97116
98117 public async Task < IReadOnlyCollection < Episode > > GetEpisodesAsync ( Anime anime , CancellationToken cancellationToken = default )
@@ -199,7 +218,7 @@ public async Task<IReadOnlyCollection<StreamLink>> GetStreamsAsync(Episode episo
199218 private async Task ResolveSourceAsync ( ProviderSource source , ConcurrentBag < StreamLink > collector , ConcurrentBag < string > attempts , CancellationToken cancellationToken )
200219 {
201220 using var timeoutCts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken ) ;
202- timeoutCts . CancelAfter ( TimeSpan . FromSeconds ( 5 ) ) ;
221+ timeoutCts . CancelAfter ( TimeSpan . FromSeconds ( 10 ) ) ;
203222 var host = "unknown" ;
204223
205224 try
@@ -210,7 +229,16 @@ private async Task ResolveSourceAsync(ProviderSource source, ConcurrentBag<Strea
210229
211230 using var response = await SendWithRetryAsync ( new Uri ( absoluteUrl ) , timeoutCts . Token ) ;
212231 response . EnsureSuccessStatusCode ( ) ;
213- var payload = await response . Content . ReadAsStringAsync ( timeoutCts . Token ) ;
232+ const int maxBytes = 10 * 1024 * 1024 ; // 10 MB safety cap
233+ var length = response . Content . Headers . ContentLength ;
234+ if ( length . HasValue && length . Value > maxBytes )
235+ {
236+ attempts . Add ( $ "{ source . Name } @{ host } : payload-too-large") ;
237+ _logger . LogDebug ( "Skipping source {Source} because payload length {Length} exceeds cap {Cap}" , source . Name , length , maxBytes ) ;
238+ return ;
239+ }
240+
241+ var payload = await ReadContentWithLimitAsync ( response . Content , maxBytes , timeoutCts . Token ) ;
214242
215243 var links = await ExtractLinksAsync ( payload , absoluteUrl , source . Name , timeoutCts . Token ) ;
216244 foreach ( var link in links )
@@ -237,6 +265,30 @@ private async Task ResolveSourceAsync(ProviderSource source, ConcurrentBag<Strea
237265 }
238266 }
239267
268+ private static async Task < string > ReadContentWithLimitAsync ( HttpContent content , int maxBytes , CancellationToken cancellationToken )
269+ {
270+ await using var stream = await content . ReadAsStreamAsync ( cancellationToken ) ;
271+ await using var buffer = new MemoryStream ( ) ;
272+ var temp = new byte [ 8192 ] ;
273+ try
274+ {
275+ int read ;
276+ while ( ( read = await stream . ReadAsync ( temp . AsMemory ( 0 , temp . Length ) , cancellationToken ) ) > 0 )
277+ {
278+ if ( buffer . Length + read > maxBytes )
279+ {
280+ throw new HttpRequestException ( $ "Content exceeded limit of { maxBytes } bytes") ;
281+ }
282+ buffer . Write ( temp , 0 , read ) ;
283+ }
284+
285+ return Encoding . UTF8 . GetString ( buffer . ToArray ( ) ) ;
286+ }
287+ finally
288+ {
289+ }
290+ }
291+
240292 private async Task < IReadOnlyCollection < StreamLink > > ExtractLinksAsync ( string payload , string sourceUrl , string provider , CancellationToken cancellationToken )
241293 {
242294 var links = new List < StreamLink > ( ) ;
@@ -604,11 +656,32 @@ private static (string showId, int episodeNumber) ParseEpisodeId(Episode episode
604656
605657 private Uri BuildApiUri ( string gql , object variables )
606658 {
659+ if ( string . IsNullOrWhiteSpace ( _options . ApiBase ) )
660+ {
661+ throw new InvalidOperationException ( "AllAnime ApiBase is not configured. Check your appsettings.json or appsettings.user.json." ) ;
662+ }
663+
664+ var apiBase = _options . ApiBase . Trim ( ) ;
665+ if ( ! Uri . TryCreate ( apiBase , UriKind . Absolute , out var baseUri ) )
666+ {
667+ throw new InvalidOperationException ( $ "AllAnime ApiBase '{ apiBase } ' is not a valid URI. It should be like 'https://api.example.com'.") ;
668+ }
669+
670+ var builder = new UriBuilder ( baseUri )
671+ {
672+ Path = $ "{ baseUri . AbsolutePath . TrimEnd ( '/' ) } /api"
673+ } ;
674+
607675 var query = $ "query={ Uri . EscapeDataString ( gql ) } &variables={ Uri . EscapeDataString ( JsonSerializer . Serialize ( variables , _serializerOptions ) ) } ";
608- return new Uri ( $ "{ _options . ApiBase . TrimEnd ( '/' ) } /api?{ query } ") ;
676+ builder . Query = query ;
677+ return builder . Uri ;
609678 }
610679
611- private Uri BuildDetailUri ( string id ) => new ( $ "https://{ _options . BaseHost } /anime/{ id } ") ;
680+ private Uri BuildDetailUri ( string id )
681+ {
682+ var host = ResolveBaseHost ( ) ;
683+ return new UriBuilder ( "https" , host , - 1 , $ "anime/{ id } ") . Uri ;
684+ }
612685
613686 private static int ParseQualityScore ( string quality )
614687 {
@@ -627,15 +700,49 @@ private string EnsureAbsolute(string path)
627700 return path ;
628701 }
629702
630- var baseUrl = $ "https://{ _options . BaseHost } ";
631- return path . StartsWith ( '/' ) ? $ "{ baseUrl } { path } " : $ "{ baseUrl } /{ path } ";
703+ var host = ResolveBaseHost ( ) ;
704+ var builder = new UriBuilder ( "https" , host )
705+ {
706+ Path = path . StartsWith ( '/' ) ? path : $ "/{ path } "
707+ } ;
708+ return builder . Uri . ToString ( ) ;
709+ }
710+
711+ private string ResolveBaseHost ( )
712+ {
713+ if ( ! string . IsNullOrWhiteSpace ( _options . BaseHost ) )
714+ {
715+ var hostText = _options . BaseHost . Trim ( ) ;
716+ if ( hostText . Contains ( "://" , StringComparison . Ordinal ) )
717+ {
718+ if ( Uri . TryCreate ( hostText , UriKind . Absolute , out var parsed ) )
719+ {
720+ return parsed . Host ;
721+ }
722+ }
723+ hostText = hostText . TrimEnd ( '/' ) ;
724+ if ( ! string . IsNullOrWhiteSpace ( hostText ) )
725+ {
726+ return hostText ;
727+ }
728+ }
729+
730+ if ( ! string . IsNullOrWhiteSpace ( _options . ApiBase ) && Uri . TryCreate ( _options . ApiBase , UriKind . Absolute , out var api ) )
731+ {
732+ return api . Host ;
733+ }
734+
735+ throw new InvalidOperationException ( "AllAnime BaseHost is not configured and ApiBase is invalid." ) ;
632736 }
633737
634738 private HttpRequestMessage BuildRequest ( Uri uri )
635739 {
636740 var request = new HttpRequestMessage ( HttpMethod . Get , uri ) ;
637- request . Headers . Referrer = new Uri ( _options . Referer ) ;
638- request . Headers . TryAddWithoutValidation ( "Origin" , _options . Referer . TrimEnd ( '/' ) ) ;
741+ if ( Uri . TryCreate ( _options . Referer , UriKind . Absolute , out var refUri ) )
742+ {
743+ request . Headers . Referrer = refUri ;
744+ request . Headers . TryAddWithoutValidation ( "Origin" , refUri . GetLeftPart ( UriPartial . Authority ) ) ;
745+ }
639746 request . Headers . UserAgent . ParseAdd ( _options . UserAgent ) ;
640747 request . Headers . Accept . ParseAdd ( "application/json, */*" ) ;
641748 request . Headers . AcceptLanguage . ParseAdd ( "en-US,en;q=0.9" ) ;
0 commit comments