bunkr-api 0.1.0b1__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 +532 -0
- bunkr_api/config.py +26 -0
- bunkr_api/core/__init__.py +0 -0
- bunkr_api/core/db.py +318 -0
- bunkr_api/core/scraper.py +310 -0
- bunkr_api/core/tokens.py +197 -0
- bunkr_api/inspector.py +520 -0
- bunkr_api/media/__init__.py +0 -0
- bunkr_api/media/downloader.py +427 -0
- bunkr_api/media/player.py +479 -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.0b1.dist-info/METADATA +681 -0
- bunkr_api-0.1.0b1.dist-info/RECORD +22 -0
- bunkr_api-0.1.0b1.dist-info/WHEEL +5 -0
- bunkr_api-0.1.0b1.dist-info/entry_points.txt +7 -0
- bunkr_api-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- bunkr_api-0.1.0b1.dist-info/top_level.txt +1 -0
bunkr_api/__init__.py
ADDED
bunkr_api/__main__.py
ADDED
bunkr_api/api.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from curl_cffi.requests import AsyncSession
|
|
5
|
+
|
|
6
|
+
from .config import DB_PATH, DEFAULT_OUTPUT_DIR, SEARCH_MODES, SORT_TYPES
|
|
7
|
+
|
|
8
|
+
# Internal Package Imports
|
|
9
|
+
from .core.db import DatabaseManager
|
|
10
|
+
from .core.scraper import ScraperEngine
|
|
11
|
+
from .core.tokens import refresh_all_tokens_async
|
|
12
|
+
from .media.downloader import DownloadEngine
|
|
13
|
+
from .media.player import PlayerEngine
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BunkrAPI:
|
|
17
|
+
def __init__(self, db_path: str = DB_PATH):
|
|
18
|
+
"""
|
|
19
|
+
Initializes the Bunkr API Facade.
|
|
20
|
+
:param db_path: Path to the SQLite database file.
|
|
21
|
+
"""
|
|
22
|
+
self.db = DatabaseManager(db_path)
|
|
23
|
+
self.scraper = ScraperEngine(self.db)
|
|
24
|
+
self.downloader = DownloadEngine(self.db)
|
|
25
|
+
self.player = PlayerEngine(self.db)
|
|
26
|
+
|
|
27
|
+
# ============================================================
|
|
28
|
+
# DATA RETRIEVAL (Catalog)
|
|
29
|
+
# ============================================================
|
|
30
|
+
|
|
31
|
+
def get_albums(self) -> list:
|
|
32
|
+
"""Returns all cataloged albums in the database."""
|
|
33
|
+
return [dict(r) for r in self.db.get_all_albums()]
|
|
34
|
+
|
|
35
|
+
def get_album(self, album_id: int) -> dict | None:
|
|
36
|
+
"""Returns a single album's metadata, or None if it doesn't exist."""
|
|
37
|
+
row = self.db.get_album(album_id)
|
|
38
|
+
return dict(row) if row else None
|
|
39
|
+
|
|
40
|
+
def get_assets(self, album_id: int) -> list:
|
|
41
|
+
"""Returns all assets for a specific album ID."""
|
|
42
|
+
return [dict(r) for r in self.db.get_album_assets(album_id)]
|
|
43
|
+
|
|
44
|
+
def get_valid_url(self, asset_id: int) -> str:
|
|
45
|
+
"""Returns a fresh, valid signed CDN URL for a single asset,
|
|
46
|
+
re-minting a new token if the cached one is missing or expiring
|
|
47
|
+
soon. Returns "" if no asset with that id exists.
|
|
48
|
+
"""
|
|
49
|
+
return self.db.get_valid_url(asset_id)
|
|
50
|
+
|
|
51
|
+
# ============================================================
|
|
52
|
+
# SCRAPING & RESOLUTION
|
|
53
|
+
# ============================================================
|
|
54
|
+
|
|
55
|
+
async def search(self, term: str, mode: str = "broad", per: int = 20, sort: str = "latest") -> list:
|
|
56
|
+
"""
|
|
57
|
+
Programmatic search. Returns a list of album dictionaries.
|
|
58
|
+
"""
|
|
59
|
+
url_mode = SEARCH_MODES.get(mode, "broad")
|
|
60
|
+
url_sort = SORT_TYPES.get(sort, "latest")
|
|
61
|
+
|
|
62
|
+
async with AsyncSession(impersonate="chrome") as session:
|
|
63
|
+
query = {'search': term, 'mode': url_mode, 'per': str(per), 'sort': url_sort}
|
|
64
|
+
search_url = f"https://balbums.st/?{urllib.parse.urlencode(query)}" if term else "https://balbums.st/"
|
|
65
|
+
|
|
66
|
+
res = await session.get(search_url, verify=False, timeout=30)
|
|
67
|
+
return self.scraper.parse_albums(res.text)
|
|
68
|
+
|
|
69
|
+
async def resolve_album(self, album_url: str, search_context: str = "API_User", save_json: bool = False) -> int:
|
|
70
|
+
"""
|
|
71
|
+
Scrapes a specific bunkr URL and registers it in the database.
|
|
72
|
+
:return: The new Database ID of the registered album.
|
|
73
|
+
"""
|
|
74
|
+
async with AsyncSession(impersonate="chrome") as session:
|
|
75
|
+
return await self.scraper.scrape_album(
|
|
76
|
+
session=session,
|
|
77
|
+
url=album_url,
|
|
78
|
+
search_term=search_context,
|
|
79
|
+
save_json=save_json
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def resolve_and_download(
|
|
83
|
+
self, album_url: str, search_context: str = "API_User",
|
|
84
|
+
workers: int = 3, output_dir: Path = DEFAULT_OUTPUT_DIR, save_json: bool = False,
|
|
85
|
+
) -> int:
|
|
86
|
+
"""
|
|
87
|
+
One-shot convenience: scrapes+registers a bunkr album URL, then
|
|
88
|
+
immediately downloads it. Equivalent to calling resolve_album()
|
|
89
|
+
followed by download_album() yourself.
|
|
90
|
+
:return: The database ID of the resolved album.
|
|
91
|
+
"""
|
|
92
|
+
album_id = await self.resolve_album(album_url, search_context=search_context, save_json=save_json)
|
|
93
|
+
await self.download_album(album_id, workers=workers, output_dir=output_dir)
|
|
94
|
+
return album_id
|
|
95
|
+
|
|
96
|
+
# ============================================================
|
|
97
|
+
# MEDIA EXECUTION
|
|
98
|
+
# ============================================================
|
|
99
|
+
|
|
100
|
+
async def download_album(self, album_id: int, workers: int = 3, output_dir: Path = DEFAULT_OUTPUT_DIR):
|
|
101
|
+
"""
|
|
102
|
+
Trigger a multi-threaded download for an album.
|
|
103
|
+
"""
|
|
104
|
+
# 1. Fetch metadata
|
|
105
|
+
with self.db.connection() as conn:
|
|
106
|
+
album = conn.execute("SELECT title FROM albums WHERE id=?", (album_id,)).fetchone()
|
|
107
|
+
|
|
108
|
+
if not album:
|
|
109
|
+
raise ValueError(f"Album ID {album_id} not found.")
|
|
110
|
+
|
|
111
|
+
# 2. Fetch assets and format for engine
|
|
112
|
+
assets = self.db.get_album_assets(album_id)
|
|
113
|
+
dl_list = []
|
|
114
|
+
for a in assets:
|
|
115
|
+
d = dict(a)
|
|
116
|
+
d['db_asset_id'] = d['id']
|
|
117
|
+
d['album_title'] = album['title']
|
|
118
|
+
d['album_id'] = album_id
|
|
119
|
+
dl_list.append(d)
|
|
120
|
+
|
|
121
|
+
# 3. Await the downloader run
|
|
122
|
+
await self.downloader.run(dl_list, workers=workers, output_dir=output_dir)
|
|
123
|
+
|
|
124
|
+
def _prepare_dl_list(self, assets) -> list[dict]:
|
|
125
|
+
"""Shared prep for download_staged/retry_failed: both fetch rows
|
|
126
|
+
that already carry album_title via a JOIN (unlike get_album_assets,
|
|
127
|
+
which doesn't), so they only need db_asset_id added.
|
|
128
|
+
"""
|
|
129
|
+
dl_list = []
|
|
130
|
+
for a in assets:
|
|
131
|
+
d = dict(a)
|
|
132
|
+
d['db_asset_id'] = d['id']
|
|
133
|
+
dl_list.append(d)
|
|
134
|
+
return dl_list
|
|
135
|
+
|
|
136
|
+
async def download_staged(self, workers: int = 3, output_dir: Path = DEFAULT_OUTPUT_DIR):
|
|
137
|
+
"""
|
|
138
|
+
Downloads every asset currently flagged as staged — either directly,
|
|
139
|
+
or via its parent album being staged. A no-op if nothing is staged.
|
|
140
|
+
"""
|
|
141
|
+
assets = self.db.get_staged_assets()
|
|
142
|
+
dl_list = self._prepare_dl_list(assets)
|
|
143
|
+
await self.downloader.run(dl_list, workers=workers, output_dir=output_dir)
|
|
144
|
+
|
|
145
|
+
async def retry_failed(self, workers: int = 3, output_dir: Path = DEFAULT_OUTPUT_DIR):
|
|
146
|
+
"""
|
|
147
|
+
Retries every asset currently marked FAILED. A no-op if nothing has
|
|
148
|
+
failed.
|
|
149
|
+
"""
|
|
150
|
+
assets = self.db.get_failed_assets()
|
|
151
|
+
dl_list = self._prepare_dl_list(assets)
|
|
152
|
+
await self.downloader.run(dl_list, workers=workers, output_dir=output_dir)
|
|
153
|
+
|
|
154
|
+
async def stream_album(self, album_id: int, indices_spec: str = "all", player: str = "mpv"):
|
|
155
|
+
"""
|
|
156
|
+
Resolves tokens and launches the media player.
|
|
157
|
+
"""
|
|
158
|
+
assets = self.db.get_album_assets(album_id)
|
|
159
|
+
if not assets:
|
|
160
|
+
raise ValueError(f"No assets to stream for ID {album_id}")
|
|
161
|
+
|
|
162
|
+
from .utils.formatting import parse_selection
|
|
163
|
+
indices = parse_selection(indices_spec, total_items=len(assets))
|
|
164
|
+
|
|
165
|
+
# 1. Standardize selected assets
|
|
166
|
+
selected_assets = [dict(assets[i-1]) for i in indices]
|
|
167
|
+
|
|
168
|
+
# 2. Await the token refresh
|
|
169
|
+
await self.player.resolve_tokens_async(selected_assets)
|
|
170
|
+
|
|
171
|
+
# 3. Build final queue
|
|
172
|
+
queue = []
|
|
173
|
+
for i in indices:
|
|
174
|
+
a = dict(assets[i-1])
|
|
175
|
+
url = self.db.get_valid_url(a['id'])
|
|
176
|
+
queue.append((i, a['title'], url))
|
|
177
|
+
|
|
178
|
+
# 4. Launch player
|
|
179
|
+
if player == "vlc":
|
|
180
|
+
await self.player.play_vlc(queue)
|
|
181
|
+
else:
|
|
182
|
+
await self.player.play_mpv(queue)
|
|
183
|
+
|
|
184
|
+
# ============================================================
|
|
185
|
+
# MAINTENANCE
|
|
186
|
+
# ============================================================
|
|
187
|
+
|
|
188
|
+
async def refresh_tokens(self, album_id: int | None = None):
|
|
189
|
+
"""
|
|
190
|
+
Refresh signed CDN tokens asynchronously.
|
|
191
|
+
"""
|
|
192
|
+
raw_assets = self.db.get_needs_refresh(album_id=album_id)
|
|
193
|
+
expiring_assets = [dict(row) for row in raw_assets]
|
|
194
|
+
|
|
195
|
+
if expiring_assets:
|
|
196
|
+
max_workers = int(self.db.get_config_val("max_workers", "4"))
|
|
197
|
+
await refresh_all_tokens_async(self.db, expiring_assets, max_workers)
|
|
198
|
+
|
|
199
|
+
def delete_album(self, album_id: int) -> bool:
|
|
200
|
+
"""
|
|
201
|
+
Deletes an album and all its assets. Cascades automatically at the
|
|
202
|
+
database level, so nothing further needs cleaning up.
|
|
203
|
+
:return: True if an album was actually deleted, False if no album
|
|
204
|
+
with that id existed.
|
|
205
|
+
"""
|
|
206
|
+
return self.db.delete_album(album_id)
|