diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-07 12:22:06 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-07 12:22:06 +0200 |
| commit | 9348fb7b74e1c82c9e02bdc40c0c0c48faa2911f (patch) | |
| tree | e7a748c13da4c25f93682d430a911ff7197e106f | |
| parent | c3386f19b31706cd1deb427b521b151baf652edc (diff) | |
| download | bunkr_dl-9348fb7b74e1c82c9e02bdc40c0c0c48faa2911f.tar.gz bunkr_dl-9348fb7b74e1c82c9e02bdc40c0c0c48faa2911f.zip | |
Show colored album and file progress bars
Use rich for two live bars: the album (files done, elapsed time) and
the current file (percent, size, speed, ETA). The file bar starts at
the resumed offset and is removed when the file finishes; album bars
stay as a record. Messages go through console.out so they print above
the bars. Adds rich as a runtime dependency.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | README.md | 3 | ||||
| -rwxr-xr-x | bunkr_dl | 71 |
3 files changed, 54 insertions, 26 deletions
@@ -5,8 +5,8 @@ Guidance for AI coding agents working in this repository. ## What this is `bunkr_dl`: single-file Python script that downloads Bunkr albums and search -results. Runtime dependencies: `requests`, `beautifulsoup4`, `lxml`. Keep it a -single file and do not add dependencies. +results. Runtime dependencies: `requests`, `beautifulsoup4`, `lxml`, `rich`. +Keep it a single file and do not add dependencies without asking. On the maintainer's machine the script is installed as a symlink into the repo, so edits are live immediately: @@ -31,6 +31,8 @@ finds nothing, re-inspect the live pages with `curl` before changing code. ## Conventions +- Output goes through `console.out`, never `print`, so messages do not tear + the `rich` progress bars. - New options go in the `argparse` setup in `main()` and in the README usage table. - `download_file` resumes with a per-request `Range` header. Never mutate the @@ -6,6 +6,8 @@ search, from the command line. - Follows paginated albums (`?page=N`, 100 files per page). - Saves files under their original names. - Resumes partially downloaded files when run again against the same folder. +- Shows a progress bar for the album (files) and for the current file + (size, speed, ETA). - Works with any Bunkr mirror domain, since links are resolved relative to the URL you pass. @@ -16,6 +18,7 @@ Python 3 with: - `requests` - `beautifulsoup4` - `lxml` +- `rich` ## Usage @@ -18,6 +18,11 @@ import requests from bs4 import BeautifulSoup +from rich.console import Console, Group +from rich.live import Live +from rich.progress import (Progress, TextColumn, BarColumn, MofNCompleteColumn, + TimeElapsedColumn, TaskProgressColumn, DownloadColumn, + TransferSpeedColumn, TimeRemainingColumn) import re import os import sys @@ -32,13 +37,22 @@ headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36' } +# Album bars stay as a record, file bars are removed when done. Messages go +# through console.out so they print above the bars without tearing them. +console = Console(highlight=False) +album_bars = Progress(TextColumn("{task.description:30}"), BarColumn(complete_style="magenta"), + MofNCompleteColumn(), TimeElapsedColumn(), console=console) +file_bars = Progress(TextColumn("{task.description:30}"), BarColumn(complete_style="cyan"), + TaskProgressColumn(), DownloadColumn(binary_units=True), TransferSpeedColumn(), + TimeRemainingColumn(), console=console) + def get_soup(url): """Helper function to get parsed HTML""" response = requests.get(url, headers=headers) if response.status_code == 200: return BeautifulSoup(response.content, 'lxml') else: - print(f"Failed to fetch {url}, status code: {response.status_code}") + console.out(f"Failed to fetch {url}, status code: {response.status_code}") return None def is_search_page(url): @@ -72,7 +86,7 @@ def find_album_links_from_search(base_url): soup = get_soup(search_url) if not soup: - print(f"Failed to fetch page {page}") + console.out(f"Failed to fetch page {page}") break # Find album links on the current search page @@ -130,7 +144,7 @@ def get_final_media_link(download_page_url): sign.raise_for_status() sign = sign.json() except (requests.exceptions.RequestException, ValueError, KeyError) as e: - print(f"Failed to resolve {download_page_url}: {e}") + console.out(f"Failed to resolve {download_page_url}: {e}") return None url = f"{meta['mediafiles']}{quote(meta['path'])}?token={sign['token']}&ex={sign['ex']}" @@ -150,32 +164,38 @@ def download_file(file_url, file_name, save_dir, retries=5): try: with requests.get(file_url, headers=req_headers, stream=True, timeout=60) as response: if response.status_code == 416: # Range past end: already complete - print(f"Already downloaded: {local_filename}") + console.out(f"Already downloaded: {local_filename}") return local_filename response.raise_for_status() # Check if it's a resumable download if response.status_code == 206: - print(f"Resuming download for: {local_filename}") + done = os.path.getsize(local_filename) mode = 'ab' else: # Full body: start over instead of appending to a partial file - print(f"Downloading: {local_filename}") + done = 0 mode = 'wb' - - with open(local_filename, mode) as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: # Filter out keep-alive chunks - f.write(chunk) - print(f"Download complete: {local_filename}") + total = done + int(response.headers.get('Content-Length', 0)) + + task = file_bars.add_task(file_name[:30], total=total or None, completed=done) + try: + with open(local_filename, mode) as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: # Filter out keep-alive chunks + f.write(chunk) + file_bars.advance(task, len(chunk)) + finally: + file_bars.remove_task(task) + console.out(f"Download complete: {local_filename}") return local_filename except (requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError, requests.exceptions.Timeout) as e: attempt += 1 - print(f"Error during download: {e}. Retrying {attempt}/{retries}...") + console.out(f"Error during download: {e}. Retrying {attempt}/{retries}...") time.sleep(5) # Wait before retrying except Exception as e: - print(f"Failed to download {file_url}: {e}") + console.out(f"Failed to download {file_url}: {e}") break - print(f"Failed to download {file_url} after {retries} attempts.") + console.out(f"Failed to download {file_url} after {retries} attempts.") return None def process_album(album_url, base_save_directory): @@ -204,10 +224,11 @@ def process_album(album_url, base_save_directory): page += 1 if not media_tile_links: - print(f"No media links found in album {album_url}") + console.out(f"No media links found in album {album_url}") return # Step 2: Follow each media tile link and extract the download page URL + task = album_bars.add_task(album_title[:30], total=len(media_tile_links)) for media_link in media_tile_links: download_link = find_download_link(media_link) if download_link: @@ -217,9 +238,10 @@ def process_album(album_url, base_save_directory): # Step 4: Download the file download_file(*final_media, album_save_directory) else: - print(f"Failed to get final media from {download_link}") + console.out(f"Failed to get final media from {download_link}") else: - print(f"Failed to find download link on {media_link}") + console.out(f"Failed to find download link on {media_link}") + album_bars.advance(task) def main(): parser = argparse.ArgumentParser( @@ -255,28 +277,29 @@ def main(): # Check if the URL is a search result or a direct album link if is_search_page(url): - print(f"Processing search results from {url}") + console.out(f"Processing search results from {url}") # Get all album links from the search page album_links = find_album_links_from_search(url) if not album_links: - print("No albums found in the search results.") + console.out("No albums found in the search results.") return # Process each album found in the search results for album_link in album_links: - print(f"Processing album {album_link}") + console.out(f"Processing album {album_link}") process_album(album_link, save_directory) else: # Direct album link, process it - print(f"Processing album {url}") + console.out(f"Processing album {url}") process_album(url, save_directory) if __name__ == "__main__": try: - main() + with Live(Group(album_bars, file_bars), console=console): + main() except KeyboardInterrupt: # Partial file stays on disk; rerunning the same command resumes it - print("\nInterrupted. Rerun the same command to resume.") + console.out("\nInterrupted. Rerun the same command to resume.") sys.exit(130) |
