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,480 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import shutil
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ from contextlib import suppress
12
+ from pathlib import Path
13
+
14
+ from rich.console import Console
15
+ from rich.live import Live
16
+ from rich.panel import Panel
17
+ from rich.progress import BarColumn, Progress, TextColumn
18
+ from rich.prompt import Prompt
19
+
20
+ # Internal package imports
21
+ from ..core.tokens import mint_single_url_async
22
+ from ..utils.formatting import clean_dragged_path, parse_selection
23
+
24
+ console = Console()
25
+
26
+
27
+ class PlayerEngine:
28
+ def __init__(self, db):
29
+ self.db = db
30
+
31
+ def connect_to_ipc(self, ipc_path: str):
32
+ """Establishes connection to MPV IPC (Named Pipe on Win, Unix Socket on Linux)."""
33
+ if os.name == "nt":
34
+ try:
35
+ return open(ipc_path, "r+b", buffering=0)
36
+ except OSError as e:
37
+ raise ConnectionError(f"Failed to open Windows named pipe {ipc_path}: {e}") from e
38
+ else:
39
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
40
+ sock.connect(ipc_path)
41
+ return sock.makefile("rw", encoding="utf-8")
42
+
43
+ def poll_mpv_status(self, ipc_path: str, stop_event: threading.Event, total_tracks: int):
44
+ """Restored: Full Cache Syncing and Subscribes to MPV properties."""
45
+ sock_file = None
46
+ for _ in range(50):
47
+ if stop_event.is_set():
48
+ return
49
+ if os.name == "nt" or os.path.exists(ipc_path):
50
+ try:
51
+ sock_file = self.connect_to_ipc(ipc_path)
52
+ break
53
+ except Exception:
54
+ pass
55
+ time.sleep(0.1)
56
+
57
+ if not sock_file:
58
+ return
59
+
60
+ state = {
61
+ "media-title": None,
62
+ "time-pos": None,
63
+ "duration": None,
64
+ "percent-pos": None,
65
+ "demuxer-cache-duration": None,
66
+ "playlist-pos": None,
67
+ }
68
+
69
+ observed = {
70
+ 1: "media-title",
71
+ 2: "time-pos",
72
+ 3: "duration",
73
+ 4: "percent-pos",
74
+ 5: "demuxer-cache-duration",
75
+ 6: "playlist-pos",
76
+ }
77
+
78
+ try:
79
+ for obs_id, prop_name in observed.items():
80
+ payload = json.dumps({"command": ["observe_property", obs_id, prop_name]}) + "\n"
81
+ if os.name == "nt":
82
+ sock_file.write(payload.encode("utf-8"))
83
+ else:
84
+ sock_file.write(payload)
85
+ if os.name != "nt":
86
+ sock_file.flush()
87
+ except Exception:
88
+ return
89
+
90
+ progress_bar = Progress(
91
+ TextColumn("[bold cyan]{task.description}"),
92
+ BarColumn(bar_width=40),
93
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
94
+ TextColumn("({task.fields[time_pos]} / {task.fields[duration]})"),
95
+ TextColumn("[yellow]Cache: {task.fields[cache_duration]}s"),
96
+ )
97
+
98
+ track_task = progress_bar.add_task(
99
+ "Buffering...", total=100, time_pos="00:00", duration="00:00", cache_duration="0.0"
100
+ )
101
+
102
+ def fmt_time(s):
103
+ if s is None:
104
+ return "00:00"
105
+ return f"{int(s) // 60:02d}:{int(s) % 60:02d}"
106
+
107
+ with Live(
108
+ Panel(
109
+ progress_bar,
110
+ title="[bold green]Live Stream Player Status[/bold green]",
111
+ border_style="green",
112
+ ),
113
+ refresh_per_second=10,
114
+ ):
115
+ while not stop_event.is_set():
116
+ try:
117
+ if os.name == "nt":
118
+ raw_line = sock_file.readline()
119
+ if not raw_line:
120
+ break
121
+ line = raw_line.decode("utf-8", errors="ignore").strip()
122
+ else:
123
+ line = sock_file.readline().strip()
124
+ if not line:
125
+ break
126
+
127
+ data = json.loads(line)
128
+ if data.get("event") == "property-change":
129
+ obs_id = data.get("id")
130
+ prop_name = observed.get(obs_id)
131
+ if prop_name:
132
+ state[prop_name] = data.get("data")
133
+
134
+ t_prefix = (
135
+ f"[{int(state['playlist-pos'] or 0) + 1}/{total_tracks}] "
136
+ if state["playlist-pos"] is not None
137
+ else ""
138
+ )
139
+ cache_val = state["demuxer-cache-duration"]
140
+
141
+ progress_bar.update(
142
+ track_task,
143
+ description=f"Playing: {t_prefix}{state['media-title'] or '...'}"[:45],
144
+ completed=int(state["percent-pos"] or 0),
145
+ time_pos=fmt_time(state["time-pos"]),
146
+ duration=fmt_time(state["duration"]),
147
+ cache_duration=f"{cache_val:.1f}" if cache_val is not None else "0.0",
148
+ )
149
+ except Exception: # noqa: S112
150
+ continue
151
+ with suppress(OSError, AttributeError):
152
+ sock_file.close()
153
+
154
+ async def resolve_tokens_async(self, assets):
155
+ """Parallel token refresh for streaming with NULL safety and fixed ID mapping."""
156
+ from curl_cffi.requests import AsyncSession
157
+
158
+ now = time.time()
159
+
160
+ needed = []
161
+ for a in assets:
162
+ asset_dict = dict(a)
163
+ url = asset_dict.get("signed_cdn_url")
164
+ expiry = asset_dict.get("token_expiry_timestamp")
165
+ if not url or expiry is None or expiry < (now + 60):
166
+ needed.append(asset_dict)
167
+
168
+ if not needed:
169
+ return
170
+
171
+ console.print(
172
+ f"[bold yellow][*][/bold yellow] [Escape Hatch] Concurrent batch-refresh triggered for [cyan]{len(needed)}[/cyan] asset(s)..."
173
+ )
174
+
175
+ max_workers = int(self.db.get_config_val("max_workers", "4"))
176
+ sem = asyncio.Semaphore(max_workers)
177
+
178
+ async def worker(session, a):
179
+ async with sem:
180
+ try:
181
+ # Use 'id' explicitly to avoid KeyError
182
+ fid = str(a.get("true_file_id") or a.get("slug_id"))
183
+ url = await mint_single_url_async(session, fid)
184
+ self.db.update_asset_url(a["id"], url)
185
+ except Exception as e:
186
+ safe_title = (a.get("title") or "unknown")[:20]
187
+ console.print(f"[dim red]Minting failed for {safe_title}: {e}[/dim red]")
188
+
189
+ async with AsyncSession(impersonate="chrome") as session:
190
+ await asyncio.gather(*[worker(session, a) for a in needed])
191
+
192
+ async def play_mpv(self, playback_queue):
193
+ """Assembles M3U and launches MPV with IPC polling. Restored Feedback logs."""
194
+ console.print(
195
+ f"\n[bold green][*][/bold green] Assembling Playlist ([cyan]{len(playback_queue)} tracks[/cyan])..."
196
+ )
197
+
198
+ try:
199
+ with tempfile.NamedTemporaryFile(
200
+ delete=False, suffix=".m3u", mode="w", encoding="utf-8"
201
+ ) as f:
202
+ f.write("#EXTM3U\n")
203
+ for idx, title, url in playback_queue:
204
+ f.write(f"#EXTINF:-1,{idx}. {title}\n{url}\n")
205
+ p_path = f.name
206
+ except Exception as e:
207
+ console.print(
208
+ f"[bold red][-][/bold red] Failed to generate temporary play-queue file: {e}"
209
+ )
210
+ return
211
+
212
+ ipc = (
213
+ rf"\\.\pipe\mpv_{os.getpid()}"
214
+ if os.name == "nt"
215
+ else os.path.join(tempfile.gettempdir(), f"mpv_{os.getpid()}")
216
+ )
217
+ stop_event = threading.Event()
218
+ poll_thread = threading.Thread(
219
+ target=self.poll_mpv_status, args=(ipc, stop_event, len(playback_queue)), daemon=True
220
+ )
221
+
222
+ console.print("[bold green][*][/bold green] Launching MPV engine with IPC control...")
223
+
224
+ try:
225
+ # Wrap blocking subprocess in thread to keep loop alive
226
+ proc = await asyncio.to_thread(
227
+ subprocess.Popen,
228
+ ["mpv", "--force-window", f"--input-ipc-server={ipc}", "--idle=once", p_path],
229
+ stdout=subprocess.DEVNULL,
230
+ stderr=subprocess.DEVNULL,
231
+ )
232
+ poll_thread.start()
233
+ await asyncio.to_thread(proc.wait)
234
+ except FileNotFoundError:
235
+ console.print(
236
+ "[bold red][-][/bold red] Error: 'mpv' executable not found on system PATH."
237
+ )
238
+ except KeyboardInterrupt:
239
+ console.print("\n[bold yellow][!][/bold yellow] Playback interrupted by user.")
240
+ with suppress(Exception):
241
+ proc.terminate()
242
+ finally:
243
+ stop_event.set()
244
+ if os.path.exists(p_path):
245
+ os.unlink(p_path)
246
+ console.print("[bold green][+][/bold green] Player session closed safely.")
247
+
248
+ async def play_vlc(self, playback_queue):
249
+ """VLC handoff logic. Restored Feedback logs."""
250
+ console.print(
251
+ f"\n[bold green][*][/bold green] Assembling Playlist ([cyan]{len(playback_queue)} tracks[/cyan])..."
252
+ )
253
+
254
+ try:
255
+ with tempfile.NamedTemporaryFile(
256
+ delete=False, suffix=".m3u", mode="w", encoding="utf-8"
257
+ ) as f:
258
+ f.write("#EXTM3U\n")
259
+ for idx, title, url in playback_queue:
260
+ f.write(f"#EXTINF:-1,{idx}. {title}\n{url}\n")
261
+ p_path = f.name
262
+ except Exception as e:
263
+ console.print(
264
+ f"[bold red][-][/bold red] Failed to generate temporary play-queue file: {e}"
265
+ )
266
+ return
267
+
268
+ vlc = "vlc"
269
+ if os.name == "nt" and not shutil.which("vlc"):
270
+ vlc = r"C:\Program Files\VideoLAN\VLC\vlc.exe"
271
+
272
+ console.print(
273
+ "[bold green][*][/bold green] Streaming via VLC... close the window to return."
274
+ )
275
+ try:
276
+ await asyncio.to_thread(subprocess.run, [vlc, p_path], check=False)
277
+ finally:
278
+ if os.path.exists(p_path):
279
+ os.unlink(p_path)
280
+ console.print("[bold green][+][/bold green] Player session closed safely.")
281
+
282
+
283
+ def prompt_for_inputs(db):
284
+ """Interactive catalog browser."""
285
+ db_albums = []
286
+ try:
287
+ db_albums = db.get_all_albums() or []
288
+ except Exception as e:
289
+ console.print(f"[bold red][-][/bold red] Warning: Could not query DB catalog: {e}")
290
+
291
+ console.print()
292
+ if db_albums:
293
+ console.print("[bold magenta][*] Discovered Albums Cataloged in DB:[/bold magenta]")
294
+ for idx, album in enumerate(db_albums, start=1):
295
+ album_dict = dict(album)
296
+ staged_flag = (
297
+ " [bold green][STAGED][/bold green]" if album_dict.get("is_staged") == 1 else ""
298
+ )
299
+ total_assets = album_dict.get("total_assets") or 0
300
+ completed_assets = album_dict.get("completed_assets") or 0
301
+ completed_flag = (
302
+ " [bold blue][COMPLETED][/bold blue]"
303
+ if total_assets > 0 and completed_assets == total_assets
304
+ else ""
305
+ )
306
+ console.print(
307
+ f" [cyan]{idx:2d}[/cyan] • [yellow]{album_dict['title']}[/yellow] ({album_dict['file_count']} items){staged_flag}{completed_flag} [dim](DB ID: {album_dict['id']})[/dim]"
308
+ )
309
+ console.print()
310
+
311
+ console.print("[dim]Special keywords: 'staged' (pulls all staged items)[/dim]")
312
+ try:
313
+ raw = Prompt.ask(
314
+ "[bold cyan][?][/bold cyan] Choose an album by number, drop a fresh JSON path, or 'q' to exit"
315
+ ).strip()
316
+ except KeyboardInterrupt:
317
+ console.print("\n[bold yellow][!][/bold yellow] Session cancelled.")
318
+ sys.exit(0)
319
+
320
+ if raw.lower() in ("q", "quit", "exit"):
321
+ sys.exit(0)
322
+
323
+ if raw.lower() == "staged":
324
+ player = Prompt.ask(
325
+ "[bold cyan][?][/bold cyan] Select Media Player Engine",
326
+ choices=["mpv", "vlc"],
327
+ default="mpv",
328
+ )
329
+ return None, None, "all", player, True
330
+
331
+ raw = clean_dragged_path(raw)
332
+ input_path, db_id = None, None
333
+
334
+ if raw.isdigit():
335
+ num_val = int(raw)
336
+ if db_albums and 1 <= num_val <= len(db_albums):
337
+ db_id = db_albums[num_val - 1]["id"]
338
+ console.print(
339
+ f"[*] Resolved selection to: [yellow]{db_albums[num_val - 1]['title']}[/yellow] (DB ID: {db_id})"
340
+ )
341
+ else:
342
+ db_id = num_val
343
+ else:
344
+ candidate = Path(raw).expanduser()
345
+ if candidate.exists() and candidate.is_file():
346
+ input_path = candidate
347
+ else:
348
+ console.print("[bold red][-][/bold red] Error: Selection not recognized.")
349
+ sys.exit(1)
350
+
351
+ selection = Prompt.ask(
352
+ "[bold cyan][?][/bold cyan] Enter item index, list, or range [dim](or Press Enter for ALL)[/dim]"
353
+ ).strip()
354
+ if not selection:
355
+ selection = "all"
356
+ player = Prompt.ask(
357
+ "[bold cyan][?][/bold cyan] Select Media Player Engine",
358
+ choices=["mpv", "vlc"],
359
+ default="mpv",
360
+ )
361
+ return input_path, db_id, selection, player, False
362
+
363
+
364
+ async def run_player_logic(args_db_id=None, args_number=None, args_player=None, args_staged=False):
365
+ """Internal orchestration to handle both API and CLI calls."""
366
+ from ..core.db import DatabaseManager
367
+
368
+ db = DatabaseManager()
369
+ engine = PlayerEngine(db)
370
+
371
+ input_json_path = None
372
+ db_id = args_db_id
373
+ selection_arg = args_number
374
+ player_choice = args_player
375
+ run_staged = args_staged
376
+
377
+ if not db_id and not run_staged and not selection_arg:
378
+ input_json_path, db_id, selection_arg, player_choice, run_staged = prompt_for_inputs(db)
379
+
380
+ if not player_choice:
381
+ player_choice = "mpv"
382
+
383
+ files_list = []
384
+
385
+ if run_staged:
386
+ console.print("[bold cyan][*] Extracting all active staged files...[/bold cyan]")
387
+ rows = db.get_staged_assets()
388
+ for asset in rows:
389
+ files_list.append(
390
+ {
391
+ "id": asset["id"],
392
+ "title": asset["title"] or asset["original_filename"],
393
+ "signed_cdn_url": asset["signed_cdn_url"],
394
+ "token_expiry_timestamp": asset["token_expiry_timestamp"],
395
+ "true_file_id": asset["true_file_id"],
396
+ }
397
+ )
398
+ elif db_id:
399
+ console.print(f"[*] Querying database tracker for Album ID: {db_id}...")
400
+ assets = db.get_album_assets(db_id)
401
+ for asset in assets:
402
+ files_list.append(
403
+ {
404
+ "id": asset["id"],
405
+ "title": asset["title"] or asset["original_filename"],
406
+ "signed_cdn_url": asset["signed_cdn_url"],
407
+ "token_expiry_timestamp": asset["token_expiry_timestamp"],
408
+ "true_file_id": asset["true_file_id"],
409
+ }
410
+ )
411
+ elif input_json_path:
412
+ console.print(f"[*] Reading legacy fallback catalog from {input_json_path}...")
413
+ with open(input_json_path, encoding="utf-8") as f:
414
+ data = json.load(f)
415
+ for item in data.get("files_found", []):
416
+ files_list.append(
417
+ {
418
+ "id": None,
419
+ "title": item.get("original") or item.get("title"),
420
+ "signed_cdn_url": item.get("signed_cdn_url"),
421
+ "token_expiry_timestamp": None,
422
+ "true_file_id": item.get("true_file_id"),
423
+ }
424
+ )
425
+
426
+ if not files_list:
427
+ console.print("[bold red][!] No assets available to stream.[/bold red]")
428
+ return
429
+
430
+ try:
431
+ indices = parse_selection(selection_arg or "all", total_items=len(files_list))
432
+ except ValueError as e:
433
+ console.print(f"[bold red][!] {e}[/bold red]")
434
+ return
435
+
436
+ selected_assets = [files_list[i - 1] for i in indices]
437
+ db_backed_assets = [a for a in selected_assets if a["id"] is not None]
438
+
439
+ if db_backed_assets:
440
+ await engine.resolve_tokens_async(db_backed_assets)
441
+
442
+ # Format playback queue
443
+ queue = []
444
+ for i in indices:
445
+ item = files_list[i - 1]
446
+ url = db.get_valid_url(item["id"]) if item["id"] else item["signed_cdn_url"]
447
+ if url:
448
+ queue.append((i, item["title"], url))
449
+
450
+ if player_choice == "vlc":
451
+ await engine.play_vlc(queue)
452
+ else:
453
+ await engine.play_mpv(queue)
454
+
455
+
456
+ def main():
457
+ """Standalone CLI entry point."""
458
+ import argparse
459
+
460
+ parser = argparse.ArgumentParser(
461
+ prog="bunkr-stream",
462
+ description="Bunkr Standalone Streamer CLI"
463
+ )
464
+ parser.add_argument("--db-id", type=int, help="Database ID for album to stream")
465
+ parser.add_argument("-n", "--number", type=str, help="Item/file selection")
466
+ parser.add_argument("--player", choices=["mpv", "vlc"], help="Select media player engine")
467
+ parser.add_argument("--staged", action="store_true", help="Stream staged items")
468
+ args = parser.parse_args()
469
+
470
+ loop_f = asyncio.SelectorEventLoop if sys.platform == "win32" else None
471
+ if loop_f:
472
+ asyncio.run(
473
+ run_player_logic(args.db_id, args.number, args.player, args.staged), loop_factory=loop_f
474
+ )
475
+ else:
476
+ asyncio.run(run_player_logic(args.db_id, args.number, args.player, args.staged))
477
+
478
+
479
+ if __name__ == "__main__":
480
+ main()
File without changes
@@ -0,0 +1,86 @@
1
+ import re
2
+ import time
3
+ import urllib.parse
4
+
5
+
6
+ def format_bytes(num_bytes) -> str:
7
+ """Original byte formatter."""
8
+ if not isinstance(num_bytes, (int, float)): return str(num_bytes)
9
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
10
+ if num_bytes < 1024.0: return f"{num_bytes:.2f} {unit}"
11
+ num_bytes /= 1024.0
12
+ return f"{num_bytes:.2f} PB"
13
+
14
+ def clean_dragged_path(raw: str) -> str:
15
+ """Original path cleaner."""
16
+ if not raw: return ""
17
+ text = raw.strip().replace("\\ ", " ")
18
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
19
+ text = text[1:-1].strip()
20
+ return text
21
+
22
+ def slugify_filename(idx: int, title: str) -> str:
23
+ """Standard slugifier for filenames."""
24
+ clean = re.sub(r'[^a-zA-Z0-9\s]', '', title)
25
+ clean = re.sub(r'\s+', "_", clean).strip("_")
26
+ return f"{idx:02d}_{clean if clean else 'output'}"
27
+
28
+ def get_album_folder_name(album_id, album_title: str) -> str:
29
+ """Restored: The specific #ID_Title folder naming logic."""
30
+ clean = re.sub(r'[\\/*?:"<>|]', "", album_title or "unknown_album")
31
+ clean = re.sub(r'\s+', "_", clean).strip("_") or "unknown_album"
32
+ return f"#{album_id}_{clean}"
33
+
34
+ def extract_expiry_from_url(url_str: str | None) -> int | None:
35
+ if not url_str: return None
36
+ try:
37
+ parsed = urllib.parse.urlparse(url_str)
38
+ q = dict(urllib.parse.parse_qsl(parsed.query))
39
+ return int(q['ex']) if 'ex' in q else None
40
+ except Exception:
41
+ return None
42
+
43
+ def parse_and_check_expiry(expiry: int | None) -> str:
44
+ """Restored: The detailed colored expiry status from read.py."""
45
+ if not expiry: return "[dim white]No token found[/dim white]"
46
+ current = int(time.time())
47
+ if current > expiry: return "[bold red]Expired ❌[/bold red]"
48
+ rem = expiry - current
49
+ hours = rem // 3600
50
+ mins = (rem % 3600) // 60
51
+ if hours > 0:
52
+ return f"[bold green]Valid ({hours}h {mins}m left) ✅[/bold green]"
53
+ return f"[bold yellow]Valid ({mins}m left) ⚠️[/bold yellow]"
54
+
55
+ def parse_selection(spec: str, total_items: int) -> list[int]:
56
+ """Restored: The advanced selection parser (1,3,5-10)."""
57
+ if not spec or spec.strip().lower() == "all":
58
+ return list(range(1, total_items + 1))
59
+
60
+ selected = set()
61
+ for chunk in (c.strip() for c in spec.split(",") if c.strip()):
62
+ if "-" in chunk:
63
+ try:
64
+ start_str, end_str = chunk.split("-", 1)
65
+ start, end = int(start_str), int(end_str)
66
+ if start > end:
67
+ start, end = end, start
68
+ selected.update(range(start, end + 1))
69
+ except ValueError as e:
70
+ raise ValueError(f"Invalid range format: '{chunk}'") from e
71
+ else:
72
+ try:
73
+ selected.add(int(chunk))
74
+ except ValueError as e:
75
+ raise ValueError(f"Invalid numeric index: '{chunk}'") from e
76
+
77
+ valid = [i for i in selected if 1 <= i <= total_items]
78
+ if not valid and spec.strip():
79
+ raise ValueError(
80
+ f"Selection '{spec}' out of range (1-{total_items})"
81
+ )
82
+
83
+ return sorted(valid)
84
+
85
+ def sanitize_filename_simple(name: str) -> str:
86
+ return "".join(c for c in name if c.isalnum() or c in "._- ()").strip()
@@ -0,0 +1,19 @@
1
+ import asyncio
2
+
3
+ import urllib3
4
+ from curl_cffi.curl import CurlError
5
+
6
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
7
+
8
+ async def execute_request_with_retry_async(session, url, method="GET", json_payload=None, headers=None, retries=3, delay=1, timeout=30):
9
+ for attempt in range(1, retries + 1):
10
+ try:
11
+ if method.upper() == "POST":
12
+ res = await session.post(url, json=json_payload, headers=headers, verify=False, timeout=timeout)
13
+ else:
14
+ res = await session.get(url, headers=headers, verify=False, timeout=timeout)
15
+ res.raise_for_status()
16
+ return res
17
+ except CurlError:
18
+ if attempt == retries: raise
19
+ await asyncio.sleep(delay)