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/cli.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
from curl_cffi.requests import AsyncSession
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.prompt import IntPrompt, Prompt
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from .config import (
|
|
18
|
+
DEFAULT_JSON_DIR,
|
|
19
|
+
SEARCH_MODES,
|
|
20
|
+
SORT_TYPES,
|
|
21
|
+
TOP_CATEGORIES,
|
|
22
|
+
VALID_COUNTS,
|
|
23
|
+
VERSION,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Internal package imports
|
|
27
|
+
from .core.db import DatabaseManager
|
|
28
|
+
from .core.scraper import ScraperEngine
|
|
29
|
+
from .core.tokens import refresh_all_tokens_async
|
|
30
|
+
from .media.downloader import DownloadEngine
|
|
31
|
+
from .media.player import PlayerEngine
|
|
32
|
+
from .utils.formatting import (
|
|
33
|
+
clean_dragged_path,
|
|
34
|
+
format_bytes,
|
|
35
|
+
parse_and_check_expiry,
|
|
36
|
+
parse_selection,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
db = DatabaseManager()
|
|
41
|
+
|
|
42
|
+
def show_interactive_options(album_id, page_assets, start_idx, total_pages, current_page, total_items):
|
|
43
|
+
"""Provides a prompt lifecycle allowing hands-free secondary script operations."""
|
|
44
|
+
has_expired_tokens = False
|
|
45
|
+
for asset in page_assets:
|
|
46
|
+
token_status = parse_and_check_expiry(asset["token_expiry_timestamp"])
|
|
47
|
+
if "Expired" in token_status:
|
|
48
|
+
has_expired_tokens = True
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
minter_style = " [bold red blink]5. Mint new tokens (⚠️ EXPIRED)[/bold red blink]" if has_expired_tokens else " [bold white]5.[/bold white] Mint new tokens"
|
|
52
|
+
|
|
53
|
+
console.print("\n[bold cyan][交互 Engine] Select an Action Context:[/bold cyan]")
|
|
54
|
+
console.print(" Navigation -> [bold white]n[/bold white]: Next Page | [bold white]p[/bold white]: Prev Page")
|
|
55
|
+
console.print(" [bold white]1.[/bold white] Stream target(s) [dim](Accepts: 5 | 3,7,12 | 1-5 | staged | Enter for ALL)[/dim]")
|
|
56
|
+
console.print(" [bold white]2.[/bold white] Download target(s) [dim](Accepts: 5 | 3,7,12 | 1-10 | staged)[/dim]")
|
|
57
|
+
console.print(" [bold white]3.[/bold white] Download ALL assets in this album")
|
|
58
|
+
console.print(" [bold white]4.[/bold white] Copy link to stdout")
|
|
59
|
+
console.print(f"{minter_style} [dim](Manual batch refresh with feedback)[/dim]")
|
|
60
|
+
console.print(" [bold white]6.[/bold white] Stage/Unstage assets [dim](1: Album | 2: Assets)[/dim]")
|
|
61
|
+
console.print(" [bold white]q.[/bold white] Exit this stage")
|
|
62
|
+
|
|
63
|
+
return Prompt.ask("\n[bold cyan][?][/bold cyan] Choose option", choices=["1", "2", "3", "4", "5", "6", "n", "p", "q"], default="q").lower()
|
|
64
|
+
|
|
65
|
+
async def show_album_details(album_id):
|
|
66
|
+
"""
|
|
67
|
+
Restored: The full detailed Dashboard with all original 7 actions.
|
|
68
|
+
MODIFIED: Now async def to support await calls to engines.
|
|
69
|
+
"""
|
|
70
|
+
downloader = DownloadEngine(db)
|
|
71
|
+
player = PlayerEngine(db)
|
|
72
|
+
page_size = 10
|
|
73
|
+
current_page = 1
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
# 1. Fetch Fresh State
|
|
77
|
+
with db.connection() as conn:
|
|
78
|
+
album = conn.execute("SELECT * FROM albums WHERE id = ?;", (album_id,)).fetchone()
|
|
79
|
+
if not album:
|
|
80
|
+
console.print("[bold red][!] Database album record missing.[/bold red]")
|
|
81
|
+
break
|
|
82
|
+
album = dict(album)
|
|
83
|
+
assets = db.get_album_assets(album_id)
|
|
84
|
+
total_items = len(assets)
|
|
85
|
+
total_pages = (total_items + page_size - 1) // page_size
|
|
86
|
+
|
|
87
|
+
# 2. Render Header
|
|
88
|
+
staged_badge = " [bold green][STAGED][/bold green]" if album.get("is_staged") else ""
|
|
89
|
+
completed_count = sum(1 for x in assets if dict(x).get("download_status") == "COMPLETED")
|
|
90
|
+
completed_badge = (
|
|
91
|
+
" [bold blue][COMPLETED][/bold blue]"
|
|
92
|
+
if total_items > 0 and completed_count == total_items
|
|
93
|
+
else ""
|
|
94
|
+
)
|
|
95
|
+
summary = (
|
|
96
|
+
f"[bold cyan]Origin Context:[/bold cyan] {album['search_term'] or 'Direct Link / Import'}\n"
|
|
97
|
+
f"[bold cyan]Album Global Index:[/bold cyan] #{album['global_index']}\n"
|
|
98
|
+
f"[bold cyan]Reported Dataset Size:[/bold cyan] {album['aggregate_size']} ({album['file_count']} files){staged_badge}{completed_badge}"
|
|
99
|
+
)
|
|
100
|
+
console.print(Panel(summary, title=f"[bold green]Parsed DB Record: {album['title']}[/bold green]", expand=False))
|
|
101
|
+
|
|
102
|
+
# 3. Render Table
|
|
103
|
+
start = (current_page - 1) * page_size
|
|
104
|
+
page_assets = assets[start:start+page_size]
|
|
105
|
+
|
|
106
|
+
table = Table(title=f"Deep Resolved Assets Inventory (Page {current_page}/{total_pages} | Items {start + 1}-{min(start + page_size, total_items)} of {total_items})", style="dim white")
|
|
107
|
+
table.add_column("#", justify="right", style="magenta")
|
|
108
|
+
table.add_column("Asset Original Name / Storage Name", style="white")
|
|
109
|
+
table.add_column("Size", justify="center", style="green")
|
|
110
|
+
table.add_column("Link Token Lifespan Metric")
|
|
111
|
+
table.add_column("Preferred Content Target URL", style="blue")
|
|
112
|
+
|
|
113
|
+
for i, asset in enumerate(page_assets, start=start + 1):
|
|
114
|
+
a = dict(asset)
|
|
115
|
+
status = parse_and_check_expiry(a['token_expiry_timestamp'])
|
|
116
|
+
name = a['title']
|
|
117
|
+
if a.get('is_staged'):
|
|
118
|
+
name = f"[bold green][S][/bold green] {name}"
|
|
119
|
+
|
|
120
|
+
table.add_row(
|
|
121
|
+
str(i), name[:50],
|
|
122
|
+
format_bytes(a['raw_size_bytes']),
|
|
123
|
+
status,
|
|
124
|
+
(a['signed_cdn_url'] or a['source_url'] or "N/A")[:35] + "..."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
console.print(table)
|
|
128
|
+
|
|
129
|
+
# 4. Interaction Prompts
|
|
130
|
+
act = show_interactive_options(
|
|
131
|
+
album_id=album_id,
|
|
132
|
+
page_assets=page_assets,
|
|
133
|
+
start_idx=start,
|
|
134
|
+
total_pages=total_pages,
|
|
135
|
+
current_page=current_page,
|
|
136
|
+
total_items=total_items
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if act == 'q': break
|
|
140
|
+
if act == 'n' and current_page < total_pages: current_page += 1; continue
|
|
141
|
+
if act == 'p' and current_page > 1: current_page -= 1; continue
|
|
142
|
+
|
|
143
|
+
if act == "1":
|
|
144
|
+
sel = Prompt.ask("[bold cyan][?][/bold cyan] Stream selection (Enter for all)").strip() or "all"
|
|
145
|
+
indices = parse_selection(sel, total_items=total_items)
|
|
146
|
+
p_engine = Prompt.ask("[bold cyan][?][/bold cyan] Media Player Engine", choices=["mpv", "vlc"], default="mpv")
|
|
147
|
+
|
|
148
|
+
selected_assets = [dict(assets[i-1]) for i in indices]
|
|
149
|
+
# Await the token refresh
|
|
150
|
+
await player.resolve_tokens_async(selected_assets)
|
|
151
|
+
|
|
152
|
+
queue = []
|
|
153
|
+
for i in indices:
|
|
154
|
+
a = dict(assets[i-1])
|
|
155
|
+
url = db.get_valid_url(a['id'])
|
|
156
|
+
queue.append((i, a['title'], url))
|
|
157
|
+
|
|
158
|
+
if p_engine == "mpv":
|
|
159
|
+
await player.play_mpv(queue)
|
|
160
|
+
else:
|
|
161
|
+
await player.play_vlc(queue)
|
|
162
|
+
|
|
163
|
+
elif act == "2":
|
|
164
|
+
sel = Prompt.ask("[bold cyan][?][/bold cyan] Enter item index, list, or range").strip()
|
|
165
|
+
if not sel: continue
|
|
166
|
+
indices = parse_selection(sel, total_items=total_items)
|
|
167
|
+
workers = IntPrompt.ask("[bold cyan][?][/bold cyan] Worker concurrency (MAX=5)", default=1)
|
|
168
|
+
dl_list = []
|
|
169
|
+
for i in indices:
|
|
170
|
+
d = dict(assets[i-1])
|
|
171
|
+
d['db_asset_id'] = d['id']; d['album_title'] = album['title']; d['album_id'] = album['id']
|
|
172
|
+
dl_list.append(d)
|
|
173
|
+
# Await the downloader
|
|
174
|
+
await downloader.run(dl_list, workers=workers)
|
|
175
|
+
|
|
176
|
+
elif act == "3":
|
|
177
|
+
workers = IntPrompt.ask("[bold cyan][?][/bold cyan] Worker concurrency (MAX=5)", default=1)
|
|
178
|
+
dl_list = []
|
|
179
|
+
for a in assets:
|
|
180
|
+
d = dict(a)
|
|
181
|
+
d['db_asset_id'] = d['id']; d['album_title'] = album['title']; d['album_id'] = album['id']
|
|
182
|
+
dl_list.append(d)
|
|
183
|
+
# Await the downloader
|
|
184
|
+
await downloader.run(dl_list, workers=workers)
|
|
185
|
+
|
|
186
|
+
elif act == "4":
|
|
187
|
+
sel = Prompt.ask(f"[bold cyan][?][/bold cyan] Enter row index ({start+1}-{start+len(page_assets)})")
|
|
188
|
+
try:
|
|
189
|
+
idx = int(sel); target = dict(assets[idx-1])
|
|
190
|
+
url = target.get("signed_cdn_url") or target.get("source_url")
|
|
191
|
+
console.print(f"\n[bold green][+][/bold green] Endpoint: [bold white]{url}[/bold white]\n")
|
|
192
|
+
Prompt.ask("[dim white]Press Enter to return...[/dim white]")
|
|
193
|
+
except (ValueError, IndexError, KeyboardInterrupt):
|
|
194
|
+
console.print("[bold red][!] Invalid selection.[/bold red]")
|
|
195
|
+
|
|
196
|
+
elif act == "5":
|
|
197
|
+
raw_assets = db.get_needs_refresh(album_id=album_id)
|
|
198
|
+
expiring_assets = [dict(row) for row in raw_assets]
|
|
199
|
+
if expiring_assets:
|
|
200
|
+
max_workers = int(db.get_config_val("max_workers", "4"))
|
|
201
|
+
await refresh_all_tokens_async(db, expiring_assets, max_workers)
|
|
202
|
+
console.print(f"[bold green][+][/bold green] Refreshed {len(expiring_assets)} token(s).")
|
|
203
|
+
else:
|
|
204
|
+
console.print("[dim]No tokens currently need refreshing.[/dim]")
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
elif act == "6":
|
|
208
|
+
sub = Prompt.ask("[bold cyan][?][/bold cyan] 1: Stage Album | 2: Unstage Album | 3: Stage Assets | 4: Unstage Assets", choices=["1","2","3","4"], default="1")
|
|
209
|
+
with db.connection() as conn:
|
|
210
|
+
if sub == "1":
|
|
211
|
+
conn.execute("UPDATE albums SET is_staged=1 WHERE id=?", (album_id,))
|
|
212
|
+
conn.execute("UPDATE assets SET is_staged=1 WHERE album_id=?", (album_id,))
|
|
213
|
+
elif sub == "2":
|
|
214
|
+
conn.execute("UPDATE albums SET is_staged=0 WHERE id=?", (album_id,))
|
|
215
|
+
conn.execute("UPDATE assets SET is_staged=0 WHERE album_id=?", (album_id,))
|
|
216
|
+
elif sub in ("3","4"):
|
|
217
|
+
sel = Prompt.ask("[bold cyan][?][/bold cyan] Enter indices/range (or 'all')").strip()
|
|
218
|
+
idx_list = parse_selection(sel, total_items=total_items)
|
|
219
|
+
val = 1 if sub == "3" else 0
|
|
220
|
+
for i in idx_list:
|
|
221
|
+
conn.execute("UPDATE assets SET is_staged=? WHERE id=?", (val, assets[i-1]['id']))
|
|
222
|
+
console.print("[bold green][+][/bold green] Staging state updated.")
|
|
223
|
+
time.sleep(1)
|
|
224
|
+
|
|
225
|
+
async def run_scrape_interactive(search_seed=None, mode_seed=None, per_seed=None, sort_seed=None, save_json_seed=None, output_dir_seed=None):
|
|
226
|
+
"""Restored: Multi-page search loop with original informative descriptive prompt."""
|
|
227
|
+
scraper = ScraperEngine(db)
|
|
228
|
+
search_term = search_seed if search_seed is not None else Prompt.ask("[bold cyan][?][/bold cyan] Enter search term [dim](Blank for homepage)[/dim]").strip()
|
|
229
|
+
if mode_seed: url_mode = mode_seed
|
|
230
|
+
else:
|
|
231
|
+
mode_choice = Prompt.ask("[bold cyan][?][/bold cyan] Mode", choices=list(SEARCH_MODES.keys()), default="broad").lower()
|
|
232
|
+
url_mode = SEARCH_MODES[mode_choice]
|
|
233
|
+
url_per = per_seed if per_seed is not None else IntPrompt.ask("[bold cyan][?][/bold cyan] Results per page", choices=[str(c) for c in VALID_COUNTS], default=20)
|
|
234
|
+
if sort_seed: url_sort = sort_seed
|
|
235
|
+
else:
|
|
236
|
+
sort_choice = Prompt.ask("[bold cyan][?][/bold cyan] Sort", choices=["latest", "oldest", "most files"], default="latest").lower()
|
|
237
|
+
url_sort = SORT_TYPES[sort_choice]
|
|
238
|
+
save_json = save_json_seed if save_json_seed is not None else (Prompt.ask("[bold cyan][?][/bold cyan] Save JSON backup?", choices=["y", "n"], default="n") == "y")
|
|
239
|
+
|
|
240
|
+
async with AsyncSession(impersonate="chrome") as session:
|
|
241
|
+
current_page = 1
|
|
242
|
+
while True:
|
|
243
|
+
query = {'search': search_term, 'mode': url_mode, 'per': str(url_per), 'sort': url_sort}
|
|
244
|
+
if current_page > 1: query['page'] = str(current_page)
|
|
245
|
+
search_url = f"https://balbums.st/?{urllib.parse.urlencode(query)}"
|
|
246
|
+
console.print(f"\n[bold yellow][*][/bold yellow] Loading Search Results (Page {current_page})...")
|
|
247
|
+
|
|
248
|
+
from .utils.http import execute_request_with_retry_async
|
|
249
|
+
res = await execute_request_with_retry_async(session, search_url)
|
|
250
|
+
soup = BeautifulSoup(res.text, 'html.parser')
|
|
251
|
+
albums = scraper.parse_albums(res.text)
|
|
252
|
+
total_pages = scraper.extract_page_metadata(soup)
|
|
253
|
+
|
|
254
|
+
if not albums:
|
|
255
|
+
console.print("[bold red][!] No albums discovered.[/bold red]")
|
|
256
|
+
if current_page > 1: current_page -= 1; continue
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
start_idx = ((current_page - 1) * url_per) + 1
|
|
260
|
+
end_idx = start_idx + len(albums) - 1
|
|
261
|
+
|
|
262
|
+
display_search = f'"{search_term}"' if search_term else '"Homepage"'
|
|
263
|
+
header_title = f"{display_search} Results Page {current_page} of {total_pages} (Items {start_idx}-{end_idx} loaded) Mode: {url_mode}"
|
|
264
|
+
table = Table(title=header_title, style="dim white")
|
|
265
|
+
table.add_column("#", justify="right", style="magenta")
|
|
266
|
+
table.add_column("Title / Target Album", style="white")
|
|
267
|
+
table.add_column("Files (Est.)", justify="center", style="green")
|
|
268
|
+
table.add_column("Source URL", style="blue")
|
|
269
|
+
|
|
270
|
+
for i, album in enumerate(albums, start=start_idx):
|
|
271
|
+
table.add_row(str(i), album['title'][:60], album.get('file_count', '???'), album['url'][:40] + "...")
|
|
272
|
+
console.print(table)
|
|
273
|
+
|
|
274
|
+
prompt_text = (
|
|
275
|
+
f"\n[bold cyan][?][/bold cyan] Enter selection number ({start_idx}-{end_idx}), "
|
|
276
|
+
f"[bold white]'n'[/bold white] for next page, "
|
|
277
|
+
f"[bold white]'p'[/bold white] for previous page (or [bold red]q[/bold red] to quit)"
|
|
278
|
+
)
|
|
279
|
+
choice = Prompt.ask(prompt_text).strip().lower()
|
|
280
|
+
|
|
281
|
+
if choice == 'q': return None
|
|
282
|
+
if choice == 'n': current_page += 1; continue
|
|
283
|
+
if choice == 'p' and current_page > 1: current_page -= 1; continue
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
idx = int(choice)
|
|
287
|
+
if start_idx <= idx <= end_idx:
|
|
288
|
+
return await scraper.scrape_album(
|
|
289
|
+
session,
|
|
290
|
+
albums[idx-start_idx]['url'],
|
|
291
|
+
search_term,
|
|
292
|
+
album_number_index=idx,
|
|
293
|
+
save_json=save_json,
|
|
294
|
+
output_dir=output_dir_seed
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
console.print(f"[bold red][!] Selection {idx} is out of range.[/bold red]")
|
|
298
|
+
except (ValueError, IndexError):
|
|
299
|
+
console.print("[bold red][!] Please enter a valid number or navigation command.[/bold red]")
|
|
300
|
+
|
|
301
|
+
async def run_top_engine_interactive(category_seed=None, save_json_seed=None, output_dir_seed=None):
|
|
302
|
+
"""Restored: Trending loop with original descriptive prompt and flag bypass."""
|
|
303
|
+
scraper = ScraperEngine(db)
|
|
304
|
+
cat = category_seed if category_seed is not None else Prompt.ask("[bold cyan][?][/bold cyan] Category", choices=list(TOP_CATEGORIES.keys()), default="albums")
|
|
305
|
+
lapse = Prompt.ask("[bold cyan][?][/bold cyan] Timeframe", choices=["24h", "7d", "30d", "all"], default="24h")
|
|
306
|
+
save_json = save_json_seed if save_json_seed is not None else (Prompt.ask("[bold cyan][?][/bold cyan] Save JSON backup?", choices=["y", "n"], default="n") == "y")
|
|
307
|
+
|
|
308
|
+
async with AsyncSession(impersonate="chrome") as session:
|
|
309
|
+
current_page = 1
|
|
310
|
+
while True:
|
|
311
|
+
top_url = f"https://balbums.st/{TOP_CATEGORIES[cat]}?lapse={lapse}"
|
|
312
|
+
if current_page > 1: top_url += f"&page={current_page}"
|
|
313
|
+
|
|
314
|
+
console.print(f"\n[bold yellow][*][/bold yellow] Loading Trending {cat.capitalize()}...")
|
|
315
|
+
from .utils.http import execute_request_with_retry_async
|
|
316
|
+
res = await execute_request_with_retry_async(session, top_url)
|
|
317
|
+
items = scraper.parse_top_items(res.text, cat)
|
|
318
|
+
if not items: return None
|
|
319
|
+
|
|
320
|
+
start_idx = (current_page - 1) * 15 + 1
|
|
321
|
+
end_idx = start_idx + len(items) - 1
|
|
322
|
+
table = Table(title=f"Trending {cat.capitalize()} ({lapse}) - Page {current_page}", style="dim white")
|
|
323
|
+
table.add_column("#", justify="right", style="magenta"); table.add_column("Title"); table.add_column("Files (Est.)", style="green")
|
|
324
|
+
for i, item in enumerate(items, start_idx):
|
|
325
|
+
table.add_row(str(i), item['title'], item.get('file_count', '1 file'))
|
|
326
|
+
console.print(table)
|
|
327
|
+
|
|
328
|
+
prompt_text = (
|
|
329
|
+
f"\n[bold cyan][?][/bold cyan] Enter selection number ({start_idx}-{end_idx}), "
|
|
330
|
+
f"[bold white]'n'[/bold white] for next page, "
|
|
331
|
+
f"[bold white]'p'[/bold white] for previous page (or [bold red]q[/bold red] to quit)"
|
|
332
|
+
)
|
|
333
|
+
choice = Prompt.ask(prompt_text).strip().lower()
|
|
334
|
+
|
|
335
|
+
if choice == 'q': return None
|
|
336
|
+
if choice == 'n': current_page += 1; continue
|
|
337
|
+
if choice == 'p' and current_page > 1: current_page -= 1; continue
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
idx = int(choice)
|
|
341
|
+
if start_idx <= idx <= end_idx:
|
|
342
|
+
return await scraper.scrape_album(
|
|
343
|
+
session,
|
|
344
|
+
items[idx-start_idx]['url'],
|
|
345
|
+
f"top_{cat}",
|
|
346
|
+
album_number_index=idx,
|
|
347
|
+
save_json=save_json,
|
|
348
|
+
output_dir=output_dir_seed
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
console.print(f"[bold red][!] Selection {idx} is out of range.[/bold red]")
|
|
352
|
+
except (ValueError, IndexError):
|
|
353
|
+
console.print("[bold red][!] Please enter a valid number or navigation command.[/bold red]")
|
|
354
|
+
|
|
355
|
+
async def main_loop():
|
|
356
|
+
"""Restored: Original Informative vertical menu with Trending support."""
|
|
357
|
+
while True:
|
|
358
|
+
albums = db.get_all_albums()
|
|
359
|
+
console.print("\n[bold magenta][*] Discovered Albums Cataloged in DB:[/bold magenta]")
|
|
360
|
+
if not albums:
|
|
361
|
+
console.print("[dim] (No records cataloged yet)[/dim]")
|
|
362
|
+
else:
|
|
363
|
+
for i, a in enumerate(albums, start=1):
|
|
364
|
+
a_dict = dict(a)
|
|
365
|
+
staged = " [bold green][STAGED][/bold green]" if a_dict.get('is_staged') else ""
|
|
366
|
+
total_assets = a_dict.get('total_assets') or 0
|
|
367
|
+
completed_assets = a_dict.get('completed_assets') or 0
|
|
368
|
+
completed = " [bold blue][COMPLETED][/bold blue]" if total_assets > 0 and completed_assets == total_assets else ""
|
|
369
|
+
console.print(f" [bold cyan]{i:2d}[/bold cyan] • [yellow]{a_dict['title']}[/yellow] ({a_dict['file_count']} items){staged}{completed} [dim white](DB ID: {a_dict['id']})[/dim white]")
|
|
370
|
+
|
|
371
|
+
console.print()
|
|
372
|
+
console.print(" [bold white]s.[/bold white] Search for or discover a new album")
|
|
373
|
+
console.print(" [bold white]t.[/bold white] Browse trending and top media")
|
|
374
|
+
console.print(" [bold white]d.[/bold white] Delete an album")
|
|
375
|
+
console.print(" [bold white]q.[/bold white] Exit reader")
|
|
376
|
+
console.print()
|
|
377
|
+
|
|
378
|
+
raw = Prompt.ask("[bold cyan][?][/bold cyan] Choose an album #, drop a JSON path, or select an option").strip()
|
|
379
|
+
if not raw: continue
|
|
380
|
+
|
|
381
|
+
processed_path = clean_dragged_path(raw)
|
|
382
|
+
if processed_path.lower().endswith('.json') and os.path.exists(processed_path):
|
|
383
|
+
try:
|
|
384
|
+
with open(processed_path, encoding='utf-8') as f:
|
|
385
|
+
data = json.load(f)
|
|
386
|
+
new_id, _new_count, _updated_count = db.register_album_from_json(data)
|
|
387
|
+
await show_album_details(new_id)
|
|
388
|
+
continue
|
|
389
|
+
except Exception as e:
|
|
390
|
+
console.print(f"[red][!] JSON import failed: {e}[/red]")
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
cmd = raw.lower()
|
|
394
|
+
if cmd == 'q': break
|
|
395
|
+
if cmd == 's':
|
|
396
|
+
new_id = await run_scrape_interactive()
|
|
397
|
+
if new_id: await show_album_details(new_id)
|
|
398
|
+
continue
|
|
399
|
+
if cmd == 't':
|
|
400
|
+
new_id = await run_top_engine_interactive()
|
|
401
|
+
if new_id: await show_album_details(new_id)
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if cmd == 'd':
|
|
405
|
+
del_spec = Prompt.ask("[bold red][?][/bold red] Album number(s) to delete (e.g. 1, 2-4)")
|
|
406
|
+
if not del_spec: continue
|
|
407
|
+
try:
|
|
408
|
+
indices = parse_selection(del_spec, total_items=len(albums))
|
|
409
|
+
targets = [albums[idx-1] for idx in indices]
|
|
410
|
+
target_ids = [a['id'] for a in targets]
|
|
411
|
+
labels = [f"#{a['id']} \"{a['title']}\"" for a in targets]
|
|
412
|
+
|
|
413
|
+
if Prompt.ask(f"Permanently delete {', '.join(labels)}?", choices=["y", "n"], default="n") == "y":
|
|
414
|
+
with db.connection() as conn:
|
|
415
|
+
p_holders = ",".join("?" for _ in target_ids)
|
|
416
|
+
conn.execute(f"DELETE FROM assets WHERE album_id IN ({p_holders})", target_ids)
|
|
417
|
+
conn.execute(f"DELETE FROM albums WHERE id IN ({p_holders})", target_ids)
|
|
418
|
+
console.print("[bold green][+][/bold green] Deleted:")
|
|
419
|
+
for label in labels:
|
|
420
|
+
console.print(f" [dim]-[/dim] {label}")
|
|
421
|
+
except Exception as e:
|
|
422
|
+
console.print(f"[red][!] Deletion failed: {e}[/red]")
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
choice_idx = int(raw)
|
|
427
|
+
if 1 <= choice_idx <= len(albums):
|
|
428
|
+
await show_album_details(albums[choice_idx-1]['id'])
|
|
429
|
+
except (ValueError, IndexError):
|
|
430
|
+
console.print("[red][!] Invalid selection or unknown command.[/red]")
|
|
431
|
+
|
|
432
|
+
async def _run():
|
|
433
|
+
"""
|
|
434
|
+
MASTER CLI ENTRY POINT (async implementation)
|
|
435
|
+
(Matches logic from original read.py main block)
|
|
436
|
+
"""
|
|
437
|
+
parser = argparse.ArgumentParser(
|
|
438
|
+
prog="bunkr-api",
|
|
439
|
+
description=f"Bunkr API Manager {VERSION} - Interactive Dashboard & Master CLI",
|
|
440
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
441
|
+
epilog="""
|
|
442
|
+
Master Help - Related Binaries:
|
|
443
|
+
bunk-api Dive straight into the main interactive CLI.
|
|
444
|
+
bunkr-scrape Search bunkr by creator name.
|
|
445
|
+
bunkr-stream Stream content from a database ID (bunkr-stream --db-id 3).
|
|
446
|
+
bunkr-download Batch download content from a database ID (bunkr-download --db-id 3).
|
|
447
|
+
bunkr-inspect Database maintenance and reporting.
|
|
448
|
+
bunkr-mint Open concurrent token minting daemon.
|
|
449
|
+
"""
|
|
450
|
+
)
|
|
451
|
+
# Core Metadata
|
|
452
|
+
parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {VERSION}')
|
|
453
|
+
|
|
454
|
+
# Search & Scraping Flags
|
|
455
|
+
parser.add_argument("search", nargs="?", default=None, help="The search query")
|
|
456
|
+
parser.add_argument("-m", "--mode", choices=list(SEARCH_MODES.keys()), help="Search mode")
|
|
457
|
+
parser.add_argument("-p", "--per", type=int, choices=VALID_COUNTS, help="Results per page")
|
|
458
|
+
parser.add_argument("-s", "--sort", choices=["latest", "oldest", "mostfiles"], help="Sorting metric")
|
|
459
|
+
parser.add_argument("-t", "--top", nargs="?", const="prompt", help="Trending category")
|
|
460
|
+
parser.add_argument("--save-json", action="store_true", help="Save backup JSON")
|
|
461
|
+
parser.add_argument("-o", "--output", type=Path, default=DEFAULT_JSON_DIR, help="Target directory to save JSON")
|
|
462
|
+
|
|
463
|
+
# Working Flags
|
|
464
|
+
parser.add_argument('--db-id', type=int, help="Jump directly to an album in database by ID.")
|
|
465
|
+
parser.add_argument('-i', '--input', type=str, help="Import a legacy JSON file and view it.")
|
|
466
|
+
|
|
467
|
+
# NOTE: previously there was a second `path` positional here meant to
|
|
468
|
+
# catch a bare numeric ID or JSON path. Since `search` (declared above)
|
|
469
|
+
# is also an optional positional, argparse always assigns a single bare
|
|
470
|
+
# token to `search` first — `path` could never actually receive it
|
|
471
|
+
# (confirmed empirically). Removed in favor of interpreting args.search
|
|
472
|
+
# itself below, in priority order: JSON path -> numeric ID -> search term.
|
|
473
|
+
|
|
474
|
+
args = parser.parse_args()
|
|
475
|
+
|
|
476
|
+
# Route 1: Handle Import (-i or a bare positional ending in .json)
|
|
477
|
+
target_path = args.input or (args.search if args.search and args.search.endswith('.json') else None)
|
|
478
|
+
if target_path:
|
|
479
|
+
processed = clean_dragged_path(target_path)
|
|
480
|
+
if os.path.exists(processed):
|
|
481
|
+
try:
|
|
482
|
+
with open(processed, encoding='utf-8') as f:
|
|
483
|
+
data = json.load(f)
|
|
484
|
+
new_id, _new_count, _updated_count = db.register_album_from_json(data)
|
|
485
|
+
await show_album_details(new_id)
|
|
486
|
+
return
|
|
487
|
+
except Exception as e:
|
|
488
|
+
console.print(f"[bold red][!] Import failed: {e}[/bold red]")
|
|
489
|
+
sys.exit(1)
|
|
490
|
+
|
|
491
|
+
# Route 2: Handle Direct Jump (--db-id or a bare numeric positional)
|
|
492
|
+
raw_val = args.db_id or (int(args.search) if args.search and args.search.isdigit() else None)
|
|
493
|
+
if raw_val:
|
|
494
|
+
await show_album_details(raw_val)
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
# Route 3: Handle Scraper Command Direct Executions
|
|
498
|
+
url_mode = SEARCH_MODES.get(args.mode) if args.mode else None
|
|
499
|
+
if args.search or args.top or args.mode or args.save_json or args.output != DEFAULT_JSON_DIR:
|
|
500
|
+
if args.top:
|
|
501
|
+
album_id = await run_top_engine_interactive(
|
|
502
|
+
category_seed=args.top if args.top != "prompt" else None,
|
|
503
|
+
save_json_seed=args.save_json,
|
|
504
|
+
output_dir_seed=args.output
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
album_id = await run_scrape_interactive(
|
|
508
|
+
search_seed=args.search,
|
|
509
|
+
mode_seed=url_mode,
|
|
510
|
+
per_seed=args.per,
|
|
511
|
+
sort_seed=args.sort,
|
|
512
|
+
save_json_seed=args.save_json,
|
|
513
|
+
output_dir_seed=args.output
|
|
514
|
+
)
|
|
515
|
+
if album_id:
|
|
516
|
+
await show_album_details(album_id)
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
# Default: Interactive Main Menu Loop
|
|
520
|
+
try:
|
|
521
|
+
await main_loop()
|
|
522
|
+
except KeyboardInterrupt:
|
|
523
|
+
console.print("\n[bold yellow][!] Session terminated gracefully.[/bold yellow]")
|
|
524
|
+
sys.exit(0)
|
|
525
|
+
|
|
526
|
+
def main():
|
|
527
|
+
"""
|
|
528
|
+
Synchronous entry point. This is what pyproject.toml's [project.scripts]
|
|
529
|
+
'bunkr-api = "bunkr_api.cli:main"' actually calls — entry-point scripts
|
|
530
|
+
invoke the target function directly, they do NOT await coroutines. So
|
|
531
|
+
this must stay a plain sync function that wraps the real async logic in
|
|
532
|
+
asyncio.run(), same pattern as downloader.py/player.py/tokens.py's main().
|
|
533
|
+
"""
|
|
534
|
+
loop_f = asyncio.SelectorEventLoop if sys.platform == 'win32' else None
|
|
535
|
+
if loop_f:
|
|
536
|
+
asyncio.run(_run(), loop_factory=loop_f)
|
|
537
|
+
else:
|
|
538
|
+
asyncio.run(_run())
|
|
539
|
+
|
|
540
|
+
if __name__ == "__main__":
|
|
541
|
+
main()
|
bunkr_api/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
VERSION = "0.1.0"
|
|
4
|
+
|
|
5
|
+
APP_DIR = Path.home() / ".bunkr_api"
|
|
6
|
+
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
7
|
+
|
|
8
|
+
DB_PATH = str(APP_DIR / "media_tracker.db")
|
|
9
|
+
|
|
10
|
+
LOG_DIR = APP_DIR / "logs"
|
|
11
|
+
LOG_DIR.mkdir(exist_ok=True)
|
|
12
|
+
|
|
13
|
+
DEFAULT_OUTPUT_DIR = Path.home() / "Downloads" / "bunkr_downloads"
|
|
14
|
+
DEFAULT_JSON_DIR = DEFAULT_OUTPUT_DIR / "jsons"
|
|
15
|
+
|
|
16
|
+
HEADERS = {
|
|
17
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
18
|
+
"Origin": "https://bunkr.cr",
|
|
19
|
+
"Referer": "https://bunkr.cr/",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
SEARCH_MODES = {"broad": "broad", "strict": "strict", "fuzzy": "fuzzy", "substring": "substring", "whole": "whole"}
|
|
23
|
+
SORT_TYPES = {"latest": "latest", "oldest": "oldest", "most files": "mostfiles"}
|
|
24
|
+
TOP_CATEGORIES = {"albums": "topalbums", "videos": "topvideos", "files": "topfiles", "images": "topimages"}
|
|
25
|
+
VALID_COUNTS = [20, 40, 60, 100]
|
|
26
|
+
|
|
File without changes
|