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
|
@@ -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()
|
bunkr_api/utils/http.py
ADDED
|
@@ -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)
|