aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-07 12:22:06 +0200
committerDanilo M. <danix@danix.xyz>2026-10-07 12:22:06 +0200
commit9348fb7b74e1c82c9e02bdc40c0c0c48faa2911f (patch)
treee7a748c13da4c25f93682d430a911ff7197e106f
parentc3386f19b31706cd1deb427b521b151baf652edc (diff)
downloadbunkr_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.md6
-rw-r--r--README.md3
-rwxr-xr-xbunkr_dl71
3 files changed, 54 insertions, 26 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 3919287..75a0929 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/README.md b/README.md
index b9d552b..d643c5a 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/bunkr_dl b/bunkr_dl
index 9eb83d3..36f9264 100755
--- a/bunkr_dl
+++ b/bunkr_dl
@@ -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)