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.
- bunkr_api/__init__.py +5 -0
- bunkr_api/__main__.py +4 -0
- bunkr_api/api.py +206 -0
- bunkr_api/cli.py +541 -0
- bunkr_api/config.py +26 -0
- bunkr_api/core/__init__.py +0 -0
- bunkr_api/core/db.py +353 -0
- bunkr_api/core/scraper.py +310 -0
- bunkr_api/core/tokens.py +197 -0
- bunkr_api/inspector.py +534 -0
- bunkr_api/media/__init__.py +0 -0
- bunkr_api/media/downloader.py +437 -0
- bunkr_api/media/player.py +480 -0
- bunkr_api/utils/__init__.py +0 -0
- bunkr_api/utils/formatting.py +86 -0
- bunkr_api/utils/http.py +19 -0
- bunkr_api-0.1.0.dist-info/METADATA +697 -0
- bunkr_api-0.1.0.dist-info/RECORD +22 -0
- bunkr_api-0.1.0.dist-info/WHEEL +5 -0
- bunkr_api-0.1.0.dist-info/entry_points.txt +7 -0
- bunkr_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- bunkr_api-0.1.0.dist-info/top_level.txt +1 -0
bunkr_api/core/tokens.py
ADDED
|
@@ -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)
|