bunkr-api 0.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.
@@ -0,0 +1,197 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import sqlite3
5
+ import sys
6
+ import time
7
+ import urllib.parse
8
+
9
+ from curl_cffi.requests import AsyncSession
10
+ from curl_cffi.requests.errors import CurlError
11
+ from rich.console import Console
12
+ from rich.progress import (
13
+ BarColumn,
14
+ Progress,
15
+ SpinnerColumn,
16
+ TaskProgressColumn,
17
+ TextColumn,
18
+ )
19
+
20
+ from ..config import HEADERS
21
+ from ..utils.http import execute_request_with_retry_async
22
+
23
+ console = Console()
24
+
25
+ async def mint_single_url_async(session, file_id: str) -> str:
26
+ """
27
+ Asynchronously resolves a true_file_id and fetches a fresh signed CDN URL.
28
+ This is the core network logic.
29
+ """
30
+ if not file_id or file_id == "None":
31
+ raise ValueError("Invalid or empty file ID provided.")
32
+
33
+ mint_headers = HEADERS.copy()
34
+ mint_headers.update({
35
+ "Content-Type": "application/json",
36
+ "Origin": "https://dl.bunkr.cr",
37
+ "Referer": f"https://dl.bunkr.cr/file/{file_id}",
38
+ })
39
+
40
+ # Step 1: Query Metadata API
41
+ meta_url = "https://dl.bunkr.cr/api/_001_v2"
42
+ payload = {"id": str(file_id)}
43
+ meta_res = await execute_request_with_retry_async(session, meta_url, method="POST", json_payload=payload, headers=mint_headers)
44
+ meta_data = meta_res.json()
45
+
46
+ cdn_host = meta_data.get("mediafiles")
47
+ storage_path = meta_data.get("path")
48
+ original_name = meta_data.get("original")
49
+
50
+ if not all([cdn_host, storage_path, original_name]):
51
+ raise ValueError(f"API response missing data for ID {file_id}")
52
+
53
+ # Step 2: Request Dynamic Validation Token
54
+ encoded_path = urllib.parse.quote(storage_path)
55
+ sign_url = f"https://glb-apisign.cdn.cr/sign?path={encoded_path}"
56
+ sign_res = await execute_request_with_retry_async(session, sign_url, method="GET", headers=mint_headers)
57
+ sign_data = sign_res.json()
58
+
59
+ token = sign_data.get("token")
60
+ ex = sign_data.get("ex")
61
+
62
+ # Step 3: URL Stitching
63
+ encoded_name = urllib.parse.quote(original_name)
64
+ return f"{cdn_host}{storage_path}?n={encoded_name}&token={token}&ex={ex}"
65
+
66
+
67
+ def mint_now(file_id: str) -> str:
68
+ """
69
+ Synchronous wrapper to run the async minter.
70
+ Used as an escape hatch by the Database Manager.
71
+ """
72
+ async def _run_single():
73
+ async with AsyncSession(impersonate="chrome") as session:
74
+ return await mint_single_url_async(session, file_id)
75
+
76
+ loop_factory = asyncio.SelectorEventLoop if sys.platform == 'win32' else None
77
+ if loop_factory:
78
+ return asyncio.run(_run_single(), loop_factory=loop_factory)
79
+ else:
80
+ return asyncio.run(_run_single())
81
+
82
+
83
+ async def process_asset_task(session, db, sem, asset, progress, task_id):
84
+ """
85
+ Worker task that processes a single asset token refresh in a batch.
86
+ """
87
+ async with sem:
88
+ # Resolve ID from various possible keys
89
+ raw_id = asset.get("true_file_id") or asset.get("slug_id")
90
+ file_id = str(raw_id).strip() if raw_id is not None else None
91
+
92
+ # Fallback Parsing from source URL if ID is missing
93
+ if not file_id and asset.get("source_url"):
94
+ source_url = asset["source_url"]
95
+ match = re.search(r'/f/([^./?]+)', source_url)
96
+ if match:
97
+ file_id = match.group(1)
98
+ else:
99
+ parsed_path = urllib.parse.urlparse(source_url).path
100
+ file_id = os.path.basename(parsed_path.rstrip("/"))
101
+
102
+ if not file_id:
103
+ progress.advance(task_id)
104
+ return
105
+
106
+ try:
107
+ fresh_url = await mint_single_url_async(session, file_id)
108
+ db.update_asset_url(asset["id"], fresh_url)
109
+ except (TimeoutError, CurlError, sqlite3.Error, KeyError, ValueError):
110
+ pass
111
+ finally:
112
+ progress.advance(task_id)
113
+
114
+
115
+ async def refresh_all_tokens_async(db, assets, max_workers: int):
116
+ """
117
+ Batch-processes a list of assets concurrently using a semaphore.
118
+ """
119
+ sem = asyncio.Semaphore(max_workers)
120
+
121
+ with Progress(
122
+ SpinnerColumn(),
123
+ TextColumn("[progress.description]{task.description}"),
124
+ BarColumn(bar_width=40, finished_style="green"),
125
+ TaskProgressColumn(),
126
+ console=console
127
+ ) as progress:
128
+ task_id = progress.add_task(
129
+ f"[cyan]Refreshing {len(assets)} tokens...",
130
+ total=len(assets)
131
+ )
132
+
133
+ async with AsyncSession(impersonate="chrome") as session:
134
+ tasks = [
135
+ process_asset_task(session, db, sem, asset, progress, task_id)
136
+ for asset in assets
137
+ ]
138
+ await asyncio.gather(*tasks)
139
+
140
+
141
+ def daemon_loop(album_id: int | None = None):
142
+ """
143
+ Restored: Loop that polls the database and renews expiring tokens.
144
+ If album_id is specified, it performs a one-shot targeted refresh.
145
+ """
146
+ from .db import DatabaseManager
147
+
148
+ db = DatabaseManager()
149
+ max_workers = int(db.get_config_val("max_workers", "4"))
150
+
151
+ if album_id:
152
+ console.print(f"[bold green][+][/bold green] Targeted Refresh for Album ID: [bold cyan]{album_id}[/bold cyan]")
153
+ else:
154
+ console.print("[bold green][+][/bold green] Token Minter Background Daemon active.")
155
+ console.print("[dim]Monitoring database... Press Ctrl+C to stop.[/dim]")
156
+
157
+ while True:
158
+ try:
159
+ # Query DB for assets that are NULL or expiring soon
160
+ raw_assets = db.get_needs_refresh(album_id=album_id)
161
+ expiring_assets = [dict(row) for row in raw_assets]
162
+
163
+ if expiring_assets:
164
+ loop_f = asyncio.SelectorEventLoop if sys.platform == 'win32' else None
165
+ if loop_f:
166
+ asyncio.run(refresh_all_tokens_async(db, expiring_assets, max_workers), loop_factory=loop_f)
167
+ else:
168
+ asyncio.run(refresh_all_tokens_async(db, expiring_assets, max_workers))
169
+
170
+ # If we were targeting a specific album, exit after one pass
171
+ if album_id:
172
+ console.print("[bold green][+][/bold green] Targeted refresh complete.")
173
+ break
174
+
175
+ # Otherwise, wait for the poll interval
176
+ poll_interval = int(db.get_config_val("minter_poll_interval_seconds", "60"))
177
+ time.sleep(poll_interval)
178
+
179
+ except KeyboardInterrupt:
180
+ console.print("\n[yellow][!][/yellow] Minter shut down.")
181
+ break
182
+ except Exception as e:
183
+ console.print(f"[bold red][x] Minter Error:[/bold red] {e}")
184
+ if album_id:
185
+ break
186
+ time.sleep(10)
187
+
188
+
189
+ def main():
190
+ import argparse
191
+ parser = argparse.ArgumentParser(
192
+ prog="bunkr-mint",
193
+ description="Token Refresh Utility. Run 'bunkr-mint' with no options to launch the background daemon."
194
+ )
195
+ parser.add_argument('-a', '--album-id', type=int, help="Target a specific album ID for a one-shot refresh")
196
+ args = parser.parse_args()
197
+ daemon_loop(album_id=args.album_id)