mangadl-cli 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mangadl-cli might be problematic. Click here for more details.

mangadl/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "1.1.0"
2
+ __author__ = "aydinAGF"
mangadl/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
mangadl/cli.py ADDED
@@ -0,0 +1,532 @@
1
+ import sys
2
+ import os
3
+ import re
4
+ import json
5
+ import time
6
+ import threading
7
+ import shutil
8
+ import argparse
9
+ from typing import List, Optional
10
+
11
+ from .scraper import search_manga, get_all_chapters, filter_chapters, get_chapter_images
12
+ from .downloader import download_chapter, download_chapter_temp, _pack_cbz_merged, _is_termux, DEFAULT_DOWNLOAD_DIR, _has_aria2c, _has_pillow
13
+
14
+ BANNER = r"""
15
+ ╔══════════════════════════════════════════════╗
16
+ ║ Manga Downloader ║
17
+ ║ weebcentral.com | aydinAGF | CBZ/PDF ║
18
+ ╚══════════════════════════════════════════════╝
19
+ """
20
+
21
+ HELP = """
22
+ Chapter range formats:
23
+ 1 → single chapter
24
+ 2-8 → chapters 2 to 8
25
+ 1-90 → chapters 1 to 90
26
+ all → every chapter
27
+ 1,5,10 → specific chapters
28
+ 1-10,20 → mixed
29
+ """
30
+
31
+ FORMAT_MENU = """
32
+ Output format:
33
+ 1. CBZ — comic book archive (one per chapter)
34
+ 2. PDF — single PDF per chapter{pdf_note}
35
+ 3. Images — raw image folder (one folder per chapter)
36
+ 4. CBZ (All-in-One) — merge all selected chapters into one big CBZ
37
+ """
38
+
39
+
40
+ def _session_path(download_dir: str, safe_title: str) -> str:
41
+ return os.path.join(download_dir, safe_title, ".mangadl_session.json")
42
+
43
+
44
+ def _load_session(download_dir: str, safe_title: str) -> dict:
45
+ path = _session_path(download_dir, safe_title)
46
+ if os.path.exists(path):
47
+ try:
48
+ with open(path, "r", encoding="utf-8") as f:
49
+ return json.load(f)
50
+ except (json.JSONDecodeError, OSError):
51
+ return {}
52
+ return {}
53
+
54
+
55
+ def _save_session(download_dir: str, safe_title: str, session: dict):
56
+ path = _session_path(download_dir, safe_title)
57
+ os.makedirs(os.path.dirname(path), exist_ok=True)
58
+ with open(path, "w", encoding="utf-8") as f:
59
+ json.dump(session, f, indent=2)
60
+
61
+
62
+ def _clear_session(download_dir: str, safe_title: str):
63
+ path = _session_path(download_dir, safe_title)
64
+ if os.path.exists(path):
65
+ os.remove(path)
66
+
67
+
68
+ def _live_search() -> Optional[dict]:
69
+ try:
70
+ from prompt_toolkit import prompt
71
+ from prompt_toolkit.completion import Completer, Completion
72
+
73
+ class MangaCompleter(Completer):
74
+ def __init__(self):
75
+ self._cache: dict = {}
76
+ self._last_text = ""
77
+ self._results: List[dict] = []
78
+
79
+ def get_completions(self, document, complete_event):
80
+ text = document.text_before_cursor.strip()
81
+ if len(text) < 2 or text == self._last_text:
82
+ for r in self._results:
83
+ yield Completion(r["title"], start_position=-len(text))
84
+ return
85
+ self._last_text = text
86
+ try:
87
+ self._results = search_manga(text)
88
+ except Exception:
89
+ self._results = []
90
+ for r in self._results:
91
+ yield Completion(r["title"], start_position=-len(text))
92
+
93
+ completer = MangaCompleter()
94
+ print("\n Type manga name — suggestions appear as you type. Press Tab to pick, Enter to search.\n")
95
+ try:
96
+ query = prompt(" Search: ", completer=completer, complete_while_typing=True)
97
+ except (KeyboardInterrupt, EOFError):
98
+ return None
99
+
100
+ query = query.strip()
101
+ if not query:
102
+ return None
103
+ print(f"\n Searching for '{query}'...")
104
+ results = search_manga(query)
105
+
106
+ except ImportError:
107
+ try:
108
+ print("\n Type manga name and press Enter.\n")
109
+ query = input(" Search: ").strip()
110
+ except (KeyboardInterrupt, EOFError):
111
+ return None
112
+ if not query:
113
+ return None
114
+ print(f" Searching for '{query}'...")
115
+ results = search_manga(query)
116
+
117
+ if not results:
118
+ print(" [!] No results found. Try a different name.")
119
+ return None
120
+
121
+ print()
122
+ print(" +--------------------------------------------+")
123
+ print(" | Search Results |")
124
+ print(" +--------------------------------------------+")
125
+ for i, r in enumerate(results[:15], 1):
126
+ title = r["title"][:48]
127
+ print(f" | {i:>2}. {title:<48}|")
128
+ print(" +--------------------------------------------+")
129
+
130
+ while True:
131
+ try:
132
+ choice = input(f"\n Pick a number (1-{min(len(results), 15)}): ").strip()
133
+ if not choice:
134
+ continue
135
+ idx = int(choice) - 1
136
+ if 0 <= idx < min(len(results), 15):
137
+ return results[idx]
138
+ print(" [!] Invalid choice.")
139
+ except ValueError:
140
+ print(" [!] Please enter a number.")
141
+ except (KeyboardInterrupt, EOFError):
142
+ return None
143
+
144
+
145
+ def _get_chapter_range(ch_min: float, ch_max: float, total: int) -> str:
146
+ print(HELP)
147
+ while True:
148
+ try:
149
+ raw = input(f" Enter chapter range ({ch_min:.0f}-{ch_max:.0f} available): ").strip()
150
+ if raw:
151
+ return raw
152
+ except (KeyboardInterrupt, EOFError):
153
+ return ""
154
+
155
+
156
+ def _get_output_format() -> str:
157
+ pdf_note = "" if _has_pillow() else " [!] requires: pip install Pillow"
158
+ print(FORMAT_MENU.format(pdf_note=pdf_note))
159
+ formats = {1: "cbz", 2: "pdf", 3: "images", 4: "cbz-merge"}
160
+ while True:
161
+ try:
162
+ raw = input(" Select format (1-4) [default: 1]: ").strip()
163
+ if not raw:
164
+ return "cbz"
165
+ num = int(raw)
166
+ if num in formats:
167
+ chosen = formats[num]
168
+ if chosen == "pdf" and not _has_pillow():
169
+ print(" [!] Pillow not installed. Install it first: pip install Pillow")
170
+ continue
171
+ return chosen
172
+ print(" [!] Enter 1, 2, 3, or 4.")
173
+ except ValueError:
174
+ print(" [!] Enter 1, 2, 3, or 4.")
175
+ except (KeyboardInterrupt, EOFError):
176
+ return "cbz"
177
+
178
+
179
+ def _progress_bar(done: int, total: int, width: int = 30) -> str:
180
+ pct = done / total if total else 0
181
+ filled = int(width * pct)
182
+ bar = "#" * filled + "-" * (width - filled)
183
+ return f"[{bar}] {done}/{total}"
184
+
185
+
186
+ def _spinner_thread(stop: threading.Event, label: str):
187
+ frames = ["|", "/", "-", "\\"]
188
+ i = 0
189
+ while not stop.is_set():
190
+ sys.stdout.write(f"\r {frames[i % len(frames)]} {label}...")
191
+ sys.stdout.flush()
192
+ stop.wait(0.1)
193
+ i += 1
194
+ sys.stdout.write("\r" + " " * 70 + "\r")
195
+ sys.stdout.flush()
196
+
197
+
198
+ def _pick_download_dir(default: str) -> str:
199
+ print(f" Download directory: {default}")
200
+ try:
201
+ raw = input(" Change directory? (y/N) [default: keep current]: ").strip().lower()
202
+ except (KeyboardInterrupt, EOFError):
203
+ print()
204
+ return default
205
+ if raw not in ("y", "yes"):
206
+ return default
207
+ print(" Enter new path (or press Enter to keep current):")
208
+ try:
209
+ new_path = input(f" [{default}]: ").strip()
210
+ except (KeyboardInterrupt, EOFError):
211
+ print()
212
+ return default
213
+ if not new_path:
214
+ return default
215
+ new_path = os.path.expanduser(new_path)
216
+ os.makedirs(new_path, exist_ok=True)
217
+ print(f" Download directory set to: {new_path}\n")
218
+ return new_path
219
+
220
+
221
+ def main():
222
+ parser = argparse.ArgumentParser(
223
+ prog="mangadl",
224
+ description="Manga downloader for Termux — downloads from weebcentral.com",
225
+ add_help=True,
226
+ )
227
+ parser.add_argument(
228
+ "--dir",
229
+ metavar="PATH",
230
+ default=None,
231
+ help=f"Set download directory (default: {DEFAULT_DOWNLOAD_DIR})",
232
+ )
233
+ args = parser.parse_args()
234
+
235
+ print(BANNER)
236
+
237
+ if not _has_aria2c():
238
+ if _is_termux():
239
+ print(" [~] aria2c not found — downloads will use threads (slower).")
240
+ print(" Install with: pkg install aria2\n")
241
+ else:
242
+ print(" [~] aria2c not found — downloads will use threads (slower).")
243
+ print(" Install: https://aria2.github.io/\n")
244
+
245
+ if args.dir:
246
+ download_dir = os.path.expanduser(args.dir)
247
+ os.makedirs(download_dir, exist_ok=True)
248
+ print(f" Download directory: {download_dir}\n")
249
+ else:
250
+ download_dir = os.environ.get("MANGA_DOWNLOAD_DIR", DEFAULT_DOWNLOAD_DIR)
251
+ download_dir = _pick_download_dir(download_dir)
252
+
253
+ while True:
254
+ manga = _live_search()
255
+ if not manga:
256
+ print("\n Goodbye!\n")
257
+ break
258
+
259
+ title = manga["title"]
260
+ series_id = manga["series_id"]
261
+ print(f"\n Selected: {title}")
262
+
263
+ stop = threading.Event()
264
+ t = threading.Thread(target=_spinner_thread, args=(stop, "Fetching chapter list"), daemon=True)
265
+ t.start()
266
+ chapters = get_all_chapters(series_id)
267
+ stop.set()
268
+ t.join()
269
+
270
+ if not chapters:
271
+ print(" [!] Could not fetch chapter list. Try again.")
272
+ continue
273
+
274
+ total_ch = len(chapters)
275
+ ch_min = chapters[0][0]
276
+ ch_max = chapters[-1][0]
277
+ print(f" Found {total_ch} chapters (Ch {ch_min:.0f} - Ch {ch_max:.0f})\n")
278
+
279
+ raw_range = _get_chapter_range(ch_min, ch_max, total_ch)
280
+ if not raw_range:
281
+ continue
282
+
283
+ selected = filter_chapters(chapters, raw_range)
284
+ if not selected:
285
+ print(" [!] No chapters matched that range.")
286
+ continue
287
+
288
+ output_format = _get_output_format()
289
+ ext_label = {"cbz": "CBZ", "pdf": "PDF", "images": "Images", "cbz-merge": "CBZ (All-in-One)"}[output_format]
290
+
291
+ print(f"\n Downloading {len(selected)} chapter(s) -> {ext_label}")
292
+ print(f" Saving to: {download_dir}/{title}/")
293
+ print(f" Downloader: {'aria2c' if _has_aria2c() else 'threaded'}\n")
294
+
295
+ safe_title = re.sub(r'[\\/*?:"<>|]', "_", title).strip()
296
+
297
+ if output_format == "cbz-merge":
298
+ session = _load_session(download_dir, safe_title)
299
+ resume = False
300
+ tmp_dirs = []
301
+ downloaded_chapters = set()
302
+
303
+ if session and session.get("title") == title:
304
+ saved_selection = session.get("selected_chapters", [])
305
+ saved_tmps = session.get("tmp_dirs", [])
306
+ valid_tmps = [td for td in saved_tmps if os.path.isdir(td)]
307
+ if valid_tmps and saved_selection:
308
+ downloaded_chapters = set(saved_selection)
309
+ tmp_dirs = valid_tmps
310
+ print(f" [~] Found previous session: {len(valid_tmps)} chapter(s) already downloaded.")
311
+ try:
312
+ choice = input(" Resume? (Y/n): ").strip().lower()
313
+ except (KeyboardInterrupt, EOFError):
314
+ choice = "n"
315
+ if choice != "n":
316
+ resume = True
317
+ print(f" Resuming with {len(valid_tmps)} cached chapter(s)...\n")
318
+
319
+ if not resume:
320
+ session = {"title": title, "selected_chapters": [], "tmp_dirs": []}
321
+ downloaded_chapters = set()
322
+
323
+ pending = [(ch_num, ch_url) for ch_num, ch_url in selected if ch_num not in downloaded_chapters]
324
+ total_selected = len(selected)
325
+
326
+ max_retries = 3
327
+ for attempt in range(max_retries):
328
+ if attempt > 0:
329
+ print(f"\n Retry attempt {attempt + 1}/{max_retries}...")
330
+ pending = [(ch_num, ch_url) for ch_num, ch_url in selected if ch_num not in downloaded_chapters]
331
+
332
+ if not pending:
333
+ break
334
+
335
+ for i, (ch_num, ch_url) in enumerate(pending, 1):
336
+ idx = [c for c, _ in selected].index(ch_num) + 1
337
+ ch_label = f"Ch {ch_num:.0f}" if ch_num == int(ch_num) else f"Ch {ch_num}"
338
+ prefix = f" [{idx}/{total_selected}] {ch_label}"
339
+
340
+ stop2 = threading.Event()
341
+ t2 = threading.Thread(target=_spinner_thread, args=(stop2, f"{prefix} -- fetching pages"), daemon=True)
342
+ t2.start()
343
+ images = get_chapter_images(ch_url)
344
+ stop2.set()
345
+ t2.join()
346
+
347
+ if not images:
348
+ print(f" [!] {ch_label}: no images found, skipping.")
349
+ continue
350
+
351
+ page_count = len(images)
352
+
353
+ def on_progress(done: int, total: int, _prefix=prefix):
354
+ bar = _progress_bar(done, total)
355
+ sys.stdout.write(f"\r {_prefix} {bar}")
356
+ sys.stdout.flush()
357
+
358
+ sys.stdout.write(f"\r {prefix} {_progress_bar(0, page_count)}")
359
+ sys.stdout.flush()
360
+
361
+ tmp_dir, ok, log = download_chapter_temp(ch_num, images, on_progress)
362
+
363
+ if ok:
364
+ tmp_dirs.append(tmp_dir)
365
+ downloaded_chapters.add(ch_num)
366
+ sys.stdout.write(f"\r {prefix} {_progress_bar(page_count, page_count)} OK\n")
367
+ session["selected_chapters"] = list(downloaded_chapters)
368
+ session["tmp_dirs"] = tmp_dirs
369
+ _save_session(download_dir, safe_title, session)
370
+ else:
371
+ sys.stdout.write(f"\r {prefix} FAILED\n")
372
+ sys.stdout.flush()
373
+ sys.stdout.flush()
374
+
375
+ failed = [ch for ch, _ in selected if ch not in downloaded_chapters]
376
+ if failed:
377
+ failed_labels = [f"Ch {ch:.0f}" if ch == int(ch) else f"Ch {ch}" for ch in failed]
378
+ print(f"\n [!] {len(failed)} chapter(s) failed: {', '.join(failed_labels)}")
379
+ while True:
380
+ try:
381
+ choice = input(" Retry failed chapters? (Y/n): ").strip().lower()
382
+ except (KeyboardInterrupt, EOFError):
383
+ choice = "n"
384
+ if choice == "n":
385
+ break
386
+ retry_failed = [(ch, [u for c, u in selected if c == ch][0]) for ch in failed]
387
+ for ch_num, ch_url in retry_failed:
388
+ idx = [c for c, _ in selected].index(ch_num) + 1
389
+ ch_label = f"Ch {ch_num:.0f}" if ch_num == int(ch_num) else f"Ch {ch_num}"
390
+ prefix = f" [{idx}/{total_selected}] {ch_label}"
391
+
392
+ stop2 = threading.Event()
393
+ t2 = threading.Thread(target=_spinner_thread, args=(stop2, f"{prefix} -- fetching pages"), daemon=True)
394
+ t2.start()
395
+ images = get_chapter_images(ch_url)
396
+ stop2.set()
397
+ t2.join()
398
+
399
+ if not images:
400
+ print(f" [!] {ch_label}: no images found, skipping.")
401
+ continue
402
+
403
+ page_count = len(images)
404
+
405
+ def on_progress(done: int, total: int, _prefix=prefix):
406
+ bar = _progress_bar(done, total)
407
+ sys.stdout.write(f"\r {_prefix} {bar}")
408
+ sys.stdout.flush()
409
+
410
+ sys.stdout.write(f"\r {prefix} {_progress_bar(0, page_count)}")
411
+ sys.stdout.flush()
412
+
413
+ tmp_dir, ok, log = download_chapter_temp(ch_num, images, on_progress)
414
+
415
+ if ok:
416
+ tmp_dirs.append(tmp_dir)
417
+ downloaded_chapters.add(ch_num)
418
+ sys.stdout.write(f"\r {prefix} {_progress_bar(page_count, page_count)} OK\n")
419
+ session["selected_chapters"] = list(downloaded_chapters)
420
+ session["tmp_dirs"] = tmp_dirs
421
+ _save_session(download_dir, safe_title, session)
422
+ else:
423
+ sys.stdout.write(f"\r {prefix} FAILED\n")
424
+ sys.stdout.flush()
425
+ sys.stdout.flush()
426
+ failed = [ch for ch, _ in selected if ch not in downloaded_chapters]
427
+ if not failed:
428
+ break
429
+ print(f"\n [!] Still {len(failed)} chapter(s) failed.")
430
+
431
+ if tmp_dirs:
432
+ out_path = os.path.join(download_dir, safe_title, f"{safe_title} - Merged ({len(tmp_dirs)} ch).cbz")
433
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
434
+
435
+ def on_merge_progress(done: int, total: int):
436
+ bar = _progress_bar(done, total)
437
+ sys.stdout.write(f"\r Merging pages {bar}")
438
+ sys.stdout.flush()
439
+
440
+ print(f"\n Merging {len(tmp_dirs)} chapter(s) into one CBZ...")
441
+ merge_ok, merge_log = _pack_cbz_merged(tmp_dirs, out_path, on_merge_progress)
442
+
443
+ if merge_ok:
444
+ for td in tmp_dirs:
445
+ shutil.rmtree(td, ignore_errors=True)
446
+ _clear_session(download_dir, safe_title)
447
+ sys.stdout.write(f"\r Merging pages {_progress_bar(1, 1)} OK\n")
448
+ print(f"\n Saved: {out_path}")
449
+ print(f" ({len(downloaded_chapters)}/{total_selected} chapters, {merge_log})")
450
+ if failed:
451
+ print(f" [!] {len(failed)} chapter(s) skipped: {', '.join(f'Ch {c:.0f}' if c == int(c) else f'Ch {c}' for c in failed)}")
452
+ else:
453
+ print(f"\n [!] Merge failed: {merge_log}")
454
+ print(" Temp files kept — you can resume next time.")
455
+
456
+ continue
457
+
458
+ ok_count = 0
459
+ for i, (ch_num, ch_url) in enumerate(selected, 1):
460
+ ch_label = f"Ch {ch_num:.0f}" if ch_num == int(ch_num) else f"Ch {ch_num}"
461
+ prefix = f" [{i}/{len(selected)}] {ch_label}"
462
+
463
+ stop2 = threading.Event()
464
+ t2 = threading.Thread(target=_spinner_thread, args=(stop2, f"{prefix} -- fetching pages"), daemon=True)
465
+ t2.start()
466
+ images = get_chapter_images(ch_url)
467
+ stop2.set()
468
+ t2.join()
469
+
470
+ if not images:
471
+ print(f" [!] {ch_label}: no images found, skipping.")
472
+ continue
473
+
474
+ page_count = len(images)
475
+
476
+ def on_progress(done: int, total: int, _prefix=prefix):
477
+ bar = _progress_bar(done, total)
478
+ sys.stdout.write(f"\r {_prefix} {bar}")
479
+ sys.stdout.flush()
480
+
481
+ sys.stdout.write(f"\r {prefix} {_progress_bar(0, page_count)}")
482
+ sys.stdout.flush()
483
+
484
+ ok, log = download_chapter(
485
+ ch_num, images, title, download_dir,
486
+ on_progress, verbose=True,
487
+ output_format=output_format,
488
+ )
489
+
490
+ ch_full = ch_label.replace("Ch ", "Chapter ")
491
+ if output_format == "pdf":
492
+ out_name = f"{safe_title} - {ch_full}.pdf"
493
+ elif output_format == "images":
494
+ out_name = f"{ch_full}/"
495
+ else:
496
+ out_name = f"{safe_title} - {ch_full}.cbz"
497
+
498
+ if ok:
499
+ sys.stdout.write(
500
+ f"\r {prefix} {_progress_bar(page_count, page_count)} OK -> {out_name}\n"
501
+ )
502
+ ok_count += 1
503
+ else:
504
+ sys.stdout.write(f"\r {prefix} FAILED\n")
505
+ sys.stdout.flush()
506
+ print()
507
+ print(" -- Error log -----------------------------------------")
508
+ for line in log.strip().splitlines():
509
+ print(f" {line}")
510
+ print(" -------------------------------------------------------")
511
+ print()
512
+ sys.stdout.flush()
513
+
514
+ print()
515
+ if ok_count == len(selected):
516
+ print(f" All {ok_count} chapter(s) saved as {ext_label} to: {download_dir}/{title}/")
517
+ else:
518
+ print(f" {ok_count}/{len(selected)} chapter(s) saved.")
519
+
520
+ print()
521
+ try:
522
+ again = input(" Download another manga? (Y/n): ").strip().lower()
523
+ if again == "n":
524
+ print("\n Goodbye!\n")
525
+ break
526
+ except (KeyboardInterrupt, EOFError):
527
+ print("\n\n Goodbye!\n")
528
+ break
529
+
530
+
531
+ if __name__ == "__main__":
532
+ main()
mangadl/downloader.py ADDED
@@ -0,0 +1,372 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ import threading
6
+ import zipfile
7
+ import urllib.request
8
+ from typing import List, Callable, Optional
9
+
10
+
11
+ def _is_termux() -> bool:
12
+ return os.path.isdir("/data/data/com.termux") or "TERMUX_VERSION" in os.environ
13
+
14
+
15
+ def _default_download_dir() -> str:
16
+ if os.name == "nt":
17
+ home = os.path.expanduser("~")
18
+ for candidate in [
19
+ os.path.join(home, "Downloads", "Manga"),
20
+ os.path.join(home, "Manga"),
21
+ ]:
22
+ parent = os.path.dirname(candidate)
23
+ if os.path.isdir(parent):
24
+ return candidate
25
+ return os.path.join(home, "Downloads", "Manga")
26
+ if os.path.isdir("/sdcard"):
27
+ return "/sdcard/Manga"
28
+ home = os.path.expanduser("~")
29
+ return os.path.join(home, "Downloads", "Manga")
30
+
31
+
32
+ def _get_temp_dir() -> str:
33
+ if _is_termux():
34
+ termux_tmp = "/data/data/com.termux/files/usr/tmp"
35
+ if os.path.isdir(termux_tmp):
36
+ return termux_tmp
37
+ return "/tmp"
38
+ for var in ["TEMP", "TMP"]:
39
+ d = os.environ.get(var)
40
+ if d and os.path.isdir(d):
41
+ return d
42
+ return "/tmp"
43
+
44
+
45
+ DEFAULT_DOWNLOAD_DIR = _default_download_dir()
46
+ ARIA2C_CONNECTIONS = 8 if _is_termux() else 16
47
+ FALLBACK_WORKERS = 4 if _is_termux() else 8
48
+ MIN_IMAGE_BYTES = 2048
49
+ REQUEST_TIMEOUT = 30 if _is_termux() else 20
50
+ HEADERS = {
51
+ "User-Agent": "Mozilla/5.0 (Linux; Android 13; Termux) AppleWebKit/537.36",
52
+ "Referer": "https://weebcentral.com/",
53
+ "Accept": "image/png,image/webp,image/*,*/*",
54
+ }
55
+
56
+
57
+ def _safe_dir_name(name: str) -> str:
58
+ return re.sub(r'[\\/*?:"<>|]', "_", name).strip()
59
+
60
+
61
+ def _has_aria2c() -> bool:
62
+ return shutil.which("aria2c") is not None
63
+
64
+
65
+ def _has_pillow() -> bool:
66
+ try:
67
+ import PIL
68
+ return True
69
+ except ImportError:
70
+ return False
71
+
72
+
73
+ def _count_valid_images(directory: str) -> int:
74
+ count = 0
75
+ for f in os.listdir(directory):
76
+ if f.startswith("."):
77
+ continue
78
+ if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
79
+ path = os.path.join(directory, f)
80
+ if os.path.getsize(path) >= MIN_IMAGE_BYTES:
81
+ count += 1
82
+ return count
83
+
84
+
85
+ def _sorted_images(directory: str) -> List[str]:
86
+ files = []
87
+ for f in sorted(os.listdir(directory)):
88
+ if f.startswith("."):
89
+ continue
90
+ if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
91
+ path = os.path.join(directory, f)
92
+ if os.path.getsize(path) >= MIN_IMAGE_BYTES:
93
+ files.append(path)
94
+ return files
95
+
96
+
97
+ def _download_with_aria2c(urls: List[str], output_dir: str) -> tuple:
98
+ url_file = os.path.join(output_dir, ".aria2c_urls.txt")
99
+ with open(url_file, "w") as f:
100
+ for i, url in enumerate(urls):
101
+ ext = url.split("?")[0].rsplit(".", 1)[-1] or "jpg"
102
+ filename = f"{i+1:03d}.{ext}"
103
+ f.write(f"{url}\n")
104
+ f.write(f"\tout={filename}\n")
105
+
106
+ try:
107
+ proc = subprocess.Popen(
108
+ [
109
+ "aria2c",
110
+ f"--input-file={url_file}",
111
+ f"--dir={output_dir}",
112
+ f"-x{ARIA2C_CONNECTIONS}",
113
+ f"-s{ARIA2C_CONNECTIONS}",
114
+ "-k", "512K",
115
+ "--auto-file-renaming=false",
116
+ "--allow-overwrite=true",
117
+ "--summary-interval=0",
118
+ "--console-log-level=notice",
119
+ "--referer=https://weebcentral.com/",
120
+ "--user-agent=Mozilla/5.0 (Linux; Android 13; Termux) AppleWebKit/537.36",
121
+ "--header=Accept: image/png,image/webp,image/*,*/*",
122
+ "--header=Accept-Language: en-US,en;q=0.9",
123
+ ],
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.STDOUT,
126
+ )
127
+ raw_out, _ = proc.communicate()
128
+ log = raw_out.decode("utf-8", errors="ignore")
129
+ valid = _count_valid_images(output_dir)
130
+ success = valid >= max(1, int(len(urls) * 0.9))
131
+ return success, log
132
+ finally:
133
+ if os.path.exists(url_file):
134
+ os.remove(url_file)
135
+
136
+
137
+ def _download_fallback(
138
+ urls: List[str],
139
+ output_dir: str,
140
+ on_progress: Optional[Callable] = None,
141
+ ) -> tuple:
142
+ lock = threading.Lock()
143
+ results = [False] * len(urls)
144
+ errors: List[str] = []
145
+
146
+ def worker(idx: int, url: str):
147
+ ext = url.split("?")[0].rsplit(".", 1)[-1] or "jpg"
148
+ path = os.path.join(output_dir, f"{idx+1:03d}.{ext}")
149
+ ok = False
150
+ try:
151
+ req = urllib.request.Request(url, headers=HEADERS)
152
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
153
+ data = resp.read()
154
+ if len(data) >= MIN_IMAGE_BYTES:
155
+ with open(path, "wb") as fh:
156
+ fh.write(data)
157
+ ok = True
158
+ except Exception as e:
159
+ with lock:
160
+ errors.append(f" [{idx+1:03d}] {e}")
161
+ results[idx] = ok
162
+ if on_progress:
163
+ with lock:
164
+ on_progress(sum(1 for r in results if r), len(urls))
165
+
166
+ from concurrent.futures import ThreadPoolExecutor
167
+ with ThreadPoolExecutor(max_workers=FALLBACK_WORKERS) as pool:
168
+ futures = [pool.submit(worker, i, u) for i, u in enumerate(urls)]
169
+ for f in futures:
170
+ f.result()
171
+
172
+ success = sum(results) >= max(1, int(len(urls) * 0.9))
173
+ return success, "\n".join(errors) if errors else "OK"
174
+
175
+
176
+ def _pack_cbz(image_dir: str, cbz_path: str) -> bool:
177
+ files = _sorted_images(image_dir)
178
+ if not files:
179
+ return False
180
+ try:
181
+ with zipfile.ZipFile(cbz_path, "w", compression=zipfile.ZIP_STORED) as zf:
182
+ for fpath in files:
183
+ zf.write(fpath, arcname=os.path.basename(fpath))
184
+ return True
185
+ except Exception:
186
+ return False
187
+
188
+
189
+ def _pack_cbz_merged(
190
+ chapter_dirs: List[str],
191
+ cbz_path: str,
192
+ on_progress: Optional[Callable] = None,
193
+ ) -> tuple:
194
+ all_files = []
195
+ for ch_dir in chapter_dirs:
196
+ files = _sorted_images(ch_dir)
197
+ all_files.extend(files)
198
+
199
+ if not all_files:
200
+ return False, "No valid images found across any chapter."
201
+
202
+ try:
203
+ total = len(all_files)
204
+ with zipfile.ZipFile(cbz_path, "w", compression=zipfile.ZIP_STORED) as zf:
205
+ for i, fpath in enumerate(all_files):
206
+ ext = os.path.splitext(fpath)[1].lower() or ".jpg"
207
+ arcname = f"{i + 1:04d}{ext}"
208
+ zf.write(fpath, arcname=arcname)
209
+ if on_progress:
210
+ on_progress(i + 1, total)
211
+ return True, f"Merged {total} pages into single CBZ."
212
+ except Exception as e:
213
+ return False, f"Merged CBZ failed: {e}"
214
+
215
+
216
+ def download_chapter_temp(
217
+ chapter_num: float,
218
+ images: List[str],
219
+ on_progress: Optional[Callable] = None,
220
+ ) -> tuple:
221
+ tmp_dir = os.path.join(_get_temp_dir(), f".mangadl_ch_{chapter_num}_{os.getpid()}")
222
+ os.makedirs(tmp_dir, exist_ok=True)
223
+
224
+ try:
225
+ if _has_aria2c():
226
+ ok, log = _download_with_aria2c(images, tmp_dir)
227
+ if on_progress:
228
+ on_progress(len(images), len(images))
229
+ if not ok:
230
+ log += "\n [~] aria2c failed — retrying with built-in downloader...\n"
231
+ for f in os.listdir(tmp_dir):
232
+ fp = os.path.join(tmp_dir, f)
233
+ if os.path.getsize(fp) < MIN_IMAGE_BYTES:
234
+ os.remove(fp)
235
+ ok2, log2 = _download_fallback(images, tmp_dir, on_progress)
236
+ log += log2
237
+ ok = ok2
238
+ else:
239
+ ok, log = _download_fallback(images, tmp_dir, on_progress)
240
+
241
+ if not ok:
242
+ valid = _count_valid_images(tmp_dir)
243
+ log += f"\n [!] Only {valid}/{len(images)} pages downloaded successfully."
244
+ return tmp_dir, False, log
245
+
246
+ return tmp_dir, True, log
247
+ except Exception as e:
248
+ return tmp_dir, False, str(e)
249
+
250
+
251
+ def _pack_pdf(image_dir: str, pdf_path: str) -> bool:
252
+ try:
253
+ from PIL import Image
254
+ except ImportError:
255
+ return False
256
+
257
+ files = _sorted_images(image_dir)
258
+ if not files:
259
+ return False
260
+ try:
261
+ imgs = []
262
+ for fpath in files:
263
+ img = Image.open(fpath).convert("RGB")
264
+ imgs.append(img)
265
+ if not imgs:
266
+ return False
267
+ imgs[0].save(
268
+ pdf_path,
269
+ save_all=True,
270
+ append_images=imgs[1:],
271
+ format="PDF",
272
+ )
273
+ return True
274
+ except Exception:
275
+ return False
276
+
277
+
278
+ def _copy_images(image_dir: str, dest_dir: str) -> bool:
279
+ files = _sorted_images(image_dir)
280
+ if not files:
281
+ return False
282
+ os.makedirs(dest_dir, exist_ok=True)
283
+ for fpath in files:
284
+ shutil.copy2(fpath, dest_dir)
285
+ return True
286
+
287
+
288
+ def download_chapter(
289
+ chapter_num: float,
290
+ images: List[str],
291
+ manga_title: str,
292
+ base_dir: str = DEFAULT_DOWNLOAD_DIR,
293
+ on_progress: Optional[Callable] = None,
294
+ verbose: bool = False,
295
+ output_format: str = "cbz",
296
+ ) -> tuple:
297
+ if not images:
298
+ return False, "No image URLs found for this chapter."
299
+
300
+ safe_title = _safe_dir_name(manga_title)
301
+ ch_label = (
302
+ f"Chapter {int(chapter_num)}"
303
+ if chapter_num == int(chapter_num)
304
+ else f"Chapter {chapter_num}"
305
+ )
306
+
307
+ series_dir = os.path.join(base_dir, safe_title)
308
+ os.makedirs(series_dir, exist_ok=True)
309
+
310
+ if output_format == "pdf":
311
+ out_path = os.path.join(series_dir, f"{safe_title} - {ch_label}.pdf")
312
+ elif output_format == "images":
313
+ out_path = os.path.join(series_dir, ch_label)
314
+ else:
315
+ out_path = os.path.join(series_dir, f"{safe_title} - {ch_label}.cbz")
316
+
317
+ if output_format == "images":
318
+ if os.path.isdir(out_path) and len(_sorted_images(out_path)) > 0:
319
+ if on_progress:
320
+ on_progress(len(images), len(images))
321
+ return True, "Already downloaded (skipped)."
322
+ else:
323
+ if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
324
+ if on_progress:
325
+ on_progress(len(images), len(images))
326
+ return True, "Already downloaded (skipped)."
327
+
328
+ tmp_dir = os.path.join(series_dir, f".tmp_{ch_label}")
329
+ os.makedirs(tmp_dir, exist_ok=True)
330
+
331
+ log = ""
332
+ try:
333
+ if _has_aria2c():
334
+ ok, log = _download_with_aria2c(images, tmp_dir)
335
+ if on_progress:
336
+ on_progress(len(images), len(images))
337
+ if not ok:
338
+ log += "\n [~] aria2c failed — retrying with built-in downloader...\n"
339
+ for f in os.listdir(tmp_dir):
340
+ fp = os.path.join(tmp_dir, f)
341
+ if os.path.getsize(fp) < MIN_IMAGE_BYTES:
342
+ os.remove(fp)
343
+ ok2, log2 = _download_fallback(images, tmp_dir, on_progress)
344
+ log += log2
345
+ ok = ok2
346
+ else:
347
+ ok, log = _download_fallback(images, tmp_dir, on_progress)
348
+
349
+ if not ok:
350
+ valid = _count_valid_images(tmp_dir)
351
+ log += f"\n [!] Only {valid}/{len(images)} pages downloaded successfully."
352
+ return False, log
353
+
354
+ if output_format == "pdf":
355
+ packed = _pack_pdf(tmp_dir, out_path)
356
+ elif output_format == "images":
357
+ packed = _copy_images(tmp_dir, out_path)
358
+ else:
359
+ packed = _pack_cbz(tmp_dir, out_path)
360
+
361
+ if not packed:
362
+ if output_format == "pdf" and not _has_pillow():
363
+ log += "\n [!] PDF output requires Pillow: pip install Pillow"
364
+ else:
365
+ log += "\n [!] Packing failed — no valid images in temp folder."
366
+ return False, log
367
+
368
+ return True, log
369
+
370
+ finally:
371
+ if os.path.exists(tmp_dir):
372
+ shutil.rmtree(tmp_dir, ignore_errors=True)
mangadl/scraper.py ADDED
@@ -0,0 +1,142 @@
1
+ import re
2
+ import os
3
+ import urllib.request
4
+ import urllib.parse
5
+ from typing import List, Optional, Tuple, Dict
6
+
7
+ BASE_URL = "https://weebcentral.com"
8
+ REQUEST_TIMEOUT = 30 if os.path.isdir("/data/data/com.termux") or "TERMUX_VERSION" in os.environ else 20
9
+ HEADERS = {
10
+ "User-Agent": "Mozilla/5.0 (Linux; Android 13; Termux) AppleWebKit/537.36",
11
+ "Accept": "text/html,*/*",
12
+ "Referer": BASE_URL + "/",
13
+ }
14
+
15
+
16
+ def _get(url: str, extra_headers: Optional[dict] = None) -> str:
17
+ h = dict(HEADERS)
18
+ if extra_headers:
19
+ h.update(extra_headers)
20
+ req = urllib.request.Request(url, headers=h)
21
+ resp = urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT)
22
+ return resp.read().decode("utf-8", errors="ignore")
23
+
24
+
25
+ def _post(url: str, data: dict, extra_headers: Optional[dict] = None) -> str:
26
+ h = dict(HEADERS)
27
+ h["Content-Type"] = "application/x-www-form-urlencoded"
28
+ h["HX-Request"] = "true"
29
+ if extra_headers:
30
+ h.update(extra_headers)
31
+ encoded = urllib.parse.urlencode(data).encode()
32
+ req = urllib.request.Request(url, data=encoded, headers=h)
33
+ resp = urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT)
34
+ return resp.read().decode("utf-8", errors="ignore")
35
+
36
+
37
+ def search_manga(query: str) -> List[Dict]:
38
+ try:
39
+ html = _post(
40
+ f"{BASE_URL}/search/simple?location=main",
41
+ {"text": query},
42
+ {"HX-Request": "true"},
43
+ )
44
+ except Exception:
45
+ return []
46
+
47
+ results = []
48
+ pairs = re.findall(
49
+ r'href="(https://weebcentral\.com/series/([^/]+)/([^"]+))"[^>]*>.*?'
50
+ r'<div[^>]*class="[^"]*flex-1[^"]*"[^>]*>\s*(.*?)\s*</div>',
51
+ html,
52
+ re.DOTALL,
53
+ )
54
+ for url, series_id, slug, raw_title in pairs:
55
+ title = re.sub(r"<[^>]+>", "", raw_title).strip()
56
+ if title:
57
+ results.append({"title": title, "url": url, "series_id": series_id, "slug": slug})
58
+ return results
59
+
60
+
61
+ def get_all_chapters(series_id: str) -> List[Tuple[float, str]]:
62
+ url = f"{BASE_URL}/series/{series_id}/full-chapter-list"
63
+ try:
64
+ html = _get(url, {"HX-Request": "true"})
65
+ except Exception:
66
+ return []
67
+
68
+ pairs = re.findall(
69
+ r'href="(https://weebcentral\.com/chapters/[^"]+)"[^>]*>.*?'
70
+ r'<span class="">\s*([^<]+?)\s*</span>',
71
+ html,
72
+ re.DOTALL,
73
+ )
74
+
75
+ result = []
76
+ for ch_url, title_text in pairs:
77
+ num_match = re.search(r'([\d]+(?:\.\d+)?)\s*$', title_text.strip())
78
+ if not num_match:
79
+ continue
80
+ try:
81
+ ch_num = float(num_match.group(1))
82
+ result.append((ch_num, ch_url))
83
+ except ValueError:
84
+ continue
85
+
86
+ result.sort(key=lambda x: x[0])
87
+ return result
88
+
89
+
90
+ def filter_chapters(
91
+ chapters: List[Tuple[float, str]], chapter_range: str
92
+ ) -> List[Tuple[float, str]]:
93
+ if chapter_range.strip().lower() == "all":
94
+ return chapters
95
+
96
+ ch_map = {ch_num: ch_url for ch_num, ch_url in chapters}
97
+ all_nums = sorted(ch_map.keys())
98
+
99
+ selected = set()
100
+ for part in chapter_range.split(","):
101
+ part = part.strip()
102
+ if "-" in part:
103
+ bounds = part.split("-", 1)
104
+ try:
105
+ lo, hi = float(bounds[0]), float(bounds[1])
106
+ for n in all_nums:
107
+ if lo <= n <= hi:
108
+ selected.add(n)
109
+ except ValueError:
110
+ pass
111
+ else:
112
+ try:
113
+ n = float(part)
114
+ closest = min(all_nums, key=lambda x: abs(x - n))
115
+ if abs(closest - n) < 0.6:
116
+ selected.add(closest)
117
+ except ValueError:
118
+ pass
119
+
120
+ return [(n, ch_map[n]) for n in sorted(selected) if n in ch_map]
121
+
122
+
123
+ def get_chapter_images(chapter_url: str) -> List[str]:
124
+ chapter_id = chapter_url.rstrip("/").split("/")[-1]
125
+ img_url = f"{BASE_URL}/chapters/{chapter_id}/images?is_prev=False&reading_style=long_strip"
126
+ try:
127
+ html = _get(img_url, {"HX-Request": "true"})
128
+ except Exception:
129
+ return []
130
+
131
+ imgs = re.findall(
132
+ r'<img\s[^>]*src="(https://[^"]+\.(?:jpg|png|webp|jpeg)[^"]*)"',
133
+ html,
134
+ re.IGNORECASE,
135
+ )
136
+ seen = set()
137
+ unique = []
138
+ for img in imgs:
139
+ if img not in seen:
140
+ seen.add(img)
141
+ unique.append(img)
142
+ return unique
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.4
2
+ Name: mangadl-cli
3
+ Version: 1.1.0
4
+ Summary: A manga downloader CLI for Termux — downloads from weebcentral.com with CBZ, PDF, or image output, chapter range selection, live search, and aria2c downloading.
5
+ Author: aydinAGF
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/aydinAGF/mangadl
8
+ Keywords: manga,downloader,termux,weebcentral,android,cli,aria2c
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Multimedia
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: prompt_toolkit>=3.0.0
17
+ Requires-Dist: Pillow>=9.0.0
18
+
19
+ # mangadl
20
+
21
+ A manga downloader CLI that downloads from [weebcentral.com](https://weebcentral.com). Works on **Android (Termux)**, **Windows**, **Linux**, and **macOS**.
22
+
23
+ ---
24
+
25
+ ## Features
26
+
27
+ - **Live search** — type manga name, get suggestions as you type
28
+ - **Chapter range selection** — single, range, batch, mixed
29
+ - **Multiple output formats** — CBZ, PDF, Images, or merged All-in-One CBZ
30
+ - **Fast downloads** — aria2c multi-connection downloading
31
+ - **Resume support** — interrupted downloads continue from where they stopped
32
+ - **Auto-retry** — failed chapters retry automatically
33
+ - **Cross-platform** — same command on all platforms
34
+
35
+ ---
36
+
37
+ ## Install
38
+
39
+ ### Android (Termux)
40
+
41
+ ```bash
42
+ # Update packages
43
+ pkg update && pkg upgrade
44
+
45
+ # Install dependencies
46
+ pkg install aria2 python
47
+
48
+ # Install mangadl
49
+ pip install mangadl
50
+ ```
51
+
52
+ Grant storage access so mangadl can save to your gallery:
53
+ ```bash
54
+ termux-setup-storage
55
+ ```
56
+
57
+ ### Windows
58
+
59
+ 1. Install Python from https://www.python.org/downloads/ (check "Add to PATH")
60
+ 2. Open Command Prompt or PowerShell:
61
+ ```bash
62
+ pip install mangadl
63
+ ```
64
+
65
+ Optional — install aria2 for faster downloads: https://aria2.github.io/
66
+
67
+ ### Linux (Debian/Ubuntu)
68
+
69
+ ```bash
70
+ # Install dependencies
71
+ sudo apt update
72
+ sudo apt install python3-pip aria2
73
+
74
+ # Install mangadl
75
+ pip install mangadl
76
+ ```
77
+
78
+ ### macOS
79
+
80
+ ```bash
81
+ # Install dependencies
82
+ brew install aria2
83
+
84
+ # Install mangadl
85
+ pip3 install mangadl
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Usage
91
+
92
+ ```bash
93
+ mangadl
94
+ ```
95
+
96
+ 1. Type manga name — suggestions appear as you type
97
+ 2. Pick a result from the numbered list
98
+ 3. Enter chapter range (see format below)
99
+ 4. Choose output format
100
+ 5. Downloads start automatically
101
+
102
+ ### Command-line options
103
+
104
+ ```bash
105
+ # Set custom download directory
106
+ mangadl --dir /path/to/manga
107
+
108
+ # Show help
109
+ mangadl --help
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Output formats
115
+
116
+ | Format | Description |
117
+ |--------|-------------|
118
+ | **CBZ** | Comic book archive — one file per chapter, best for comic readers |
119
+ | **PDF** | Single PDF per chapter (requires `pip install Pillow`) |
120
+ | **Images** | Raw image folder — one folder per chapter |
121
+ | **CBZ (All-in-One)** | Merge all selected chapters into one big CBZ file |
122
+
123
+ ### All-in-One CBZ
124
+
125
+ When you select "CBZ (All-in-One)", mangadl:
126
+ 1. Downloads all selected chapters to temp files
127
+ 2. Numbers pages sequentially across chapters (0001, 0002, ...)
128
+ 3. Merges everything into a single CBZ
129
+
130
+ Example: Chapter 1 (20 pages) + Chapter 2 (15 pages) = one CBZ with 35 pages numbered 0001-0035.
131
+
132
+ ---
133
+
134
+ ## Chapter range formats
135
+
136
+ | Input | Meaning |
137
+ |-------|---------|
138
+ | `1` | Single chapter 1 |
139
+ | `2-8` | Chapters 2 through 8 |
140
+ | `1-90` | Chapters 1 through 90 |
141
+ | `all` | Every chapter |
142
+ | `1,5,10` | Specific chapters |
143
+ | `1-10,20,30-35` | Mixed ranges |
144
+
145
+ ---
146
+
147
+ ## Default download locations
148
+
149
+ | Platform | Default path |
150
+ |----------|-------------|
151
+ | Android (Termux) | `/sdcard/Manga/` |
152
+ | Windows | `C:\Users\<you>\Downloads\Manga\` |
153
+ | Linux | `~/Downloads/Manga/` |
154
+ | macOS | `~/Downloads/Manga/` |
155
+
156
+ You can change the directory:
157
+ - At startup when prompted
158
+ - With the `--dir` flag: `mangadl --dir /custom/path`
159
+ - With environment variable: `MANGA_DOWNLOAD_DIR=/path manga2`
160
+
161
+ ---
162
+
163
+ ## Requirements
164
+
165
+ | Tool | Required? | Install |
166
+ |------|-----------|---------|
167
+ | Python 3.8+ | Yes | Pre-installed on most systems |
168
+ | aria2 | Recommended | `pkg install aria2` / `apt install aria2` / `brew install aria2` |
169
+ | prompt_toolkit | Yes (included) | Installed automatically |
170
+ | Pillow | Optional | `pip install Pillow` (for PDF output) |
171
+
172
+ Without aria2, mangadl uses built-in threaded downloads (slower but works).
173
+
174
+ ---
175
+
176
+ ## Troubleshooting
177
+
178
+ ### aria2c not found
179
+ ```bash
180
+ # Termux
181
+ pkg install aria2
182
+
183
+ # Windows — download from https://aria2.github.io/
184
+ # Linux
185
+ sudo apt install aria2
186
+
187
+ # macOS
188
+ brew install aria2
189
+ ```
190
+
191
+ ### Permission denied on /sdcard (Termux)
192
+ ```bash
193
+ termux-setup-storage
194
+ ```
195
+
196
+ ### Python not found (Windows)
197
+ Make sure you checked "Add Python to PATH" during install. Then restart your terminal.
198
+
199
+ ### Slow downloads
200
+ Install aria2 for faster multi-connection downloading.
201
+
202
+ ---
203
+
204
+ ## License
205
+
206
+ MIT
@@ -0,0 +1,10 @@
1
+ mangadl/__init__.py,sha256=wtxrJTbU0eJJbo3nQ9mx96IOvuQAtlScbTKpRfwisbc,46
2
+ mangadl/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ mangadl/cli.py,sha256=LW5K0zG8bARokEq-ucB2FSXtWzGR0rRlDfpWh1n1fMs,20864
4
+ mangadl/downloader.py,sha256=CaYdHyXnPoAq_j_2SWuCZGqwP9LwjID0qty50xqvYS8,11911
5
+ mangadl/scraper.py,sha256=C3-cXfnNrVn2Y5QAj6XnVyA6kyoMIlqjX1o9P4GECYs,4329
6
+ mangadl_cli-1.1.0.dist-info/METADATA,sha256=qeE2jsxMQdx7SWlGLnyDp2ufmdI6QAxrHe70yD-aX5I,4984
7
+ mangadl_cli-1.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ mangadl_cli-1.1.0.dist-info/entry_points.txt,sha256=kHnLVJiPFwNS91CXriWmpm1mCryeGcksuG-EQv6wxqY,45
9
+ mangadl_cli-1.1.0.dist-info/top_level.txt,sha256=QYyBQW5jyNN7kfHTEAdG5Sub3KjfcNYgnWPThzhBhDg,8
10
+ mangadl_cli-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mangadl = mangadl.cli:main
@@ -0,0 +1 @@
1
+ mangadl