fix(downloads): unstall anime downloads on sources that return URLs with query strings or HTTP 206

Two related bugs left anime downloads stuck at 0% with no error visible to
the user. Manga downloads from clean HTTPS sources were unaffected.

1) lib/utils/extensions/string_extensions.dart

   isMediaVideo() did a plain endsWith on the full URL string, so URLs
   shaped like https://host/play/{id}/video.mp4?for={token} (used by
   AnimeGG and several other sources) failed the filter because of the
   trailing `?for=...` query string. With both m3u8Urls and nonM3u8Urls
   empty in downloadChapter, the surrounding
   Future.doWhile(() => isOk == true) poll never sets isOk and waits
   forever -- MDownloader is never constructed.

   Fix: match against Uri.tryParse(this)?.path instead of the full
   string, and use a leading "." in the suffix so e.g. "flashmp4" cannot
   accidentally match.

2) lib/services/download_manager/download_isolate_pool.dart

   Once the URL passes the filter, _downloadFile (anime branch) opens a
   streaming request. When the source extension sets Range: bytes=0-,
   the server correctly responds with HTTP 206 Partial Content. The
   previous "if (response.statusCode != 200)" check rejected that,
   retried 3x, and threw. The throw was masked by an outer catch(_) in
   downloadChapter, so the user only saw a forever-spinner.

   Fix: accept any 2xx (>= 200 && < 300). Same fix applied to
   _downloadSegment for HLS segment fetches.

Repro
- Source: AnimeGG (en) -- install via Mangayomi extensions
- Pick any episode (tested with Toriko Ep 147, Gintama Ep 39, Grand Blue
  Ep 12)
- Tap the download icon

Before: an empty
".../AnimeGG (EN)/<series>/<episode>.mp4"-named folder is created, the
download icon stays in the spinner state, no error toast.

After: the .mp4 is written to disk at the size declared in Content-Length
(65,026,283 bytes for Toriko Ep 147), plays in the system video player.

Tested on macOS 26.3 / Apple Silicon with AnimeGG (multiple episodes,
multiple series, including a 180 MB 720p) and a manga control
(Asura Scans, 9 pages) on the same build to confirm no regression on the
manga path.
This commit is contained in:
Mehakdeep Singh 2026-05-09 19:07:45 -07:00
parent 25c1d72c8b
commit bbc7fa04f5
2 changed files with 19 additions and 6 deletions

View file

@ -509,9 +509,14 @@ Future<void> _downloadFile(
var request = Request('GET', Uri.parse(pageUrl.url));
request.headers.addAll(pageUrl.headers ?? {});
StreamedResponse response = await client.send(request);
if (response.statusCode != 200) {
// Accept any 2xx including 206 Partial Content, which the server
// returns when the source extension sends `Range: bytes=0-` on the
// streaming request (e.g. AnimeGG). Rejecting 206 here caused 3
// retries silent stall.
if (response.statusCode < 200 || response.statusCode >= 300) {
throw DownloadPoolException(
'Failed to download file: ${pageUrl.fileName!}',
'Failed to download file: ${pageUrl.fileName!} '
'(status ${response.statusCode})',
);
}
int total = response.contentLength ?? 0;
@ -617,8 +622,12 @@ Future<void> _downloadSegment(
}
StreamedResponse response = await _withRetry(() => client.send(request), 3);
if (response.statusCode != 200) {
throw DownloadPoolException('Failed to download segment: ${ts.name}');
// Accept any 2xx (including 206 Partial Content) see comment in
// _downloadFile.
if (response.statusCode < 200 || response.statusCode >= 300) {
throw DownloadPoolException(
'Failed to download segment: ${ts.name} (status ${response.statusCode})',
);
}
final sink = file.openWrite();

View file

@ -59,7 +59,11 @@ extension StringExtensions on String {
}
bool isMediaVideo() {
return [
// Match against the URL path only query strings (e.g. AnimeGG's
// `?for=...`) and fragments must not defeat the suffix check. Use a
// leading `.` so e.g. `flashmp4` doesn't accidentally match.
final lower = (Uri.tryParse(this)?.path ?? this).toLowerCase();
return const [
"3gp",
"avi",
"mpg",
@ -73,7 +77,7 @@ extension StringExtensions on String {
"wmv",
"mkv",
"mov",
].any((extension) => toLowerCase().endsWith(extension));
].any((extension) => lower.endsWith(".$extension"));
}
}