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/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