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 ADDED
@@ -0,0 +1,5 @@
1
+ from .api import BunkrAPI
2
+ from .config import VERSION
3
+
4
+ __version__ = VERSION
5
+ __all__ = ["BunkrAPI"]
bunkr_api/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main_loop
2
+
3
+ if __name__ == "__main__":
4
+ main_loop()
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)